Editor/ExternalBridge/McpBridgeServer.cs
#nullable enable
using System;
using System.IO;
using System.Linq;
using System.Net;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Sandbox;
namespace Editor.Mcp
{
public static class McpBridgeServer
{
private const int MaxBodyBytes = 1024 * 1024;
private static HttpListener? _listener;
private static CancellationTokenSource? _cts;
private static Task? _listenTask;
private static string? _token;
private static int _port;
[McpTool("pocketknife_bridge_start")]
public static string StartBridge([Description("Port to listen on")] int port = 7270)
{
try
{
if (port is < 1024 or > 65535) return McpJson.Write(ToolOutcome<object>.Failure(null, ToolStatus.Failed, "bridge.invalid_port", "Port must be between 1024 and 65535."));
if (_listener?.IsListening == true) return McpJson.Write(ToolOutcome<object>.Ready(new { Port = _port, Endpoint = $"http://127.0.0.1:{_port}/mcp-bridge/v1", Token = _token, AlreadyRunning = true }));
_port = port; _token = Convert.ToHexString(RandomNumberGenerator.GetBytes(32)).ToLowerInvariant(); _cts = new CancellationTokenSource(); _listener = new HttpListener(); _listener.Prefixes.Add($"http://127.0.0.1:{port}/mcp-bridge/"); _listener.Start();
_listenTask = Task.Run(ListenAsync, _cts.Token);
return McpJson.Write(ToolOutcome<object>.Ready(new { Port = port, Endpoint = $"http://127.0.0.1:{port}/mcp-bridge/v1", Token = _token, Protocol = "hearth-mcp-bridge/v1" }));
}
catch (Exception ex) { _listener = null; return McpJson.Write(ToolOutcome<object>.Failure(null, ToolStatus.Failed, "bridge.start_failed", "Bridge startup failed.", ex)); }
}
[McpTool("pocketknife_bridge_stop")]
public static string StopBridge()
{
if (_listener?.IsListening != true) return McpJson.Write(ToolOutcome<object>.Degraded(new { Running = false }, "bridge.not_running", "Bridge is not running."));
try { _cts?.Cancel(); _listener.Stop(); _listener.Close(); _listener = null; _token = null; return McpJson.Write(ToolOutcome<object>.Ready(new { Running = false })); }
catch (Exception ex) { return McpJson.Write(ToolOutcome<object>.Failure(null, ToolStatus.Failed, "bridge.stop_failed", "Bridge shutdown failed.", ex)); }
}
private static async Task ListenAsync()
{
try { while (_cts?.IsCancellationRequested != true && _listener?.IsListening == true) { var context = await _listener.GetContextAsync(); _ = HandleRequestAsync(context); } }
catch (HttpListenerException) { }
catch (ObjectDisposedException) { }
catch (Exception ex) { Console.Error.WriteLine($"[MCP Bridge] Listener stopped: {ex.Message}"); }
}
private static async Task HandleRequestAsync(HttpListenerContext context)
{
var response = context.Response; try
{
if (context.Request.HttpMethod == "GET" && context.Request.Url?.AbsolutePath.EndsWith("/health", StringComparison.OrdinalIgnoreCase) == true) { await WriteJson(response, 200, McpJson.Write(ToolOutcome<object>.Ready(new { Running = true, Port = _port, Protocol = "hearth-mcp-bridge/v1" }))); return; }
if (context.Request.HttpMethod != "POST") { await WriteJson(response, 405, McpJson.Write(ToolOutcome<object>.Failure(null, ToolStatus.Failed, "bridge.method_not_allowed", "Only POST command requests and GET health are supported."))); return; }
if (!string.Equals(context.Request.Headers["Authorization"], "Bearer " + _token, StringComparison.Ordinal)) { await WriteJson(response, 401, McpJson.Write(ToolOutcome<object>.Failure(null, ToolStatus.Failed, "bridge.unauthorized", "A valid bearer token is required."))); return; }
if (context.Request.ContentLength64 > MaxBodyBytes) { await WriteJson(response, 413, McpJson.Write(ToolOutcome<object>.Failure(null, ToolStatus.Failed, "bridge.body_too_large", "Request body exceeds the 1 MiB limit."))); return; }
using var reader = new StreamReader(context.Request.InputStream, context.Request.ContentEncoding); var payload = await reader.ReadToEndAsync(); if (Encoding.UTF8.GetByteCount(payload) > MaxBodyBytes) { await WriteJson(response, 413, McpJson.Write(ToolOutcome<object>.Failure(null, ToolStatus.Failed, "bridge.body_too_large", "Request body exceeds the 1 MiB limit."))); return; }
using var document = JsonDocument.Parse(payload); var root = document.RootElement; var command = root.TryGetProperty("command", out var c) ? c.GetString() : null; var requestId = root.TryGetProperty("id", out var id) ? id.ToString() : Guid.NewGuid().ToString("N");
if (command == "health") { await WriteJson(response, 200, McpJson.Write(ToolOutcome<object>.Ready(new { Id = requestId, Running = true }))); return; }
if (command == "editor_action" && root.TryGetProperty("action", out var actionElement)) { var action = actionElement.GetString() ?? ""; if (!IsApprovedAction(action)) { await WriteJson(response, 403, McpJson.Write(ToolOutcome<object>.Failure(null, ToolStatus.Failed, "bridge.command_not_allowed", $"Editor action '{action}' is not allowlisted."))); return; } if (!EditorRuntime.Trigger(action)) { await WriteJson(response, 503, McpJson.Write(ToolOutcome<object>.Failure(null, ToolStatus.Unavailable, "bridge.editor_action_unavailable", "The loaded editor build does not expose ActionManager.Trigger."))); return; } await WriteJson(response, 200, McpJson.Write(ToolOutcome<object>.Degraded(new { Id = requestId, Command = command, Action = action, Accepted = true }, "bridge.postcondition_unverified", "The action was dispatched, but active editor-state verification must be performed through the live editor MCP."))); return; }
await WriteJson(response, 400, McpJson.Write(ToolOutcome<object>.Failure(null, ToolStatus.Failed, "bridge.unknown_command", "Unknown bridge command. Supported commands: health, editor_action.")));
}
catch (JsonException ex) { await WriteJson(response, 400, McpJson.Write(ToolOutcome<object>.Failure(null, ToolStatus.Failed, "bridge.invalid_json", "Request body was not valid JSON.", ex))); }
catch (Exception ex) { await WriteJson(response, 500, McpJson.Write(ToolOutcome<object>.Failure(null, ToolStatus.Failed, "bridge.request_failed", "Bridge request failed.", ex))); }
finally { response.Close(); }
}
private static bool IsApprovedAction(string action) => new[] { "assets.refresh_all", "project.compile", "navmesh.bake", "screenshot.capture", "window.scene", "window.code", "window.assetbrowser" }.Contains(action, StringComparer.OrdinalIgnoreCase);
private static async Task WriteJson(HttpListenerResponse response, int status, string json) { var bytes = Encoding.UTF8.GetBytes(json); response.StatusCode = status; response.ContentType = "application/json"; response.ContentEncoding = Encoding.UTF8; response.ContentLength64 = bytes.Length; await response.OutputStream.WriteAsync(bytes, 0, bytes.Length); }
}
}