using System.Net; using System.Net.Http.Headers; using System.Text; using System.Text.Json.Nodes; namespace ClawdDotNet.Core.Mcp; /// /// #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. /// /// The bridge is that child: ClawdDotNet --mcp-bridge --instance <dir>. It /// reads one JSON-RPC message per line, hands it to the instance over the MCP socket /// () and writes the answer back /// as one line. It decides nothing — the instance checks the token and every call. /// /// The token never comes from the command line. A command line is visible to /// every process on the machine. It comes from or from a file /// named in , which must not be readable by anyone but its /// owner (0600). /// 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"; /// JSON-RPC error codes for what only the bridge can know. public const int Unauthorized = -32001; public const int Unreachable = -32002; /// /// 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. /// public static (string? Token, string? Problem) ReadToken(Func 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}"); } } /// /// 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. /// 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 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}"); } } /// An error for the request on — none for a notification. 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(); } }