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,
|
||||||
(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),
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue