Files
ClawdDotNet/src/ClawdDotNet.Core/Mcp/McpStdioBridge.cs
T
RichardandClaude Opus 5.5 cb46289bb6 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>
2026-09-25 17:28:47 +02:00

138 lines
5.6 KiB
C#

using System.Net;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json.Nodes;
namespace ClawdDotNet.Core.Mcp;
/// <summary>
/// #713: The MCP server over stdio — for a runtime started locally (Claude Code), which
/// launches an MCP server as a child process and speaks to it through stdin and stdout.
///
/// <para>The bridge is that child: <c>ClawdDotNet --mcp-bridge --instance &lt;dir&gt;</c>. It
/// reads one JSON-RPC message per line, hands it to the instance over the MCP socket
/// (<see cref="Instances.InstanceEndpoint.McpSocketFileName"/>) and writes the answer back
/// as one line. It decides nothing — the instance checks the token and every call.</para>
///
/// <para><b>The token never comes from the command line.</b> A command line is visible to
/// every process on the machine. It comes from <see cref="TokenVariable"/> or from a file
/// named in <see cref="TokenFileVariable"/>, which must not be readable by anyone but its
/// owner (0600).</para>
/// </summary>
public static class McpStdioBridge
{
public const string Argument = "--mcp-bridge";
public const string TokenVariable = "CLAWD_MCP_TOKEN";
public const string TokenFileVariable = "CLAWD_MCP_TOKEN_FILE";
/// <summary>JSON-RPC error codes for what only the bridge can know.</summary>
public const int Unauthorized = -32001;
public const int Unreachable = -32002;
/// <summary>
/// The token from the environment or from its file. Returns an error text instead when
/// there is none, or when the file is readable by others.
/// </summary>
public static (string? Token, string? Problem) ReadToken(Func<string, string?> environment)
{
if (environment(TokenVariable) is { Length: > 0 } direct)
return (direct.Trim(), null);
if (environment(TokenFileVariable) is not { Length: > 0 } file)
return (null, $"No token: set {TokenVariable} or {TokenFileVariable} (a file readable only by its owner).");
try
{
if (!OperatingSystem.IsWindows())
{
var mode = File.GetUnixFileMode(file);
const UnixFileMode others = UnixFileMode.GroupRead | UnixFileMode.GroupWrite
| UnixFileMode.OtherRead | UnixFileMode.OtherWrite;
if ((mode & others) != 0)
return (null, $"The token file {file} is readable by others — set it to 0600.");
}
var token = File.ReadAllText(file).Trim();
return token.Length == 0 ? (null, $"The token file {file} is empty.") : (token, null);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
return (null, $"The token file {file} cannot be read: {ex.Message}");
}
}
/// <summary>
/// Passes messages until stdin ends. A failed request becomes a JSON-RPC error for that
/// request, not the end of the bridge — the runtime decides what to do with it.
/// </summary>
public static async Task RunAsync(TextReader input, TextWriter output, HttpClient client, string token, CancellationToken ct)
{
while (await input.ReadLineAsync(ct) is { } line)
{
if (string.IsNullOrWhiteSpace(line))
continue;
var answer = await ForwardAsync(line, client, token, ct);
if (answer is null)
continue;
// stdio framing: one message per line, so no line breaks inside one.
await output.WriteLineAsync(answer.Replace("\r", "").Replace("\n", ""));
await output.FlushAsync(ct);
}
}
private static async Task<string?> ForwardAsync(string line, HttpClient client, string token, CancellationToken ct)
{
using var request = new HttpRequestMessage(HttpMethod.Post, "/mcp")
{
Content = new StringContent(line, Encoding.UTF8, "application/json")
};
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
try
{
using var response = await client.SendAsync(request, ct);
if (response.StatusCode is HttpStatusCode.Accepted or HttpStatusCode.NoContent)
return null;
if (response.StatusCode == HttpStatusCode.Unauthorized)
return ErrorFor(line, Unauthorized, "The token is not valid (anymore) — the run may have ended.");
var body = await response.Content.ReadAsStringAsync(ct);
return response.IsSuccessStatusCode && body.Length > 0
? body
: ErrorFor(line, -32603, $"The instance answered HTTP {(int)response.StatusCode}.");
}
catch (HttpRequestException ex)
{
return ErrorFor(line, Unreachable, $"The instance is not reachable: {ex.Message}");
}
}
/// <summary>An error for the request on <paramref name="line"/> — none for a notification.</summary>
private static string? ErrorFor(string line, int code, string message)
{
JsonNode? id = null;
try
{
if (JsonNode.Parse(line) is JsonObject request)
{
if (!request.ContainsKey("id"))
return null;
id = request["id"]?.DeepClone();
}
}
catch (System.Text.Json.JsonException)
{
}
return new JsonObject
{
["jsonrpc"] = "2.0",
["id"] = id,
["error"] = new JsonObject { ["code"] = code, ["message"] = message }
}.ToJsonString();
}
}