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();
}
}