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
@@ -115,6 +115,50 @@ internal static class McpEndpoints
? Results.Ok(new OutcomeDto(true, "Revoked."))
: Results.NotFound(new OutcomeDto(false, $"No active trigger '{id}'.")));
// #197: External MCP servers — the human adds them, the agents use them as tools.
var servers = app.MapGroup("/api/mcp-servers");
servers.MapGet("/", (AppHost host) => Results.Ok(host.Instance.McpServers.Select(s => ServerDto(host, s)).ToList()));
servers.MapPost("/", async (AppHost host, McpServerInputDto input, CancellationToken ct) =>
{
var id = (input.Id ?? "").Trim().ToLowerInvariant();
if (!Core.Mcp.McpConnections.IdPattern().IsMatch(id))
return Results.BadRequest(new OutcomeDto(false, "An id is 1–31 lower-case letters, digits, '-' or '_'."));
if (host.Instance.McpServers.Any(s => string.Equals(s.Id, id, StringComparison.OrdinalIgnoreCase)))
return Results.BadRequest(new OutcomeDto(false, $"There is already a server '{id}'."));
var server = new Core.Mcp.McpServerConfig { Id = id };
if (Apply(server, input) is { } problem)
return Results.BadRequest(new OutcomeDto(false, problem));
host.Instance.McpServers.Add(server);
return await SaveAndSyncAsync(host, $"MCP server '{server.Name}' added as tool {Core.Mcp.McpServerTool.NameFor(id)}.", ct);
});
servers.MapPut("/{id}", async (AppHost host, string id, McpServerInputDto input, CancellationToken ct) =>
{
if (host.Instance.McpServers.FirstOrDefault(s => string.Equals(s.Id, id, StringComparison.OrdinalIgnoreCase)) is not { } server)
return Results.NotFound(new OutcomeDto(false, $"No server '{id}'."));
if (Apply(server, input) is { } problem)
return Results.BadRequest(new OutcomeDto(false, problem));
return await SaveAndSyncAsync(host, $"MCP server '{server.Name}' saved.", ct);
});
servers.MapDelete("/{id}", async (AppHost host, string id, CancellationToken ct) =>
{
var removed = host.Instance.McpServers.RemoveAll(s => string.Equals(s.Id, id, StringComparison.OrdinalIgnoreCase));
return removed == 0
? Results.NotFound(new OutcomeDto(false, $"No server '{id}'."))
: await SaveAndSyncAsync(host, $"MCP server '{id}' removed. Agents that had its tool keep the entry until it is taken away.", ct);
});
servers.MapPost("/{id}/refresh", async (AppHost host, string id, CancellationToken ct) =>
{
if (host.McpServers?.Find(id) is not { } tool)
return Results.NotFound(new OutcomeDto(false, $"No server '{id}'."));
var ok = await tool.RefreshAsync(ct);
return Results.Ok(new OutcomeDto(ok, ok ? $"{tool.RemoteTools.Count} tool(s) found." : $"Not reachable: {tool.Problem}"));
});
// Streamable HTTP lets a server refuse the event stream; this one keeps no sessions.
app.MapMethods("/mcp", ["GET", "DELETE"], () => Results.StatusCode(StatusCodes.Status405MethodNotAllowed));
@@ -150,6 +194,58 @@ internal static class McpEndpoints
: Results.NotFound(new OutcomeDto(false, $"No active token '{id}'.")));
}
// ─── external MCP servers (#197) ───
private static McpServerDto ServerDto(AppHost host, Core.Mcp.McpServerConfig s)
{
var tool = host.McpServers?.Find(s.Id);
return new McpServerDto(s.Id, s.Name, s.Url, Core.Mcp.McpServerTool.NameFor(s.Id), s.Enabled, s.TimeoutSeconds,
s.Headers.Keys.Order(StringComparer.OrdinalIgnoreCase).ToList(), s.WithoutApproval,
tool?.RemoteTools.Select(t => new McpRemoteToolDto(t.Name, t.Description)).ToList() ?? [],
tool?.RefreshedAtUtc, tool?.Problem);
}
/// <summary>Writes the input into the server; headers only where a value was given — an empty value removes one.</summary>
private static string? Apply(Core.Mcp.McpServerConfig server, McpServerInputDto input)
{
if (input.Name is not null)
server.Name = input.Name.Trim();
if (string.IsNullOrWhiteSpace(server.Name))
server.Name = server.Id;
if (input.Url is not null)
{
if (!Uri.TryCreate(input.Url.Trim(), UriKind.Absolute, out var uri) || uri.Scheme is not ("http" or "https"))
return "The address must be an http(s) URL.";
server.Url = uri.ToString();
}
if (string.IsNullOrWhiteSpace(server.Url))
return "The server needs an address.";
if (input.Enabled is { } enabled)
server.Enabled = enabled;
if (input.TimeoutSeconds is { } timeout)
server.TimeoutSeconds = Math.Clamp(timeout, 5, 600);
if (input.WithoutApproval is not null)
server.WithoutApproval = input.WithoutApproval.Select(o => o.Trim()).Where(o => o.Length > 0).Distinct().ToList();
foreach (var (name, value) in input.Headers ?? [])
{
if (string.IsNullOrWhiteSpace(name) || name.Any(c => c <= ' ' || c == ':'))
return $"'{name}' is no header name.";
if (string.IsNullOrEmpty(value))
server.Headers.Remove(name);
else
server.Headers[name.Trim()] = value;
}
return null;
}
private static async Task<IResult> SaveAndSyncAsync(AppHost host, string message, CancellationToken ct)
{
host.SaveInstance(new App.InstanceChange(App.InstanceChangeKind.Settings));
if (host.McpServers is { } connections)
await connections.SyncAsync(ct);
return Results.Ok(new OutcomeDto(true, message));
}
/// <summary>Only the header — never the query string: an address ends up in logs.</summary>
private static string? BearerToken(HttpRequest request)
{