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);
}
}
}
@@ -0,0 +1,170 @@
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;
/// <summary>
/// #713: The MCP server on the instance's HTTP surface. What must hold: its socket leads to
/// <c>/mcp</c> 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.
/// </summary>
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<AgentConfig> { new() { AgentId = "coder", Tools = { ["Echo"] = new Dictionary<string, object?>() } } };
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<string>().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<ToolResult> ExecuteAsync(JsonElement input, AgentToolContext context, CancellationToken ct)
=> Task.FromResult(ToolResult.Ok("echo"));
}
private sealed class NoModel : IChatCompletionClient
{
public Task<ChatResponse> CompleteAsync(ChatRequest request, CancellationToken ct)
=> throw new InvalidOperationException("No model in this test.");
}
private sealed class MemoryState : IStateStore
{
private readonly ConcurrentDictionary<string, string> _values = new();
public Task<string?> 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; }
}
}