Files

216 lines
8.9 KiB
C#

using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace Volcanic.AgenticCore.Extensions.ClaudeCode.Bridge;
/// <summary>
/// Proposal-only MCP stdio server. It lists the tool catalogue the provider wrote for it and refuses
/// every <c>tools/call</c>: Claude Code's <c>dontAsk</c> mode denies the call itself, and the provider
/// captures the model's <c>tool_use</c> block as a proposal for the core to execute.
/// Line-delimited JSON-RPC 2.0 over stdio; a request over <see cref="MaxRequestBytes"/> is fatal so
/// rejected framing state is never reused.
/// </summary>
public static class Bridge
{
public const int MaxRequestBytes = 1024 * 1024;
public const string CatalogVariable = "VOLCANIC_CLAUDE_TOOL_CATALOG";
public const string ReadyVariable = "VOLCANIC_CLAUDE_TOOL_READY";
public const string ViolationVariable = "VOLCANIC_CLAUDE_TOOL_VIOLATION";
public const string ServerName = "volcanic-tool-proposals";
public const string ServerVersion = "1.0.0";
public const string ProtocolVersion = "2025-11-25";
public const string ExecutionRefused = "Security invariant: this server proposes tools but never executes them";
public static int Main()
{
var env = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
foreach (System.Collections.DictionaryEntry entry in Environment.GetEnvironmentVariables())
env[(string)entry.Key] = entry.Value as string;
var utf8 = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false);
using var input = new StreamReader(Console.OpenStandardInput(), utf8, detectEncodingFromByteOrderMarks: false);
using var output = new StreamWriter(Console.OpenStandardOutput(), utf8) { AutoFlush = true, NewLine = "\n" };
using var error = new StreamWriter(Console.OpenStandardError(), utf8) { AutoFlush = true, NewLine = "\n" };
return Run(input, output, error, env);
}
/// <summary>Serves requests from <paramref name="input"/> until EOF. Returns 0 on EOF, 1 on a fatal request, 2 on a startup failure.</summary>
public static int Run(TextReader input, TextWriter output, TextWriter error, IReadOnlyDictionary<string, string?> env)
{
if (!env.TryGetValue(CatalogVariable, out var catalogPath) || string.IsNullOrWhiteSpace(catalogPath))
return FailStartup(error, $"{CatalogVariable} is required");
JsonArray tools;
try
{
var node = JsonNode.Parse(File.ReadAllText(catalogPath));
tools = node as JsonArray ?? throw new InvalidDataException("catalog must be an array");
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException or InvalidDataException or NotSupportedException)
{
return FailStartup(error, $"invalid tool catalog: {ex.Message}");
}
env.TryGetValue(ReadyVariable, out var readyPath);
env.TryGetValue(ViolationVariable, out var violationPath);
var server = new Server(output, tools, readyPath, violationPath);
try
{
var buffer = new char[8192];
var pending = new StringBuilder();
while (true)
{
var read = input.Read(buffer, 0, buffer.Length);
if (read <= 0) break;
var start = 0;
for (var i = 0; i < read; i++)
{
if (buffer[i] != '\n') continue;
pending.Append(buffer, start, i - start);
start = i + 1;
if (!server.AcceptLine(TakeLine(pending))) return 1;
}
pending.Append(buffer, start, read - start);
// Every char is at least one byte, so this many chars can never fit the byte bound (one CR allowed).
if (pending.Length > MaxRequestBytes + 1)
{
server.FailOversized();
return 1;
}
}
if (pending.Length > 0 && !server.AcceptLine(TakeLine(pending))) return 1;
return 0;
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
error.WriteLine($"MCP bridge failure: {ex.Message}");
error.Flush();
return 1;
}
}
private static string TakeLine(StringBuilder pending)
{
if (pending.Length > 0 && pending[^1] == '\r') pending.Length--;
var line = pending.ToString();
pending.Clear();
return line;
}
private static int FailStartup(TextWriter error, string message)
{
error.WriteLine(message);
error.Flush();
return 2;
}
private sealed class Server(TextWriter output, JsonArray tools, string? readyPath, string? violationPath)
{
private static readonly JsonSerializerOptions WireOptions = new(JsonSerializerDefaults.General);
/// <summary>Handles one line. Returns false when the request was oversized and the server must stop.</summary>
public bool AcceptLine(string line)
{
if (Encoding.UTF8.GetByteCount(line) > MaxRequestBytes)
{
FailOversized();
return false;
}
if (string.IsNullOrWhiteSpace(line)) return true;
JsonNode? message;
try
{
message = JsonNode.Parse(line);
}
catch (JsonException)
{
SendError(null, -32700, "Invalid JSON-RPC request");
return true;
}
if (message is not JsonObject request)
{
SendError(null, -32600, "Invalid JSON-RPC request");
return true;
}
var hasId = request.ContainsKey("id");
var id = request["id"];
var method = request["method"] is JsonValue value && value.TryGetValue<string>(out var m) ? m : null;
switch (method)
{
case "initialize":
if (hasId)
{
Send(new JsonObject
{
["jsonrpc"] = "2.0",
["id"] = id?.DeepClone(),
["result"] = new JsonObject
{
["protocolVersion"] = ProtocolVersion,
["capabilities"] = new JsonObject { ["tools"] = new JsonObject() },
["serverInfo"] = new JsonObject { ["name"] = ServerName, ["version"] = ServerVersion },
},
});
}
return true;
case "tools/list":
if (hasId)
{
Send(new JsonObject
{
["jsonrpc"] = "2.0",
["id"] = id?.DeepClone(),
["result"] = new JsonObject { ["tools"] = tools.DeepClone() },
});
}
Mark(readyPath, "ready\n");
return true;
case "tools/call":
Mark(violationPath, "tools/call reached proposal-only MCP server\n");
if (hasId) SendError(id, -32001, ExecutionRefused);
return true;
default:
// Notifications (no id) such as notifications/initialized are ignored.
if (hasId) SendError(id, -32601, $"Method not found: {method ?? "null"}");
return true;
}
}
public void FailOversized() => SendError(null, -32700, "MCP request exceeds the bridge limit");
private void SendError(JsonNode? id, int code, string message) => Send(new JsonObject
{
["jsonrpc"] = "2.0",
["id"] = id?.DeepClone(),
["error"] = new JsonObject { ["code"] = code, ["message"] = message },
});
private void Send(JsonObject response)
{
output.Write(response.ToJsonString(WireOptions));
output.Write('\n');
output.Flush();
}
private static void Mark(string? path, string contents)
{
if (string.IsNullOrEmpty(path)) return;
try
{
var options = new FileStreamOptions { Mode = FileMode.CreateNew, Access = FileAccess.Write, Share = FileShare.None };
if (!OperatingSystem.IsWindows()) options.UnixCreateMode = UnixFileMode.UserRead | UnixFileMode.UserWrite;
using var stream = new FileStream(path, options);
stream.Write(Encoding.UTF8.GetBytes(contents));
}
catch (IOException) when (File.Exists(path))
{
// Marker already written by an earlier request: keep the first one.
}
}
}
}