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:
EugeneTes
2026-09-26 20:49:21 +02:00
parent 83ad683bd1
commit 2c5d5dfcb4
5 changed files with 597 additions and 5 deletions

View File

@@ -172,8 +172,9 @@ callTimeoutSeconds: 120, startupTimeoutSeconds: 120 (the first npx run downloads
process environment hook, which keeps `PLAYWRIGHT_BROWSERS_PATH`, so it applies. In the container that's `/opt/ms-playwright`, set in the image. A missing browser
shows up as an error result from the first `browser_*` call (the MCP server itself starts fine), and the model
reports it.
- `PromptSection` (added through `BeforeAgentStartEvent` → `SystemPromptOptions.Sections["browser"]` while any
`browser_*` tool is active):
- `PromptSection` (added through `BeforeAgentStartEvent` → `SystemPromptOptions.Sections["browser"]` while any of
the server's registered tools is active (for the preset, the `browser_*` tools); a server's `promptSection` works
the same way under its own name):
```
## Browser
@@ -221,7 +222,8 @@ provider's aliasing:
- The MCP server is always called with the original `mcpName`.
- Collisions with other tools follow the existing `ToolRegistry` rules. An MCP tool named like a built-in
(`read`, `bash`, ...) would **replace** it, so `McpExtension` refuses to register an exposed name that
`api.GetAllTools()` already reports with source `built-in`. It logs a warning suggesting a `toolPrefix`. A clash
`api.GetAllTools()` already reports with source `built-in`. It logs a warning suggesting a `toolPrefix`. The
built-in check runs against `GetAllTools()` at activation, so it covers every built-in, active or not. A clash
with another extension's tool is already ignored and recorded by the runner. Between two MCP servers, the first
configured wins, and a warning names both.
- Through the Claude Code provider these tools reach the model as `mcp__volcanic__<exposedName>`. Nothing else is
@@ -302,8 +304,11 @@ One per server per extension instance (that is, per session):
### 9. The command — `/mcp`
Lists each configured server for this session: state (`not started` / `running pid N` / `failed: <reason>` /
`disabled`), number of tools, last error, and the stderr tail with `/mcp <name>`. It costs no model call.
Lists each configured server for this session with its state and number of tools. It costs no model call. States:
`not started` / `running` / `failed: <reason>` / `disabled` (the preset adds `(set PLAYWRIGHT_ENABLED=true)`). The SDK
doesn't expose a live server's pid, so `/mcp` shows none. `/mcp <name>` shows the unexpanded command, the names (never
the values) of its `env` entries, server info, tools, the last error and the stderr tail. With a UI the output is a
notification; without one it is a `mcp` custom message appended without a turn.
`/mcp restart <name>` disposes the session process so the next call starts fresh (clears a wedged browser).
### 10. Host wiring (Telegram)

View 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 &lt;name&gt;</c> details, <c>/mcp restart &lt;name&gt;</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];
}

View 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; } = [];
}

View File

@@ -0,0 +1,284 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Volcanic.AgenticCore.Abstractions.Extensions;
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;
using Volcanic.AgenticCore.Features.Agent;
using Volcanic.AgenticCore.Testing;
namespace Volcanic.AgenticCore.Extensions.Tests.Mcp;
public class McpExtensionTests
{
private sealed class ToolExtension(ITool tool) : IExtension
{
public string Name => "extra-tools";
public ValueTask ActivateAsync(IExtensionApi api, CancellationToken cancellationToken) { api.RegisterTool(tool); return ValueTask.CompletedTask; }
}
public sealed record SendArgs(string Path);
private static McpOptions With(params (string Name, McpServerConfig Config)[] servers)
{
var options = new McpOptions();
foreach (var (name, config) in servers) options.Servers[name] = config;
return options;
}
private static McpOptions PresetOnFake() => new() { Playwright = new PlaywrightOptions { Enabled = true, Command = FakeMcp.AppHostPath } };
private static string LastToolText(AgentSession session) => session.Messages.OfType<ToolResultMessage>().Last().Text;
private static string LastCustomText(AgentSession session) => session.Messages.OfType<CustomMessage>().Last().Text;
private static int Pid(string log, int index) => FakeMcp.Starts(log)[index].GetProperty("pid").GetInt32();
[Fact]
public async Task Activation_registers_one_tool_per_mcp_tool()
{
await using var host = new McpTestHost(With(("fake", FakeMcp.Server())));
var session = await host.CreateSessionAsync();
foreach (var name in new[] { "echo", "image", "fail", "slow", "weird_name_x", "weird_name_x_2", "noschema", FakeServerProgram.LongToolName[..48] })
Assert.Contains(name, session.ActiveTools);
Assert.Empty(session.ExtensionErrors);
}
[Fact]
public async Task Activation_starts_no_session_process()
{
await using var host = new McpTestHost(h => With(("fake", FakeMcp.Server("count-starts", h.LogPath))));
await host.CreateSessionAsync();
var start = Assert.Single(FakeMcp.Starts(host.LogPath));
Assert.Contains("volcanic-mcp-discovery", start.GetProperty("cwd").GetString()!, StringComparison.Ordinal);
}
[Fact]
public async Task First_call_starts_the_process_in_the_workspace_with_expanded_args()
{
await using var host = new McpTestHost(h => With(("fake", FakeMcp.Server("count-starts", h.LogPath, c => c.Args.AddRange(["--marker", "${workspace}/m"])))));
host.Faux.Enqueue(FauxTurn.Tool("echo", new { text = "hi" }), FauxTurn.Say("done"));
var session = await host.CreateSessionAsync();
await session.PromptAsync("go");
Assert.Equal("echo: hi", LastToolText(session));
var starts = FakeMcp.Starts(host.LogPath);
Assert.Equal(2, starts.Count);
Assert.Equal(Path.GetFullPath(host.Workspace), Path.GetFullPath(starts[1].GetProperty("cwd").GetString()!));
Assert.Contains($"{host.Workspace}/m", starts[1].GetProperty("args").EnumerateArray().Select(a => a.GetString()));
}
[Fact]
public async Task Concurrent_sessions_share_one_discovery()
{
await using var host = new McpTestHost(h => With(("fake", FakeMcp.Server("count-starts", h.LogPath))));
await Task.WhenAll(host.CreateSessionAsync(), host.CreateSessionAsync(), host.CreateSessionAsync());
Assert.Single(FakeMcp.Starts(host.LogPath));
}
[Fact]
public async Task Session_activates_with_the_other_servers_when_one_hangs()
{
await using var host = new McpTestHost(With(
("hang", FakeMcp.Server("hang-on-start", configure: c => c.StartupTimeoutSeconds = 1)),
("ok", FakeMcp.Server())));
var session = await host.CreateSessionAsync();
Assert.Contains("echo", session.ActiveTools);
Assert.Empty(session.ExtensionErrors);
await session.PromptAsync("/mcp");
var report = LastCustomText(session);
Assert.Contains("- hang: failed: hang MCP server did not start within 1s", report, StringComparison.Ordinal);
Assert.Contains("- ok: not started · 8 tools", report, StringComparison.Ordinal);
}
[Fact]
public async Task Tool_named_like_a_builtin_is_refused_and_the_builtin_stays()
{
await using var host = new McpTestHost(With(("clash", FakeMcp.Server("builtin-clash"))));
File.WriteAllText(Path.Combine(host.Workspace, "x.txt"), "hello from the built-in read");
host.Faux.Enqueue(FauxTurn.Tool("read", new { path = "x.txt" }), FauxTurn.Say("done"));
var session = await host.CreateSessionAsync();
await session.PromptAsync("go");
var text = LastToolText(session);
Assert.Contains("hello from the built-in read", text, StringComparison.Ordinal);
Assert.DoesNotContain("called read", text, StringComparison.Ordinal);
Assert.True(host.Logs.Logger.Has(LogLevel.Warning, "toolPrefix"));
}
[Fact]
public async Task Same_tool_on_two_servers_the_first_configured_wins()
{
var logA = Path.Combine(Path.GetTempPath(), $"volcanic-mcp-a-{Guid.NewGuid():N}.log");
var logB = Path.Combine(Path.GetTempPath(), $"volcanic-mcp-b-{Guid.NewGuid():N}.log");
try
{
await using var host = new McpTestHost(With(("a", FakeMcp.Server("count-starts", logA)), ("b", FakeMcp.Server("count-starts", logB))));
host.Faux.Enqueue(FauxTurn.Tool("echo", new { text = "who" }), FauxTurn.Say("done"));
var session = await host.CreateSessionAsync();
await session.PromptAsync("go");
Assert.Equal(2, FakeMcp.Starts(logA).Count); // discovery + the session process that served echo
Assert.Single(FakeMcp.Starts(logB)); // discovery only
Assert.Contains(host.Logs.Logger.Messages(LogLevel.Warning), m => m.Contains("echo", StringComparison.Ordinal) && m.Contains("a and b", StringComparison.Ordinal));
}
finally
{
File.Delete(logA);
File.Delete(logB);
}
}
[Fact]
public async Task Browser_section_appears_only_while_the_preset_tools_are_active()
{
await using var host = new McpTestHost(PresetOnFake());
host.Faux.Enqueue(FauxTurn.Say("one"), FauxTurn.Say("two"));
var session = await host.CreateSessionAsync();
Assert.Contains("browser_navigate", session.ActiveTools);
await session.PromptAsync("first");
Assert.Contains("## Browser", host.Faux.Requests[0].SystemPrompt, StringComparison.Ordinal);
session.SetActiveTools(["read", "bash", "edit", "write"]);
await session.PromptAsync("second");
Assert.DoesNotContain("## Browser", host.Faux.Requests[1].SystemPrompt, StringComparison.Ordinal);
}
[Fact]
public async Task Browser_section_mentions_send_only_when_a_send_tool_exists()
{
var send = new DelegateTool<SendArgs>("send", "Send a workspace file", (_, _, _) => Task.FromResult(ToolResult.Text("sent")));
await using (var withSend = new McpTestHost(PresetOnFake(), extra: [new ToolExtension(send)]))
{
withSend.Faux.Enqueue(FauxTurn.Say("ok"));
await (await withSend.CreateSessionAsync()).PromptAsync("hi");
Assert.Contains("call `send` with that", withSend.Faux.Requests[0].SystemPrompt, StringComparison.Ordinal);
}
await using var without = new McpTestHost(PresetOnFake());
without.Faux.Enqueue(FauxTurn.Say("ok"));
await (await without.CreateSessionAsync()).PromptAsync("hi");
Assert.Contains("## Browser", without.Faux.Requests[0].SystemPrompt, StringComparison.Ordinal);
Assert.DoesNotContain("`send`", without.Faux.Requests[0].SystemPrompt, StringComparison.Ordinal);
}
[Fact]
public async Task Generic_prompt_section_is_added_while_its_tools_are_active()
{
await using var host = new McpTestHost(With(("fake", FakeMcp.Server(configure: c => c.PromptSection = "Use the fake tools carefully."))));
host.Faux.Enqueue(FauxTurn.Say("ok"));
await (await host.CreateSessionAsync()).PromptAsync("hi");
Assert.Contains("Use the fake tools carefully.", host.Faux.Requests[0].SystemPrompt, StringComparison.Ordinal);
}
[Fact]
public async Task New_session_stops_the_process_and_the_next_call_starts_a_fresh_one()
{
await using var host = new McpTestHost(h => With(("fake", FakeMcp.Server("count-starts", h.LogPath))));
host.Faux.Enqueue(FauxTurn.Tool("echo", new { text = "1" }), FauxTurn.Say("a"), FauxTurn.Tool("echo", new { text = "2" }), FauxTurn.Say("b"));
var session = await host.CreateSessionAsync();
await session.PromptAsync("first");
var first = Pid(host.LogPath, 1);
await session.NewSessionAsync();
await FakeMcp.WaitForAsync(() => FakeMcp.HasExited(first), "the old session's process to stop");
await session.PromptAsync("second");
Assert.Equal("echo: 2", LastToolText(session));
Assert.Equal(3, FakeMcp.Starts(host.LogPath).Count);
Assert.NotEqual(first, Pid(host.LogPath, 2));
}
[Fact]
public async Task Disposing_the_session_kills_the_process()
{
await using var host = new McpTestHost(h => With(("fake", FakeMcp.Server("count-starts", h.LogPath))));
host.Faux.Enqueue(FauxTurn.Tool("echo", new { text = "1" }), FauxTurn.Say("a"));
var session = await host.CreateSessionAsync();
await session.PromptAsync("go");
var pid = Pid(host.LogPath, 1);
await session.DisposeAsync();
await FakeMcp.WaitForAsync(() => FakeMcp.HasExited(pid), "the process to be killed");
}
[Fact]
public async Task Mcp_command_lists_servers_without_a_model_call()
{
await using var host = new McpTestHost(With(("fake", FakeMcp.Server())));
var session = await host.CreateSessionAsync();
await session.PromptAsync("/mcp");
Assert.Empty(host.Faux.Requests);
var report = LastCustomText(session);
Assert.Contains("MCP servers:", report, StringComparison.Ordinal);
Assert.Contains("- fake: not started · 8 tools", report, StringComparison.Ordinal);
Assert.Contains("- playwright: disabled (set PLAYWRIGHT_ENABLED=true)", report, StringComparison.Ordinal);
}
[Fact]
public async Task Mcp_command_shows_details_and_the_stderr_tail_for_one_server()
{
await using var host = new McpTestHost(h => With(("fake", FakeMcp.Server("die-after-first-call,count-starts", h.LogPath, c =>
{
c.Env["FAKE_MCP_DIE_MESSAGE"] = "details please";
c.Env["API_THING"] = "value-not-shown";
}))));
host.Faux.Enqueue(FauxTurn.Tool("echo", new { text = "1" }), FauxTurn.Say("a"));
var session = await host.CreateSessionAsync();
await session.PromptAsync("go");
var pid = Pid(host.LogPath, 1);
await FakeMcp.WaitForAsync(() => FakeMcp.HasExited(pid), "the fake to die");
host.Faux.Enqueue(FauxTurn.Tool("echo", new { text = "2" }), FauxTurn.Say("b"));
await session.PromptAsync("again");
await session.PromptAsync("/mcp fake");
var details = LastCustomText(session);
Assert.Contains("fake", details, StringComparison.Ordinal);
Assert.Contains("failed:", details, StringComparison.Ordinal);
Assert.Contains("dying: details please", details, StringComparison.Ordinal);
Assert.Contains("Tools: echo", details, StringComparison.Ordinal);
Assert.Contains("API_THING", details, StringComparison.Ordinal);
Assert.DoesNotContain("value-not-shown", details, StringComparison.Ordinal);
}
[Fact]
public async Task Mcp_restart_stops_the_process()
{
await using var host = new McpTestHost(h => With(("fake", FakeMcp.Server("count-starts", h.LogPath))));
host.Faux.Enqueue(FauxTurn.Tool("echo", new { text = "1" }), FauxTurn.Say("a"));
var session = await host.CreateSessionAsync();
await session.PromptAsync("go");
var pid = Pid(host.LogPath, 1);
await session.PromptAsync("/mcp restart fake");
Assert.Equal("Stopped fake; the next call starts a fresh process.", LastCustomText(session));
await FakeMcp.WaitForAsync(() => FakeMcp.HasExited(pid), "the process to stop");
}
[Fact]
public void ActivatorUtilities_uses_the_options_constructor_when_options_are_registered()
{
var options = new McpOptions { Servers = { ["x"] = new McpServerConfig { Command = "x" } } };
using var provider = new ServiceCollection().AddSingleton(options).BuildServiceProvider();
var extension = (McpExtension)ActivatorUtilities.CreateInstance(provider, typeof(McpExtension));
Assert.Same(options, extension.Options);
}
[Fact]
public void ActivatorUtilities_falls_back_to_the_parameterless_constructor()
{
using var provider = new ServiceCollection().BuildServiceProvider();
var extension = (McpExtension)ActivatorUtilities.CreateInstance(provider, typeof(McpExtension));
Assert.Empty(extension.Options.Servers);
}
}

View File

@@ -0,0 +1,82 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Time.Testing;
using Volcanic.AgenticCore.Abstractions.Extensions;
using Volcanic.AgenticCore.Abstractions.Processes;
using Volcanic.AgenticCore.Extensions.Mcp;
using Volcanic.AgenticCore.Features.Agent;
using Volcanic.AgenticCore.Features.Extensions;
using Volcanic.AgenticCore.Features.Processes;
using Volcanic.AgenticCore.Features.Sessions;
using Volcanic.AgenticCore.Hosting;
using Volcanic.AgenticCore.Testing;
namespace Volcanic.AgenticCore.Extensions.Tests.Mcp.Support;
/// <summary>
/// A core host with the FauxProvider and the MCP extension over an injected host environment: the policy and the
/// extension's <c>${env:…}</c> lookup both read <see cref="HostEnvironment"/>, and the catalogue cache is private to the host.
/// </summary>
internal sealed class McpTestHost : IAsyncDisposable
{
private readonly ServiceProvider _services;
private readonly string _root = Path.Combine(Path.GetTempPath(), "volcanic-mcp-host", Guid.NewGuid().ToString("N"));
private readonly List<AgentSession> _sessions = [];
public McpTestHost(McpOptions options, Action<Dictionary<string, string?>>? environment = null, IEnumerable<string>? secretValues = null, params IExtension[] extra)
: this(_ => options, environment, secretValues, extra) { }
/// <summary><paramref name="options"/> runs once the host's paths exist, so configs can point at <see cref="LogPath"/>.</summary>
public McpTestHost(Func<McpTestHost, McpOptions> options, Action<Dictionary<string, string?>>? environment = null, IEnumerable<string>? secretValues = null, params IExtension[] extra)
{
Directory.CreateDirectory(Workspace);
Directory.CreateDirectory(AgentDir);
HostEnvironment = McpTestEnvironment.Snapshot(("VOLCANIC_AGENT_DIR", AgentDir));
environment?.Invoke(HostEnvironment);
var cache = new McpCatalogCache(Time);
var lookup = McpTestEnvironment.Lookup(HostEnvironment);
var processOptions = new ProcessEnvironmentOptions { HardenHostProcess = false };
foreach (var secret in secretValues ?? []) processOptions.SecretValues.Add(secret);
var services = new ServiceCollection();
services.AddSingleton<ILoggerFactory>(Logs);
services.AddSingleton<IProcessSpawnPolicy>(new ProcessSpawnPolicy(processOptions, null, () => HostEnvironment));
services.AddAgenticCore(o =>
{
o.AgentDir = AgentDir;
o.DiscoverExtensionAssemblies = false;
o.DefaultModel = FauxProvider.ModelKey;
o.ProcessEnvironment = processOptions;
o.SessionStoreFactory = _ => new InMemorySessionStore();
o.Extensions.Add(ExtensionRegistration.FromInstance(Faux.Extension));
var mcpOptions = options(this);
o.Extensions.Add(new ExtensionRegistration("mcp", _ => new McpExtension(mcpOptions, cache, lookup, Time), "test"));
foreach (var e in extra) o.Extensions.Add(ExtensionRegistration.FromInstance(e));
});
_services = services.BuildServiceProvider();
}
public FauxProvider Faux { get; } = new();
public FakeTimeProvider Time { get; } = new();
public RecordingLoggerFactory Logs { get; } = new();
public Dictionary<string, string?> HostEnvironment { get; }
public string Workspace => Path.Combine(_root, "ws");
public string AgentDir => Path.Combine(_root, "agent");
public string LogPath => Path.Combine(_root, "fake.log");
public async Task<AgentSession> CreateSessionAsync(TimeZoneInfo? zone = null)
{
var session = await _services.GetRequiredService<IAgentSessionFactory>()
.CreateAsync(new AgentSessionOptions { Cwd = Workspace, TimeZone = zone });
lock (_sessions) _sessions.Add(session);
return session;
}
public async ValueTask DisposeAsync()
{
foreach (var session in _sessions) await session.DisposeAsync();
await _services.DisposeAsync();
try { Directory.Delete(_root, true); } catch (IOException) { } catch (UnauthorizedAccessException) { }
}
}