From c1bde804d4c22151a05da4fee2d083974ea036e4 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Tue, 9 Sep 2025 18:46:42 -0700 Subject: [PATCH] telemetry: main-thread routing + timeout for manage_scene; stderr + rotating file logs; Cloud Run endpoint in config; minor robustness in scene tool --- UnityMcpBridge/Editor/MCPForUnityBridge.cs | 94 ++++++++++++++++++- UnityMcpBridge/UnityMcpServer~/src/config.py | 2 +- UnityMcpBridge/UnityMcpServer~/src/server.py | 15 +++ .../UnityMcpServer~/src/tools/manage_scene.py | 2 +- 4 files changed, 109 insertions(+), 4 deletions(-) diff --git a/UnityMcpBridge/Editor/MCPForUnityBridge.cs b/UnityMcpBridge/Editor/MCPForUnityBridge.cs index ef66855..5ee3c0c 100644 --- a/UnityMcpBridge/Editor/MCPForUnityBridge.cs +++ b/UnityMcpBridge/Editor/MCPForUnityBridge.cs @@ -38,6 +38,7 @@ namespace MCPForUnity.Editor string, (string commandJson, TaskCompletionSource tcs) > commandQueue = new(); + private static int mainThreadId; private static int currentUnityPort = 6400; // Dynamic port, starts with default private static bool isAutoConnectMode = false; private const ulong MaxFrameBytes = 64UL * 1024 * 1024; // 64 MiB hard cap for framed payloads @@ -109,6 +110,8 @@ namespace MCPForUnity.Editor static MCPForUnityBridge() { + // Record the main thread ID for safe thread checks + try { mainThreadId = Thread.CurrentThread.ManagedThreadId; } catch { mainThreadId = 0; } // Skip bridge in headless/batch environments (CI/builds) unless explicitly allowed via env // CI override: set UNITY_MCP_ALLOW_BATCH=1 to allow the bridge in batch mode if (Application.isBatchMode && string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("UNITY_MCP_ALLOW_BATCH"))) @@ -539,7 +542,39 @@ namespace MCPForUnity.Editor commandQueue[commandId] = (commandText, tcs); } - string response = await tcs.Task.ConfigureAwait(false); + // Wait for the handler to produce a response, but do not block indefinitely + string response; + try + { + using var respCts = new CancellationTokenSource(FrameIOTimeoutMs); + var completed = await Task.WhenAny(tcs.Task, Task.Delay(FrameIOTimeoutMs, respCts.Token)).ConfigureAwait(false); + if (completed == tcs.Task) + { + // Got a result from the handler + respCts.Cancel(); + response = tcs.Task.Result; + } + else + { + // Timeout: return a structured error so the client can recover + var timeoutResponse = new + { + status = "error", + error = $"Command processing timed out after {FrameIOTimeoutMs} ms", + }; + response = JsonConvert.SerializeObject(timeoutResponse); + } + } + catch (Exception ex) + { + var errorResponse = new + { + status = "error", + error = ex.Message, + }; + response = JsonConvert.SerializeObject(errorResponse); + } + byte[] responseBytes = System.Text.Encoding.UTF8.GetBytes(response); await WriteFrameAsync(stream, responseBytes); } @@ -816,6 +851,60 @@ namespace MCPForUnity.Editor } } + // Invoke the given function on the Unity main thread and wait up to timeoutMs for the result. + // Returns null on timeout or error; caller should provide a fallback error response. + private static object InvokeOnMainThreadWithTimeout(Func func, int timeoutMs) + { + if (func == null) return null; + try + { + // If we are already on the main thread, execute directly to avoid deadlocks + try + { + if (Thread.CurrentThread.ManagedThreadId == mainThreadId) + { + return func(); + } + } + catch { } + + object result = null; + Exception captured = null; + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + EditorApplication.delayCall += () => + { + try + { + result = func(); + } + catch (Exception ex) + { + captured = ex; + } + finally + { + try { tcs.TrySetResult(true); } catch { } + } + }; + + // Wait for completion with timeout (Editor thread will pump delayCall) + bool completed = tcs.Task.Wait(timeoutMs); + if (!completed) + { + return null; // timeout + } + if (captured != null) + { + return Response.Error($"Main thread handler error: {captured.Message}"); + } + return result; + } + catch (Exception ex) + { + return Response.Error($"Failed to invoke on main thread: {ex.Message}"); + } + } + // Helper method to check if a string is valid JSON private static bool IsValidJson(string text) { @@ -880,7 +969,8 @@ namespace MCPForUnity.Editor // Maps the command type (tool name) to the corresponding handler's static HandleCommand method // Assumes each handler class has a static method named 'HandleCommand' that takes JObject parameters "manage_script" => ManageScript.HandleCommand(paramsObject), - "manage_scene" => ManageScene.HandleCommand(paramsObject), + // Run scene operations on the main thread to avoid deadlocks/hangs + "manage_scene" => InvokeOnMainThreadWithTimeout(() => ManageScene.HandleCommand(paramsObject), FrameIOTimeoutMs) ?? Response.Error("manage_scene timed out on main thread"), "manage_editor" => ManageEditor.HandleCommand(paramsObject), "manage_gameobject" => ManageGameObject.HandleCommand(paramsObject), "manage_asset" => ManageAsset.HandleCommand(paramsObject), diff --git a/UnityMcpBridge/UnityMcpServer~/src/config.py b/UnityMcpBridge/UnityMcpServer~/src/config.py index 0f22fed..5d05567 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/config.py +++ b/UnityMcpBridge/UnityMcpServer~/src/config.py @@ -36,7 +36,7 @@ class ServerConfig: # Telemetry settings telemetry_enabled: bool = True - telemetry_endpoint: str = "https://api-prod.coplay.dev/telemetry/events" + telemetry_endpoint: str = "https://unity-mcp-telemetry-a6uvvbgbsa-uc.a.run.app/telemetry/events" # Create a global config instance config = ServerConfig() \ No newline at end of file diff --git a/UnityMcpBridge/UnityMcpServer~/src/server.py b/UnityMcpBridge/UnityMcpServer~/src/server.py index 26b824f..da5ae94 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/server.py +++ b/UnityMcpBridge/UnityMcpServer~/src/server.py @@ -1,5 +1,6 @@ from mcp.server.fastmcp import FastMCP, Context, Image import logging +from logging.handlers import RotatingFileHandler import os from dataclasses import dataclass from contextlib import asynccontextmanager @@ -17,6 +18,20 @@ logging.basicConfig( force=True # Ensure our handler replaces any prior stdout handlers ) logger = logging.getLogger("mcp-for-unity-server") + +# Also write logs to a rotating file so logs are available when launched via stdio +try: + import os as _os + _log_dir = _os.path.join(_os.path.expanduser("~/Library/Application Support/UnityMCP"), "Logs") + _os.makedirs(_log_dir, exist_ok=True) + _file_path = _os.path.join(_log_dir, "unity_mcp_server.log") + _fh = RotatingFileHandler(_file_path, maxBytes=512*1024, backupCount=2, encoding="utf-8") + _fh.setFormatter(logging.Formatter(config.log_format)) + _fh.setLevel(getattr(logging, config.log_level)) + logger.addHandler(_fh) +except Exception: + # Never let logging setup break startup + pass # Quieten noisy third-party loggers to avoid clutter during stdio handshake for noisy in ("httpx", "urllib3"): try: diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_scene.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_scene.py index ef92725..67caa64 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_scene.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_scene.py @@ -12,7 +12,7 @@ def register_manage_scene_tools(mcp: FastMCP): @mcp.tool() @telemetry_tool("manage_scene") def manage_scene( - ctx: Any, + ctx: Context, action: str, name: str, path: str,