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; /// /// #713: The MCP server over HTTP (streamable HTTP, answered as JSON — no event stream) and /// the administration of personal tokens. /// /// /mcp lies outside /api 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. /// internal static class McpEndpoints { /// One JSON-RPC message is small; anything this large is not one. 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())); 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()); 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); } /// Writes the input into the server; headers only where a value was given — an empty value removes one. 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 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)); } /// Only the header — never the query string: an address ends up in logs. private static string? BearerToken(HttpRequest request) { var header = request.Headers.Authorization.ToString(); return header.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase) ? header[7..].Trim() : null; } }