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:
Richard
2026-09-25 17:28:47 +02:00
co-authored by Claude Opus 5.5
parent b5f65b8cf7
commit cb46289bb6
25 changed files with 1672 additions and 12 deletions
+10
View File
@@ -135,6 +135,16 @@ public sealed record TaskDto(
decimal? EstimateUsd = null,
DateTime? FillAlertedAt = null);
/// <summary>#713: A personal MCP token as listed — never the token itself.</summary>
public sealed record McpTokenDto(
string Id, string AgentId, string Label, DateTime CreatedAt, DateTime ExpiresAt, DateTime? RevokedAt, bool Active);
/// <summary>#713: A new personal token — the only answer that carries it in clear.</summary>
public sealed record McpTokenIssuedDto(McpTokenDto Info, string Token);
/// <summary>#713: What the WebUI sends to issue a personal token.</summary>
public sealed record McpTokenInputDto(string AgentId, string? Label = null, int? Days = null);
/// <summary>#717: A special budget — the amount a human approved for a task tree, and what it used.</summary>
public sealed record SpecialBudgetDto(string TaskId, decimal AmountUsd, decimal SpentUsd, string Reason, string RequestedBy, DateTime ApprovedAt);
@@ -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;
}
}
+10
View File
@@ -201,12 +201,15 @@ public sealed class InstanceHttpHost : IAsyncDisposable
// 3. Statische Dateien - die leere Huelle, bewusst ohne Token.
// 4. Endpunkte.
app.Use(ReportUnhandled(host, loggerFactory));
// #713: The MCP socket leads to /mcp and nowhere else — before any other gate.
app.Use(McpTransport.OnlyMcp);
app.Use(CheckToken);
if (options is not null)
MapStaticFiles(app);
app.MapApi();
app.MapMcp(loggerFactory);
return app;
}
@@ -228,12 +231,19 @@ public sealed class InstanceHttpHost : IAsyncDisposable
kestrel.ListenNamedPipe(
InstanceEndpoint.PipeName(instancePath),
listen => listen.Use(LocalTransport.Mark));
kestrel.ListenNamedPipe(
InstanceEndpoint.McpPipeName(instancePath),
listen => listen.Use(McpTransport.Mark));
}
else
{
kestrel.ListenUnixSocket(
InstanceEndpoint.SocketPath(instancePath),
listen => listen.Use(LocalTransport.Mark));
// #713: The MCP server's own socket — never the instance socket (see McpTransport).
kestrel.ListenUnixSocket(
InstanceEndpoint.McpSocketPath(instancePath),
listen => listen.Use(McpTransport.Mark));
}
if (options is null) return;
@@ -0,0 +1,42 @@
using Microsoft.AspNetCore.Connections;
using Microsoft.AspNetCore.Http;
namespace ClawdDotNet.Web.Security;
/// <summary>
/// #713: The mark of a connection over the MCP socket (<c>mcp.sock</c>).
///
/// <para>The counterpart of <see cref="LocalTransport"/>, with the opposite meaning. The
/// instance socket opens everything without a token; the MCP socket opens nothing but
/// <c>/mcp</c>, and that only with an MCP token. It is what a foreign runtime — later in its
/// sandbox (#654) — is given, so it must not lead anywhere else. Set only by the listener
/// itself, like the other mark.</para>
/// </summary>
internal sealed class McpTransport
{
private static readonly McpTransport Instance = new();
private McpTransport() { }
public static Func<ConnectionDelegate, ConnectionDelegate> Mark
=> next => connection =>
{
connection.Features.Set(Instance);
return next(connection);
};
public static bool Applies(HttpContext context)
=> context.Features.Get<McpTransport>() is not null;
/// <summary>Over the MCP socket, every path but <c>/mcp</c> does not exist.</summary>
public static async Task OnlyMcp(HttpContext context, RequestDelegate next)
{
if (Applies(context) && !context.Request.Path.Equals("/mcp", StringComparison.Ordinal))
{
context.Response.StatusCode = StatusCodes.Status404NotFound;
return;
}
await next(context);
}
}