diff options
Diffstat (limited to 'tests/Agent.Core.Tests/McpStdioTests.cs')
| -rw-r--r-- | tests/Agent.Core.Tests/McpStdioTests.cs | 157 |
1 files changed, 157 insertions, 0 deletions
diff --git a/tests/Agent.Core.Tests/McpStdioTests.cs b/tests/Agent.Core.Tests/McpStdioTests.cs new file mode 100644 index 0000000..66f4851 --- /dev/null +++ b/tests/Agent.Core.Tests/McpStdioTests.cs @@ -0,0 +1,157 @@ +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<McpClientTool> 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<string, object?> { ["request"] = "deterministic test" }, + cancellationToken: TestContext.Current.CancellationToken); + string text = Assert.IsType<TextContentBlock>(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<string, object?> { ["task_id"] = taskId, ["session_id"] = sessionId }, + cancellationToken: TestContext.Current.CancellationToken); + Assert.Contains("task_id:", Assert.IsType<TextContentBlock>(Assert.Single(status.Content)).Text, StringComparison.Ordinal); + + CallToolResult invalid = await client.CallToolAsync( + "agent_run", + new Dictionary<string, object?> { ["request"] = "x", ["model"] = "forbidden" }, + cancellationToken: TestContext.Current.CancellationToken); + Assert.True(invalid.IsError); + + CallToolResult invalidImage = await client.CallToolAsync( + "agent_run", + new Dictionary<string, object?> + { + ["request"] = "x", + ["images"] = new[] { new Dictionary<string, object?> { ["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(); + } + } +} |
