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:
@@ -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