Editor-side JSON-RPC protocol handler for the ProtonMcpBridge. It implements JSON-RPC 2.0 methods initialize, ping, tools/list and tools/call, routes tool calls to EngineBridge, and returns success or error responses.
using System;
using System.Linq;
using System.Text.Json.Nodes;
using System.Threading.Tasks;
namespace ProtonMcpBridge;
/// <summary>
/// The JSON-RPC 2.0 / MCP shell: handles initialize and ping, routes tools/list and tools/call to
/// <see cref="EngineBridge"/>.
/// </summary>
internal static class Protocol
{
/// <summary>
/// Protocol revisions this speaks, newest first. An unknown request is answered with the newest.
/// </summary>
static readonly string[] SupportedProtocolVersions =
[
"2025-11-25",
"2025-06-18",
"2025-03-26",
"2024-11-05",
];
/// <summary>
/// Sent at initialize and injected into the agent's context - registry-wide conventions that apply
/// across every tool.
/// </summary>
static readonly string Instructions = """
This server is embedded in the s&box editor and operates on the project that is currently open in it. Start with editor_status to see what's open and whether play mode is running. Your tool list is just the entry points - the real tools live in editor and addon code and come and go as code hotloads. Find them with search_tools, invoke them with call_tool, or batch several invocations with call_tools. After doing anything, read_console shows what the editor logged - errors and exceptions land there. Tool failures come back as results with isError rather than protocol errors, so read them and adapt.
Conventions, everywhere in the registry:
- Paging is 'limit' and 'offset'. Defaults and ranges are part of each parameter's schema, and out of range values clamp rather than error.
- Vectors and angles are comma strings, not arrays or objects - a position is 'x,y,z', view angles are 'pitch,yaw,roll'.
- The coordinate system is Source engine convention, like Half-Life 2: one unit is one inch, +x is forward, +y is left, +z is up. Angles are degrees.
- Game objects and components are identified by guid. Assets are identified by the relative path asset_search returns.
- Every tool that edits the scene pushes an undo step, so the user can ctrl+z your changes like their own.
""";
/// <summary>
/// Handle a single message, returning null when it doesn't warrant a response (notifications).
/// Runs on the thread pool - tool calls marshal onto the main thread inside the registry.
/// </summary>
public static async Task<JsonRpcResponse> Handle(JsonRpcMessage message)
{
// Notifications get no response; a missing method means a response to a server-initiated request.
if (message.IsNotification || message.Method is null)
return null;
try
{
var result = message.Method switch
{
"initialize" => Initialize(message.Params),
"ping" => new JsonObject(),
"tools/list" => EngineBridge.ListJson(),
"tools/call" => await ToolsCall(message.Params),
_ => throw new McpException(
JsonRpcError.MethodNotFound,
$"Method not found: {message.Method}"
),
};
return JsonRpcResponse.Success(message.Id, result);
}
catch (McpException e)
{
return JsonRpcResponse.Failure(message.Id, e.Code, e.Message);
}
catch (Exception e)
{
return JsonRpcResponse.Failure(message.Id, JsonRpcError.InternalError, e.Message);
}
}
static JsonNode Initialize(JsonNode param)
{
var requested = param?["protocolVersion"]?.ToString();
var version = SupportedProtocolVersions.Contains(requested)
? requested
: SupportedProtocolVersions[0];
return new JsonObject
{
["protocolVersion"] = version,
["capabilities"] = new JsonObject
{
["tools"] = new JsonObject { ["listChanged"] = false },
},
["serverInfo"] = new JsonObject
{
["name"] = "sbox-editor",
["title"] = "s&box Editor (Proton bridge)",
["version"] = $"{Sandbox.Application.Version}",
["description"] =
"The s&box editor - live tools for the project open in it, served over a Wine-safe TCP transport",
},
["instructions"] = Instructions,
};
}
static async Task<JsonNode> ToolsCall(JsonNode param)
{
var name = param?["name"]?.ToString()?.Trim();
if (string.IsNullOrEmpty(name))
throw new McpException(JsonRpcError.InvalidParams, "Missing tool name");
return await EngineBridge.Call(name, param?["arguments"]);
}
}