telemetry: main-thread routing + timeout for manage_scene; stderr + rotating file logs; Cloud Run endpoint in config; minor robustness in scene tool
parent
1e003748d8
commit
c1bde804d4
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue