summaryrefslogtreecommitdiff
path: root/tests/Agent.Core.Tests/McpStdioTests.cs
blob: fcef80e735519e45539170c385db5d814d539a27 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
using Agent.Mcp.TestHost;
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 localeDirectory = CreateLocale(directory);
        string application = typeof(Marker).Assembly.Location;
        var transport = new StdioClientTransport(new StdioClientTransportOptions
        {
            Name = "agent-mcp-test",
            Command = "dotnet",
            Arguments = [application, localeDirectory, System.IO.Path.Combine(directory.Path, "mcp-tasks")],
            WorkingDirectory = directory.Path
        });

        await using McpClient client = await McpClient.CreateAsync(transport,
            new McpClientOptions { ProtocolVersion = "2025-11-25" }, 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 CreateLocale(TestDirectory directory)
    {
        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"}""");
        return System.IO.Path.Combine(directory.Path, "config", "locales");
    }

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