forked from admin/VolcanicAgenticCore
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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) { }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user