diff --git a/VolcanicAgenticCore.slnx b/VolcanicAgenticCore.slnx index 47dd7b2..233cedd 100644 --- a/VolcanicAgenticCore.slnx +++ b/VolcanicAgenticCore.slnx @@ -18,6 +18,7 @@ + diff --git a/docs/superpowers/specs/2026-09-26-mcp-client-playwright-design.md b/docs/superpowers/specs/2026-09-26-mcp-client-playwright-design.md index 81c0b56..643f1d7 100644 --- a/docs/superpowers/specs/2026-09-26-mcp-client-playwright-design.md +++ b/docs/superpowers/specs/2026-09-26-mcp-client-playwright-design.md @@ -319,15 +319,21 @@ At startup the log reads `MCP: playwright enabled (headless, chromium)` or No test touches the network, npm or a real browser. - **Fake stdio server** `tests/Volcanic.AgenticCore.Extensions.Tests.FakeMcpServer`: a small console app built - with the SDK's server package and launched with `dotnet `. Its behaviour is chosen by env var - `FAKE_MCP_MODE`: - - `normal`: `echo`, `image` (1×1 PNG), `fail` (returns `isError`), `slow` (awaits cancellation), `weird.name/x`, and - a tool with no `inputSchema`; + with the SDK's server API (in `ModelContextProtocol.Core`, no hosting package) and launched with `dotnet `. + Its behaviour is chosen by env var `FAKE_MCP_MODE` (comma-separated, so modes combine): + - `normal`: `echo`, `image` (1×1 PNG), `fail` (returns `isError`), `slow` (awaits cancellation and logs + `{"event":"cancelled"}` to `$FAKE_MCP_LOG` when cancelled), `weird.name/x`, `weird_name_x` (collides with + `weird.name/x` after sanitising), a tool whose name is longer than 48 characters, and a tool with no `inputSchema`; - `crash-on-start`: writes a traceback to stderr and exits 1; - `hang-on-start`: never answers `initialize`; - - `die-after-first-call`: exits after one call; + - `die-after-first-call`: about 200 ms after answering the first call, writes `dying: $FAKE_MCP_DIE_MESSAGE` to + stderr and exits 3; - `env`: adds a tool that returns the server's environment as text; - - `count-starts`: appends a line to `$FAKE_MCP_LOG` per start, to count spawns. + - `builtin-clash`: adds a tool named `read`; + - `count-starts`: appends a line to `$FAKE_MCP_LOG` per start, to count spawns, with the pid, cwd, args and (with + `env`) the environment. + - With `--output-dir` in its argv it also offers `browser_navigate` and `browser_take_screenshot` (a `filename` + is written relative to its cwd), so the Playwright preset can run against the fake. - **Tests** (`tests/Volcanic.AgenticCore.Extensions.Tests/Mcp/`): - catalogue: names, descriptions and schemas pass through verbatim; the MCP name is used on the wire; a prefix is applied; `weird.name/x` → `weird_name_x`; >48 chars truncated; duplicates suffixed; a missing schema becomes diff --git a/tests/Volcanic.AgenticCore.Extensions.Tests.FakeMcpServer/FakeServerProgram.cs b/tests/Volcanic.AgenticCore.Extensions.Tests.FakeMcpServer/FakeServerProgram.cs new file mode 100644 index 0000000..601353e --- /dev/null +++ b/tests/Volcanic.AgenticCore.Extensions.Tests.FakeMcpServer/FakeServerProgram.cs @@ -0,0 +1,153 @@ +using System.Collections; +using System.Text; +using System.Text.Json; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; + +namespace Volcanic.AgenticCore.Extensions.Tests.FakeMcpServer; + +/// Test MCP stdio server. FAKE_MCP_MODE (comma-separated) selects behaviour; FAKE_MCP_LOG receives JSON lines. +public static class FakeServerProgram +{ + public const string LongToolName = "a_tool_whose_name_is_much_longer_than_forty_eight_characters"; + public const string Png1x1 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="; + public const string EchoSchema = """{"type":"object","properties":{"text":{"type":"string","description":"Text to echo"}},"required":["text"]}"""; + public const int DieExitCode = 3; + + public static async Task Main(string[] args) + { + var modes = (Environment.GetEnvironmentVariable("FAKE_MCP_MODE") is { Length: > 0 } m ? m : "normal") + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .ToHashSet(StringComparer.Ordinal); + var log = Environment.GetEnvironmentVariable("FAKE_MCP_LOG"); + if (modes.Contains("count-starts")) + Log(log, new { @event = "start", pid = Environment.ProcessId, cwd = Directory.GetCurrentDirectory(), args, env = modes.Contains("env") ? Snapshot() : null }); + + if (modes.Contains("crash-on-start")) + { + await Console.Error.WriteLineAsync("Traceback (most recent call last):"); + await Console.Error.WriteLineAsync(" File \"fake_server.py\", line 1, in "); + await Console.Error.WriteLineAsync("RuntimeError: fake crash on start"); + return 1; + } + if (modes.Contains("hang-on-start")) + { + await Console.In.ReadToEndAsync(); // swallow initialize, never answer; exit once the client closes stdin + return 0; + } + + var playwrightLike = args.Contains("--output-dir"); + var calls = 0; + var options = new McpServerOptions + { + ServerInfo = new Implementation { Name = "fake-mcp", Version = "1.2.3" }, + Capabilities = new ServerCapabilities { Tools = new ToolsCapability() }, + Handlers = new McpServerHandlers + { + ListToolsHandler = (_, _) => ValueTask.FromResult(new ListToolsResult { Tools = Tools(modes, playwrightLike) }), + CallToolHandler = async (request, ct) => + { + var result = await CallAsync(request.Params!, log, ct); + if (modes.Contains("die-after-first-call") && Interlocked.Increment(ref calls) == 1) _ = DieSoonAsync(); + return result; + }, + }, + }; + await using var server = McpServer.Create(new StdioServerTransport(options), options); + await server.RunAsync(); + return 0; + } + + private static List Tools(HashSet modes, bool playwrightLike) + { + var tools = new List + { + Make("echo", "Echoes text back", EchoSchema), + Make("image", "Returns a 1x1 PNG"), + Make("fail", "Always fails"), + Make("slow", "Waits until cancelled"), + Make("weird.name/x", "Has an awkward name"), + Make("weird_name_x", "Collides with weird.name/x after sanitising"), + new() { Name = "noschema", Description = "Has no input schema" }, + Make(LongToolName, "Has a long name"), + }; + if (modes.Contains("env")) tools.Add(Make("env", "Returns the server's environment")); + if (modes.Contains("builtin-clash")) tools.Add(Make("read", "Clashes with the built-in read tool")); + if (playwrightLike) + { + tools.Add(Make("browser_navigate", "Navigate to a URL", """{"type":"object","properties":{"url":{"type":"string"}},"required":["url"]}""")); + tools.Add(Make("browser_take_screenshot", "Take a screenshot", """{"type":"object","properties":{"filename":{"type":"string"}}}""")); + } + return tools; + } + + private static Tool Make(string name, string description, string schema = """{"type":"object","properties":{}}""") => + new() { Name = name, Description = description, InputSchema = JsonSerializer.Deserialize(schema) }; + + private static async ValueTask CallAsync(CallToolRequestParams call, string? log, CancellationToken ct) + { + string Arg(string name) => + call.Arguments is { } a && a.TryGetValue(name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString()! : ""; + switch (call.Name) + { + case "echo": + return Text($"echo: {Arg("text")}"); + case "image": + return new CallToolResult { Content = [Image()] }; + case "fail": + return new CallToolResult { IsError = true, Content = [new TextContentBlock { Text = "fake failure: nothing works" }, Image()] }; + case "slow": + try { await Task.Delay(Timeout.Infinite, ct); } + catch (OperationCanceledException) { Log(log, new { @event = "cancelled", tool = "slow" }); throw; } + return Text("slow finished"); + case "env": + return Text(string.Join('\n', Snapshot().OrderBy(kv => kv.Key, StringComparer.Ordinal).Select(kv => $"{kv.Key}={kv.Value}"))); + case "browser_navigate": + return Text($"navigated to {Arg("url")}"); + case "browser_take_screenshot": + var filename = Arg("filename"); + if (filename.Length == 0) return new CallToolResult { Content = [Image()] }; + var path = Path.GetFullPath(filename, Directory.GetCurrentDirectory()); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + await File.WriteAllBytesAsync(path, Convert.FromBase64String(Png1x1), ct); + return Text($"saved {filename}"); + default: + return Text($"called {call.Name}"); + } + } + + private static CallToolResult Text(string text) => new() { Content = [new TextContentBlock { Text = text }] }; + + private static ImageContentBlock Image() => new() { Data = Encoding.UTF8.GetBytes(Png1x1), MimeType = "image/png" }; + + private static Dictionary Snapshot() + { + var env = new Dictionary(StringComparer.Ordinal); + foreach (DictionaryEntry e in Environment.GetEnvironmentVariables()) env[(string)e.Key] = (string?)e.Value; + return env; + } + + private static async Task DieSoonAsync() + { + await Task.Delay(200); + await Console.Error.WriteLineAsync($"dying: {Environment.GetEnvironmentVariable("FAKE_MCP_DIE_MESSAGE")}"); + await Console.Error.FlushAsync(); + Environment.Exit(DieExitCode); + } + + private static void Log(string? path, object entry) + { + if (string.IsNullOrEmpty(path)) return; + var bytes = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(entry) + "\n"); + for (var attempt = 0; ; attempt++) + { + try + { + using var stream = new FileStream(path, FileMode.Append, FileAccess.Write, FileShare.ReadWrite); + stream.Write(bytes); + return; + } + catch (IOException) when (attempt < 20) { Thread.Sleep(25); } + } + } +} diff --git a/tests/Volcanic.AgenticCore.Extensions.Tests.FakeMcpServer/Volcanic.AgenticCore.Extensions.Tests.FakeMcpServer.csproj b/tests/Volcanic.AgenticCore.Extensions.Tests.FakeMcpServer/Volcanic.AgenticCore.Extensions.Tests.FakeMcpServer.csproj new file mode 100644 index 0000000..8066bd0 --- /dev/null +++ b/tests/Volcanic.AgenticCore.Extensions.Tests.FakeMcpServer/Volcanic.AgenticCore.Extensions.Tests.FakeMcpServer.csproj @@ -0,0 +1,11 @@ + + + Exe + false + false + Test helper: a stdio MCP server whose behaviour FAKE_MCP_MODE selects. + + + + + diff --git a/tests/Volcanic.AgenticCore.Extensions.Tests/Mcp/FakeMcpServerSmokeTests.cs b/tests/Volcanic.AgenticCore.Extensions.Tests/Mcp/FakeMcpServerSmokeTests.cs new file mode 100644 index 0000000..3c9a882 --- /dev/null +++ b/tests/Volcanic.AgenticCore.Extensions.Tests/Mcp/FakeMcpServerSmokeTests.cs @@ -0,0 +1,95 @@ +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using Volcanic.AgenticCore.Extensions.Tests.FakeMcpServer; +using Volcanic.AgenticCore.Extensions.Tests.Mcp.Support; + +namespace Volcanic.AgenticCore.Extensions.Tests.Mcp; + +public class FakeMcpServerSmokeTests : IDisposable +{ + private readonly string _dir = Path.Combine(Path.GetTempPath(), "volcanic-fake-mcp", Guid.NewGuid().ToString("N")); + private string LogPath => Path.Combine(_dir, "fake.log"); + + public FakeMcpServerSmokeTests() => Directory.CreateDirectory(_dir); + public void Dispose() { try { Directory.Delete(_dir, true); } catch (IOException) { } } + + private async Task ConnectAsync(string mode, List? stderr = null, params string[] extraArgs) + { + var transport = new StdioClientTransport(new StdioClientTransportOptions + { + Name = "fake", + Command = "dotnet", + Arguments = [FakeMcp.DllPath, .. extraArgs], + WorkingDirectory = _dir, + EnvironmentVariables = new Dictionary { ["FAKE_MCP_MODE"] = mode, ["FAKE_MCP_LOG"] = LogPath }, + StandardErrorLines = line => { if (stderr is not null) lock (stderr) stderr.Add(line); }, + // The SDK waits this long for the process to exit before it closes stdin, so the default 5 s is spent on every dispose. + ShutdownTimeout = TimeSpan.FromMilliseconds(200), + }); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + return await McpClient.CreateAsync(transport, cancellationToken: cts.Token); + } + + [Fact] + public async Task Normal_mode_lists_the_expected_tools() + { + await using var client = await ConnectAsync("normal"); + var tools = await client.ListToolsAsync(); + + Assert.Equal(["echo", "image", "fail", "slow", "weird.name/x", "weird_name_x", "noschema", FakeServerProgram.LongToolName], + tools.Select(t => t.Name)); + Assert.Equal("fake-mcp", client.ServerInfo.Name); + } + + [Fact] + public async Task Echo_image_and_fail_behave() + { + await using var client = await ConnectAsync("normal"); + + var echo = await client.CallToolAsync("echo", new Dictionary { ["text"] = "hi" }); + Assert.Equal("echo: hi", Assert.IsType(Assert.Single(echo.Content)).Text); + + var image = await client.CallToolAsync("image", new Dictionary()); + Assert.Equal("image/png", Assert.IsType(Assert.Single(image.Content)).MimeType); + + var fail = await client.CallToolAsync("fail", new Dictionary()); + Assert.True(fail.IsError); + } + + [Fact] + public async Task Env_mode_adds_the_env_tool_and_count_starts_logs_the_start() + { + await using var client = await ConnectAsync("env,count-starts"); + var tools = await client.ListToolsAsync(); + Assert.Contains(tools, t => t.Name == "env"); + + var env = await client.CallToolAsync("env", new Dictionary()); + Assert.Contains("FAKE_MCP_MODE=env,count-starts", Assert.IsType(Assert.Single(env.Content)).Text, StringComparison.Ordinal); + + var start = Assert.Single(FakeMcp.Starts(LogPath)); + Assert.True(start.GetProperty("pid").GetInt32() > 0); + Assert.Equal(Path.GetFullPath(_dir), Path.GetFullPath(start.GetProperty("cwd").GetString()!)); + Assert.Equal("env,count-starts", start.GetProperty("env").GetProperty("FAKE_MCP_MODE").GetString()); + } + + [Fact] + public async Task Crash_on_start_fails_the_handshake_with_the_traceback_on_stderr() + { + var stderr = new List(); + await Assert.ThrowsAnyAsync(() => ConnectAsync("crash-on-start", stderr)); + await FakeMcp.WaitForAsync(() => { lock (stderr) return stderr.Any(l => l.Contains("fake crash on start", StringComparison.Ordinal)); }, "the traceback"); + lock (stderr) Assert.Contains(stderr, l => l.StartsWith("Traceback", StringComparison.Ordinal)); + } + + [Fact] + public async Task Output_dir_argument_adds_browser_tools() + { + await using var client = await ConnectAsync("normal", null, "--output-dir", Path.Combine(_dir, "browser")); + var tools = await client.ListToolsAsync(); + Assert.Contains(tools, t => t.Name == "browser_navigate"); + + var saved = await client.CallToolAsync("browser_take_screenshot", new Dictionary { ["filename"] = "browser/shot.png" }); + Assert.Equal("saved browser/shot.png", Assert.IsType(Assert.Single(saved.Content)).Text); + Assert.True(File.Exists(Path.Combine(_dir, "browser", "shot.png"))); + } +} diff --git a/tests/Volcanic.AgenticCore.Extensions.Tests/Mcp/Support/FakeMcp.cs b/tests/Volcanic.AgenticCore.Extensions.Tests/Mcp/Support/FakeMcp.cs new file mode 100644 index 0000000..7eb7f1f --- /dev/null +++ b/tests/Volcanic.AgenticCore.Extensions.Tests/Mcp/Support/FakeMcp.cs @@ -0,0 +1,64 @@ +using System.Diagnostics; +using System.Text.Json; +using Volcanic.AgenticCore.Extensions.Mcp; + +namespace Volcanic.AgenticCore.Extensions.Tests.Mcp.Support; + +/// Launch configs and log reading for the fake MCP server copied next to the tests. +internal static class FakeMcp +{ + private const string AssemblyName = "Volcanic.AgenticCore.Extensions.Tests.FakeMcpServer"; + + public static string DllPath => Path.Combine(AppContext.BaseDirectory, AssemblyName + ".dll"); + + /// The apphost, for configs that can't put the dll in argv (the Playwright preset's Command). + public static string AppHostPath => Path.Combine(AppContext.BaseDirectory, OperatingSystem.IsWindows() ? AssemblyName + ".exe" : AssemblyName); + + public static McpServerConfig Server(string mode = "normal", string? log = null, Action? configure = null) + { + var config = new McpServerConfig + { + Command = "dotnet", + Args = [DllPath], + StartupTimeoutSeconds = 30, + CallTimeoutSeconds = 30, + IdleTimeoutSeconds = 0, + }; + config.Env["FAKE_MCP_MODE"] = mode; + if (log is not null) config.Env["FAKE_MCP_LOG"] = log; + configure?.Invoke(config); + return config; + } + + public static IReadOnlyList ReadLog(string path) + { + if (!File.Exists(path)) return []; + using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + using var reader = new StreamReader(stream); + return reader.ReadToEnd().Split('\n', StringSplitOptions.RemoveEmptyEntries) + .Select(line => JsonSerializer.Deserialize(line)).ToList(); + } + + public static IReadOnlyList Starts(string path) => + ReadLog(path).Where(e => e.GetProperty("event").GetString() == "start").ToList(); + + public static async Task WaitForAsync(Func condition, string what, int timeoutMs = 15_000) + { + var watch = Stopwatch.StartNew(); + while (!condition()) + { + if (watch.ElapsedMilliseconds > timeoutMs) throw new TimeoutException($"Timed out waiting for {what}."); + await Task.Delay(50); + } + } + + public static bool HasExited(int pid) + { + try + { + using var process = Process.GetProcessById(pid); + return process.HasExited; + } + catch (ArgumentException) { return true; } + } +} diff --git a/tests/Volcanic.AgenticCore.Extensions.Tests/Volcanic.AgenticCore.Extensions.Tests.csproj b/tests/Volcanic.AgenticCore.Extensions.Tests/Volcanic.AgenticCore.Extensions.Tests.csproj index c24a327..3772f09 100644 --- a/tests/Volcanic.AgenticCore.Extensions.Tests/Volcanic.AgenticCore.Extensions.Tests.csproj +++ b/tests/Volcanic.AgenticCore.Extensions.Tests/Volcanic.AgenticCore.Extensions.Tests.csproj @@ -22,5 +22,6 @@ +