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