using System.Net; using System.Net.Sockets; using Agent.Core.Localization; using Agent.Core.Ollama; using Agent.Core.Runtime; using Agent.Mcp; using ModelContextProtocol; using ModelContextProtocol.Client; namespace Agent.Core.Tests; public sealed class McpHttpTests { [Fact(Timeout = 30_000)] public async Task Loopback_streamable_http_works_and_get_sse_legacy_sse_are_rejected() { int port = FreePort(); using var stop = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); LocaleCatalog locale = LocaleCatalog.Load(Path.GetFullPath("config/locales"), "en"); var settings = new OllamaSettings(new Uri("http://127.0.0.1:1"), "unused", false, "0", new Dictionary()); Task server = McpHost.RunHttpAsync(locale, settings, new AgentRuntimeSettings("", "en", [], "sandbox"), new VisionLimits(), "127.0.0.1", port, stop.Token); using var http = new HttpClient(); Uri endpoint = new($"http://127.0.0.1:{port}/mcp"); await WaitUntilListening(http, endpoint, TestContext.Current.CancellationToken); var transport = new HttpClientTransport(new HttpClientTransportOptions { Name = "http-test", Endpoint = endpoint, TransportMode = HttpTransportMode.StreamableHttp }); 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)); } using HttpResponseMessage get = await http.GetAsync(endpoint, TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.MethodNotAllowed, get.StatusCode); using var hostileOrigin = new HttpRequestMessage(HttpMethod.Post, endpoint); hostileOrigin.Headers.Add("Origin", "https://example.invalid"); using HttpResponseMessage forbidden = await http.SendAsync(hostileOrigin, TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.Forbidden, forbidden.StatusCode); using var loopbackOrigin = new HttpRequestMessage(HttpMethod.Get, endpoint); loopbackOrigin.Headers.Add("Origin", $"http://127.0.0.1:{port}"); using HttpResponseMessage loopbackAllowed = await http.SendAsync(loopbackOrigin, TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.MethodNotAllowed, loopbackAllowed.StatusCode); Assert.Equal(HttpStatusCode.NotFound, (await http.GetAsync(new Uri($"http://127.0.0.1:{port}/sse"), TestContext.Current.CancellationToken)).StatusCode); Assert.Equal(HttpStatusCode.NotFound, (await http.PostAsync(new Uri($"http://127.0.0.1:{port}/message"), null, TestContext.Current.CancellationToken)).StatusCode); await stop.CancelAsync(); await server; } [Fact] public async Task Non_loopback_bind_is_rejected_before_server_start() { LocaleCatalog locale = LocaleCatalog.Load(Path.GetFullPath("config/locales"), "en"); var settings = new OllamaSettings(new Uri("http://127.0.0.1:1"), "unused", false, "0", new Dictionary()); await Assert.ThrowsAsync(() => McpHost.RunHttpAsync(locale, settings, new AgentRuntimeSettings("", "en", [], "sandbox"), new VisionLimits(), "0.0.0.0", 8080, TestContext.Current.CancellationToken)); } private static int FreePort() { TcpListener listener = new(IPAddress.Loopback, 0); listener.Start(); int port = ((IPEndPoint)listener.LocalEndpoint).Port; listener.Stop(); return port; } private static async Task WaitUntilListening(HttpClient client, Uri endpoint, CancellationToken cancellationToken) { for (int attempt = 0; attempt < 100; attempt++) { try { using HttpResponseMessage response = await client.GetAsync(endpoint, cancellationToken); return; } catch (HttpRequestException) when (attempt < 99) { await Task.Delay(25, cancellationToken); } } } }