telemetry: main-thread routing + timeout for manage_scene; stderr + rotating file logs; Cloud Run endpoint in config; minor robustness in scene tool

main
David Sarno 2025-09-09 18:46:42 -07:00
parent 1e003748d8
commit c1bde804d4
4 changed files with 109 additions and 4 deletions

View File

@ -38,6 +38,7 @@ namespace MCPForUnity.Editor
string, string,
(string commandJson, TaskCompletionSource<string> tcs) (string commandJson, TaskCompletionSource<string> tcs)
> commandQueue = new(); > commandQueue = new();
private static int mainThreadId;
private static int currentUnityPort = 6400; // Dynamic port, starts with default private static int currentUnityPort = 6400; // Dynamic port, starts with default
private static bool isAutoConnectMode = false; private static bool isAutoConnectMode = false;
private const ulong MaxFrameBytes = 64UL * 1024 * 1024; // 64 MiB hard cap for framed payloads private const ulong MaxFrameBytes = 64UL * 1024 * 1024; // 64 MiB hard cap for framed payloads
@ -109,6 +110,8 @@ namespace MCPForUnity.Editor
static MCPForUnityBridge() 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 // 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 // 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"))) if (Application.isBatchMode && string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("UNITY_MCP_ALLOW_BATCH")))
@ -539,7 +542,39 @@ namespace MCPForUnity.Editor
commandQueue[commandId] = (commandText, tcs); 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); byte[] responseBytes = System.Text.Encoding.UTF8.GetBytes(response);
await WriteFrameAsync(stream, responseBytes); 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<object> 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<bool>(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 // Helper method to check if a string is valid JSON
private static bool IsValidJson(string text) 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 // 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 // Assumes each handler class has a static method named 'HandleCommand' that takes JObject parameters
"manage_script" => ManageScript.HandleCommand(paramsObject), "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_editor" => ManageEditor.HandleCommand(paramsObject),
"manage_gameobject" => ManageGameObject.HandleCommand(paramsObject), "manage_gameobject" => ManageGameObject.HandleCommand(paramsObject),
"manage_asset" => ManageAsset.HandleCommand(paramsObject), "manage_asset" => ManageAsset.HandleCommand(paramsObject),

View File

@ -36,7 +36,7 @@ class ServerConfig:
# Telemetry settings # Telemetry settings
telemetry_enabled: bool = True 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 # Create a global config instance
config = ServerConfig() config = ServerConfig()

View File

@ -1,5 +1,6 @@
from mcp.server.fastmcp import FastMCP, Context, Image from mcp.server.fastmcp import FastMCP, Context, Image
import logging import logging
from logging.handlers import RotatingFileHandler
import os import os
from dataclasses import dataclass from dataclasses import dataclass
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
@ -17,6 +18,20 @@ logging.basicConfig(
force=True # Ensure our handler replaces any prior stdout handlers force=True # Ensure our handler replaces any prior stdout handlers
) )
logger = logging.getLogger("mcp-for-unity-server") 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 # Quieten noisy third-party loggers to avoid clutter during stdio handshake
for noisy in ("httpx", "urllib3"): for noisy in ("httpx", "urllib3"):
try: try:

View File

@ -12,7 +12,7 @@ def register_manage_scene_tools(mcp: FastMCP):
@mcp.tool() @mcp.tool()
@telemetry_tool("manage_scene") @telemetry_tool("manage_scene")
def manage_scene( def manage_scene(
ctx: Any, ctx: Context,
action: str, action: str,
name: str, name: str,
path: str, path: str,