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:
co-authored by
Claude Opus 5.5
parent
44844a41a0
commit
caf4323012
@@ -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)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user