MCP-Client fuer HTTP-Server: externe Werkzeuge durch dieselben Pruefstellen (#197)

- Core/Mcp/McpClient: Streamable HTTP (MCP 2025-06-18) - initialize, tools/list
  with paging, tools/call; answers as JSON or event stream; session kept and
  opened again when the server forgets it; answers capped at 4 MB.
- McpServerTool: one server is one tool (mcp_<id>); its remote tools are the
  operations, so rights, preflight, staging, lead approvals, votes, audit and
  budget apply. On approval by default unless the human trusts an operation;
  reads foreign content; the server's descriptions are cut short and marked.
- McpConnections: registers, refreshes every 15 minutes, follows changes.
- InstanceConfig.mcpServers with protected headers; /api/mcp-servers;
  settings card "External MCP servers"; tool catalog includes the servers.
- stdio servers are not part of this (sandbox runner, separate step).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-10-01 18:15:44 +02:00
co-authored by Claude Opus 5.5
parent 44844a41a0
commit caf4323012
19 changed files with 1089 additions and 5 deletions
@@ -0,0 +1,241 @@
using System.Net;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Mcp;
using ClawdDotNet.Core.Security;
using ClawdDotNet.Core.Staging;
using ClawdDotNet.Core.Tools;
using ClawdDotNet.Core.Tests.Infrastructure;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
namespace ClawdDotNet.Core.Tests.Mcp;
/// <summary>
/// #197: An external MCP server as one tool — spoken to over Streamable HTTP (JSON or an event
/// stream), on approval by default, its words cut short and its results foreign content.
/// </summary>
public sealed class McpClientTests
{
/// <summary>A small MCP server: two tools, a session, answers as JSON or as an event stream.</summary>
private sealed class FakeServer(bool eventStream = false) : HttpMessageHandler
{
public List<JsonObject> Received { get; } = [];
public List<string?> Sessions { get; } = [];
public List<string?> Authorization { get; } = [];
public bool ForgetSessionOnce { get; set; }
private int _session;
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
{
var message = JsonNode.Parse(await request.Content!.ReadAsStringAsync(ct))!.AsObject();
Received.Add(message);
Sessions.Add(request.Headers.TryGetValues("Mcp-Session-Id", out var s) ? s.First() : null);
Authorization.Add(request.Headers.Authorization?.ToString());
var method = message["method"]!.GetValue<string>();
if (message["id"] is null)
return new HttpResponseMessage(HttpStatusCode.Accepted);
if (ForgetSessionOnce && method != "initialize")
{
ForgetSessionOnce = false;
return new HttpResponseMessage(HttpStatusCode.NotFound);
}
JsonNode result = method switch
{
"initialize" => new JsonObject { ["protocolVersion"] = McpClient.ProtocolVersion, ["capabilities"] = new JsonObject() },
"tools/list" => new JsonObject
{
["tools"] = new JsonArray(
new JsonObject { ["name"] = "send_invoice", ["description"] = new string('d', 500), ["inputSchema"] = JsonNode.Parse("""{"type":"object","properties":{"customer":{"type":"string"}},"required":["customer"]}""") },
new JsonObject { ["name"] = "stock", ["description"] = "Stock level.", ["inputSchema"] = new JsonObject { ["type"] = "object" } })
},
"tools/call" when message["params"]!["name"]!.GetValue<string>() == "stock"
=> new JsonObject { ["content"] = new JsonArray(new JsonObject { ["type"] = "text", ["text"] = "340 pieces" }, new JsonObject { ["type"] = "image", ["mimeType"] = "image/png", ["data"] = "AAA" }) },
"tools/call" => new JsonObject { ["isError"] = true, ["content"] = new JsonArray(new JsonObject { ["type"] = "text", ["text"] = "customer unknown" }) },
_ => new JsonObject()
};
var answer = new JsonObject { ["jsonrpc"] = "2.0", ["id"] = message["id"]!.DeepClone(), ["result"] = result }.ToJsonString();
var response = new HttpResponseMessage(HttpStatusCode.OK);
if (method == "initialize")
response.Headers.Add("Mcp-Session-Id", "session-" + ++_session);
response.Content = eventStream
? new StringContent($"event: message\ndata: {{\"jsonrpc\":\"2.0\",\"method\":\"notifications/progress\"}}\n\nevent: message\ndata: {answer}\n\n", Encoding.UTF8, "text/event-stream")
: new StringContent(answer, Encoding.UTF8, "application/json");
return response;
}
}
private static McpServerConfig Config() => new()
{
Id = "shop", Name = "Shop", Url = "https://mcp.example/shop",
Headers = new() { ["Authorization"] = "Bearer secret-1" }, WithoutApproval = ["stock"]
};
private static (McpServerTool Tool, FakeServer Server) Connect(bool eventStream = false)
{
var server = new FakeServer(eventStream);
var config = Config();
return (new McpServerTool(config, new McpClient(config, new HttpClient(server))), server);
}
private static AgentToolContext Context()
=> new("buyer", "inst", new Dictionary<string, object?>(), new NullStateStore(), NullLogger.Instance, CancellationToken.None);
private static Task<ToolResult> Run(McpServerTool tool, string json)
=> tool.ExecuteAsync(JsonDocument.Parse(json).RootElement, Context(), CancellationToken.None);
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task The_tools_of_the_server_become_the_operations_of_one_tool(bool eventStream)
{
var (tool, server) = Connect(eventStream);
(await tool.RefreshAsync(default)).ShouldBeTrue(tool.Problem);
tool.Name.ShouldBe("mcp_shop");
tool.RemoteTools.Select(t => t.Name).ShouldBe(["send_invoice", "stock"]);
tool.InputSchema.GetProperty("properties").GetProperty("action").GetProperty("enum").GetArrayLength().ShouldBe(2);
server.Received.Select(m => m["method"]!.GetValue<string>()).ShouldBe(["initialize", "notifications/initialized", "tools/list"]);
server.Sessions[^1].ShouldBe("session-1", "the session the server opened is carried");
server.Authorization.ShouldAllBe(a => a == "Bearer secret-1");
}
[Fact]
public async Task A_call_returns_the_text_and_names_what_it_left_out()
{
var (tool, _) = Connect();
await tool.RefreshAsync(default);
var result = await Run(tool, """{"action":"stock","arguments":{"item":"A4"}}""");
result.Success.ShouldBeTrue(result.ErrorMessage);
result.Content.ShouldContain("340 pieces");
result.Content.ShouldContain("[image content omitted: image/png]");
}
[Fact]
public async Task An_error_of_the_server_is_an_error_of_the_tool()
{
var (tool, _) = Connect();
await tool.RefreshAsync(default);
var result = await Run(tool, """{"action":"send_invoice","arguments":{"customer":"x"}}""");
result.Success.ShouldBeFalse();
result.ErrorMessage!.ShouldContain("customer unknown");
}
[Fact]
public async Task A_forgotten_session_is_opened_again()
{
var (tool, server) = Connect();
await tool.RefreshAsync(default);
server.ForgetSessionOnce = true;
(await Run(tool, """{"action":"stock"}""")).Success.ShouldBeTrue();
server.Received.Count(m => m["method"]!.GetValue<string>() == "initialize").ShouldBe(2);
server.Sessions[^1].ShouldBe("session-2");
}
[Fact]
public async Task Preflight_names_unknown_tools_and_missing_arguments_before_anything_is_staged()
{
var (tool, _) = Connect();
await tool.RefreshAsync(default);
tool.Preflight(JsonDocument.Parse("""{"action":"delete_all"}""").RootElement, Context())!.ShouldContain("has no tool 'delete_all'");
tool.Preflight(JsonDocument.Parse("""{"action":"send_invoice","arguments":{}}""").RootElement, Context())!.ShouldContain("customer");
tool.Preflight(JsonDocument.Parse("""{"action":"send_invoice","arguments":{"customer":"c"}}""").RootElement, Context()).ShouldBeNull();
}
[Fact]
public async Task Foreign_tools_stand_on_approval_unless_trusted_and_their_words_are_cut_short()
{
var (tool, _) = Connect();
await tool.RefreshAsync(default);
tool.Vorgabe("send_invoice", Context()).ShouldBe(StagingDecision.Approve);
tool.Vorgabe("stock", Context()).ShouldBeNull("trusted by the human");
tool.ReadsForeignContent.ShouldBeTrue();
tool.Description.ShouldContain("the server's own words");
tool.Description.ShouldNotContain(new string('d', McpServerTool.MaxRemoteDescription + 1));
}
[Fact]
public async Task An_unreachable_server_keeps_its_problem_and_no_tools()
{
var config = Config();
var tool = new McpServerTool(config, new McpClient(config, new HttpClient(new FailingHandler())));
(await tool.RefreshAsync(default)).ShouldBeFalse();
tool.Problem.ShouldNotBeNull();
tool.Description.ShouldContain("Not reachable");
}
private sealed class FailingHandler : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
=> Task.FromResult(new HttpResponseMessage(HttpStatusCode.Unauthorized));
}
[Fact]
public async Task The_connections_follow_the_configuration()
{
var registry = new ToolRegistry();
var servers = new List<McpServerConfig> { Config() };
await using var connections = new McpConnections(registry, () => servers, NullLogger.Instance, () => new FakeServer());
await connections.SyncAsync(default);
registry.Get("mcp_shop").ShouldNotBeNull();
connections.Find("shop")!.RemoteTools.Count.ShouldBe(2);
servers.Clear();
await connections.SyncAsync(default);
registry.Get("mcp_shop").ShouldBeNull("a removed server is no tool any more");
}
[Fact]
public void The_event_stream_yields_the_answer_with_our_id_only()
{
var body = "data: {\"jsonrpc\":\"2.0\",\"id\":7,\"result\":{\"x\":1}}\n\ndata: {\"jsonrpc\":\"2.0\",\"id\":8,\"result\":{\"x\":2}}\n\n";
McpClient.FromEventStream(body, 8)!["result"]!["x"]!.GetValue<int>().ShouldBe(2);
McpClient.FromEventStream(body, 9).ShouldBeNull();
}
[Fact]
public void The_headers_of_a_server_are_kept_protected()
{
var keys = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N"));
SecretKeyStore.UseDirectory(keys);
try
{
var instance = new InstanceConfig();
instance.McpServers.Add(Config());
ConfigSecrets.Protect(instance);
instance.McpServers[0].Headers["Authorization"].ShouldNotContain("secret-1");
ConfigSecrets.Unprotect(instance);
instance.McpServers[0].Headers["Authorization"].ShouldBe("Bearer secret-1");
}
finally
{
SecretKeyStore.UseDirectory(null);
try { Directory.Delete(keys, recursive: true); } catch { /* cleanup is secondary */ }
}
}
}
internal sealed class NullStateStore : ClawdDotNet.Core.State.IStateStore
{
public Task<string?> GetAsync(string key, CancellationToken ct) => Task.FromResult<string?>(null);
public Task SetAsync(string key, string value, CancellationToken ct) => Task.CompletedTask;
public Task DeleteAsync(string key, CancellationToken ct) => Task.CompletedTask;
}