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
+3 -2
View File
@@ -21,7 +21,8 @@ internal static class Mapping
this AgentConfig agent,
IReadOnlyDictionary<string, string> toolDescriptions,
bool isRunning,
Func<string, Core.Tools.IAgentTool?>? tools = null)
Func<string, Core.Tools.IAgentTool?>? tools = null,
IReadOnlyList<string>? catalog = null)
=> new(
agent.AgentId,
agent.DisplayName,
@@ -35,7 +36,7 @@ internal static class Mapping
agent.Budget.DailyCostUsd,
agent.Budget.DailyTokens,
isRunning,
AgentAdministration.KnownTools
(catalog ?? AgentAdministration.KnownTools)
.Select(name => new AgentToolDto(
name,
toolDescriptions.GetValueOrDefault(name) ?? "",
@@ -0,0 +1,14 @@
namespace ClawdDotNet.Web.Contracts;
/// <summary>#197: An external MCP server — header names only, never their values.</summary>
public sealed record McpServerDto(
string Id, string Name, string Url, string ToolName, bool Enabled, int TimeoutSeconds,
IReadOnlyList<string> HeaderNames, IReadOnlyList<string> WithoutApproval,
IReadOnlyList<McpRemoteToolDto> Tools, DateTime? RefreshedAt, string? Problem);
public sealed record McpRemoteToolDto(string Name, string Description);
/// <summary>What the human changes; null leaves a field as it is, an empty header value removes the header.</summary>
public sealed record McpServerInputDto(
string? Id, string? Name, string? Url, bool? Enabled, int? TimeoutSeconds,
Dictionary<string, string?>? Headers, IReadOnlyList<string>? WithoutApproval);
@@ -101,7 +101,7 @@ internal static class ApiEndpoints
var descriptions = host.AgentAdmin.ToolDescriptions();
return host.AgentAdmin.All
.Select(a => a.ToDto(descriptions, host.Engine?.IsRunning(a.AgentId) ?? false, host.Tools.Get))
.Select(a => a.ToDto(descriptions, host.Engine?.IsRunning(a.AgentId) ?? false, host.Tools.Get, host.AgentAdmin.ToolCatalog()))
.ToList();
});
@@ -125,7 +125,8 @@ internal static class ApiEndpoints
? Results.Ok(agent.ToDto(
host.AgentAdmin.ToolDescriptions(),
host.Engine?.IsRunning(agent.AgentId) ?? false,
host.Tools.Get))
host.Tools.Get,
host.AgentAdmin.ToolCatalog()))
: Results.NotFound(new OutcomeDto(false, $"Kein Agent mit der ID '{agentId}'.")));
agents.MapPost("/", (AppHost host, NewAgentDto input) =>
@@ -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)
{
@@ -61,7 +61,7 @@ internal static class OrganisationEndpoints
.ToList()))
.ToList();
return new OrganisationDto(units, agents, chart.Problems(), AgentAdministration.KnownTools, workspaces);
return new OrganisationDto(units, agents, chart.Problems(), host.AgentAdmin.ToolCatalog(), workspaces);
});
org.MapPost("/units", (AppHost host, UnitInputDto input) =>