- redact /mcp output, and stderr lines as they arrive (log, errors, tail)
- take host servers by name: configuration binding sorts keys anyway
- dispose kills the tree only without an exit code and only if the pid's
start time matches the spawn (no kill of a reused pid)
- idle timer generation guards stale callbacks; catalogue check runs in
the background with a 10 s cap; shutdown stops servers concurrently
- stop resets a failed status; /mcp shows an exited process; ordinal names
- spec: ${workspace} in command, ordering, dispose and /mcp wording
81 lines
4.3 KiB
C#
81 lines
4.3 KiB
C#
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>";
|
|
|
|
/// <summary>The text goes through <paramref name="redact"/>: it can hold a server's stderr and last error.</summary>
|
|
public static CommandDefinition Create(IExtensionApi api, IReadOnlyList<McpServerState> servers, Func<string, string> redact) => new(Description, async (args, ctx, _) =>
|
|
{
|
|
var text = redact(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 when server.Connection.ProcessExited => "exited (restarts on the next call)",
|
|
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.Ordinal));
|
|
|
|
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];
|
|
}
|