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>
138 lines
5.6 KiB
C#
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 <dir></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();
|
|
}
|
|
}
|