From 98cff9633fc039570044959ef2466476e75e1f5c Mon Sep 17 00:00:00 2001 From: dehuaichendragonplus Date: Fri, 5 Jun 2026 17:17:25 +0800 Subject: [PATCH] feat: optional out-of-process broker transport (survives domain reloads) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit funplay's HttpMCPTransport binds a TcpListener inside Unity's managed AppDomain. Every domain reload (script recompile / entering Play Mode) destroys that AppDomain, so the listener dies and the port is released; MCP client requests landing in the reload window fail with "connection refused", and the tool call that triggered the reload loses its response. A socket in the reloadable AppDomain fundamentally cannot survive an AppDomain unload — the only robust fix is to move the client-facing socket out of process. This adds an opt-in out-of-process broker, realizing the IsAttachedToExistingServer seam already present in the codebase. No external runtime is required: the broker runs under the Unity-bundled Mono. - keepalive-broker.cs: standalone broker (TcpListener, raw HTTP, conservative C# / Mono 4.5 profile). Owns the client-facing port; holds in-flight client requests across the reload gap and re-queues a request that was pulled-but-unanswered when the plugin reloaded mid-call. Header framing (X-Broker-ReqId) keeps it a dumb byte forwarder — no JSON parsing on the broker side. - BrokerClientTransport (IMCPTransport, IsAttachedToExistingServer => true): instead of binding a listener, long-polls the broker (GET /_b/pull), dispatches through the existing MCPRequestHandler, and pushes responses (POST /_b/push). Uses HttpWebRequest (editor BCL, no new asmdef refs). - BrokerProcessManager: launches the broker under the bundled Mono and kills it on switch-to-direct / editor quit (EditorApplication.quitting). Resolves Mono dynamically because its path differs by Unity version (Contents/MonoBleedingEdge vs Unity 6's Contents/Resources/Scripting/ MonoBleedingEdge) — known candidates + bounded recursive search, never a hard-coded path. If the prebuilt .exe is absent it compiles keepalive-broker.cs once with the bundled mcs (cached under Library/). Tracks the process via a Library/funplay-broker.pid file so it can be reacquired/ stopped after a domain reload. A port-open guard adopts an already-listening broker instead of launching a duplicate, so the broker is reused across reloads and editor restarts (stable PID, no churn / no orphan). - MCPServerService: selects BrokerClientTransport when broker mode is on and the broker started, else kills any stale broker (freeing the port) and uses HttpMCPTransport. If Mono is missing or the broker fails to launch it logs a warning and falls back to the in-process transport. - Settings (BrokerModeEnabled / BrokerMonoPath, persisted) + a "Broker mode" toggle, optional Mono path override, and status line in the server controls panel. The broker binds the same port as the server, so MCP clients need no config change — the switch is transparent. Default (broker mode off) is byte-for-byte the existing in-process behavior. Verified on macOS / Apple Silicon: - Compatibility: the bundled Mono exists in every Unity 2019+ editor; the same broker .exe (built with Unity 6 mcs) runs under both Unity 6000 and Unity 2022.3 Mono (6.13.0). - Domain-reload survival: an MCP client driven by a continuous request loop through a real RequestScriptReload kept 100% success (zero connection-refused); the request spanning the reload was held ~13s and returned 200; the broker process PID stayed stable across the reload (reused, not restarted). - Editor restart: closing Unity kills the broker (quitting hook); reopening auto-launches a fresh broker (persisted broker mode -> server auto-start -> EnsureRunning), confirmed end-to-end with a real editor restart. Windows (path-verified against Unity docs/community; not yet run on a Windows editor): the bundled Mono is mono.exe at \Data\MonoBleedingEdge\bin\ — applicationContentsPath (=\Data) plus the resolver's first candidate resolves it directly. Unity 6.3's Mono->.NET relocation moved MonoBleedingEdge only on macOS (to Contents/Resources/Scripting/MonoBleedingEdge); on Windows it stays at Data\MonoBleedingEdge. mcs for the compile-on-demand fallback is at ...\MonoBleedingEdge\lib\mono\4.5\mcs.exe. If Mono is ever absent the plugin falls back to the in-process HttpMCPTransport. --- .gitignore | 2 + Broker~/README.md | 78 ++++ Broker~/keepalive-broker.cs | 399 ++++++++++++++++++ Editor/MCP/Server/BrokerClientTransport.cs | 283 +++++++++++++ .../MCP/Server/BrokerClientTransport.cs.meta | 11 + Editor/MCP/Server/BrokerProcessManager.cs | 346 +++++++++++++++ .../MCP/Server/BrokerProcessManager.cs.meta | 11 + .../Server/FunplayMCPServerControlsPanel.cs | 83 +++- Editor/MCP/Server/MCPServerService.cs | 19 +- Editor/Settings/ISettingsController.cs | 7 + Editor/Settings/SettingsController.cs | 30 ++ 11 files changed, 1267 insertions(+), 2 deletions(-) create mode 100644 Broker~/README.md create mode 100644 Broker~/keepalive-broker.cs create mode 100644 Editor/MCP/Server/BrokerClientTransport.cs create mode 100644 Editor/MCP/Server/BrokerClientTransport.cs.meta create mode 100644 Editor/MCP/Server/BrokerProcessManager.cs create mode 100644 Editor/MCP/Server/BrokerProcessManager.cs.meta 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; } } }