Files
EugeneTes 2a201f7d95 fix(mcp): address review findings
- 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
2026-09-26 21:18:12 +02:00

125 lines
6.1 KiB
C#

using Microsoft.Extensions.Logging;
using ModelContextProtocol.Client;
using Volcanic.AgenticCore.Abstractions.Processes;
namespace Volcanic.AgenticCore.Extensions.Mcp;
/// <summary>
/// Process-wide cache of <c>tools/list</c> results, keyed by server name and a digest of its unexpanded configuration.
/// Concurrent requests share one discovery. Success is kept for the process lifetime, failure for <see cref="FailureCooldown"/>.
/// </summary>
public sealed class McpCatalogCache
{
/// <summary>How long a failed discovery is remembered before the next request tries again.</summary>
public static readonly TimeSpan FailureCooldown = TimeSpan.FromSeconds(60);
/// <summary>Explicit process-wide state, like <c>ClaudeLoginCoordinator.Shared</c>.</summary>
public static McpCatalogCache Shared { get; } = new(TimeProvider.System);
private readonly TimeProvider _time;
private readonly Lock _lock = new();
private readonly Dictionary<string, Task<McpCatalog>> _tasks = new(StringComparer.Ordinal);
private readonly Dictionary<string, DateTimeOffset> _failedAt = new(StringComparer.Ordinal);
internal McpCatalogCache(TimeProvider time) => _time = time;
/// <summary>
/// The server's catalogue, discovering it if nothing is cached or a failure's cooldown has passed. The shared discovery
/// runs detached from any caller's token; each caller waits with its own. Throws <see cref="McpServerStartException"/>.
/// <paramref name="redact"/> is applied to the server's stderr before it is logged or put in an error message.
/// </summary>
internal Task<McpCatalog> GetAsync(string name, McpServerConfig config, IProcessSpawnPolicy processes, Func<string, string?> getEnv, ILogger logger, CancellationToken cancellationToken, Func<string, string>? redact = null)
{
var key = McpLaunch.CacheKey(name, config);
Task<McpCatalog> task;
lock (_lock)
{
_tasks.TryGetValue(key, out var existing);
var expired = existing is { IsFaulted: true }
&& _failedAt.TryGetValue(key, out var failedAt)
&& _time.GetUtcNow() - failedAt >= FailureCooldown;
if (existing is null || expired)
{
_failedAt.Remove(key);
existing = Task.Run(() => DiscoverAsync(key, name, config, processes, getEnv, logger, redact), CancellationToken.None);
_tasks[key] = existing;
}
task = existing;
}
return task.WaitAsync(cancellationToken);
}
private async Task<McpCatalog> DiscoverAsync(string key, string name, McpServerConfig config, IProcessSpawnPolicy processes, Func<string, string?> getEnv, ILogger logger, Func<string, string>? redact)
{
var dir = Path.Combine(Path.GetTempPath(), "volcanic-mcp-discovery", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var stderr = new StderrTail(redact);
McpProcess? process = null;
try
{
ProcessSpawnContext spawn;
try { spawn = processes.Prepare(McpLaunch.BuildRequest(name, config, dir, getEnv)); }
catch (Exception ex) { throw new McpServerStartException($"{name}: process environment hook failed: {ex.Message}", ex); }
process = await McpProcess.StartAsync(name, config, spawn, stderr, logger, CancellationToken.None).ConfigureAwait(false);
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(config.StartupTimeoutSeconds));
IList<McpClientTool> tools;
try { tools = await process.Client.ListToolsAsync(cancellationToken: cts.Token).ConfigureAwait(false); }
catch (OperationCanceledException)
{
throw new McpServerStartException($"{name} MCP server did not list its tools within {config.StartupTimeoutSeconds}s{McpProcess.TailSuffix(stderr)}");
}
catch (Exception ex)
{
throw new McpServerStartException($"{name} MCP server failed to list its tools: {ex.Message}{McpProcess.TailSuffix(stderr)}", ex);
}
var catalog = McpCatalog.From(name, config, process.Client.ServerInfo, [.. tools.Select(t => t.ProtocolTool)]);
logger.LogInformation("MCP server {Name}: {Count} tools ({Server} {Version})", name, catalog.Tools.Count, catalog.ServerInfoName, catalog.ServerVersion);
return catalog;
}
catch (Exception ex)
{
lock (_lock) _failedAt[key] = _time.GetUtcNow(); // recorded before the task faults
logger.LogWarning("MCP server {Name} is unavailable: {Message}", name, redact is null ? ex.Message : redact(ex.Message));
if (ex is McpServerStartException) throw;
throw new McpServerStartException($"{name} MCP server discovery failed: {ex.Message}", ex);
}
finally
{
// Detached: the SDK spends ShutdownTimeout on every dispose, and no caller needs to wait for it.
_ = Task.Run(() => CleanUpAsync(name, process, dir, logger), CancellationToken.None);
}
}
private static async Task CleanUpAsync(string name, McpProcess? process, string dir, ILogger logger)
{
try
{
if (process is not null) await process.DisposeAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
logger.LogDebug(ex, "MCP server {Name}: stopping the discovery process failed", name);
}
// Windows can hold the cwd for a moment after the process exits.
for (var attempt = 1; ; attempt++)
{
try
{
if (Directory.Exists(dir)) Directory.Delete(dir, recursive: true);
return;
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
if (attempt == 5)
{
logger.LogDebug(ex, "MCP server {Name}: could not delete the discovery directory {Dir}", name, dir);
return;
}
await Task.Delay(TimeSpan.FromMilliseconds(200 * attempt)).ConfigureAwait(false);
}
}
}
}