diff --git a/.gitignore b/.gitignore index 114bd25..ea026ad 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,8 @@ .DS_Store Thumbs.db !Documentation~/ +!Broker~/ +!Broker~/** # Builds *.apk diff --git a/Broker~/README.md b/Broker~/README.md new file mode 100644 index 0000000..822abbb --- /dev/null +++ b/Broker~/README.md @@ -0,0 +1,78 @@ +# Keepalive Broker (optional, survives domain reloads) + +An **opt-in** out-of-process transport that keeps MCP client connections alive across Unity +domain reloads (script recompile / entering Play Mode). It needs **no external runtime** — the +broker runs under the **Unity-bundled Mono**. + +## Why + +funplay's default `HttpMCPTransport` binds a `TcpListener` **inside Unity's managed AppDomain**. +Every domain reload destroys that AppDomain, so the listener dies and the port is released. From +`beforeAssemblyReload` until the post-reload restart rebinds, any MCP client request to the port +fails with **connection refused** — and the tool call that *triggered* the reload (`enter_play_mode`, +`request_recompile`) loses its response. + +A socket living in the reloadable AppDomain fundamentally cannot survive an AppDomain unload. The +only robust fix is to move the client-facing socket **out of process**. + +## How it works + +``` +MCP client (Claude) ──HTTP POST──► broker (separate process, owns the port) ◄──long-poll── Unity plugin + ▲ │ holds request across reload (BrokerClientTransport) + └────────── response ◄─────────────┘ pulls / executes / pushes +``` + +- The **broker** (`keepalive-broker.cs`, compiled to an .exe) is a standalone process run under the + Unity-bundled Mono. It owns the client-facing port and never reloads. +- The Unity plugin runs `BrokerClientTransport` instead of `HttpMCPTransport`: it connects OUT to the + broker, long-polls `/_b/pull` for work, runs each request through the normal `MCPRequestHandler`, + and `POST`s the result to `/_b/push`. This realizes the `IsAttachedToExistingServer` seam. +- On a domain reload only the plugin's poll connection drops. The broker **holds** any in-flight + client request (and re-queues a request that was pulled but not yet answered). funplay's existing + post-reload restart re-creates `BrokerClientTransport`, which re-attaches and drains the queue. The + client just sees a slower call — never a refusal. + +Back-channel protocol (raw HTTP, **header framing** — the broker is a dumb byte forwarder, no JSON +parsing on its side): + +| Direction | Request | Response | +|---|---|---| +| Unity → broker | `GET /_b/pull` | `200` + header `X-Broker-ReqId: N` + body = client json-rpc, or `204` (no work) | +| Unity → broker | `POST /_b/push` + header `X-Broker-ReqId: N` + body = json-rpc response | `200` | +| MCP client → broker | `POST /` + json-rpc | held until Unity answers (or 120s deadline) | + +## Usage + +In the **Funplay MCP** window, tick **"Broker mode (survive domain reloads)"**. That's it: + +- The plugin **auto-launches** the broker under the Unity-bundled Mono (`/.../MonoBleedingEdge/ + bin/mono keepalive-broker.exe `) and **auto-kills** it when you untick the toggle or quit + the editor. No Node.js or other install is required. +- If the prebuilt `keepalive-broker.exe` is not shipped, the plugin compiles `keepalive-broker.cs` once + with the bundled C# compiler (`mcs`) into `Library/funplay-broker/` and caches it. +- The broker binds the **same port** as the MCP server, so **MCP clients need no change** — they keep + pointing at `http://127.0.0.1:/`. The mode switch is transparent to the client. +- Settings persist (`UserSettings/FunplayMcpSettings.json` → `brokerModeEnabled` / `brokerMonoPath`), + so broker mode is restored automatically on the next Unity launch. + +If Mono cannot be located or the broker fails to start, the plugin logs a warning and **falls back to +the in-process `HttpMCPTransport`**, so funplay keeps working. Headless/CI: set `brokerModeEnabled: true` +in `FunplayMcpSettings.json` (no UI needed). + +## Compatibility + +The bundled Mono is present in every Unity 2019+ editor (it is the editor scripting runtime), but its +path varies by version — e.g. `Contents/MonoBleedingEdge/` (≤2022) vs `Contents/Resources/Scripting/ +MonoBleedingEdge/` (Unity 6). `BrokerProcessManager` resolves it dynamically (known candidates + +bounded recursive search), so a hard-coded path is never assumed. The broker uses only basic BCL +(TcpListener, threads) and is compiled for the Mono 4.5 profile. + +Verified: the same broker .exe (built with Unity 6's `mcs`) runs correctly under both Unity 6000's +Mono and Unity 2022.3's Mono (6.13.0); a real domain-reload run kept an MCP client at 100% success +(zero connection-refused), with the request spanning the reload held until the plugin re-attached. + +## Notes / future work + +- `initialize` / `tools/list` could be cached by the broker to keep the session valid even if a reload + lands between handshake and first call (currently relayed like any request). diff --git a/Broker~/keepalive-broker.cs b/Broker~/keepalive-broker.cs new file mode 100644 index 0000000..ee3e80c --- /dev/null +++ b/Broker~/keepalive-broker.cs @@ -0,0 +1,399 @@ +// Copyright (C) Funplay. Licensed under MIT. +// +// Out-of-process keepalive broker for the Funplay Unity MCP server (C# / Mono edition). +// +// Runs as a SEPARATE process under the Unity-bundled Mono runtime, so it is unaffected by +// Unity AppDomain reloads. It owns the client-facing port; the Unity plugin connects OUT to +// it (BrokerClientTransport) and long-polls for work. When Unity reloads, only the plugin's +// poll connection drops — the broker keeps every MCP client connection open and re-queues any +// in-flight request, so the client sees a slightly slower call instead of "connection refused". +// +// One TCP listener on 127.0.0.1:, three roles by HTTP path (raw HTTP, like HttpMCPTransport): +// • MCP client -> POST / held open until Unity answers (or 120s deadline) +// • Unity plugin-> GET /_b/pull long-poll: 200 + header "X-Broker-ReqId: N" + body = client +// request body, or 204 when there is no work +// POST /_b/push header "X-Broker-ReqId: N" + body = response; forwarded +// verbatim to the matching client (no JSON parsing here) +// +// Build (once, with the Unity-bundled compiler): +// /bin/mcs -out:keepalive-broker.exe keepalive-broker.cs +// Run: +// /bin/mono keepalive-broker.exe +// +// Conservative C# (mono 4.5 profile) so the same .exe runs on Unity 2019+ bundled Mono. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Threading; + +namespace FunplayBroker +{ + internal static class KeepaliveBroker + { + private const int PullTimeoutMs = 25000; // long-poll hold for Unity + private const long HoldDeadlineMs = 120000; // max hold for a client request (~ funplay restart window) + private const long RedeliverAfterMs = 1500; // re-queue in-flight reqs unanswered this long + private const string ReqIdHeader = "X-Broker-ReqId"; + + private static readonly object Gate = new object(); + private static readonly Dictionary PendingMap = new Dictionary(); + private static readonly List QueueIds = new List(); + private static Waiter WaitingPull; + private static long _seq; + private static bool _unityConnected; + + private sealed class Pending + { + public TcpClient Client; + public NetworkStream Stream; + public string Body; + public long DeliveredAt; + public bool Delivered; + public long EnqueuedAt; + } + + private sealed class Waiter + { + public TcpClient Client; + public NetworkStream Stream; + public long StashedAt; + } + + private static long NowMs() + { + return (long)(DateTime.UtcNow - new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc)).TotalMilliseconds; + } + + private static void Log(string m) + { + Console.Error.WriteLine("[broker " + DateTime.Now.ToString("HH:mm:ss.fff") + "] " + m); + } + + public static int Main(string[] args) + { + int port = 8765; + if (args.Length > 0) int.TryParse(args[0], out port); + if (port <= 0) port = 8765; + + TcpListener listener = null; + int bindAttempts = 0; + while (true) + { + try + { + listener = new TcpListener(IPAddress.Loopback, port); + listener.Server.NoDelay = true; + listener.Start(); + break; + } + catch (SocketException ex) + { + // EADDRINUSE: when funplay switches Direct->Broker it frees the port a moment + // before we grab it (OS TIME_WAIT). Retry briefly. + if ((ex.SocketErrorCode == SocketError.AddressAlreadyInUse) && bindAttempts < 40) + { + if (bindAttempts == 0) Log("port " + port + " busy, retrying bind for up to 10s..."); + bindAttempts++; + Thread.Sleep(250); + continue; + } + Log("fatal: cannot bind port " + port + ": " + ex.Message); + return 1; + } + } + + Log("listening on http://127.0.0.1:" + port + "/ (front=/ back=/_b/pull,/_b/push)"); + + var sweeper = new Thread(SweepLoop); + sweeper.IsBackground = true; + sweeper.Start(); + + while (true) + { + TcpClient client; + try { client = listener.AcceptTcpClient(); } + catch (Exception ex) { Log("accept error: " + ex.Message); continue; } + + var t = new Thread(HandleConnectionThread); + t.IsBackground = true; + t.Start(client); + } + } + + private static void HandleConnectionThread(object state) + { + var client = (TcpClient)state; + try { HandleConnection(client); } + catch (Exception ex) { Log("handler error: " + ex.Message); SafeClose(client); } + } + + private static void HandleConnection(TcpClient client) + { + client.NoDelay = true; + var stream = client.GetStream(); + + string method, path, body; + Dictionary headers; + if (!ReadHttpRequest(stream, out method, out path, out headers, out body)) + { + SafeClose(client); + return; + } + + if (method == "GET" && path.StartsWith("/_b/pull")) + { + HandlePull(client, stream); + return; // stream kept open until matched or timed out + } + + if (method == "POST" && path.StartsWith("/_b/push")) + { + long reqId = 0; + string idStr; + if (headers.TryGetValue(ReqIdHeader.ToLowerInvariant(), out idStr)) long.TryParse(idStr, out reqId); + HandlePush(reqId, body); + WriteResponse(stream, 200, "OK", "application/json", null, "{\"ok\":true}"); + SafeClose(client); + return; + } + + if (method == "OPTIONS") + { + WriteResponse(stream, 204, "No Content", "text/plain", + "Access-Control-Allow-Methods: POST, OPTIONS\r\nAccess-Control-Allow-Headers: Content-Type\r\n", ""); + SafeClose(client); + return; + } + + // A front MCP client request: stash it open, queue, try to hand to a waiting pull. + HandleClientRequest(client, stream, body); + } + + private static void HandlePull(TcpClient client, NetworkStream stream) + { + lock (Gate) + { + if (!_unityConnected) { _unityConnected = true; Log("Unity attached (pull)"); } + + // Replace any previous waiting pull (Unity reconnected). + if (WaitingPull != null) + { + TryWrite204(WaitingPull.Stream); + SafeClose(WaitingPull.Client); + WaitingPull = null; + } + + WaitingPull = new Waiter { Client = client, Stream = stream, StashedAt = NowMs() }; + TryDispatch(); + } + } + + private static void HandlePush(long reqId, string body) + { + lock (Gate) + { + Pending p; + if (reqId != 0 && PendingMap.TryGetValue(reqId, out p)) + { + PendingMap.Remove(reqId); + try + { + WriteResponse(p.Stream, 200, "OK", "application/json; charset=utf-8", null, body ?? ""); + } + catch (Exception ex) { Log("#" + reqId + " client gone before push delivered: " + ex.Message); } + SafeClose(p.Client); + } + } + } + + private static void HandleClientRequest(TcpClient client, NetworkStream stream, string body) + { + lock (Gate) + { + long reqId = ++_seq; + PendingMap[reqId] = new Pending + { + Client = client, Stream = stream, Body = body ?? "", + EnqueuedAt = NowMs(), Delivered = false, DeliveredAt = 0, + }; + QueueIds.Add(reqId); + Log("#" + reqId + " client request queued (unity=" + (_unityConnected ? "up" : "DOWN") + ", queue=" + QueueIds.Count + ")"); + TryDispatch(); + } + } + + // Hand the oldest queued request to a waiting pull, if both exist. Caller holds Gate. + private static void TryDispatch() + { + if (WaitingPull == null) return; + + long now = NowMs(); + foreach (var kv in PendingMap) + { + var p = kv.Value; + if (p.Delivered && !QueueIds.Contains(kv.Key) && (now - p.DeliveredAt) > RedeliverAfterMs) + { + p.Delivered = false; + QueueIds.Add(kv.Key); + Log("#" + kv.Key + " re-queued (in-flight across a Unity gap -> redeliver)"); + } + } + if (QueueIds.Count == 0) return; + + long reqId = QueueIds[0]; + QueueIds.RemoveAt(0); + Pending pending; + if (!PendingMap.TryGetValue(reqId, out pending)) return; + + var pull = WaitingPull; + WaitingPull = null; + try + { + WriteResponse(pull.Stream, 200, "OK", "application/json; charset=utf-8", + ReqIdHeader + ": " + reqId + "\r\n", pending.Body); + pending.Delivered = true; + pending.DeliveredAt = NowMs(); + } + catch (Exception ex) + { + Log("dispatch write failed for #" + reqId + ": " + ex.Message); + QueueIds.Insert(0, reqId); // try again on next pull + } + finally + { + SafeClose(pull.Client); + } + } + + private static void SweepLoop() + { + while (true) + { + Thread.Sleep(1000); + lock (Gate) + { + long now = NowMs(); + if (WaitingPull != null && (now - WaitingPull.StashedAt) > PullTimeoutMs) + { + TryWrite204(WaitingPull.Stream); + SafeClose(WaitingPull.Client); + WaitingPull = null; + } + + var expired = new List(); + foreach (var kv in PendingMap) + if ((now - kv.Value.EnqueuedAt) > HoldDeadlineMs) expired.Add(kv.Key); + foreach (var id in expired) + { + var p = PendingMap[id]; + PendingMap.Remove(id); + QueueIds.Remove(id); + try { WriteResponse(p.Stream, 504, "Gateway Timeout", "application/json", null, "{\"error\":\"broker_hold_deadline_exceeded\"}"); } + catch { } + SafeClose(p.Client); + Log("#" + id + " hold-deadline exceeded"); + } + } + } + } + + // ---- HTTP helpers (raw, mirrors HttpMCPTransport) ---- + + private static bool ReadHttpRequest(NetworkStream stream, out string method, out string path, + out Dictionary headers, out string body) + { + method = null; path = null; body = null; + headers = new Dictionary(); + + var buffer = new byte[8192]; + var raw = new MemoryStream(); + int headerEnd = -1; + while (headerEnd < 0) + { + int read; + try { read = stream.Read(buffer, 0, buffer.Length); } + catch { return false; } + if (read == 0) return false; + raw.Write(buffer, 0, read); + if (raw.Length > 256 * 1024) return false; + headerEnd = FindHeaderEnd(raw.GetBuffer(), (int)raw.Length); + } + + var bytes = raw.ToArray(); + var headerText = Encoding.ASCII.GetString(bytes, 0, headerEnd); + var lines = headerText.Split(new[] { "\r\n" }, StringSplitOptions.None); + if (lines.Length == 0) return false; + + var reqLine = lines[0].Split(' '); + if (reqLine.Length < 2) return false; + method = reqLine[0]; + path = reqLine[1]; + + int contentLength = 0; + for (int i = 1; i < lines.Length; i++) + { + int sep = lines[i].IndexOf(':'); + if (sep <= 0) continue; + var name = lines[i].Substring(0, sep).Trim().ToLowerInvariant(); + var value = lines[i].Substring(sep + 1).Trim(); + headers[name] = value; + if (name == "content-length") int.TryParse(value, out contentLength); + } + + int bodyStart = headerEnd + 4; + var bodyBytes = new byte[contentLength]; + int copied = Math.Min(contentLength, bytes.Length - bodyStart); + if (copied > 0) Buffer.BlockCopy(bytes, bodyStart, bodyBytes, 0, copied); + while (copied < contentLength) + { + int read; + try { read = stream.Read(bodyBytes, copied, contentLength - copied); } + catch { break; } + if (read == 0) break; + copied += read; + } + body = Encoding.UTF8.GetString(bodyBytes, 0, copied); + return true; + } + + private static int FindHeaderEnd(byte[] buffer, int length) + { + for (int i = 3; i < length; i++) + if (buffer[i - 3] == '\r' && buffer[i - 2] == '\n' && buffer[i - 1] == '\r' && buffer[i] == '\n') + return i - 3; + return -1; + } + + private static void WriteResponse(NetworkStream stream, int status, string reason, string contentType, + string extraHeaders, string body) + { + var bodyBytes = Encoding.UTF8.GetBytes(body ?? ""); + var header = "HTTP/1.1 " + status + " " + reason + "\r\n" + + "Content-Type: " + contentType + "\r\n" + + "Content-Length: " + bodyBytes.Length + "\r\n" + + "Connection: close\r\n" + + "Access-Control-Allow-Origin: *\r\n" + + (extraHeaders ?? "") + + "\r\n"; + var headerBytes = Encoding.ASCII.GetBytes(header); + stream.Write(headerBytes, 0, headerBytes.Length); + if (bodyBytes.Length > 0) stream.Write(bodyBytes, 0, bodyBytes.Length); + stream.Flush(); + } + + private static void TryWrite204(NetworkStream stream) + { + try { WriteResponse(stream, 204, "No Content", "text/plain", null, ""); } + catch { } + } + + private static void SafeClose(TcpClient client) + { + try { if (client != null) client.Close(); } catch { } + } + } +} diff --git a/Editor/MCP/Server/BrokerClientTransport.cs b/Editor/MCP/Server/BrokerClientTransport.cs new file mode 100644 index 0000000..021c1ec --- /dev/null +++ b/Editor/MCP/Server/BrokerClientTransport.cs @@ -0,0 +1,283 @@ +// Copyright (C) Funplay. Licensed under MIT. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Net; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Funplay.Editor.Settings; +using UnityEngine; + +namespace Funplay.Editor.MCP.Server +{ + /// + /// Out-of-process MCP transport. Instead of binding a + /// inside Unity's managed AppDomain (which is destroyed on every domain reload, dropping the + /// client connection), this transport connects OUT to a persistent broker process and long-polls + /// it for MCP requests, pushing responses back over HTTP. + /// + /// Because the broker owns the client-facing socket and lives outside Unity, MCP client + /// connections survive Unity domain reloads (script recompile / entering Play Mode): only this + /// poll loop drops, and funplay's existing post-reload restart re-creates the transport, which + /// re-attaches to the broker. The broker holds in-flight client requests across the gap, so the + /// client sees a slightly slower call instead of "connection refused". + /// + /// Back-channel protocol (HTTP long-poll, header framing — see Broker~/keepalive-broker.cs): + /// GET {broker}/_b/pull -> 200 + header "X-Broker-ReqId: N" + body = client json-rpc | 204 no work + /// POST {broker}/_b/push -> header "X-Broker-ReqId: N" + body = json-rpc response + /// + internal class BrokerClientTransport : IMCPTransport + { + private readonly string _brokerBaseUrl; + private CancellationTokenSource _cts; + private volatile bool _isRunning; + private const int PullTimeoutMs = 35000; // > broker's long-poll hold (25s) + private const int PushTimeoutMs = 10000; + private const int ReconnectBackoffMs = 500; + private const string ReqIdHeader = "X-Broker-ReqId"; + + public bool IsRunning => _isRunning; + + /// Mirrors the seam consumed by : this transport + /// is attached to an external (broker) server rather than owning an in-process listener. + public bool IsAttachedToExistingServer => true; + + public event Action> OnRequestReceived; + + public BrokerClientTransport(int port) + { + _brokerBaseUrl = $"http://127.0.0.1:{port}"; + } + + public Task StartAsync(CancellationToken ct = default) + { + if (_isRunning) return Task.FromResult(true); + + _cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + _isRunning = true; + _ = Task.Run(() => PollLoopAsync(_cts.Token), _cts.Token); + + PluginDebugLogger.Log($"[Funplay MCP Server] Broker-client transport attached to {_brokerBaseUrl}/"); + return Task.FromResult(true); + } + + public Task StopAsync() + { + Stop(); + return Task.CompletedTask; + } + + public void Stop() + { + if (!_isRunning && _cts == null) return; + _isRunning = false; + try { _cts?.Cancel(); } catch { /* ignore */ } + try { _cts?.Dispose(); } catch { /* ignore */ } + _cts = null; + PluginDebugLogger.Log("[Funplay MCP Server] Broker-client transport stopped"); + } + + public void Dispose() => Stop(); + + private async Task PollLoopAsync(CancellationToken ct) + { + while (!ct.IsCancellationRequested && _isRunning) + { + PullResult pull; + try + { + pull = await Task.Run(() => PullOnce(), ct); + } + catch (OperationCanceledException) + { + break; + } + catch (Exception) + { + // Broker unreachable or long-poll timed out — back off briefly and retry. + await DelaySafe(ReconnectBackoffMs, ct); + continue; + } + + if (pull == null) + continue; // 204: no work, re-poll + + // Handle + push without blocking the poll loop, so multiple requests can overlap. + _ = HandleAndPushAsync(pull.ReqId, pull.Body, ct); + } + } + + private async Task HandleAndPushAsync(long reqId, string clientBody, CancellationToken ct) + { + string responseJson; + try + { + var request = ParseJsonRequest(clientBody); + var handler = OnRequestReceived; + + if (request == null || handler == null) + { + responseJson = SerializeResponse(CreateError(null, -32000, "MCP server is stopping or not ready.")); + } + else + { + var responseTcs = new TaskCompletionSource(); + handler.Invoke(request, r => responseTcs.TrySetResult(r)); + + using (var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(60))) + using (var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(ct, timeoutCts.Token)) + { + var completed = await Task.WhenAny(responseTcs.Task, Task.Delay(-1, linkedCts.Token)); + if (completed == responseTcs.Task) + { + var response = await responseTcs.Task; + responseJson = response == null + ? SerializeResponse(CreateError(request.Id, -32000, "Empty response")) + : SerializeResponse(response); + } + else + { + responseJson = SerializeResponse(CreateError(request.Id, -32000, + timeoutCts.IsCancellationRequested ? "Request timeout" : "Request cancelled")); + } + } + } + } + catch (Exception ex) + { + responseJson = SerializeResponse(CreateError(null, -32603, $"Internal error: {ex.Message}")); + } + + try + { + await Task.Run(() => PushOnce(reqId, responseJson), ct); + } + catch (OperationCanceledException) + { + // Shutting down or reloading — the broker re-delivers this reqId to the next poll. + } + catch (Exception ex) + { + Debug.LogError($"[Funplay MCP Server] Broker push failed (reqId={reqId}): {ex.Message}"); + } + } + + private static async Task DelaySafe(int ms, CancellationToken ct) + { + try { await Task.Delay(ms, ct); } + catch (OperationCanceledException) { /* ignore */ } + } + + // ---- Back-channel HTTP client (HttpWebRequest — editor BCL, no asmdef ref) ---- + // Header framing: the broker tags pulled work with the X-Broker-ReqId response header and + // the client's raw JSON-RPC as the body; we echo that header back on push with the response + // body. No JSON envelope is parsed here, so the broker stays a dumb byte forwarder. + + private sealed class PullResult + { + public long ReqId; + public string Body; + } + + private PullResult PullOnce() + { + var req = (HttpWebRequest)WebRequest.Create(_brokerBaseUrl + "/_b/pull"); + req.Method = "GET"; + req.Timeout = PullTimeoutMs; + req.ReadWriteTimeout = PullTimeoutMs; + req.KeepAlive = false; + using (var resp = (HttpWebResponse)req.GetResponse()) + { + if (resp.StatusCode == HttpStatusCode.NoContent) + return null; + var idStr = resp.Headers[ReqIdHeader]; + long reqId; + if (string.IsNullOrEmpty(idStr) || !long.TryParse(idStr, out reqId)) + return null; + using (var stream = resp.GetResponseStream()) + using (var reader = new StreamReader(stream ?? Stream.Null, Encoding.UTF8)) + return new PullResult { ReqId = reqId, Body = reader.ReadToEnd() }; + } + } + + private void PushOnce(long reqId, string body) + { + var req = (HttpWebRequest)WebRequest.Create(_brokerBaseUrl + "/_b/push"); + req.Method = "POST"; + req.Timeout = PushTimeoutMs; + req.ReadWriteTimeout = PushTimeoutMs; + req.ContentType = "application/json; charset=utf-8"; + req.KeepAlive = false; + req.Headers[ReqIdHeader] = reqId.ToString(); + var bytes = Encoding.UTF8.GetBytes(body ?? string.Empty); + req.ContentLength = bytes.Length; + using (var rs = req.GetRequestStream()) + rs.Write(bytes, 0, bytes.Length); + using (var resp = (HttpWebResponse)req.GetResponse()) + using (var stream = resp.GetResponseStream()) + using (var reader = new StreamReader(stream ?? Stream.Null, Encoding.UTF8)) + reader.ReadToEnd(); + } + + // ---- Request parse / response serialize (mirrors HttpMCPTransport) ---- + + private MCPRequest ParseJsonRequest(string json) + { + try + { + if (!(SimpleJsonHelper.Deserialize(json) is Dictionary dict)) + return null; + + return new MCPRequest + { + JsonRpc = dict.ContainsKey("jsonrpc") ? dict["jsonrpc"]?.ToString() : "2.0", + Id = dict.ContainsKey("id") ? dict["id"] : null, + Method = dict.ContainsKey("method") ? dict["method"]?.ToString() : null, + Params = dict.ContainsKey("params") ? dict["params"] as Dictionary : new Dictionary(), + }; + } + catch + { + return null; + } + } + + private string SerializeResponse(MCPResponse response) + { + var dict = new Dictionary + { + ["jsonrpc"] = response.JsonRpc, + ["id"] = response.Id, + }; + + if (response.Error != null) + { + var errorDict = new Dictionary + { + ["code"] = response.Error.Code, + ["message"] = response.Error.Message, + }; + if (response.Error.Data != null) errorDict["data"] = response.Error.Data; + dict["error"] = errorDict; + } + else + { + dict["result"] = response.Result; + } + + return SimpleJsonHelper.Serialize(dict); + } + + private static MCPResponse CreateError(object requestId, int code, string message) + { + return new MCPResponse + { + JsonRpc = "2.0", + Id = requestId, + Error = new MCPError { Code = code, Message = message }, + }; + } + } +} diff --git a/Editor/MCP/Server/BrokerClientTransport.cs.meta b/Editor/MCP/Server/BrokerClientTransport.cs.meta new file mode 100644 index 0000000..8f1b1b3 --- /dev/null +++ b/Editor/MCP/Server/BrokerClientTransport.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c0d53a1d01e24ef8a99355d24de11d8a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/MCP/Server/BrokerProcessManager.cs b/Editor/MCP/Server/BrokerProcessManager.cs new file mode 100644 index 0000000..b5e000b --- /dev/null +++ b/Editor/MCP/Server/BrokerProcessManager.cs @@ -0,0 +1,346 @@ +// Copyright (C) Funplay. Licensed under MIT. + +using System; +using System.Diagnostics; +using System.IO; +using UnityEditor; +using UnityEngine; +using Debug = UnityEngine.Debug; + +namespace Funplay.Editor.MCP.Server +{ + /// + /// Manages the lifecycle of the out-of-process keepalive broker (Broker~/keepalive-broker.exe), + /// run under the Unity-bundled Mono runtime — so it needs no Node.js or any external install. + /// + /// The broker is a separate OS process, so it survives Unity domain reloads (that is the whole + /// point). This manager launches it when broker mode is enabled and kills it when switching back + /// to direct mode or when the editor quits. The managed Process handle is lost across a domain + /// reload, so the running broker is tracked via a PID file under Library/ and reacquired by PID. + /// + /// Mono location is resolved dynamically because its path under the editor install differs by + /// Unity version (e.g. Unity ≤2022: Contents/MonoBleedingEdge; Unity 6: Contents/Resources/ + /// Scripting/MonoBleedingEdge). If the prebuilt .exe is missing it is compiled from the shipped + /// .cs with the bundled compiler (cached under Library/). + /// + [InitializeOnLoad] + internal static class BrokerProcessManager + { + private const string ReqIdMarker = "keepalive-broker"; + + private static readonly string ProjectRoot; + private static readonly string EditorContentsPath; + private static readonly string PidFilePath; + private static readonly string PackageBrokerExe; // shipped prebuilt exe (Broker~/) + private static readonly string PackageBrokerSrc; // shipped source (Broker~/) + private static readonly object Gate = new object(); + + public static string LastError { get; private set; } + + static BrokerProcessManager() + { + ProjectRoot = Directory.GetParent(Application.dataPath)?.FullName ?? Directory.GetCurrentDirectory(); + EditorContentsPath = EditorApplication.applicationContentsPath; + PidFilePath = Path.Combine(ProjectRoot, "Library", "funplay-broker.pid"); + + var brokerDir = ResolveBrokerDir(); + PackageBrokerExe = brokerDir != null ? Path.Combine(brokerDir, "keepalive-broker.exe") : null; + PackageBrokerSrc = brokerDir != null ? Path.Combine(brokerDir, "keepalive-broker.cs") : null; + + EditorApplication.quitting += Stop; + } + + public static bool IsRunning(out int pid, out int port) + { + return TryReadPidFile(out pid, out port) && IsBrokerProcessAlive(pid); + } + + /// + /// Ensure the broker is running on . Returns false (with + /// set) if Mono / the broker assembly could not be found or launched, + /// so the caller can fall back to the in-process transport. + /// + public static bool EnsureRunning(int port, string monoPathOverride) + { + lock (Gate) + { + LastError = null; + + if (TryReadPidFile(out var existingPid, out var existingPort) && IsBrokerProcessAlive(existingPid)) + { + if (existingPort == port) return true; + KillPid(existingPid); + DeletePidFile(); + } + + // Race guard: a broker may already be listening on the port even when the pid file + // is missing/stale — e.g. a sibling start during cold-boot that bound the port before + // its pid file landed. Don't launch a duplicate; adopt the existing listener. + if (PortIsOpen(port)) + return true; + + var mono = ResolveMono(monoPathOverride); + if (string.IsNullOrEmpty(mono)) + { + LastError = "Bundled Mono runtime not found under the editor install."; + Debug.LogWarning($"[Funplay MCP Server] {LastError}"); + return false; + } + + var exe = EnsureBrokerExe(mono); + if (string.IsNullOrEmpty(exe)) + { + LastError = LastError ?? "Broker assembly not found and could not be compiled."; + Debug.LogWarning($"[Funplay MCP Server] {LastError}"); + return false; + } + + try + { + var psi = new ProcessStartInfo + { + FileName = mono, + Arguments = $"\"{exe}\" {port}", + WorkingDirectory = Path.GetDirectoryName(exe), + UseShellExecute = false, + CreateNoWindow = true, + }; + var proc = Process.Start(psi); + if (proc == null) + { + LastError = "Failed to start broker process."; + return false; + } + WritePidFile(proc.Id, port); + Debug.Log($"[Funplay MCP Server] Broker started (pid={proc.Id}, port={port}) via {mono}."); + return true; + } + catch (Exception ex) + { + LastError = $"Failed to launch broker: {ex.Message}"; + Debug.LogError($"[Funplay MCP Server] {LastError}"); + return false; + } + } + } + + public static void Stop() + { + lock (Gate) + { + if (TryReadPidFile(out var pid, out _)) + { + KillPid(pid); + DeletePidFile(); + } + } + } + + // ---- mono / exe resolution ---- + + private static bool IsWindows => Application.platform == RuntimePlatform.WindowsEditor; + + private static string ResolveMono(string overridePath) + { + if (!string.IsNullOrEmpty(overridePath) && File.Exists(overridePath)) + return overridePath; + + var exe = IsWindows ? "mono.exe" : "mono"; + var contents = EditorContentsPath ?? string.Empty; + + // Known relative layouts (Unity version dependent). + var candidates = new[] + { + Path.Combine(contents, "MonoBleedingEdge", "bin", exe), + Path.Combine(contents, "Resources", "Scripting", "MonoBleedingEdge", "bin", exe), + Path.Combine(contents, "Data", "MonoBleedingEdge", "bin", exe), // Windows applicationContentsPath safety + }; + foreach (var c in candidates) + if (File.Exists(c)) return c; + + // Fallback: find any MonoBleedingEdge under the editor install and use its bin/mono. + try + { + if (Directory.Exists(contents)) + { + foreach (var dir in Directory.GetDirectories(contents, "MonoBleedingEdge", SearchOption.AllDirectories)) + { + var p = Path.Combine(dir, "bin", exe); + if (File.Exists(p)) return p; + } + } + } + catch { /* ignore */ } + + return null; + } + + /// Return the prebuilt broker exe if shipped, otherwise compile it from the shipped + /// source with the bundled C# compiler (cached under Library/). Returns null on failure. + private static string EnsureBrokerExe(string mono) + { + if (!string.IsNullOrEmpty(PackageBrokerExe) && File.Exists(PackageBrokerExe)) + return PackageBrokerExe; + + if (string.IsNullOrEmpty(PackageBrokerSrc) || !File.Exists(PackageBrokerSrc)) + { + LastError = "Broker assembly and source both missing."; + return null; + } + + // Compile -> Library/funplay-broker/keepalive-broker.exe using the bundled compiler. + try + { + var cacheDir = Path.Combine(ProjectRoot, "Library", "funplay-broker"); + Directory.CreateDirectory(cacheDir); + var cacheExe = Path.Combine(cacheDir, "keepalive-broker.exe"); + if (File.Exists(cacheExe) && File.GetLastWriteTimeUtc(cacheExe) >= File.GetLastWriteTimeUtc(PackageBrokerSrc)) + return cacheExe; + + var monoBin = Path.GetDirectoryName(mono); + var mcsExe = Path.GetFullPath(Path.Combine(monoBin, "..", "lib", "mono", "4.5", "mcs.exe")); + if (!File.Exists(mcsExe)) + { + LastError = "Broker exe missing and bundled C# compiler (mcs.exe) not found to build it."; + return null; + } + + var psi = new ProcessStartInfo + { + FileName = mono, + Arguments = $"\"{mcsExe}\" -out:\"{cacheExe}\" \"{PackageBrokerSrc}\"", + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardError = true, + RedirectStandardOutput = true, + }; + using (var p = Process.Start(psi)) + { + var err = p.StandardError.ReadToEnd(); + p.WaitForExit(20000); + if (p.ExitCode != 0 || !File.Exists(cacheExe)) + { + LastError = "Broker compile failed: " + err; + return null; + } + } + Debug.Log($"[Funplay MCP Server] Compiled broker assembly to {cacheExe}."); + return cacheExe; + } + catch (Exception ex) + { + LastError = $"Broker compile error: {ex.Message}"; + return null; + } + } + + private static string ResolveBrokerDir() + { + try + { + var pkg = UnityEditor.PackageManager.PackageInfo.FindForAssembly(typeof(BrokerProcessManager).Assembly); + if (pkg != null && !string.IsNullOrEmpty(pkg.resolvedPath)) + { + var dir = Path.Combine(pkg.resolvedPath, "Broker~"); + if (Directory.Exists(dir)) return dir; + } + } + catch { /* fall through */ } + + var embedded = Path.GetFullPath(Path.Combine( + ProjectRoot ?? Directory.GetCurrentDirectory(), + "Packages", "com.gamebooom.unity.mcp", "Broker~")); + return Directory.Exists(embedded) ? embedded : null; + } + + // ---- pid file ---- + + private static void WritePidFile(int pid, int port) + { + try + { + Directory.CreateDirectory(Path.GetDirectoryName(PidFilePath)); + File.WriteAllText(PidFilePath, $"{pid}:{port}"); + } + catch (Exception ex) + { + Debug.LogWarning($"[Funplay MCP Server] Could not write broker pid file: {ex.Message}"); + } + } + + private static bool TryReadPidFile(out int pid, out int port) + { + pid = 0; + port = 0; + try + { + if (!File.Exists(PidFilePath)) return false; + var parts = File.ReadAllText(PidFilePath).Trim().Split(':'); + return parts.Length == 2 && int.TryParse(parts[0], out pid) && int.TryParse(parts[1], out port); + } + catch + { + return false; + } + } + + private static void DeletePidFile() + { + try { if (File.Exists(PidFilePath)) File.Delete(PidFilePath); } + catch { /* ignore */ } + } + + private static bool PortIsOpen(int port) + { + try + { + using (var client = new System.Net.Sockets.TcpClient()) + { + var ar = client.BeginConnect(System.Net.IPAddress.Loopback, port, null, null); + if (!ar.AsyncWaitHandle.WaitOne(300)) + return false; + client.EndConnect(ar); + return true; + } + } + catch + { + return false; + } + } + + private static bool IsBrokerProcessAlive(int pid) + { + if (pid <= 0) return false; + try + { + var p = Process.GetProcessById(pid); + if (p.HasExited) return false; + // Broker runs under mono; guard against PID reuse by an unrelated process. + var name = (p.ProcessName ?? string.Empty).ToLowerInvariant(); + return name.IndexOf("mono", StringComparison.Ordinal) >= 0 + || name.IndexOf(ReqIdMarker, StringComparison.Ordinal) >= 0; + } + catch + { + return false; + } + } + + private static void KillPid(int pid) + { + if (pid <= 0) return; + try + { + var p = Process.GetProcessById(pid); + if (!p.HasExited) + { + p.Kill(); + p.WaitForExit(2000); + Debug.Log($"[Funplay MCP Server] Broker process stopped (pid={pid})."); + } + } + catch { /* already gone */ } + } + } +} diff --git a/Editor/MCP/Server/BrokerProcessManager.cs.meta b/Editor/MCP/Server/BrokerProcessManager.cs.meta new file mode 100644 index 0000000..18d4316 --- /dev/null +++ b/Editor/MCP/Server/BrokerProcessManager.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: be9c1edd4406c4a7593f5004c2b37172 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/MCP/Server/FunplayMCPServerControlsPanel.cs b/Editor/MCP/Server/FunplayMCPServerControlsPanel.cs index d488fa1..56a9b58 100644 --- a/Editor/MCP/Server/FunplayMCPServerControlsPanel.cs +++ b/Editor/MCP/Server/FunplayMCPServerControlsPanel.cs @@ -3,6 +3,7 @@ using System; using Funplay.Editor.Settings; using UnityEditor; +using UnityEngine; using UnityEngine.UIElements; namespace Funplay.Editor.MCP.Server @@ -13,6 +14,9 @@ internal sealed class FunplayMCPServerControlsPanel private readonly MCPServerService _server; private readonly Action _refreshStatus; + private Label _brokerStatus; + private TextField _monoPathField; + public FunplayMCPServerControlsPanel( ISettingsController settings, MCPServerService server, @@ -36,7 +40,7 @@ public void AddTo(VisualElement parent) _ = _server.StopAsync(); EditorApplication.delayCall += () => - EditorApplication.delayCall += InvokeRefreshStatus; + EditorApplication.delayCall += () => { UpdateBrokerStatus(); InvokeRefreshStatus(); }; }); toggle.style.marginBottom = 4; parent.Add(toggle); @@ -49,6 +53,83 @@ public void AddTo(VisualElement parent) }); portField.style.marginBottom = 10; parent.Add(portField); + + // ---- Connection mode ---- + var modeToggle = new Toggle("Broker mode (survive domain reloads)"); + modeToggle.tooltip = + "Direct: in-process HTTP server (drops on every domain reload).\n" + + "Broker: a separate process owns the same port and holds client requests across\n" + + "reloads, so MCP clients never see 'connection refused'. The broker process is\n" + + "started/stopped automatically. Clients connect to the same port — no change needed."; + modeToggle.SetValueWithoutNotify(_settings.BrokerModeEnabled); + modeToggle.RegisterValueChangedCallback(evt => + { + _settings.BrokerModeEnabled = evt.newValue; + UpdateNodeFieldVisibility(evt.newValue); + + // Restart the server so the right transport is chosen. StartCoreAsync handles the + // port handoff: broker-on => launch broker on the port; broker-off => kill broker + // then bind the in-process listener. + if (_settings.MCPServerEnabled) + { + _ = _server.StopAsync(); + _ = _server.StartAsync(); + } + else if (!evt.newValue) + { + BrokerProcessManager.Stop(); + } + + EditorApplication.delayCall += () => + EditorApplication.delayCall += () => { UpdateBrokerStatus(); InvokeRefreshStatus(); }; + }); + modeToggle.style.marginBottom = 4; + parent.Add(modeToggle); + + _monoPathField = new TextField("Mono Path (optional)"); + _monoPathField.tooltip = "Override the auto-detected Unity-bundled Mono executable used to launch the broker. " + + "Leave empty to auto-detect from the editor install."; + _monoPathField.SetValueWithoutNotify(_settings.BrokerMonoPath); + _monoPathField.RegisterValueChangedCallback(evt => { _settings.BrokerMonoPath = evt.newValue; }); + _monoPathField.style.marginBottom = 4; + parent.Add(_monoPathField); + + _brokerStatus = new Label(); + _brokerStatus.style.whiteSpace = WhiteSpace.Normal; + _brokerStatus.style.opacity = 0.8f; + _brokerStatus.style.marginBottom = 10; + parent.Add(_brokerStatus); + + UpdateNodeFieldVisibility(_settings.BrokerModeEnabled); + UpdateBrokerStatus(); + } + + private void UpdateNodeFieldVisibility(bool brokerMode) + { + if (_monoPathField != null) + _monoPathField.style.display = brokerMode ? DisplayStyle.Flex : DisplayStyle.None; + } + + private void UpdateBrokerStatus() + { + if (_brokerStatus == null) + return; + + if (!_settings.BrokerModeEnabled) + { + _brokerStatus.text = "Mode: Direct (in-process). Drops on domain reload."; + return; + } + + if (BrokerProcessManager.IsRunning(out var pid, out var port)) + _brokerStatus.text = $"Mode: Broker — running (pid {pid}, port {port}). Survives domain reloads."; + else + { + var err = BrokerProcessManager.LastError; + _brokerStatus.text = string.IsNullOrEmpty(err) + ? "Mode: Broker — not running yet (starts when the server starts)." + : $"Mode: Broker — could not start: {err}"; + } } private void InvokeRefreshStatus() diff --git a/Editor/MCP/Server/MCPServerService.cs b/Editor/MCP/Server/MCPServerService.cs index fb8bc3d..2c05078 100644 --- a/Editor/MCP/Server/MCPServerService.cs +++ b/Editor/MCP/Server/MCPServerService.cs @@ -177,7 +177,24 @@ private async Task StartCoreAsync(int lifecycleVersion, CancellationTokenS var serverName = "Funplay MCP Server - " + Application.productName; var projectIdentity = FunplayProjectIdentity.FromProjectPath(_applicationPaths.ProjectPath); - transport = new HttpMCPTransport(startupPort, serverName, projectIdentity); + + // Out-of-process broker mode (survives Unity domain reloads). The broker process + // binds the SAME port as the server (startupPort), so MCP clients need no config + // change; this plugin connects to it as a client (BrokerClientTransport). When broker + // mode is off we also kill any stale broker so the in-process listener can bind. + // See Broker~/README.md for the design and the reference broker process. + if (_settings.BrokerModeEnabled && BrokerProcessManager.EnsureRunning(startupPort, _settings.BrokerMonoPath)) + { + transport = new BrokerClientTransport(startupPort); + } + else + { + if (_settings.BrokerModeEnabled) + Debug.LogWarning($"[Funplay MCP Server] Broker mode requested but the broker could not start ({BrokerProcessManager.LastError}); falling back to the in-process transport."); + else + BrokerProcessManager.Stop(); + transport = new HttpMCPTransport(startupPort, serverName, projectIdentity); + } var toolExporter = new MCPToolExporter(_settings); var executionBridge = new MCPExecutionBridge(_threadHelper, _settings, _stateController, _invoker, InteractionLog); resourceProvider = new MCPResourceProvider(_contextBuilder, _applicationPaths, InteractionLog); diff --git a/Editor/Settings/ISettingsController.cs b/Editor/Settings/ISettingsController.cs index ca951e7..a454991 100644 --- a/Editor/Settings/ISettingsController.cs +++ b/Editor/Settings/ISettingsController.cs @@ -19,6 +19,13 @@ internal interface ISettingsController bool ExecuteCodeProjectNamespaceInjectionEnabled { get; set; } bool PluginDebugLoggingEnabled { get; set; } + // Out-of-process broker mode: survives Unity domain reloads. The broker process binds the + // SAME port as MCPServerPort (so MCP clients need no config change), and this plugin connects + // to it as a client. The broker runs under the Unity-bundled Mono; BrokerMonoPath optionally + // overrides the auto-detected mono executable. + bool BrokerModeEnabled { get; set; } + string BrokerMonoPath { get; set; } + event Action OnSettingsChanged; } } diff --git a/Editor/Settings/SettingsController.cs b/Editor/Settings/SettingsController.cs index c68ab72..42a0aa1 100644 --- a/Editor/Settings/SettingsController.cs +++ b/Editor/Settings/SettingsController.cs @@ -213,6 +213,33 @@ public bool PluginDebugLoggingEnabled } } + public bool BrokerModeEnabled + { + get + { + lock (_lock) + return _settings.brokerModeEnabled; + } + set + { + UpdateSettings(data => data.brokerModeEnabled = value); + } + } + + public string BrokerMonoPath + { + get + { + lock (_lock) + return _settings.brokerMonoPath ?? string.Empty; + } + set + { + var normalized = string.IsNullOrWhiteSpace(value) ? string.Empty : value.Trim(); + UpdateSettings(data => data.brokerMonoPath = normalized); + } + } + private void UpdateSettings(Action apply) { if (apply == null) return; @@ -309,6 +336,7 @@ private static void NormalizeInPlace(SettingsData settings) return; settings.port = settings.port > 0 ? settings.port : DefaultPort; + settings.brokerMonoPath = settings.brokerMonoPath ?? string.Empty; settings.toolExportProfile = NormalizeToolExportProfile(settings.toolExportProfile); settings.coreTools = settings.coreToolsCustom ? NormalizeToolNames(settings.coreTools) : null; settings.fullTools = settings.fullToolsCustom ? NormalizeToolNames(settings.fullTools) : null; @@ -377,6 +405,8 @@ private class SettingsData public bool executeCodeProjectNamespaceInjectionConfigured = false; public bool pluginDebugLoggingEnabled = DefaultPluginDebugLoggingEnabled; public bool pluginDebugLoggingConfigured = false; + public bool brokerModeEnabled = false; + public string brokerMonoPath = string.Empty; } } }