ClawdDotNet als MCP-Server: durch dieselben Pruefstellen (#713)
Eine fremde Laufzeit oder der eigene Claude Code des Menschen spricht per MCP mit der Instanz. Der Server bietet genau die Werkzeuge des Agenten hinter dem Token an; jeder Aufruf laeuft durch die Engine wie ein Aufruf des eigenen Modells (Rechtetor, Vorpruefung, Werkzeugrechte, Staging, Audit mit Quelle mcp, Rahmung), und der Aufrufer gilt als Leser fremder Inhalte (#715). Eigener Socket mcp.sock statt manager.sock: ueber den Instanz-Socket antwortet die Verwaltung ohne Token, ueber mcp.sock gibt es nur /mcp und nur mit MCP-Token. Lauf-Tokens nur im Speicher, enden mit dem Lauf; persoenliche Tokens (hoechstens 90 Tage, widerrufbar, nur als SHA-256) in der Agentenansicht. Transporte: HTTP auf mcp.sock, Weboberflaeche und Manager (/mcp/{instanz}), stdio ueber ClawdDotNet --mcp-bridge (Token nur aus Umgebung oder 0600-Datei). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
b5f65b8cf7
commit
cb46289bb6
@@ -0,0 +1,105 @@
|
||||
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");
|
||||
});
|
||||
|
||||
// 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}'.")));
|
||||
}
|
||||
|
||||
/// <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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user