forked from admin/VolcanicAgenticCore
test(mcp): fake stdio MCP server
Spec: the fake uses the SDK's server API from ModelContextProtocol.Core and gains the tools and modes the tests need (weird_name_x, a >48-char name, builtin-clash, --output-dir browser tools, die message and exit code, count-starts details, slow cancellation log).
This commit is contained in:
@@ -18,6 +18,7 @@
|
||||
</Folder>
|
||||
<Folder Name="/tests/">
|
||||
<Project Path="tests/Volcanic.AgenticCore.Extensions.Tests/Volcanic.AgenticCore.Extensions.Tests.csproj" />
|
||||
<Project Path="tests/Volcanic.AgenticCore.Extensions.Tests.FakeMcpServer/Volcanic.AgenticCore.Extensions.Tests.FakeMcpServer.csproj" />
|
||||
<Project Path="tests/Volcanic.AgenticCore.HardeningProbe/Volcanic.AgenticCore.HardeningProbe.csproj" />
|
||||
<Project Path="tests/Volcanic.AgenticCore.Telegram.Tests/Volcanic.AgenticCore.Telegram.Tests.csproj" />
|
||||
<Project Path="tests/Volcanic.AgenticCore.Tests/Volcanic.AgenticCore.Tests.csproj" />
|
||||
|
||||
@@ -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 <dll>`. 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 <dll>`.
|
||||
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
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>Test MCP stdio server. <c>FAKE_MCP_MODE</c> (comma-separated) selects behaviour; <c>FAKE_MCP_LOG</c> receives JSON lines.</summary>
|
||||
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<int> 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 <module>");
|
||||
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<Tool> Tools(HashSet<string> modes, bool playwrightLike)
|
||||
{
|
||||
var tools = new List<Tool>
|
||||
{
|
||||
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<JsonElement>(schema) };
|
||||
|
||||
private static async ValueTask<CallToolResult> 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<string, string?> Snapshot()
|
||||
{
|
||||
var env = new Dictionary<string, string?>(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); }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>false</IsTestProject>
|
||||
<Description>Test helper: a stdio MCP server whose behaviour FAKE_MCP_MODE selects.</Description>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="ModelContextProtocol.Core" Version="2.2.0" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -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<McpClient> ConnectAsync(string mode, List<string>? stderr = null, params string[] extraArgs)
|
||||
{
|
||||
var transport = new StdioClientTransport(new StdioClientTransportOptions
|
||||
{
|
||||
Name = "fake",
|
||||
Command = "dotnet",
|
||||
Arguments = [FakeMcp.DllPath, .. extraArgs],
|
||||
WorkingDirectory = _dir,
|
||||
EnvironmentVariables = new Dictionary<string, string?> { ["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<string, object?> { ["text"] = "hi" });
|
||||
Assert.Equal("echo: hi", Assert.IsType<TextContentBlock>(Assert.Single(echo.Content)).Text);
|
||||
|
||||
var image = await client.CallToolAsync("image", new Dictionary<string, object?>());
|
||||
Assert.Equal("image/png", Assert.IsType<ImageContentBlock>(Assert.Single(image.Content)).MimeType);
|
||||
|
||||
var fail = await client.CallToolAsync("fail", new Dictionary<string, object?>());
|
||||
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<string, object?>());
|
||||
Assert.Contains("FAKE_MCP_MODE=env,count-starts", Assert.IsType<TextContentBlock>(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<string>();
|
||||
await Assert.ThrowsAnyAsync<Exception>(() => 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<string, object?> { ["filename"] = "browser/shot.png" });
|
||||
Assert.Equal("saved browser/shot.png", Assert.IsType<TextContentBlock>(Assert.Single(saved.Content)).Text);
|
||||
Assert.True(File.Exists(Path.Combine(_dir, "browser", "shot.png")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using Volcanic.AgenticCore.Extensions.Mcp;
|
||||
|
||||
namespace Volcanic.AgenticCore.Extensions.Tests.Mcp.Support;
|
||||
|
||||
/// <summary>Launch configs and log reading for the fake MCP server copied next to the tests.</summary>
|
||||
internal static class FakeMcp
|
||||
{
|
||||
private const string AssemblyName = "Volcanic.AgenticCore.Extensions.Tests.FakeMcpServer";
|
||||
|
||||
public static string DllPath => Path.Combine(AppContext.BaseDirectory, AssemblyName + ".dll");
|
||||
|
||||
/// <summary>The apphost, for configs that can't put the dll in argv (the Playwright preset's <c>Command</c>).</summary>
|
||||
public static string AppHostPath => Path.Combine(AppContext.BaseDirectory, OperatingSystem.IsWindows() ? AssemblyName + ".exe" : AssemblyName);
|
||||
|
||||
public static McpServerConfig Server(string mode = "normal", string? log = null, Action<McpServerConfig>? 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<JsonElement> 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<JsonElement>(line)).ToList();
|
||||
}
|
||||
|
||||
public static IReadOnlyList<JsonElement> Starts(string path) =>
|
||||
ReadLog(path).Where(e => e.GetProperty("event").GetString() == "start").ToList();
|
||||
|
||||
public static async Task WaitForAsync(Func<bool> 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; }
|
||||
}
|
||||
}
|
||||
@@ -22,5 +22,6 @@
|
||||
<ProjectReference Include="../../extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/Volcanic.AgenticCore.Extensions.ClaudeCode.csproj" />
|
||||
<ProjectReference Include="../../extensions/Volcanic.AgenticCore.Extensions.ClaudeCode.Bridge/Volcanic.AgenticCore.Extensions.ClaudeCode.Bridge.csproj" />
|
||||
<ProjectReference Include="../../extensions/Volcanic.AgenticCore.Extensions.Mcp/Volcanic.AgenticCore.Extensions.Mcp.csproj" />
|
||||
<ProjectReference Include="../Volcanic.AgenticCore.Extensions.Tests.FakeMcpServer/Volcanic.AgenticCore.Extensions.Tests.FakeMcpServer.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
Reference in New Issue
Block a user