Files
Playa/tests/Volcanic.AgenticCore.Extensions.Tests.FakeMcpServer/FakeServerProgram.cs
EugeneTes 52f9d88575 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).
2026-09-26 20:24:42 +02:00

154 lines
7.4 KiB
C#

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); }
}
}
}