ClawdDotNet als MCP-Server: durch dieselben Pruefstellen (#713)

Eine fremde Laufzeit oder der eigene Claude Code des Menschen spricht per MCP
mit der Instanz. Der Server bietet genau die Werkzeuge des Agenten hinter dem
Token an; jeder Aufruf laeuft durch die Engine wie ein Aufruf des eigenen
Modells (Rechtetor, Vorpruefung, Werkzeugrechte, Staging, Audit mit Quelle
mcp, Rahmung), und der Aufrufer gilt als Leser fremder Inhalte (#715).

Eigener Socket mcp.sock statt manager.sock: ueber den Instanz-Socket antwortet
die Verwaltung ohne Token, ueber mcp.sock gibt es nur /mcp und nur mit
MCP-Token. Lauf-Tokens nur im Speicher, enden mit dem Lauf; persoenliche Tokens
(hoechstens 90 Tage, widerrufbar, nur als SHA-256) in der Agentenansicht.
Transporte: HTTP auf mcp.sock, Weboberflaeche und Manager (/mcp/{instanz}),
stdio ueber ClawdDotNet --mcp-bridge (Token nur aus Umgebung oder 0600-Datei).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-25 17:28:47 +02:00
co-authored by Claude Opus 5.5
parent b5f65b8cf7
commit cb46289bb6
25 changed files with 1672 additions and 12 deletions
@@ -0,0 +1,288 @@
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;
/// <summary>
/// #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.
/// </summary>
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<JsonObject> 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<int>().ShouldBe(7);
answer["result"]!["protocolVersion"]!.GetValue<string>().ShouldBe("2025-03-26");
answer["result"]!["capabilities"]!["tools"].ShouldNotBeNull();
answer["result"]!["serverInfo"]!["name"]!.GetValue<string>().ShouldBe("clawddotnet");
}
[Fact]
public async Task An_unknown_version_gets_the_newest()
=> (await Ask("initialize", new { protocolVersion = "1999-01-01" }))["result"]!["protocolVersion"]!
.GetValue<string>().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<int>().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<string>()).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<bool>().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<bool>().ShouldBeFalse();
answer["result"]!["content"]![0]!["text"]!.GetValue<string>().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<string>().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<string>().ShouldBe("a");
answer["error"]!["code"]!.GetValue<int>().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<ToolResult> ExecuteAsync(JsonElement input, AgentToolContext context, CancellationToken ct)
{
Calls++;
LastTrust = context.Trust;
return Task.FromResult(ToolResult.Ok($"{name.ToLowerInvariant()} result"));
}
}
private sealed class RecordingHandler(Func<HttpRequestMessage, HttpResponseMessage> respond) : HttpMessageHandler
{
public List<(string Body, string? Authorization)> Requests { get; } = [];
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
{
Requests.Add((await request.Content!.ReadAsStringAsync(ct), request.Headers.Authorization?.ToString()));
return respond(request);
}
}
}