Files
ClawdDotNet/src/ClawdDotNet.Web/Endpoints/McpEndpoints.cs
T
RichardandClaude Opus 5.5 caf4323012 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>
2026-10-01 18:15:44 +02:00

256 lines
14 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using ClawdDotNet.App;
using ClawdDotNet.Core.Mcp;
using ClawdDotNet.Web.Contracts;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Logging;
namespace ClawdDotNet.Web.Endpoints;
/// <summary>
/// #713: The MCP server over HTTP (streamable HTTP, answered as JSON — no event stream) and
/// the administration of personal tokens.
///
/// <para><c>/mcp</c> lies outside <c>/api</c> on purpose: it does not take the WebUI's token
/// and does not get the socket's token-free access. The one thing that opens it is an MCP
/// token — a run's, or a personal one — and that token says which agent is asking.</para>
/// </summary>
internal static class McpEndpoints
{
/// <summary>One JSON-RPC message is small; anything this large is not one.</summary>
private const int MaxMessageBytes = 1024 * 1024;
public static void MapMcp(this WebApplication app, ILoggerFactory loggerFactory)
{
var logger = loggerFactory.CreateLogger("ClawdDotNet.Web.Mcp");
app.MapPost("/mcp", async (HttpContext context, AppHost host, CancellationToken ct) =>
{
// Browsers send an Origin; MCP clients do not. A page must not be able to talk to
// the server through the visitor's browser (DNS rebinding, MCP transport spec).
if (context.Request.Headers.Origin.Count > 0)
return Results.StatusCode(StatusCodes.Status403Forbidden);
if (host.Mcp is not { } grants || host.McpToolHost is not { } engine)
return ApiEndpoints.Unavailable("No MCP server: agents are not active in this instance.");
var grant = await grants.ResolveAsync(BearerToken(context.Request), ct);
if (grant is null)
{
context.Response.Headers.WWWAuthenticate = "Bearer";
return Results.Json(new { error = "No valid MCP token." }, statusCode: StatusCodes.Status401Unauthorized);
}
if (context.Request.ContentLength is > MaxMessageBytes)
return Results.StatusCode(StatusCodes.Status413PayloadTooLarge);
string message;
using (var reader = new StreamReader(context.Request.Body))
{
var buffer = new char[MaxMessageBytes + 1];
var read = await reader.ReadBlockAsync(buffer.AsMemory(), ct);
if (read > MaxMessageBytes)
return Results.StatusCode(StatusCodes.Status413PayloadTooLarge);
message = new string(buffer, 0, read);
}
var answer = await new McpServer(engine, logger).HandleAsync(message, grant, ct);
return answer is null
? Results.StatusCode(StatusCodes.Status202Accepted)
: Results.Text(answer, "application/json");
});
// #725: An inbound trigger — n8n or any system that sends a request. Outside /api: its
// own token is the key, and it can do one thing — hand its agent a task.
app.MapPost(Security.McpTransport.TriggerPath, async (HttpContext context, AppHost host, CancellationToken ct) =>
{
if (context.Request.Headers.Origin.Count > 0)
return Results.StatusCode(StatusCodes.Status403Forbidden);
if (host.Triggers is not { } triggers)
return ApiEndpoints.Unavailable("No inbound triggers in this instance.");
if (context.Request.ContentLength is > Core.Triggers.InboundTriggers.MaxPayloadBytes)
return Results.StatusCode(StatusCodes.Status413PayloadTooLarge);
string payload;
using (var reader = new StreamReader(context.Request.Body))
{
var buffer = new char[Core.Triggers.InboundTriggers.MaxPayloadBytes + 1];
var read = await reader.ReadBlockAsync(buffer.AsMemory(), ct);
if (read > Core.Triggers.InboundTriggers.MaxPayloadBytes)
return Results.StatusCode(StatusCodes.Status413PayloadTooLarge);
payload = new string(buffer, 0, read);
}
var result = await triggers.ReceiveAsync(BearerToken(context.Request), payload, ct);
return result.Outcome switch
{
Core.Triggers.TriggerOutcome.Accepted => Results.Json(new { ok = true, taskId = result.TaskId }, statusCode: StatusCodes.Status202Accepted),
Core.Triggers.TriggerOutcome.Unauthorized => Results.Json(new { ok = false, error = result.Message }, statusCode: StatusCodes.Status401Unauthorized),
Core.Triggers.TriggerOutcome.Limited => Results.Json(new { ok = false, error = result.Message }, statusCode: StatusCodes.Status429TooManyRequests),
Core.Triggers.TriggerOutcome.TooLarge => Results.StatusCode(StatusCodes.Status413PayloadTooLarge),
_ => Results.Json(new { ok = false, error = result.Message }, statusCode: StatusCodes.Status503ServiceUnavailable)
};
});
var triggerAdmin = app.MapGroup("/api/triggers");
triggerAdmin.MapGet("/", async (AppHost host, CancellationToken ct) =>
host.Triggers is { } triggers
? Results.Ok((await triggers.ListAsync(ct)).Select(t => new TriggerDto(t.Id, t.Label, t.AgentId, t.PerHour, t.CreatedAtUtc, t.RevokedAtUtc, t.LastUsedAtUtc, t.Received, t.Active)).ToList())
: Results.Ok(Array.Empty<TriggerDto>()));
triggerAdmin.MapPost("/", async (AppHost host, TriggerInputDto input, CancellationToken ct) =>
{
if (host.Triggers is not { } triggers)
return ApiEndpoints.Unavailable("No inbound triggers in this instance.");
if (!host.Instance.Agents.Any(a => string.Equals(a.AgentId, input.AgentId, StringComparison.OrdinalIgnoreCase)))
return Results.BadRequest(new OutcomeDto(false, $"There is no agent '{input.AgentId}'."));
var (t, token) = await triggers.IssueAsync(input.Label ?? "", input.AgentId, input.PerHour ?? 30, ct);
return Results.Ok(new TriggerIssuedDto(new TriggerDto(t.Id, t.Label, t.AgentId, t.PerHour, t.CreatedAtUtc, null, null, 0, true), token));
});
triggerAdmin.MapDelete("/{id}", async (AppHost host, string id, CancellationToken ct) =>
host.Triggers is { } triggers && await triggers.RevokeAsync(id, ct)
? 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));
var tokens = app.MapGroup("/api/mcp/tokens");
tokens.MapGet("/", async (AppHost host, CancellationToken ct) =>
{
if (host.Mcp is not { } grants)
return Results.Ok(Array.Empty<McpTokenDto>());
var now = DateTime.UtcNow;
return Results.Ok((await grants.ListPersonalAsync(ct))
.Select(t => new McpTokenDto(t.Id, t.AgentId, t.Label, t.CreatedAtUtc, t.ExpiresAtUtc, t.RevokedAtUtc, t.IsActive(now)))
.ToList());
});
tokens.MapPost("/", async (AppHost host, McpTokenInputDto input, CancellationToken ct) =>
{
if (host.Mcp is not { } grants)
return ApiEndpoints.Unavailable("No MCP server in this instance.");
if (!host.Instance.Agents.Any(a => string.Equals(a.AgentId, input.AgentId, StringComparison.OrdinalIgnoreCase)))
return Results.BadRequest(new OutcomeDto(false, $"There is no agent '{input.AgentId}'."));
var (info, token) = await grants.IssuePersonalAsync(
input.AgentId, input.Label ?? "", input.Days ?? 30, ct);
return Results.Ok(new McpTokenIssuedDto(
new McpTokenDto(info.Id, info.AgentId, info.Label, info.CreatedAtUtc, info.ExpiresAtUtc, null, true),
token));
});
tokens.MapDelete("/{id}", async (AppHost host, string id, CancellationToken ct) =>
host.Mcp is { } grants && await grants.RevokeAsync(id, ct)
? Results.Ok(new OutcomeDto(true, "Revoked."))
: 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)
{
var header = request.Headers.Authorization.ToString();
return header.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase) ? header[7..].Trim() : null;
}
}