forked from admin/VolcanicAgenticCore
feat(mcp): per-session server connection and MCP tools
McpServerConnection starts the session's server lazily through PrepareProcessAsync, applies the call timeout, reports exits with the stderr tail and restarts on the next call, stops when idle, and supports StopAsync (reusable) and idempotent DisposeAsync. McpTool exposes a catalogue entry as an ITool and maps results with McpResultMapper. Spec change (§5, Decisions): ModelContextProtocol.Core 2.2.0 does not send notifications/cancelled when a request's token fires (verified against the fake server), so calls go out through SendRequestAsync with their own request id and the connection sends notifications/cancelled itself on abort and timeout. Also documents exit detection, StopAsync vs DisposeAsync and JSON-RPC errors as ordinary error results, per the plan.
This commit is contained in:
@@ -3194,6 +3194,8 @@ internal sealed class McpServerConnection : IAsyncDisposable
|
||||
}
|
||||
```
|
||||
|
||||
(As implemented: SDK 2.2.0 does not send `notifications/cancelled` when the call's token fires, so `CallAsync` sends the request through `SendRequestAsync<CallToolRequestParams, CallToolResult>` with its own id `volcanic-<n>` and sends the notification itself on abort and timeout; the idle stop re-checks the in-flight count under the gate. The spec §5 says so.)
|
||||
|
||||
(If `ExitCode` is a non-nullable `int`, drop the `?.` before `ToString`. If `McpProtocolException` lives in another namespace, adjust the using. A JSON-RPC error from the server, e.g. invalid params, surfaces as that exception: it becomes an ordinary error result and doesn't mark the server dead.)
|
||||
|
||||
Create `extensions/Volcanic.AgenticCore.Extensions.Mcp/McpTool.cs`:
|
||||
|
||||
@@ -29,8 +29,8 @@ two meet only in tool naming (see "Names").
|
||||
(`o.AddExtension<McpExtension>()`) or dropped into an extensions directory. A small contract gap is closed in
|
||||
Abstractions (see Components §8). No runtime slice changes.
|
||||
- **Official SDK, not hand-rolled JSON-RPC.** Use the `ModelContextProtocol.Core` package (the client/transport
|
||||
core, without the hosting package). It handles the initialize handshake, protocol version negotiation, the
|
||||
`notifications/cancelled` notification on cancellation, pagination of `tools/list`, content-block types and
|
||||
core, without the hosting package). It handles the initialize handshake, protocol version negotiation,
|
||||
pagination of `tools/list`, content-block types and
|
||||
process shutdown (`StdioClientTransport`: `Command`, `Arguments`, `WorkingDirectory`, `EnvironmentVariables`,
|
||||
`StandardErrorLines`, `ShutdownTimeout`). The `ClaudeCode.Bridge` hand-rolls its protocol because it answers only
|
||||
three methods. A general client would have to reimplement much more. SDK types never leave the extension. Its
|
||||
@@ -237,18 +237,27 @@ One per server per extension instance (that is, per session):
|
||||
start: expand `${workspace}` from `ctx.Cwd` and `${env:…}`, build the command, cwd and environment through
|
||||
`ctx.PrepareProcessAsync` (so session-level `ProcessSpawnEvent` handlers run and `TZ` is the chat's zone), create `StdioClientTransport` + `McpClient` under `startupTimeout`, and route
|
||||
`StandardErrorLines` to `api.Logger` at `stderrLevel` with the prefix `"<name> mcp: "`, keeping a 20-line tail.
|
||||
- Calls use a linked token: the tool's `CancellationToken` (a session abort) plus `CancelAfter(callTimeout)`. The
|
||||
SDK sends `notifications/cancelled` for the request. A timeout becomes
|
||||
- Calls use a linked token: the tool's `CancellationToken` (a session abort) plus `CancelAfter(callTimeout)`. On an
|
||||
abort or a timeout the connection sends `notifications/cancelled` for the request itself: SDK 2.2.0 doesn't send it
|
||||
when a request's token fires (checked against the fake server), so each call goes out with its own request id
|
||||
(`volcanic-<n>`) through `SendRequestAsync` and the notification is sent best effort. A timeout becomes
|
||||
`"<tool> timed out after <n>s"`, and an abort propagates as `OperationCanceledException`, which the loop already
|
||||
handles.
|
||||
- If the process exits or the transport faults, the client is disposed and marked dead, and the call fails with
|
||||
`"<name> MCP server exited (<code>): <stderr tail>. It restarts on the next call; browser state was lost."`. The
|
||||
next call respawns it. There's no automatic retry of the failed call, because it may have had side effects.
|
||||
- The idle timer (reset per call) disposes the client after `idleTimeout` with no calls in flight.
|
||||
next call respawns it. There's no automatic retry of the failed call, because it may have had side effects. An exit
|
||||
is noticed either when the next call finds the SDK session completed or when a call fails and the process ends
|
||||
within 2 s. The exit code comes from the SDK's `StdioClientCompletionDetails`.
|
||||
- A JSON-RPC error from the server (e.g. invalid params) is an ordinary error result and doesn't mark the server dead.
|
||||
- The idle timer (reset per call) disposes the client after `idleTimeout` with no calls in flight. The in-flight count
|
||||
is taken under the start gate and the idle stop re-checks it under the same gate, so an idle stop never kills a
|
||||
process a call has just been handed.
|
||||
- `DisposeAsync` is idempotent: dispose the client, then kill the process tree if it's still alive. SDK 2.2 waits up to
|
||||
`ShutdownTimeout` for the process to exit with stdin still open, then kills its tree, so disposing a live server
|
||||
always takes about `ShutdownTimeout`. The extension calls it on every `SessionShutdownEvent` (quit, new,
|
||||
resume). A new session in the same chat starts from a fresh process, and the persistent profile keeps the logins.
|
||||
always takes about `ShutdownTimeout`. After it, calls throw `ObjectDisposedException`. `StopAsync` does the same
|
||||
but leaves the connection usable. The extension stops connections on `SessionShutdownEvent` for `new`, `resume` and
|
||||
`fork` (the same extension instance serves the next session) and disposes them for `quit` and `reload`. A new
|
||||
session in the same chat starts from a fresh process, and the persistent profile keeps the logins.
|
||||
- If the live server's `tools/list` differs from the cached catalogue (a package upgrade in place), a warning is
|
||||
logged. Calls go by name either way.
|
||||
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ModelContextProtocol;
|
||||
using ModelContextProtocol.Client;
|
||||
using ModelContextProtocol.Protocol;
|
||||
using Volcanic.AgenticCore.Abstractions.Extensions;
|
||||
using Volcanic.AgenticCore.Abstractions.Processes;
|
||||
|
||||
namespace Volcanic.AgenticCore.Extensions.Mcp;
|
||||
|
||||
internal enum McpConnectionStatus { NotStarted, Running, Failed }
|
||||
|
||||
/// <summary>One server process for one session: started on the first call, stopped on session shutdown or when idle.</summary>
|
||||
internal sealed class McpServerConnection : IAsyncDisposable
|
||||
{
|
||||
private static readonly TimeSpan ExitGrace = TimeSpan.FromSeconds(2);
|
||||
private static readonly TimeSpan StderrGrace = TimeSpan.FromMilliseconds(150);
|
||||
|
||||
private readonly McpServerConfig _config;
|
||||
private readonly ILogger _logger;
|
||||
private readonly Func<string, string?> _getEnv;
|
||||
private readonly McpCatalog? _catalog;
|
||||
private readonly TimeProvider _time;
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
private readonly StderrTail _stderr = new();
|
||||
private readonly Lock _lock = new();
|
||||
private McpProcess? _process;
|
||||
private ITimer? _idleTimer;
|
||||
private int _inFlight;
|
||||
private int _disposed;
|
||||
private long _nextRequestId;
|
||||
|
||||
public McpServerConnection(string name, McpServerConfig config, ILogger logger, Func<string, string?> getEnv, McpCatalog? catalog = null, TimeProvider? time = null)
|
||||
{
|
||||
Name = name;
|
||||
_config = config;
|
||||
_logger = logger;
|
||||
_getEnv = getEnv;
|
||||
_catalog = catalog;
|
||||
_time = time ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public string Name { get; }
|
||||
public McpConnectionStatus Status { get; private set; }
|
||||
public string? LastError { get; private set; }
|
||||
public int StartCount { get; private set; }
|
||||
public IReadOnlyList<string> StderrTail => _stderr.Lines;
|
||||
|
||||
private bool Disposed => Volatile.Read(ref _disposed) == 1;
|
||||
|
||||
/// <summary>
|
||||
/// Calls <paramref name="mcpName"/> on the session's process, starting it first if needed. Throws
|
||||
/// <see cref="TimeoutException"/>, <see cref="OperationCanceledException"/>, <see cref="McpServerExitedException"/>
|
||||
/// or <see cref="McpServerStartException"/>; a JSON-RPC error from the server surfaces as the SDK's exception.
|
||||
/// </summary>
|
||||
public async Task<CallToolResult> CallAsync(string mcpName, JsonElement arguments, IExtensionContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
var process = await EnsureStartedAsync(context, cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeout.CancelAfter(TimeSpan.FromSeconds(_config.CallTimeoutSeconds));
|
||||
var request = new CallToolRequestParams { Name = mcpName, Arguments = ToArguments(arguments) };
|
||||
var id = new RequestId($"volcanic-{Interlocked.Increment(ref _nextRequestId).ToString(CultureInfo.InvariantCulture)}");
|
||||
try
|
||||
{
|
||||
return await process.Client.SendRequestAsync<CallToolRequestParams, CallToolResult>(
|
||||
RequestMethods.ToolsCall, request, McpJsonUtilities.DefaultOptions, id, timeout.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
await SendCancelledAsync(process, id, "aborted").ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
catch (OperationCanceledException) when (timeout.IsCancellationRequested)
|
||||
{
|
||||
await SendCancelledAsync(process, id, "timed out").ConfigureAwait(false);
|
||||
throw new TimeoutException($"{mcpName} timed out after {_config.CallTimeoutSeconds}s");
|
||||
}
|
||||
catch (Exception ex) when (ex is not McpProtocolException)
|
||||
{
|
||||
if (await process.WaitForExitAsync(ExitGrace).ConfigureAwait(false) is { } exit)
|
||||
throw await OnExitedAsync(process, exit).ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
EndCall();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Stops the process; the next call starts a new one. Idempotent.</summary>
|
||||
public async ValueTask StopAsync()
|
||||
{
|
||||
await _gate.WaitAsync().ConfigureAwait(false);
|
||||
try { await StopCoreAsync().ConfigureAwait(false); }
|
||||
finally { _gate.Release(); }
|
||||
}
|
||||
|
||||
/// <summary>Stops the process and refuses later calls with <see cref="ObjectDisposedException"/>. Idempotent.</summary>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disposed, 1) == 1) return;
|
||||
await StopAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>Caller holds <see cref="_gate"/>.</summary>
|
||||
private async ValueTask StopCoreAsync()
|
||||
{
|
||||
lock (_lock) { _idleTimer?.Dispose(); _idleTimer = null; }
|
||||
if (Interlocked.Exchange(ref _process, null) is { } process) await process.DisposeAsync().ConfigureAwait(false);
|
||||
if (Status == McpConnectionStatus.Running) Status = McpConnectionStatus.NotStarted;
|
||||
}
|
||||
|
||||
/// <summary>Returns a live process with the call counted as in flight (so the idle timer can't stop it underneath).</summary>
|
||||
private async Task<McpProcess> EnsureStartedAsync(IExtensionContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(Disposed, this);
|
||||
if (_process is { } existing)
|
||||
{
|
||||
if (existing.HasExited) throw await OnExitedAsync(existing, await existing.Completion.ConfigureAwait(false)).ConfigureAwait(false);
|
||||
BeginCall();
|
||||
return existing;
|
||||
}
|
||||
|
||||
ProcessSpawnContext spawn;
|
||||
try
|
||||
{
|
||||
spawn = await context.PrepareProcessAsync(McpLaunch.BuildRequest(Name, _config, context.Cwd, _getEnv), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
throw Fail(new McpServerStartException($"{Name}: process environment hook failed: {ex.Message}", ex));
|
||||
}
|
||||
|
||||
_stderr.Clear();
|
||||
McpProcess process;
|
||||
try { process = await McpProcess.StartAsync(Name, _config, spawn, _stderr, _logger, cancellationToken).ConfigureAwait(false); }
|
||||
catch (McpServerStartException ex) { throw Fail(ex); }
|
||||
|
||||
_process = process;
|
||||
StartCount++;
|
||||
Status = McpConnectionStatus.Running;
|
||||
LastError = null;
|
||||
_logger.LogInformation("MCP server {Name} started in {Cwd}", Name, spawn.Cwd);
|
||||
await WarnIfCatalogChangedAsync(process).ConfigureAwait(false);
|
||||
BeginCall();
|
||||
return process;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private McpServerStartException Fail(McpServerStartException ex)
|
||||
{
|
||||
Status = McpConnectionStatus.Failed;
|
||||
LastError = ex.Message;
|
||||
_logger.LogWarning("{Message}", ex.Message);
|
||||
return ex;
|
||||
}
|
||||
|
||||
private async Task<McpServerExitedException> OnExitedAsync(McpProcess process, ClientCompletionDetails? details)
|
||||
{
|
||||
Interlocked.CompareExchange(ref _process, null, process);
|
||||
await process.DisposeAsync().ConfigureAwait(false);
|
||||
await Task.Delay(StderrGrace).ConfigureAwait(false); // the last stderr lines race the exit
|
||||
var code = (details as StdioClientCompletionDetails)?.ExitCode?.ToString(CultureInfo.InvariantCulture) ?? "?";
|
||||
var tail = _stderr.Lines is { Count: > 0 } lines ? string.Join('\n', lines) : "no stderr output";
|
||||
var message = $"{Name} MCP server exited ({code}): {tail}. It restarts on the next call; browser state was lost.";
|
||||
Status = McpConnectionStatus.Failed;
|
||||
LastError = message;
|
||||
_logger.LogWarning("{Message}", message);
|
||||
return new McpServerExitedException(message);
|
||||
}
|
||||
|
||||
private void BeginCall()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_inFlight++;
|
||||
_idleTimer?.Dispose();
|
||||
_idleTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void EndCall()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (--_inFlight > 0 || _config.IdleTimeoutSeconds == 0 || Disposed) return;
|
||||
_idleTimer?.Dispose();
|
||||
_idleTimer = _time.CreateTimer(_ => _ = StopIfIdleAsync(), null, TimeSpan.FromSeconds(_config.IdleTimeoutSeconds), Timeout.InfiniteTimeSpan);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task StopIfIdleAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await _gate.WaitAsync().ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
lock (_lock) { if (_inFlight > 0) return; }
|
||||
if (_process is null) return;
|
||||
_logger.LogInformation("MCP server {Name} was idle for {Seconds}s; stopping it", Name, _config.IdleTimeoutSeconds);
|
||||
await StopCoreAsync().ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_gate.Release();
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is not OutOfMemoryException)
|
||||
{
|
||||
_logger.LogWarning("MCP server {Name}: idle stop failed: {Message}", Name, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task WarnIfCatalogChangedAsync(McpProcess process)
|
||||
{
|
||||
if (_catalog is null) return;
|
||||
try
|
||||
{
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(_config.StartupTimeoutSeconds));
|
||||
var live = await process.Client.ListToolsAsync(cancellationToken: cts.Token).ConfigureAwait(false);
|
||||
var liveNames = live.Select(t => t.Name).Order(StringComparer.Ordinal);
|
||||
if (!liveNames.SequenceEqual(_catalog.Tools.Select(t => t.McpName).Order(StringComparer.Ordinal), StringComparer.Ordinal))
|
||||
_logger.LogWarning("MCP server {Name}: its tools changed since discovery; calls still go by name. Restart the host to pick up the new list.", Name);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OutOfMemoryException)
|
||||
{
|
||||
_logger.LogDebug("MCP server {Name}: could not compare its tools with the catalogue: {Message}", Name, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The SDK (2.2.0) doesn't send <c>notifications/cancelled</c> when a request's token fires (checked against the fake
|
||||
/// server), so the call carries its own id and the notification goes out here. Best effort: a dead server can't take it.
|
||||
/// </summary>
|
||||
private async Task SendCancelledAsync(McpProcess process, RequestId id, string reason)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var cts = new CancellationTokenSource(ExitGrace);
|
||||
await process.Client.SendNotificationAsync(NotificationMethods.CancelledNotification,
|
||||
new CancelledNotificationParams { RequestId = id, Reason = reason }, McpJsonUtilities.DefaultOptions, cts.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OutOfMemoryException)
|
||||
{
|
||||
_logger.LogDebug("MCP server {Name}: could not send notifications/cancelled: {Message}", Name, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static Dictionary<string, JsonElement> ToArguments(JsonElement arguments)
|
||||
{
|
||||
var result = new Dictionary<string, JsonElement>(StringComparer.Ordinal);
|
||||
if (arguments.ValueKind == JsonValueKind.Object)
|
||||
foreach (var property in arguments.EnumerateObject()) result[property.Name] = property.Value.Clone();
|
||||
return result;
|
||||
}
|
||||
}
|
||||
49
extensions/Volcanic.AgenticCore.Extensions.Mcp/McpTool.cs
Normal file
49
extensions/Volcanic.AgenticCore.Extensions.Mcp/McpTool.cs
Normal file
@@ -0,0 +1,49 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using Volcanic.AgenticCore.Abstractions.Messages;
|
||||
using Volcanic.AgenticCore.Abstractions.Tools;
|
||||
|
||||
namespace Volcanic.AgenticCore.Extensions.Mcp;
|
||||
|
||||
/// <summary>An MCP server tool as an agent tool. The schema passes through; the server validates what the core doesn't.</summary>
|
||||
internal sealed class McpTool : ITool
|
||||
{
|
||||
private static readonly JsonElement ObjectSchema = JsonSerializer.SerializeToElement(new { type = "object" });
|
||||
|
||||
private readonly McpCatalogTool _tool;
|
||||
private readonly McpServerConnection _connection;
|
||||
private readonly bool _parallel;
|
||||
|
||||
public McpTool(McpCatalogTool tool, McpServerConnection connection, bool parallel)
|
||||
{
|
||||
_tool = tool;
|
||||
_connection = connection;
|
||||
_parallel = parallel;
|
||||
Description = !string.IsNullOrWhiteSpace(tool.Description) ? tool.Description!
|
||||
: !string.IsNullOrWhiteSpace(tool.Title) ? tool.Title!
|
||||
: tool.McpName;
|
||||
Parameters = NormalizeSchema(tool.InputSchema);
|
||||
}
|
||||
|
||||
public string Name => _tool.ExposedName;
|
||||
public string Description { get; }
|
||||
public JsonElement Parameters { get; }
|
||||
public string? PromptSnippet => null;
|
||||
public ToolExecutionMode ExecutionMode => _parallel ? ToolExecutionMode.Parallel : ToolExecutionMode.Sequential;
|
||||
|
||||
public async Task<ToolResult> ExecuteAsync(ToolCallContent call, ToolInvocation invocation, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await _connection.CallAsync(_tool.McpName, call.Arguments, invocation.Context, cancellationToken).ConfigureAwait(false);
|
||||
return McpResultMapper.Map(_connection.Name, _tool.McpName, result);
|
||||
}
|
||||
|
||||
/// <summary>A missing or non-object schema becomes <c>{"type":"object"}</c>; a top-level <c>$schema</c> is dropped.</summary>
|
||||
internal static JsonElement NormalizeSchema(JsonElement schema)
|
||||
{
|
||||
if (schema.ValueKind != JsonValueKind.Object) return ObjectSchema;
|
||||
if (!schema.TryGetProperty("$schema", out _)) return schema.Clone();
|
||||
var node = JsonNode.Parse(schema.GetRawText())!.AsObject();
|
||||
node.Remove("$schema");
|
||||
return JsonSerializer.SerializeToElement(node);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using ModelContextProtocol.Protocol;
|
||||
using Volcanic.AgenticCore.Abstractions.Processes;
|
||||
using Volcanic.AgenticCore.Extensions.Mcp;
|
||||
using Volcanic.AgenticCore.Extensions.Tests.FakeMcpServer;
|
||||
using Volcanic.AgenticCore.Extensions.Tests.Mcp.Support;
|
||||
using Volcanic.AgenticCore.Features.Processes;
|
||||
|
||||
namespace Volcanic.AgenticCore.Extensions.Tests.Mcp;
|
||||
|
||||
public class McpServerConnectionTests : IAsyncLifetime
|
||||
{
|
||||
private readonly string _dir = Path.Combine(Path.GetTempPath(), "volcanic-mcp-connection", Guid.NewGuid().ToString("N"));
|
||||
private readonly Dictionary<string, string?> _env = McpTestEnvironment.Snapshot();
|
||||
private readonly RecordingLogger _logger = new();
|
||||
private readonly FakeTimeProvider _time = new();
|
||||
private readonly List<McpServerConnection> _connections = [];
|
||||
private string Workspace => Path.Combine(_dir, "ws");
|
||||
private string LogPath => Path.Combine(_dir, "fake.log");
|
||||
|
||||
public Task InitializeAsync() { Directory.CreateDirectory(Workspace); return Task.CompletedTask; }
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
foreach (var connection in _connections) await connection.DisposeAsync();
|
||||
try { Directory.Delete(_dir, true); } catch (IOException) { }
|
||||
}
|
||||
|
||||
private McpServerConnection Connect(McpServerConfig config, McpCatalog? catalog = null)
|
||||
{
|
||||
var connection = new McpServerConnection("fake", config, _logger, McpTestEnvironment.Lookup(_env), catalog, _time);
|
||||
_connections.Add(connection);
|
||||
return connection;
|
||||
}
|
||||
|
||||
private McpStubContext Context(Action<ProcessEnvironmentOptions>? configure = null) => new(Workspace, McpTestEnvironment.Policy(_env, configure));
|
||||
private static JsonElement Args(object value) => JsonSerializer.SerializeToElement(value);
|
||||
private static string TextOf(CallToolResult result) => Assert.IsType<TextContentBlock>(Assert.Single(result.Content)).Text;
|
||||
private int StartPid(int index) => FakeMcp.Starts(LogPath)[index].GetProperty("pid").GetInt32();
|
||||
|
||||
[Fact]
|
||||
public async Task First_call_starts_the_process_through_the_context()
|
||||
{
|
||||
var connection = Connect(FakeMcp.Server("count-starts", LogPath));
|
||||
var context = Context();
|
||||
Assert.Equal(McpConnectionStatus.NotStarted, connection.Status);
|
||||
|
||||
Assert.Equal("echo: hi", TextOf(await connection.CallAsync("echo", Args(new { text = "hi" }), context, CancellationToken.None)));
|
||||
|
||||
var request = Assert.Single(context.Requests);
|
||||
Assert.Equal(ProcessPurpose.McpServer, request.Purpose);
|
||||
Assert.Equal("fake", request.Origin);
|
||||
Assert.Equal(Workspace, request.Cwd);
|
||||
Assert.Equal(Path.GetFullPath(Workspace), Path.GetFullPath(Assert.Single(FakeMcp.Starts(LogPath)).GetProperty("cwd").GetString()!));
|
||||
Assert.Equal(McpConnectionStatus.Running, connection.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Second_call_reuses_the_process()
|
||||
{
|
||||
var connection = Connect(FakeMcp.Server("count-starts", LogPath));
|
||||
await connection.CallAsync("echo", Args(new { text = "1" }), Context(), CancellationToken.None);
|
||||
await connection.CallAsync("echo", Args(new { text = "2" }), Context(), CancellationToken.None);
|
||||
|
||||
Assert.Single(FakeMcp.Starts(LogPath));
|
||||
Assert.Equal(1, connection.StartCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Call_uses_the_original_mcp_name_on_the_wire()
|
||||
{
|
||||
var connection = Connect(FakeMcp.Server());
|
||||
Assert.Equal("called weird.name/x", TextOf(await connection.CallAsync("weird.name/x", Args(new { }), Context(), CancellationToken.None)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cancelling_a_call_sends_notifications_cancelled()
|
||||
{
|
||||
var connection = Connect(FakeMcp.Server("normal", LogPath));
|
||||
await connection.CallAsync("echo", Args(new { text = "warm" }), Context(), CancellationToken.None);
|
||||
|
||||
using var cts = new CancellationTokenSource();
|
||||
var call = connection.CallAsync("slow", Args(new { }), Context(), cts.Token);
|
||||
await Task.Delay(300);
|
||||
cts.Cancel();
|
||||
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => call);
|
||||
await FakeMcp.WaitForAsync(() => FakeMcp.ReadLog(LogPath).Any(e => e.GetProperty("event").GetString() == "cancelled"), "the server to see notifications/cancelled");
|
||||
Assert.Equal("echo: after", TextOf(await connection.CallAsync("echo", Args(new { text = "after" }), Context(), CancellationToken.None)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Call_timeout_names_the_tool_and_the_limit()
|
||||
{
|
||||
var connection = Connect(FakeMcp.Server(configure: c => c.CallTimeoutSeconds = 1));
|
||||
await connection.CallAsync("echo", Args(new { text = "warm" }), Context(), CancellationToken.None);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<TimeoutException>(() => connection.CallAsync("slow", Args(new { }), Context(), CancellationToken.None));
|
||||
Assert.Equal("slow timed out after 1s", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Server_exit_fails_the_next_call_and_the_one_after_restarts()
|
||||
{
|
||||
var connection = Connect(FakeMcp.Server("die-after-first-call,count-starts", LogPath, c => c.Env["FAKE_MCP_DIE_MESSAGE"] = "bye from the fake"));
|
||||
await connection.CallAsync("echo", Args(new { text = "1" }), Context(), CancellationToken.None);
|
||||
var pid = StartPid(0);
|
||||
await FakeMcp.WaitForAsync(() => FakeMcp.HasExited(pid), "the fake to die");
|
||||
|
||||
var ex = await Assert.ThrowsAsync<McpServerExitedException>(() => connection.CallAsync("echo", Args(new { text = "2" }), Context(), CancellationToken.None));
|
||||
Assert.Contains($"fake MCP server exited ({FakeServerProgram.DieExitCode}):", ex.Message, StringComparison.Ordinal);
|
||||
Assert.Contains("dying: bye from the fake", ex.Message, StringComparison.Ordinal);
|
||||
Assert.EndsWith("It restarts on the next call; browser state was lost.", ex.Message, StringComparison.Ordinal);
|
||||
Assert.Equal(McpConnectionStatus.Failed, connection.Status);
|
||||
Assert.Equal(ex.Message, connection.LastError);
|
||||
|
||||
Assert.Equal("echo: 3", TextOf(await connection.CallAsync("echo", Args(new { text = "3" }), Context(), CancellationToken.None)));
|
||||
Assert.Equal(2, FakeMcp.Starts(LogPath).Count);
|
||||
Assert.Equal(McpConnectionStatus.Running, connection.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Idle_timeout_stops_the_process()
|
||||
{
|
||||
var connection = Connect(FakeMcp.Server("count-starts", LogPath, c => c.IdleTimeoutSeconds = 60));
|
||||
await connection.CallAsync("echo", Args(new { text = "1" }), Context(), CancellationToken.None);
|
||||
var pid = StartPid(0);
|
||||
|
||||
_time.Advance(TimeSpan.FromSeconds(59));
|
||||
await Task.Delay(300);
|
||||
Assert.False(FakeMcp.HasExited(pid));
|
||||
|
||||
_time.Advance(TimeSpan.FromSeconds(2));
|
||||
await FakeMcp.WaitForAsync(() => FakeMcp.HasExited(pid), "the idle process to stop");
|
||||
await FakeMcp.WaitForAsync(() => connection.Status == McpConnectionStatus.NotStarted, "the status to reset");
|
||||
|
||||
await connection.CallAsync("echo", Args(new { text = "2" }), Context(), CancellationToken.None);
|
||||
Assert.Equal(2, FakeMcp.Starts(LogPath).Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Zero_idle_timeout_never_stops_the_process()
|
||||
{
|
||||
var connection = Connect(FakeMcp.Server("count-starts", LogPath, c => c.IdleTimeoutSeconds = 0));
|
||||
await connection.CallAsync("echo", Args(new { text = "1" }), Context(), CancellationToken.None);
|
||||
|
||||
_time.Advance(TimeSpan.FromDays(1));
|
||||
await Task.Delay(300);
|
||||
Assert.False(FakeMcp.HasExited(StartPid(0)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Start_failure_is_reported_and_the_next_call_retries()
|
||||
{
|
||||
var connection = Connect(FakeMcp.Server("crash-on-start,count-starts", LogPath));
|
||||
|
||||
var ex = await Assert.ThrowsAsync<McpServerStartException>(() => connection.CallAsync("echo", Args(new { text = "1" }), Context(), CancellationToken.None));
|
||||
Assert.Contains("fake crash on start", ex.Message, StringComparison.Ordinal);
|
||||
Assert.Equal(McpConnectionStatus.Failed, connection.Status);
|
||||
|
||||
await Assert.ThrowsAsync<McpServerStartException>(() => connection.CallAsync("echo", Args(new { text = "2" }), Context(), CancellationToken.None));
|
||||
Assert.Equal(2, FakeMcp.Starts(LogPath).Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Spawn_policy_failure_fails_the_call_with_its_message()
|
||||
{
|
||||
var connection = Connect(FakeMcp.Server());
|
||||
var context = Context(o => o.Hooks.Add(_ => throw new InvalidOperationException("nope")));
|
||||
|
||||
var ex = await Assert.ThrowsAsync<McpServerStartException>(() => connection.CallAsync("echo", Args(new { text = "1" }), context, CancellationToken.None));
|
||||
Assert.Contains("process environment hook failed:", ex.Message, StringComparison.Ordinal);
|
||||
Assert.Contains("nope", ex.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Stop_stops_the_process_and_the_next_call_restarts()
|
||||
{
|
||||
var connection = Connect(FakeMcp.Server("count-starts", LogPath));
|
||||
await connection.CallAsync("echo", Args(new { text = "1" }), Context(), CancellationToken.None);
|
||||
var pid = StartPid(0);
|
||||
|
||||
await connection.StopAsync();
|
||||
await connection.StopAsync();
|
||||
await FakeMcp.WaitForAsync(() => FakeMcp.HasExited(pid), "the process to stop");
|
||||
Assert.Equal(McpConnectionStatus.NotStarted, connection.Status);
|
||||
|
||||
await connection.CallAsync("echo", Args(new { text = "2" }), Context(), CancellationToken.None);
|
||||
Assert.Equal(2, FakeMcp.Starts(LogPath).Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Dispose_is_idempotent_and_kills_the_process()
|
||||
{
|
||||
var connection = Connect(FakeMcp.Server("count-starts", LogPath));
|
||||
await connection.CallAsync("echo", Args(new { text = "1" }), Context(), CancellationToken.None);
|
||||
var pid = StartPid(0);
|
||||
|
||||
await connection.DisposeAsync();
|
||||
await connection.DisposeAsync();
|
||||
|
||||
await FakeMcp.WaitForAsync(() => FakeMcp.HasExited(pid), "the process to be killed");
|
||||
await Assert.ThrowsAsync<ObjectDisposedException>(() => connection.CallAsync("echo", Args(new { text = "2" }), Context(), CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Changed_tool_list_is_logged_as_a_warning()
|
||||
{
|
||||
var catalog = new McpCatalog("fake", "fake-mcp", "1.2.3", [new McpCatalogTool("echo", "echo", "Echoes", null, Args(new { type = "object" }))]);
|
||||
var connection = Connect(FakeMcp.Server(), catalog);
|
||||
await connection.CallAsync("echo", Args(new { text = "1" }), Context(), CancellationToken.None);
|
||||
|
||||
Assert.True(_logger.Has(LogLevel.Warning, "changed since discovery"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
using System.Text.Json;
|
||||
using Volcanic.AgenticCore.Abstractions.Messages;
|
||||
using Volcanic.AgenticCore.Abstractions.Tools;
|
||||
using Volcanic.AgenticCore.Extensions.Mcp;
|
||||
using Volcanic.AgenticCore.Extensions.Tests.FakeMcpServer;
|
||||
using Volcanic.AgenticCore.Extensions.Tests.Mcp.Support;
|
||||
|
||||
namespace Volcanic.AgenticCore.Extensions.Tests.Mcp;
|
||||
|
||||
public class McpToolTests : IAsyncLifetime
|
||||
{
|
||||
private readonly string _dir = Path.Combine(Path.GetTempPath(), "volcanic-mcp-tool", Guid.NewGuid().ToString("N"));
|
||||
private readonly Dictionary<string, string?> _env = McpTestEnvironment.Snapshot();
|
||||
private McpServerConnection _connection = null!;
|
||||
|
||||
public Task InitializeAsync()
|
||||
{
|
||||
Directory.CreateDirectory(_dir);
|
||||
_connection = new McpServerConnection("fake", FakeMcp.Server(), new RecordingLogger(), McpTestEnvironment.Lookup(_env));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
await _connection.DisposeAsync();
|
||||
try { Directory.Delete(_dir, true); } catch (IOException) { }
|
||||
}
|
||||
|
||||
private static JsonElement Json(string json) => JsonSerializer.Deserialize<JsonElement>(json);
|
||||
private static McpCatalogTool Entry(string name = "echo", string? description = "Echoes text back", string? title = null, JsonElement schema = default) =>
|
||||
new(name, "exp_" + name, description, title, schema.ValueKind == JsonValueKind.Undefined ? Json(FakeServerProgram.EchoSchema) : schema);
|
||||
|
||||
private Task<ToolResult> Run(McpTool tool, object args) =>
|
||||
tool.ExecuteAsync(new ToolCallContent("c1", tool.Name, JsonSerializer.SerializeToElement(args)),
|
||||
new ToolInvocation("c1", new McpStubContext(_dir, McpTestEnvironment.Policy(_env)), null), CancellationToken.None);
|
||||
|
||||
[Fact]
|
||||
public void Exposes_name_description_and_schema_verbatim()
|
||||
{
|
||||
var tool = new McpTool(Entry(), _connection, parallel: false);
|
||||
Assert.Equal("exp_echo", tool.Name);
|
||||
Assert.Equal("Echoes text back", tool.Description);
|
||||
Assert.True(JsonElement.DeepEquals(Json(FakeServerProgram.EchoSchema), tool.Parameters));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Description_falls_back_to_title_then_name()
|
||||
{
|
||||
Assert.Equal("Echo tool", new McpTool(Entry(description: null, title: "Echo tool"), _connection, false).Description);
|
||||
Assert.Equal("echo", new McpTool(Entry(description: " ", title: null), _connection, false).Description);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Missing_or_non_object_schema_becomes_an_object_schema()
|
||||
{
|
||||
var missing = new McpTool(new McpCatalogTool("x", "x", "d", null, default), _connection, false);
|
||||
var boolean = new McpTool(Entry(schema: Json("true")), _connection, false);
|
||||
Assert.True(JsonElement.DeepEquals(Json("""{"type":"object"}"""), missing.Parameters));
|
||||
Assert.True(JsonElement.DeepEquals(Json("""{"type":"object"}"""), boolean.Parameters));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Top_level_dollar_schema_is_dropped()
|
||||
{
|
||||
var tool = new McpTool(Entry(schema: Json("""{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"a":{"type":"string"}}}""")), _connection, false);
|
||||
Assert.False(tool.Parameters.TryGetProperty("$schema", out _));
|
||||
Assert.Equal("string", tool.Parameters.GetProperty("properties").GetProperty("a").GetProperty("type").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tools_are_sequential_unless_the_server_is_parallel_and_have_no_prompt_snippet()
|
||||
{
|
||||
ITool sequential = new McpTool(Entry(), _connection, parallel: false);
|
||||
ITool parallel = new McpTool(Entry(), _connection, parallel: true);
|
||||
Assert.Equal(ToolExecutionMode.Sequential, sequential.ExecutionMode);
|
||||
Assert.Equal(ToolExecutionMode.Parallel, parallel.ExecutionMode);
|
||||
Assert.Null(sequential.PromptSnippet);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Image_tool_returns_image_content()
|
||||
{
|
||||
var result = await Run(new McpTool(Entry("image"), _connection, false), new { });
|
||||
var image = Assert.IsType<ImageContent>(Assert.Single(result.Content));
|
||||
Assert.Equal(new ImageContent(FakeServerProgram.Png1x1, "image/png"), image);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Fail_tool_throws_with_the_server_content()
|
||||
{
|
||||
var ex = await Assert.ThrowsAsync<ToolExecutionException>(() => Run(new McpTool(Entry("fail"), _connection, false), new { }));
|
||||
Assert.Equal("fake failure: nothing works", Assert.IsType<TextContent>(ex.Result.Content[0]).Text);
|
||||
Assert.IsType<ImageContent>(ex.Result.Content[1]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Volcanic.AgenticCore.Abstractions.Extensions;
|
||||
using Volcanic.AgenticCore.Abstractions.Processes;
|
||||
using Volcanic.AgenticCore.Abstractions.Providers;
|
||||
using Volcanic.AgenticCore.Abstractions.Sessions;
|
||||
using Volcanic.AgenticCore.Abstractions.Ui;
|
||||
using Volcanic.AgenticCore.Hosting;
|
||||
|
||||
namespace Volcanic.AgenticCore.Extensions.Tests.Mcp.Support;
|
||||
|
||||
/// <summary>A session context outside a session: <see cref="PrepareProcessAsync"/> fills scope, id and zone like the core, then runs the policy.</summary>
|
||||
internal sealed class McpStubContext(string cwd, IProcessSpawnPolicy policy) : IExtensionContext
|
||||
{
|
||||
public string Cwd { get; } = cwd;
|
||||
public string SessionId => "mcp-test";
|
||||
public bool HasUi => false;
|
||||
public IUserInterface Ui => NullUserInterface.Instance;
|
||||
public IReadOnlySessionManager SessionManager => throw new NotSupportedException();
|
||||
public IModelRegistry ModelRegistry => throw new NotSupportedException();
|
||||
public Model? Model => null;
|
||||
public ThinkingLevel ThinkingLevel => ThinkingLevel.Off;
|
||||
public TimeZoneInfo TimeZone { get; init; } = TimeZoneInfo.Utc;
|
||||
public CancellationToken Signal => CancellationToken.None;
|
||||
public ILogger Logger => NullLogger.Instance;
|
||||
public bool IsIdle() => true;
|
||||
public void Abort() { }
|
||||
public bool HasPendingMessages() => false;
|
||||
public ContextUsage? GetContextUsage() => null;
|
||||
public string GetSystemPrompt() => "";
|
||||
public void Compact(CompactOptions? options = null) { }
|
||||
|
||||
public List<ProcessSpawnRequest> Requests { get; } = [];
|
||||
|
||||
public ValueTask<ProcessSpawnContext> PrepareProcessAsync(ProcessSpawnRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var filled = request with { Scope = "mcp-test", SessionId = SessionId, TimeZoneId = request.TimeZoneId ?? TimeZoneIds.ToIana(TimeZone) };
|
||||
lock (Requests) Requests.Add(filled);
|
||||
return ValueTask.FromResult(policy.Prepare(filled));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user