using System.Collections.Concurrent; using System.Net; using System.Text; using System.Text.Json; using System.Text.Json.Nodes; using ClawdDotNet.App; using ClawdDotNet.App.Services; using ClawdDotNet.App.Settings; using ClawdDotNet.Core.Api; using ClawdDotNet.Core.Api.Models; using ClawdDotNet.Core.Config; using ClawdDotNet.Core.Engine; using ClawdDotNet.Core.Instances; using ClawdDotNet.Core.Mcp; using ClawdDotNet.Core.Security; using ClawdDotNet.Core.State; using ClawdDotNet.Core.Tools; using Microsoft.Extensions.Logging.Abstractions; using Shouldly; namespace ClawdDotNet.Web.Tests; /// /// #713: The MCP server on the instance's HTTP surface. What must hold: its socket leads to /// /mcp and nowhere else — the instance socket opens everything without a token, and a /// foreign runtime must never get that. Without an MCP token nobody is anybody, and a browser /// is turned away. /// public sealed class McpSocketTests : IDisposable { private readonly string _root = Path.Combine(Path.GetTempPath(), "clawd-tests", "mcp-" + Guid.NewGuid().ToString("N")[..8]); private const string WebToken = "ein-hinreichend-langes-testtoken"; public void Dispose() { try { Directory.Delete(_root, recursive: true); } catch { /* cleanup is secondary */ } } private AppHost NewHost(int port = 0) { var instancesDir = Path.Combine(_root, "Instances"); var instancePath = Path.Combine(instancesDir, "Test"); Directory.CreateDirectory(instancePath); var tools = new ToolRegistry(); tools.Register(new EchoTool()); var agents = new List { new() { AgentId = "coder", Tools = { ["Echo"] = new Dictionary() } } }; var engine = new AgentEngine(new NoModel(), tools, new PermissionGate(), new MemoryState(), NullLoggerFactory.Instance); engine.SetAgentConfigProvider(() => agents, "test", _ => null); return new AppHost { Settings = new SettingsManager(_root), Directories = new InstanceDirectoryManager(instancesDir), Instance = new InstanceConfig { InstanceName = "Test", WorkingDirectory = instancePath, Agents = agents, WebUiEnabled = port > 0, WebUiBinding = "loopback", WebUiToken = port > 0 ? WebToken : "", WebServerPort = port }, InstancePath = instancePath, LogDirectory = Path.Combine(instancePath, "Logs"), LoggerFactory = NullLoggerFactory.Instance, Tools = tools, McpToolHost = engine, Mcp = new McpGrants() }; } private static StringContent Rpc(string method) => new(new JsonObject { ["jsonrpc"] = "2.0", ["id"] = 1, ["method"] = method }.ToJsonString(), Encoding.UTF8, "application/json"); [Fact] public async Task The_mcp_socket_leads_to_mcp_and_nowhere_else() { await using var host = NewHost(); await using var web = InstanceHttpHost.TryStart(host, NullLoggerFactory.Instance); web.ShouldNotBeNull(); using var client = InstanceEndpoint.CreateMcpClient(host.InstancePath); (await client.GetAsync("/api/info")).StatusCode.ShouldBe(HttpStatusCode.NotFound, "the administration answers without a token on the instance socket — never on this one"); (await client.PostAsync("/api/mcp/tokens", new StringContent("{}", Encoding.UTF8, "application/json"))) .StatusCode.ShouldBe(HttpStatusCode.NotFound); } [Fact] public async Task Without_a_token_there_is_nobody_with_one_the_agents_tools() { await using var host = NewHost(); await using var web = InstanceHttpHost.TryStart(host, NullLoggerFactory.Instance); using var client = InstanceEndpoint.CreateMcpClient(host.InstancePath); (await client.PostAsync("/mcp", Rpc("tools/list"))).StatusCode.ShouldBe(HttpStatusCode.Unauthorized); using var run = host.Mcp!.OpenRun("coder", "run-1"); client.DefaultRequestHeaders.Add("Authorization", "Bearer " + run.Token); var answer = await client.PostAsync("/mcp", Rpc("tools/list")); answer.EnsureSuccessStatusCode(); JsonNode.Parse(await answer.Content.ReadAsStringAsync())!["result"]!["tools"]![0]!["name"]! .GetValue().ShouldBe("Echo"); } [Fact] public async Task Over_tcp_the_web_token_does_not_open_mcp_and_a_browser_is_turned_away() { var port = FreePort(); await using var host = NewHost(port); await using var web = InstanceHttpHost.TryStart(host, NullLoggerFactory.Instance); using var client = new HttpClient { BaseAddress = new Uri($"http://127.0.0.1:{port}") }; client.DefaultRequestHeaders.Add("Authorization", "Bearer " + WebToken); (await client.PostAsync("/mcp", Rpc("ping"))).StatusCode.ShouldBe(HttpStatusCode.Unauthorized, "the WebUI token is not an MCP token — it names no agent"); using var run = host.Mcp!.OpenRun("coder", "run-1"); using var request = new HttpRequestMessage(HttpMethod.Post, "/mcp") { Content = Rpc("ping") }; request.Headers.Add("Authorization", "Bearer " + run.Token); request.Headers.Add("Origin", "http://evil.example"); (await client.SendAsync(request)).StatusCode.ShouldBe(HttpStatusCode.Forbidden); } [Fact] public async Task A_notification_is_accepted_without_a_body() { await using var host = NewHost(); await using var web = InstanceHttpHost.TryStart(host, NullLoggerFactory.Instance); using var client = InstanceEndpoint.CreateMcpClient(host.InstancePath); using var run = host.Mcp!.OpenRun("coder", "run-1"); client.DefaultRequestHeaders.Add("Authorization", "Bearer " + run.Token); var answer = await client.PostAsync("/mcp", new StringContent("""{"jsonrpc":"2.0","method":"notifications/initialized"}""", Encoding.UTF8, "application/json")); answer.StatusCode.ShouldBe(HttpStatusCode.Accepted); } private static int FreePort() { using var probe = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0); probe.Start(); var port = ((IPEndPoint)probe.LocalEndpoint).Port; probe.Stop(); return port; } private sealed class EchoTool : IAgentTool { public string Name => "Echo"; public string Description => "Echoes."; public JsonElement InputSchema { get; } = JsonDocument.Parse("""{"type":"object"}""").RootElement.Clone(); public Task ExecuteAsync(JsonElement input, AgentToolContext context, CancellationToken ct) => Task.FromResult(ToolResult.Ok("echo")); } private sealed class NoModel : IChatCompletionClient { public Task CompleteAsync(ChatRequest request, CancellationToken ct) => throw new InvalidOperationException("No model in this test."); } private sealed class MemoryState : IStateStore { private readonly ConcurrentDictionary _values = new(); public Task GetAsync(string key, CancellationToken ct) => Task.FromResult(_values.GetValueOrDefault(key)); public Task SetAsync(string key, string value, CancellationToken ct) { _values[key] = value; return Task.CompletedTask; } public Task DeleteAsync(string key, CancellationToken ct) { _values.TryRemove(key, out _); return Task.CompletedTask; } } }