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:
co-authored by
Claude Opus 5.5
parent
b5f65b8cf7
commit
cb46289bb6
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user