From 4ad7ec3ced2cf0fbfb9651704243452b6ff0d6f4 Mon Sep 17 00:00:00 2001 From: isekream Date: Mon, 8 Jun 2026 08:39:18 +0200 Subject: [PATCH] fix(unity): make HTTP server concurrent, thread-safe, and window-independent The editor MCP server had coupled defects causing requests to hang or return empty bodies: 1. Serial request handling: HandleRequests processed each request inline on the listener thread, blocking the accept loop for up to requestTimeout seconds, so subsequent requests sat unanswered until the current one finished. Each request is now serviced on the ThreadPool so GetContext() loops immediately. 2. Cross-thread EditorApplication.delayCall: tool execution was dispatched to the main thread via `delayCall +=` from the background listener thread. delayCall is a non-thread-safe static delegate; concurrent subscription drops callbacks, so the completion flag never flips and the request times out with no body. Replaced with a thread-safe ConcurrentQueue drained by a single persistent EditorApplication.update pump. 3. Window-coupled registry + static-ctor crash: tools lived on the EditorWindow instance, so a closed window made every call fail with "window not open"; and LoadPreferences() called EditorPrefs.* directly in the static constructor, which Unity forbids ("GetInt is not allowed to be called from a ScriptableObject constructor"). The registry is now static and initialized on server start; preference loading is deferred to EditorApplication.delayCall. Adds volatile completion state for correct cross-thread visibility. Co-Authored-By: Claude Opus 4.8 --- Unity/Editor/McpUnityServer.cs | 145 +++++++++++++++++++++++---------- 1 file changed, 101 insertions(+), 44 deletions(-) diff --git a/Unity/Editor/McpUnityServer.cs b/Unity/Editor/McpUnityServer.cs index df9848f..265aeaf 100644 --- a/Unity/Editor/McpUnityServer.cs +++ b/Unity/Editor/McpUnityServer.cs @@ -4,6 +4,7 @@ using System.Net.NetworkInformation; using System.Text; using System.Threading; +using System.Collections.Concurrent; using UnityEngine; using UnityEditor; using Newtonsoft.Json; @@ -30,21 +31,36 @@ public class McpUnityServer : EditorWindow private static int requestTimeout = 10; private static bool autoStart = false; private static Thread listenerThread; + private static readonly ConcurrentQueue mainThreadActions = new ConcurrentQueue(); private Vector2 scrollPosition; private string logText = ""; private readonly List logs = new List(); - private readonly Dictionary tools = new Dictionary(); + private static readonly Dictionary tools = new Dictionary(); static McpUnityServer() { - LoadPreferences(); EditorApplication.playModeStateChanged += OnPlayModeStateChanged; - - if (autoStart) + + // Single persistent main-thread pump. Tool execution is dispatched here + // (via RunOnMainThread) instead of EditorApplication.delayCall, which is a + // non-thread-safe static delegate and drops callbacks when subscribed from + // the background listener thread under concurrency. + EditorApplication.update += PumpMainThreadActions; + + // IMPORTANT: Never call LoadPreferences() (or any EditorPrefs.*) directly + // from the static constructor of an EditorWindow/ScriptableObject-derived type. + // Unity forbids it and throws "GetInt is not allowed to be called from a + // ScriptableObject constructor". We defer it. + EditorApplication.delayCall += () => { - EditorApplication.delayCall += () => StartServer(); - } + LoadPreferences(); + + if (autoStart) + { + StartServer(); + } + }; } [MenuItem(MENU_PATH, false, 1)] @@ -215,13 +231,18 @@ public static void StartServer() try { + // Ensure the tool registry is populated before any request can arrive. + // The registry is static and independent of the EditorWindow, so the + // server functions even when the window is closed. + InitializeTools(); + httpListener = new HttpListener(); httpListener.Prefixes.Add($"http://localhost:{serverPort}/"); httpListener.Start(); - + isServerRunning = true; LogMessage($"MCP Server started on port {serverPort}"); - + // Start listening thread listenerThread = new Thread(HandleRequests); listenerThread.Start(); @@ -269,7 +290,10 @@ private static void HandleRequests() try { var context = httpListener.GetContext(); - ProcessRequest(context); + // Service each request on a worker thread so the listener can immediately + // accept the next connection. ExecuteTool blocks its worker while waiting + // for the main-thread pump; that must not stall the accept loop. + ThreadPool.QueueUserWorkItem(_ => ProcessRequest(context)); } catch (HttpListenerException) { @@ -318,10 +342,13 @@ private static void ProcessRequest(HttpListenerContext context) } } - private void InitializeTools() + private static void InitializeTools() { - tools.Clear(); - + if (tools.Count > 0) + { + return; + } + // Register all available tools RegisterTool(new ProjectAnalyzerTool()); RegisterTool(new SceneManipulationTool()); @@ -336,7 +363,7 @@ private void InitializeTools() LogMessage($"Initialized {tools.Count} MCP tools"); } - private void RegisterTool(McpToolBase tool) + private static void RegisterTool(McpToolBase tool) { if (tool != null && !string.IsNullOrEmpty(tool.ToolName)) { @@ -344,23 +371,57 @@ private void RegisterTool(McpToolBase tool) } } + /// + /// Enqueues an action to run on the Unity main thread. Thread-safe; drained by + /// PumpMainThreadActions on EditorApplication.update. + /// + public static void RunOnMainThread(Action action) + { + if (action != null) + { + mainThreadActions.Enqueue(action); + } + } + + private static void PumpMainThreadActions() + { + while (mainThreadActions.TryDequeue(out var action)) + { + try + { + action(); + } + catch (Exception e) + { + LogError($"Main-thread action failed: {e.Message}"); + } + } + } + + private class ToolExecutionState + { + public volatile bool IsComplete; + public McpResponse Result; + public Exception Error; + public volatile AsyncToolResult Async; + } + public static McpResponse ExecuteTool(string toolName, object parameters) { - if (!instance.tools.ContainsKey(toolName)) + InitializeTools(); + + if (!tools.ContainsKey(toolName)) { return McpResponse.CreateError($"Tool '{toolName}' not found"); } try { - var tool = instance.tools[toolName]; - McpResponse result = null; - Exception resultException = null; - bool isComplete = false; - AsyncToolResult asyncOp = null; + var tool = tools[toolName]; + var state = new ToolExecutionState(); - // Execute tool on main thread - EditorApplication.delayCall += () => + // Dispatch tool execution to the main thread via the persistent pump. + RunOnMainThread(() => { try { @@ -368,33 +429,32 @@ public static McpResponse ExecuteTool(string toolName, object parameters) if (execResult is AsyncToolResult async) { - asyncOp = async; + state.Async = async; } else { - result = McpResponse.CreateSuccess(execResult); + state.Result = McpResponse.CreateSuccess(execResult); LogMessage($"Executed tool: {toolName}"); - isComplete = true; + state.IsComplete = true; } } catch (Exception e) { LogError($"Error executing tool '{toolName}': {e.Message}"); - resultException = e; - isComplete = true; + state.Error = e; + state.IsComplete = true; } - }; + }); - // Wait for completion (with timeout) + // Wait for completion (with timeout) on this worker thread. var startTime = DateTime.Now; var defaultTimeout = TimeSpan.FromSeconds(requestTimeout); - while (!isComplete && DateTime.Now - startTime < defaultTimeout) + while (!state.IsComplete && DateTime.Now - startTime < defaultTimeout) { - // Check if async operation was started + var asyncOp = state.Async; if (asyncOp != null) { - // Extend timeout to accommodate the async operation var asyncTimeout = TimeSpan.FromSeconds(asyncOp.TimeoutSeconds > 0 ? asyncOp.TimeoutSeconds : requestTimeout); @@ -407,35 +467,35 @@ public static McpResponse ExecuteTool(string toolName, object parameters) if (asyncOp.IsComplete) { - result = asyncOp.Error != null + state.Result = asyncOp.Error != null ? McpResponse.CreateError(asyncOp.Error) : McpResponse.CreateSuccess(asyncOp.Result); LogMessage($"Executed async tool: {toolName}"); } else { - result = McpResponse.CreateError( + state.Result = McpResponse.CreateError( $"Async tool '{toolName}' timed out after {asyncTimeout.TotalSeconds}s"); } - isComplete = true; + state.IsComplete = true; break; } Thread.Sleep(10); } - if (!isComplete) + if (!state.IsComplete) { return McpResponse.CreateError($"Tool '{toolName}' execution timed out after {requestTimeout} seconds"); } - if (resultException != null) + if (state.Error != null) { - return McpResponse.CreateError(resultException.Message); + return McpResponse.CreateError(state.Error.Message); } - return result; + return state.Result; } catch (Exception e) { @@ -462,13 +522,10 @@ private static McpResponse ProcessMcpRequest(McpRequest request) string toolAction = methodParts[1]; string exactToolName = $"{toolCategory}_{toolAction}"; - if (instance == null) - { - return McpResponse.CreateError("MCP Server window is not open. Open it via Tools > Unity MCP > Server Window."); - } + InitializeTools(); // Try exact match first (e.g., "scene_capture") - if (instance.tools.ContainsKey(exactToolName)) + if (tools.ContainsKey(exactToolName)) { return ExecuteTool(exactToolName, request.Params); } @@ -476,7 +533,7 @@ private static McpResponse ProcessMcpRequest(McpRequest request) // Fall back to category-level tool and inject action into params. // This routes "playmode.enter" → tool "playmode" with action="enter", // and "scene.createGameObject" → tool "scene_manipulate" with action injected. - foreach (var kvp in instance.tools) + foreach (var kvp in tools) { if (kvp.Value.Category == toolCategory) {