using Agent.App.Configuration; using ModelContextProtocol; using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; using System.Net; using System.Net.Sockets; using System.Text; namespace Agent.Core.Tests; public sealed class McpStdioTests { [Fact(Timeout = 30_000)] public async Task InitializeListAndCallExposeOnlyTwoStrictTools() { using var directory = new TestDirectory(); await using var ollama = new FakeOllamaServer(); string configPath = CreateBundleConfiguration(directory, ollama.Endpoint); string application = typeof(ConfigurationLoader).Assembly.Location; var transport = new StdioClientTransport(new StdioClientTransportOptions { Name = "agent-mcp-test", Command = "dotnet", Arguments = [application, "serve", "--config", configPath, "--lang", "en"], WorkingDirectory = directory.Path }); await using McpClient client = await McpClient.CreateAsync(transport, cancellationToken: TestContext.Current.CancellationToken); IList tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); Assert.Equal(["agent_run", "agent_status"], tools.Select(static tool => tool.Name).Order(StringComparer.Ordinal)); foreach (McpClientTool tool in tools) { Assert.False(tool.JsonSchema.GetProperty("additionalProperties").GetBoolean()); } CallToolResult run = await client.CallToolAsync( "agent_run", new Dictionary { ["request"] = "deterministic test" }, cancellationToken: TestContext.Current.CancellationToken); string text = Assert.IsType(Assert.Single(run.Content)).Text; Assert.Contains("status: queued", text, StringComparison.Ordinal); Guid taskId = ReadGuid(text, "task_id"); Guid sessionId = ReadGuid(text, "session_id"); CallToolResult status = await client.CallToolAsync( "agent_status", new Dictionary { ["task_id"] = taskId, ["session_id"] = sessionId }, cancellationToken: TestContext.Current.CancellationToken); Assert.Contains("task_id:", Assert.IsType(Assert.Single(status.Content)).Text, StringComparison.Ordinal); CallToolResult invalid = await client.CallToolAsync( "agent_run", new Dictionary { ["request"] = "x", ["model"] = "forbidden" }, cancellationToken: TestContext.Current.CancellationToken); Assert.True(invalid.IsError); CallToolResult invalidImage = await client.CallToolAsync( "agent_run", new Dictionary { ["request"] = "x", ["images"] = new[] { new Dictionary { ["mime_type"] = "image/gif", ["data_base64"] = "AA==" } } }, cancellationToken: TestContext.Current.CancellationToken); Assert.True(invalidImage.IsError); } private static Guid ReadGuid(string text, string key) { string line = text.Split('\n', StringSplitOptions.TrimEntries).Single(value => value.StartsWith(key + ":", StringComparison.Ordinal)); return Guid.Parse(line[(key.Length + 1)..].Trim()); } private static string CreateBundleConfiguration(TestDirectory directory, Uri endpoint) { directory.File("prompts/user-system.md", string.Empty); directory.File("config/locales/en.json", """{"app.starting":"Starting","check.success":"OK","check.failure":"Failed {message}","check.wfp_admin_required":"Administrator required","config.invalid":"Invalid {message}","locale.invalid":"Locale {message}","mcp.task_queued":"Task {task} queued in {session}","mcp.task_not_found":"Task {task} not found","sandbox.access_denied":"Denied {path}","ollama.vision_unsupported":"No vision"}"""); Directory.CreateDirectory(System.IO.Path.Combine(directory.Path, "skills")); return directory.File("agent.yaml", $$""" schema_version: 1 application: language: en mcp: transport: stdio agent: user_system_prompt: prompts/user-system.md ollama: endpoint: {{endpoint}} model: model localization: directory: config/locales fallback: en skills: directory: skills logging: directory: logs """); } private sealed class FakeOllamaServer : IAsyncDisposable { private readonly TcpListener _listener = new(IPAddress.Loopback, 0); private readonly CancellationTokenSource _stop = new(); private readonly Task _loop; public FakeOllamaServer() { _listener.Start(); Endpoint = new Uri($"http://127.0.0.1:{((IPEndPoint)_listener.LocalEndpoint).Port}/"); _loop = RunAsync(); } public Uri Endpoint { get; } private async Task RunAsync() { try { while (!_stop.IsCancellationRequested) { using TcpClient client = await _listener.AcceptTcpClientAsync(_stop.Token); await HandleAsync(client, _stop.Token); } } catch (OperationCanceledException) when (_stop.IsCancellationRequested) { } } private static async Task HandleAsync(TcpClient client, CancellationToken cancellationToken) { NetworkStream stream = client.GetStream(); using var reader = new StreamReader(stream, Encoding.ASCII, false, 1024, true); int contentLength = 0; while (await reader.ReadLineAsync(cancellationToken) is { } line && line.Length > 0) { if (line.StartsWith("Content-Length:", StringComparison.OrdinalIgnoreCase)) contentLength = int.Parse(line[15..].Trim(), System.Globalization.CultureInfo.InvariantCulture); } if (contentLength > 0) { char[] body = new char[contentLength]; await reader.ReadBlockAsync(body.AsMemory(), cancellationToken); } byte[] payload = Encoding.UTF8.GetBytes("""{"message":{"role":"assistant","content":"done"},"prompt_eval_count":3,"eval_count":2}"""); byte[] header = Encoding.ASCII.GetBytes($"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {payload.Length}\r\nConnection: close\r\n\r\n"); await stream.WriteAsync(header, cancellationToken); await stream.WriteAsync(payload, cancellationToken); } public async ValueTask DisposeAsync() { await _stop.CancelAsync(); _listener.Stop(); await _loop; _stop.Dispose(); } } }