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,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; }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user