using System.Net; using System.Text.Json; using System.Text.Json.Nodes; using ClawdDotNet.Core.Audit; using ClawdDotNet.Core.Mcp; using ClawdDotNet.Core.Organisation; using ClawdDotNet.Core.Staging; using ClawdDotNet.Core.Storage; using ClawdDotNet.Core.Tests.Infrastructure; using ClawdDotNet.Core.Tools; using Microsoft.Extensions.Logging.Abstractions; using Shouldly; namespace ClawdDotNet.Core.Tests.Mcp; /// /// #713: ClawdDotNet as an MCP server. The rule that carries it: it is not a second way past /// the rights. The agent's tools and nothing else, every call through the engine's chain, and /// a foreign runtime counts as reading foreign content. /// public sealed class McpServerTests : IDisposable { private readonly string _directory = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N")); private readonly InMemoryAuditRepository _audit = new(); private readonly InMemoryStagingRepository _staged = new(); private readonly EngineFixture _fixture; private readonly CapturingTool _board = new("Board"); private readonly CapturingTool _mail = new("Mail"); private readonly McpServer _server; private static readonly McpGrant Grant = new() { AgentId = "coder", RunId = "run-ext-1", ExpiresAtUtc = DateTime.UtcNow.AddHours(1) }; public McpServerTests() { _fixture = new EngineFixture(_audit, new StagingGate(new StagingPolicy(), _staged)); _fixture.WithTool(_board).WithTool(_mail); _fixture.AddAgent("coder", "Board", "Mail"); _fixture.AddAgent("other", "Board"); _server = new McpServer(_fixture.Engine, NullLogger.Instance); } public void Dispose() { SqliteStorage.ReleasePooledConnections(_directory); try { Directory.Delete(_directory, recursive: true); } catch { /* cleanup is secondary */ } } private async Task Ask(string method, object? parameters = null, McpGrant? grant = null) { var request = new JsonObject { ["jsonrpc"] = "2.0", ["id"] = 7, ["method"] = method }; if (parameters is not null) request["params"] = JsonSerializer.SerializeToNode(parameters); var answer = await _server.HandleAsync(request.ToJsonString(), grant ?? Grant, default); return JsonNode.Parse(answer!)!.AsObject(); } // ═══════════════════ protocol ═══════════════════ [Fact] public async Task Initialize_answers_with_the_version_asked_for_and_offers_tools() { var answer = await Ask("initialize", new { protocolVersion = "2025-03-26", capabilities = new { }, clientInfo = new { name = "claude-code" } }); answer["id"]!.GetValue().ShouldBe(7); answer["result"]!["protocolVersion"]!.GetValue().ShouldBe("2025-03-26"); answer["result"]!["capabilities"]!["tools"].ShouldNotBeNull(); answer["result"]!["serverInfo"]!["name"]!.GetValue().ShouldBe("clawddotnet"); } [Fact] public async Task An_unknown_version_gets_the_newest() => (await Ask("initialize", new { protocolVersion = "1999-01-01" }))["result"]!["protocolVersion"]! .GetValue().ShouldBe(McpServer.ProtocolVersions[0]); [Fact] public async Task A_notification_gets_no_answer() => (await _server.HandleAsync("""{"jsonrpc":"2.0","method":"notifications/initialized"}""", Grant, default)).ShouldBeNull(); [Theory] [InlineData("not json", -32700)] [InlineData("""[{"jsonrpc":"2.0","id":1,"method":"ping"}]""", -32600)] [InlineData("""{"jsonrpc":"1.0","id":1,"method":"ping"}""", -32600)] [InlineData("""{"jsonrpc":"2.0","id":1,"method":"resources/list"}""", -32601)] public async Task What_is_not_a_request_is_answered_with_the_right_error(string message, int code) { var answer = JsonNode.Parse((await _server.HandleAsync(message, Grant, default))!)!; answer["error"]!["code"]!.GetValue().ShouldBe(code); } // ═══════════════════ the rights ═══════════════════ [Fact] public async Task The_list_holds_the_agents_tools_and_nothing_else() { var names = (await Ask("tools/list", grant: Grant with { AgentId = "other" }))["result"]!["tools"]!.AsArray() .Select(t => t!["name"]!.GetValue()).ToList(); names.ShouldContain("Board"); names.ShouldNotContain("Mail", "'other' has no Mail tool"); } [Fact] public async Task A_tool_the_agent_does_not_have_cannot_be_called() { var answer = await Ask("tools/call", new { name = "Mail", arguments = new { action = "send" } }, Grant with { AgentId = "other" }); answer["result"]!["isError"]!.GetValue().ShouldBeTrue(); _mail.Calls.ShouldBe(0); } [Fact] public async Task A_call_goes_through_the_engine_and_is_audited_as_mcp() { var answer = await Ask("tools/call", new { name = "Board", arguments = new { action = "task_list" } }); answer["result"]!["isError"]!.GetValue().ShouldBeFalse(); answer["result"]!["content"]![0]!["text"]!.GetValue().ShouldContain("board result"); _board.Calls.ShouldBe(1); var entry = _audit.Entries.Last(e => e.Tool == "Board"); entry.Source.ShouldBe(AuditSource.Mcp); entry.RunId.ShouldBe("run-ext-1"); } [Fact] public async Task A_foreign_runtime_counts_as_reading_foreign_content() { await Ask("tools/call", new { name = "Board", arguments = new { action = "task_list" } }); _board.LastTrust!.Tier.ShouldBe(TrustTier.ReadsForeignContent); _board.LastTrust.Sources.ShouldContain("external-runtime"); } [Fact] public async Task Staging_applies_as_to_the_agents_own_model() { // Mail.send needs approval by default — through the MCP server as well. var answer = await Ask("tools/call", new { name = "Mail", arguments = new { action = "send", to = "x@example.org" } }); _mail.Calls.ShouldBe(0, "proposed, not sent"); answer["result"]!["content"]![0]!["text"]!.GetValue().ShouldContain("Freigabe"); (await _staged.ListPendingAsync(default)).ShouldHaveSingleItem().AgentId.ShouldBe("coder"); } // ═══════════════════ tokens ═══════════════════ [Fact] public async Task A_runs_token_ends_with_the_run() { var grants = new McpGrants(); var run = grants.OpenRun("coder", "run-1", "t-5"); (await grants.ResolveAsync(run.Token, default))!.TaskId.ShouldBe("t-5"); run.Dispose(); (await grants.ResolveAsync(run.Token, default)).ShouldBeNull(); } [Fact] public async Task A_forgotten_runs_token_expires_anyway() { var clock = new FakeTimeProvider(DateTimeOffset.UtcNow); var grants = new McpGrants(clock: clock); var run = grants.OpenRun("coder", "run-1", lifetime: TimeSpan.FromMinutes(30)); clock.Advance(TimeSpan.FromMinutes(31)); (await grants.ResolveAsync(run.Token, default)).ShouldBeNull(); } [Fact] public async Task A_personal_token_is_kept_only_as_a_hash_and_can_be_revoked() { var storage = new SqliteStorage(Path.Combine(_directory, "state.db")); var grants = new McpGrants(storage); var (info, token) = await grants.IssuePersonalAsync("coder", "my laptop", 30, default); var grant = (await grants.ResolveAsync(token, default))!; grant.AgentId.ShouldBe("coder"); grant.IsPersonal.ShouldBeTrue(); // The database and its write-ahead log — the token must be in neither. foreach (var path in Directory.GetFiles(_directory, "state.db*")) { using var file = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); using var copy = new MemoryStream(); file.CopyTo(copy); copy.ToArray().AsSpan().IndexOf(System.Text.Encoding.UTF8.GetBytes(token)).ShouldBe(-1, $"never stored in clear ({Path.GetFileName(path)})"); } (await grants.ListPersonalAsync(default)).ShouldHaveSingleItem().Label.ShouldBe("my laptop"); (await grants.RevokeAsync(info.Id, default)).ShouldBeTrue(); (await grants.ResolveAsync(token, default)).ShouldBeNull(); } [Theory] [InlineData(null)] [InlineData("")] [InlineData("cdn_guessed")] public async Task Without_a_valid_token_there_is_nobody(string? token) => (await new McpGrants().ResolveAsync(token, default)).ShouldBeNull(); // ═══════════════════ the stdio bridge ═══════════════════ [Fact] public async Task The_bridge_carries_each_line_with_the_token_and_writes_the_answer_as_one_line() { var handler = new RecordingHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("""{"jsonrpc":"2.0","id":1,"result":{}}""") }); var output = new StringWriter(); await McpStdioBridge.RunAsync( new StringReader("""{"jsonrpc":"2.0","id":1,"method":"ping"}""" + "\n\n"), output, new HttpClient(handler) { BaseAddress = new Uri("http://instanz") }, "cdn_token", default); handler.Requests.ShouldHaveSingleItem().Authorization.ShouldBe("Bearer cdn_token"); output.ToString().ShouldBe("""{"jsonrpc":"2.0","id":1,"result":{}}""" + Environment.NewLine); } [Fact] public async Task A_rejected_token_becomes_an_error_for_that_request() { var handler = new RecordingHandler(_ => new HttpResponseMessage(HttpStatusCode.Unauthorized)); var output = new StringWriter(); await McpStdioBridge.RunAsync(new StringReader("""{"jsonrpc":"2.0","id":"a","method":"ping"}"""), output, new HttpClient(handler) { BaseAddress = new Uri("http://instanz") }, "cdn_old", default); var answer = JsonNode.Parse(output.ToString())!; answer["id"]!.GetValue().ShouldBe("a"); answer["error"]!["code"]!.GetValue().ShouldBe(McpStdioBridge.Unauthorized); } [Fact] public void The_token_comes_from_the_environment_never_from_arguments() { McpStdioBridge.ReadToken(name => name == McpStdioBridge.TokenVariable ? "cdn_x" : null).Token.ShouldBe("cdn_x"); McpStdioBridge.ReadToken(_ => null).Problem.ShouldNotBeNull(); } [Fact] public void A_token_file_others_can_read_is_refused() { if (OperatingSystem.IsWindows()) return; // file modes are a Linux matter; the target runs there Directory.CreateDirectory(_directory); var file = Path.Combine(_directory, "token"); File.WriteAllText(file, "cdn_x"); File.SetUnixFileMode(file, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.OtherRead); McpStdioBridge.ReadToken(name => name == McpStdioBridge.TokenFileVariable ? file : null) .Problem.ShouldNotBeNull().ShouldContain("0600"); File.SetUnixFileMode(file, UnixFileMode.UserRead | UnixFileMode.UserWrite); McpStdioBridge.ReadToken(name => name == McpStdioBridge.TokenFileVariable ? file : null).Token.ShouldBe("cdn_x"); } // ═══════════════════ test doubles ═══════════════════ private sealed class CapturingTool(string name) : IAgentTool { public string Name => name; public string Description => $"Test tool {name}"; public JsonElement InputSchema { get; } = JsonDocument.Parse("""{ "type": "object", "properties": { "action": { "type": "string" } } }""").RootElement.Clone(); public int Calls { get; private set; } public AgentTrust? LastTrust { get; private set; } public Task ExecuteAsync(JsonElement input, AgentToolContext context, CancellationToken ct) { Calls++; LastTrust = context.Trust; return Task.FromResult(ToolResult.Ok($"{name.ToLowerInvariant()} result")); } } private sealed class RecordingHandler(Func respond) : HttpMessageHandler { public List<(string Body, string? Authorization)> Requests { get; } = []; protected override async Task SendAsync(HttpRequestMessage request, CancellationToken ct) { Requests.Add((await request.Content!.ReadAsStringAsync(ct), request.Headers.Authorization?.ToString())); return respond(request); } } }