- 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>
256 lines
14 KiB
C#
256 lines
14 KiB
C#
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;
|
||
}
|
||
}
|