216 lines
8.9 KiB
C#
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.
|
|
}
|
|
}
|
|
}
|
|
}
|