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 commandJson, TaskCompletionSource<string> 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<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
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),

View File

@ -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()

View File

@ -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:

View File

@ -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,