forked from admin/VolcanicAgenticCore
feat(mcp): extension activation, prompt section, lifecycle and /mcp
Spec: /mcp shows no pid (the SDK doesn't expose it) and never shows env values; the prompt section follows the server's own tools; the built-in collision check covers inactive built-ins.
This commit is contained in:
78
extensions/Volcanic.AgenticCore.Extensions.Mcp/McpCommand.cs
Normal file
78
extensions/Volcanic.AgenticCore.Extensions.Mcp/McpCommand.cs
Normal file
@@ -0,0 +1,78 @@
|
||||
using System.Text;
|
||||
using Volcanic.AgenticCore.Abstractions.Extensions;
|
||||
using Volcanic.AgenticCore.Abstractions.Messages;
|
||||
|
||||
namespace Volcanic.AgenticCore.Extensions.Mcp;
|
||||
|
||||
/// <summary><c>/mcp</c>: server states, <c>/mcp <name></c> details, <c>/mcp restart <name></c>. No model call.</summary>
|
||||
internal static class McpCommand
|
||||
{
|
||||
public const string Description = "MCP servers: /mcp, /mcp <name> for details, /mcp restart <name>";
|
||||
|
||||
public static CommandDefinition Create(IExtensionApi api, IReadOnlyList<McpServerState> servers) => new(Description, async (args, ctx, _) =>
|
||||
{
|
||||
var text = await RunAsync(args, servers).ConfigureAwait(false);
|
||||
if (ctx.HasUi) ctx.Ui.Notify(text);
|
||||
else api.SendMessage(CustomMessage.FromText(McpExtension.CommandName, text), MessageDelivery.Steer, triggerTurn: false);
|
||||
});
|
||||
|
||||
public static async Task<string> RunAsync(string args, IReadOnlyList<McpServerState> servers)
|
||||
{
|
||||
var parts = args.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
if (parts.Length == 0) return Summary(servers);
|
||||
if (parts[0] == "restart")
|
||||
{
|
||||
if (parts.Length < 2) return "Usage: /mcp restart <name>";
|
||||
if (Find(servers, parts[1]) is not { } target) return Unknown(parts[1], servers);
|
||||
if (target.Connection is not { } connection) return $"{target.Entry.Name} has no process to restart ({Status(target)}).";
|
||||
await connection.StopAsync().ConfigureAwait(false);
|
||||
return $"Stopped {target.Entry.Name}; the next call starts a fresh process.";
|
||||
}
|
||||
return Find(servers, parts[0]) is { } server ? Details(server) : Unknown(parts[0], servers);
|
||||
}
|
||||
|
||||
public static string Status(McpServerState server)
|
||||
{
|
||||
if (server.Entry.Disabled) return server.Entry.DisabledHint is { } hint ? $"disabled ({hint})" : "disabled";
|
||||
if (server.Error is { } error) return $"failed: {FirstLine(error)}";
|
||||
return server.Connection!.Status switch
|
||||
{
|
||||
McpConnectionStatus.Running => "running",
|
||||
McpConnectionStatus.Failed => $"failed: {FirstLine(server.Connection.LastError ?? "unknown error")}",
|
||||
_ => "not started",
|
||||
};
|
||||
}
|
||||
|
||||
private static string Summary(IReadOnlyList<McpServerState> servers)
|
||||
{
|
||||
var sb = new StringBuilder("MCP servers:");
|
||||
foreach (var server in servers)
|
||||
sb.Append($"\n- {server.Entry.Name}: {Status(server)}{(server.Entry.Disabled ? "" : $" · {server.RegisteredTools.Count} tools")}");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string Details(McpServerState server)
|
||||
{
|
||||
var config = server.Entry.Config;
|
||||
var sb = new StringBuilder($"**{server.Entry.Name}**: {Status(server)} · {server.RegisteredTools.Count} tools");
|
||||
if (!server.Entry.Disabled)
|
||||
{
|
||||
sb.Append($"\nCommand: {string.Join(' ', [config.Command, .. config.Args])}");
|
||||
if (config.Env.Count > 0) sb.Append($"\nEnvironment: {string.Join(", ", config.Env.Keys)}"); // names only, never values
|
||||
}
|
||||
if (server.Catalog is { } catalog) sb.Append($"\nServer: {catalog.ServerInfoName} {catalog.ServerVersion}");
|
||||
if (server.RegisteredTools.Count > 0) sb.Append($"\nTools: {string.Join(", ", server.RegisteredTools)}");
|
||||
var lastError = server.Error ?? server.Connection?.LastError;
|
||||
if (lastError is not null) sb.Append($"\nLast error: {lastError}");
|
||||
if (server.Connection?.StderrTail is { Count: > 0 } tail) sb.Append($"\nStderr (last {tail.Count} lines):\n```\n{string.Join('\n', tail)}\n```");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static McpServerState? Find(IReadOnlyList<McpServerState> servers, string name) =>
|
||||
servers.FirstOrDefault(s => string.Equals(s.Entry.Name, name, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
private static string Unknown(string name, IReadOnlyList<McpServerState> servers) =>
|
||||
$"No MCP server named `{name}`. Servers: {string.Join(", ", servers.Select(s => s.Entry.Name))}.";
|
||||
|
||||
private static string FirstLine(string text) => text.Split('\n', 2)[0];
|
||||
}
|
||||
143
extensions/Volcanic.AgenticCore.Extensions.Mcp/McpExtension.cs
Normal file
143
extensions/Volcanic.AgenticCore.Extensions.Mcp/McpExtension.cs
Normal file
@@ -0,0 +1,143 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Volcanic.AgenticCore.Abstractions.Extensions;
|
||||
using Volcanic.AgenticCore.Abstractions.Extensions.Events;
|
||||
|
||||
namespace Volcanic.AgenticCore.Extensions.Mcp;
|
||||
|
||||
/// <summary>
|
||||
/// MCP client: registers the tools of the configured stdio MCP servers. The catalogue is discovered once per host process;
|
||||
/// each session gets its own server process, started on the first call.
|
||||
/// </summary>
|
||||
public sealed class McpExtension : IExtension
|
||||
{
|
||||
public const string CommandName = "mcp";
|
||||
|
||||
private readonly McpOptions _options;
|
||||
private readonly McpCatalogCache _cache;
|
||||
private readonly Func<string, string?> _getEnv;
|
||||
private readonly TimeProvider _time;
|
||||
private readonly List<McpServerState> _servers = [];
|
||||
|
||||
/// <summary>For DI: the host binds <see cref="McpOptions"/>, applies the environment and validates it.</summary>
|
||||
public McpExtension(McpOptions options)
|
||||
: this(options, McpCatalogCache.Shared, Environment.GetEnvironmentVariable, TimeProvider.System) { }
|
||||
|
||||
/// <summary>For a dropped-in assembly: only the <c>PLAYWRIGHT_*</c> variables and the user settings.</summary>
|
||||
public McpExtension()
|
||||
: this(FromEnvironment(Environment.GetEnvironmentVariable), McpCatalogCache.Shared, Environment.GetEnvironmentVariable, TimeProvider.System) { }
|
||||
|
||||
internal McpExtension(McpOptions options, McpCatalogCache cache, Func<string, string?> getEnv, TimeProvider time)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
_options = options;
|
||||
_cache = cache;
|
||||
_getEnv = getEnv;
|
||||
_time = time;
|
||||
}
|
||||
|
||||
public string Name => "mcp";
|
||||
|
||||
internal McpOptions Options => _options;
|
||||
|
||||
public async ValueTask ActivateAsync(IExtensionApi api, CancellationToken cancellationToken)
|
||||
{
|
||||
var logger = api.Logger;
|
||||
var entries = McpServerSet.Resolve(_options, McpUserSettings.Load(McpUserSettings.AgentDir(_getEnv), logger), logger);
|
||||
var states = await Task.WhenAll(entries.Select(e => PrepareAsync(e, api, cancellationToken))).ConfigureAwait(false);
|
||||
_servers.AddRange(states);
|
||||
RegisterTools(api);
|
||||
api.RegisterCommand(CommandName, McpCommand.Create(api, _servers));
|
||||
api.On<BeforeAgentStartEvent>((e, _, _) =>
|
||||
{
|
||||
AddPromptSections(api, e.SystemPromptOptions);
|
||||
return ValueTask.CompletedTask;
|
||||
});
|
||||
api.On<SessionShutdownEvent>(async (e, _, _) =>
|
||||
{
|
||||
var final = e.Reason is SessionShutdownReason.Quit or SessionShutdownReason.Reload;
|
||||
foreach (var server in _servers)
|
||||
{
|
||||
if (server.Connection is not { } connection) continue;
|
||||
if (final) await connection.DisposeAsync().ConfigureAwait(false);
|
||||
else await connection.StopAsync().ConfigureAwait(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<McpServerState> PrepareAsync(McpServerEntry entry, IExtensionApi api, CancellationToken cancellationToken)
|
||||
{
|
||||
if (entry.Disabled) return new McpServerState(entry);
|
||||
if (entry.Problem is { } problem)
|
||||
{
|
||||
api.Logger.LogError("MCP server {Name} is misconfigured: {Problem}", entry.Name, problem);
|
||||
return new McpServerState(entry) { Error = problem };
|
||||
}
|
||||
try
|
||||
{
|
||||
var catalog = await _cache.GetAsync(entry.Name, entry.Config, api.Processes, _getEnv, api.Logger, cancellationToken).ConfigureAwait(false);
|
||||
return new McpServerState(entry)
|
||||
{
|
||||
Catalog = catalog,
|
||||
Connection = new McpServerConnection(entry.Name, entry.Config, api.Logger, _getEnv, catalog, _time),
|
||||
};
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
return new McpServerState(entry) { Error = ex.Message }; // the cache has logged it
|
||||
}
|
||||
}
|
||||
|
||||
private void RegisterTools(IExtensionApi api)
|
||||
{
|
||||
var builtIns = api.GetAllTools().Where(t => t.Source == "built-in").Select(t => t.Name).ToHashSet(StringComparer.Ordinal);
|
||||
var owners = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
foreach (var server in _servers)
|
||||
{
|
||||
if (server.Catalog is not { } catalog || server.Connection is not { } connection) continue;
|
||||
foreach (var tool in catalog.Tools)
|
||||
{
|
||||
if (builtIns.Contains(tool.ExposedName))
|
||||
{
|
||||
api.Logger.LogWarning("MCP server {Server}: tool {Tool} would replace the built-in tool of the same name and is not registered; set a toolPrefix for this server.", server.Entry.Name, tool.ExposedName);
|
||||
continue;
|
||||
}
|
||||
if (owners.TryGetValue(tool.ExposedName, out var owner))
|
||||
{
|
||||
api.Logger.LogWarning("MCP tool {Tool} is offered by servers {Servers}; the first one wins.", tool.ExposedName, $"{owner} and {server.Entry.Name}");
|
||||
continue;
|
||||
}
|
||||
owners[tool.ExposedName] = server.Entry.Name;
|
||||
api.RegisterTool(new McpTool(tool, connection, server.Entry.Config.Parallel));
|
||||
server.RegisteredTools.Add(tool.ExposedName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void AddPromptSections(IExtensionApi api, SystemPromptOptions options)
|
||||
{
|
||||
var active = api.GetActiveTools().ToHashSet(StringComparer.Ordinal);
|
||||
var hasSend = api.GetAllTools().Any(t => t.Name == "send");
|
||||
foreach (var server in _servers)
|
||||
{
|
||||
if (!server.RegisteredTools.Any(active.Contains)) continue;
|
||||
if (server.Entry.PromptSection(hasSend) is { Length: > 0 } section) options.Sections[server.Entry.SectionKey] = section;
|
||||
}
|
||||
}
|
||||
|
||||
private static McpOptions FromEnvironment(Func<string, string?> getEnv)
|
||||
{
|
||||
var options = new McpOptions();
|
||||
options.ApplyEnvironment(getEnv);
|
||||
return options;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>One server in one session: its catalogue (if discovery worked), its connection and the tools it got.</summary>
|
||||
internal sealed class McpServerState(McpServerEntry entry)
|
||||
{
|
||||
public McpServerEntry Entry { get; } = entry;
|
||||
public McpCatalog? Catalog { get; init; }
|
||||
public McpServerConnection? Connection { get; init; }
|
||||
public string? Error { get; init; }
|
||||
public List<string> RegisteredTools { get; } = [];
|
||||
}
|
||||
Reference in New Issue
Block a user