Skip to content

Commit c1bde80

Browse files
committed
telemetry: main-thread routing + timeout for manage_scene; stderr + rotating file logs; Cloud Run endpoint in config; minor robustness in scene tool
1 parent 1e00374 commit c1bde80

File tree

4 files changed

+109
-4
lines changed

4 files changed

+109
-4
lines changed

UnityMcpBridge/Editor/MCPForUnityBridge.cs

Lines changed: 92 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ private static Dictionary<
3838
string,
3939
(string commandJson, TaskCompletionSource<string> tcs)
4040
> commandQueue = new();
41+
private static int mainThreadId;
4142
private static int currentUnityPort = 6400; // Dynamic port, starts with default
4243
private static bool isAutoConnectMode = false;
4344
private const ulong MaxFrameBytes = 64UL * 1024 * 1024; // 64 MiB hard cap for framed payloads
@@ -109,6 +110,8 @@ public static bool FolderExists(string path)
109110

110111
static MCPForUnityBridge()
111112
{
113+
// Record the main thread ID for safe thread checks
114+
try { mainThreadId = Thread.CurrentThread.ManagedThreadId; } catch { mainThreadId = 0; }
112115
// Skip bridge in headless/batch environments (CI/builds) unless explicitly allowed via env
113116
// CI override: set UNITY_MCP_ALLOW_BATCH=1 to allow the bridge in batch mode
114117
if (Application.isBatchMode && string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("UNITY_MCP_ALLOW_BATCH")))
@@ -539,7 +542,39 @@ private static async Task HandleClientAsync(TcpClient client, CancellationToken
539542
commandQueue[commandId] = (commandText, tcs);
540543
}
541544

542-
string response = await tcs.Task.ConfigureAwait(false);
545+
// Wait for the handler to produce a response, but do not block indefinitely
546+
string response;
547+
try
548+
{
549+
using var respCts = new CancellationTokenSource(FrameIOTimeoutMs);
550+
var completed = await Task.WhenAny(tcs.Task, Task.Delay(FrameIOTimeoutMs, respCts.Token)).ConfigureAwait(false);
551+
if (completed == tcs.Task)
552+
{
553+
// Got a result from the handler
554+
respCts.Cancel();
555+
response = tcs.Task.Result;
556+
}
557+
else
558+
{
559+
// Timeout: return a structured error so the client can recover
560+
var timeoutResponse = new
561+
{
562+
status = "error",
563+
error = $"Command processing timed out after {FrameIOTimeoutMs} ms",
564+
};
565+
response = JsonConvert.SerializeObject(timeoutResponse);
566+
}
567+
}
568+
catch (Exception ex)
569+
{
570+
var errorResponse = new
571+
{
572+
status = "error",
573+
error = ex.Message,
574+
};
575+
response = JsonConvert.SerializeObject(errorResponse);
576+
}
577+
543578
byte[] responseBytes = System.Text.Encoding.UTF8.GetBytes(response);
544579
await WriteFrameAsync(stream, responseBytes);
545580
}
@@ -816,6 +851,60 @@ private static void ProcessCommands()
816851
}
817852
}
818853

854+
// Invoke the given function on the Unity main thread and wait up to timeoutMs for the result.
855+
// Returns null on timeout or error; caller should provide a fallback error response.
856+
private static object InvokeOnMainThreadWithTimeout(Func<object> func, int timeoutMs)
857+
{
858+
if (func == null) return null;
859+
try
860+
{
861+
// If we are already on the main thread, execute directly to avoid deadlocks
862+
try
863+
{
864+
if (Thread.CurrentThread.ManagedThreadId == mainThreadId)
865+
{
866+
return func();
867+
}
868+
}
869+
catch { }
870+
871+
object result = null;
872+
Exception captured = null;
873+
var tcs = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
874+
EditorApplication.delayCall += () =>
875+
{
876+
try
877+
{
878+
result = func();
879+
}
880+
catch (Exception ex)
881+
{
882+
captured = ex;
883+
}
884+
finally
885+
{
886+
try { tcs.TrySetResult(true); } catch { }
887+
}
888+
};
889+
890+
// Wait for completion with timeout (Editor thread will pump delayCall)
891+
bool completed = tcs.Task.Wait(timeoutMs);
892+
if (!completed)
893+
{
894+
return null; // timeout
895+
}
896+
if (captured != null)
897+
{
898+
return Response.Error($"Main thread handler error: {captured.Message}");
899+
}
900+
return result;
901+
}
902+
catch (Exception ex)
903+
{
904+
return Response.Error($"Failed to invoke on main thread: {ex.Message}");
905+
}
906+
}
907+
819908
// Helper method to check if a string is valid JSON
820909
private static bool IsValidJson(string text)
821910
{
@@ -880,7 +969,8 @@ private static string ExecuteCommand(Command command)
880969
// Maps the command type (tool name) to the corresponding handler's static HandleCommand method
881970
// Assumes each handler class has a static method named 'HandleCommand' that takes JObject parameters
882971
"manage_script" => ManageScript.HandleCommand(paramsObject),
883-
"manage_scene" => ManageScene.HandleCommand(paramsObject),
972+
// Run scene operations on the main thread to avoid deadlocks/hangs
973+
"manage_scene" => InvokeOnMainThreadWithTimeout(() => ManageScene.HandleCommand(paramsObject), FrameIOTimeoutMs) ?? Response.Error("manage_scene timed out on main thread"),
884974
"manage_editor" => ManageEditor.HandleCommand(paramsObject),
885975
"manage_gameobject" => ManageGameObject.HandleCommand(paramsObject),
886976
"manage_asset" => ManageAsset.HandleCommand(paramsObject),

UnityMcpBridge/UnityMcpServer~/src/config.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ class ServerConfig:
3636

3737
# Telemetry settings
3838
telemetry_enabled: bool = True
39-
telemetry_endpoint: str = "https://api-prod.coplay.dev/telemetry/events"
39+
telemetry_endpoint: str = "https://unity-mcp-telemetry-a6uvvbgbsa-uc.a.run.app/telemetry/events"
4040

4141
# Create a global config instance
4242
config = ServerConfig()

UnityMcpBridge/UnityMcpServer~/src/server.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from mcp.server.fastmcp import FastMCP, Context, Image
22
import logging
3+
from logging.handlers import RotatingFileHandler
34
import os
45
from dataclasses import dataclass
56
from contextlib import asynccontextmanager
@@ -17,6 +18,20 @@
1718
force=True # Ensure our handler replaces any prior stdout handlers
1819
)
1920
logger = logging.getLogger("mcp-for-unity-server")
21+
22+
# Also write logs to a rotating file so logs are available when launched via stdio
23+
try:
24+
import os as _os
25+
_log_dir = _os.path.join(_os.path.expanduser("~/Library/Application Support/UnityMCP"), "Logs")
26+
_os.makedirs(_log_dir, exist_ok=True)
27+
_file_path = _os.path.join(_log_dir, "unity_mcp_server.log")
28+
_fh = RotatingFileHandler(_file_path, maxBytes=512*1024, backupCount=2, encoding="utf-8")
29+
_fh.setFormatter(logging.Formatter(config.log_format))
30+
_fh.setLevel(getattr(logging, config.log_level))
31+
logger.addHandler(_fh)
32+
except Exception:
33+
# Never let logging setup break startup
34+
pass
2035
# Quieten noisy third-party loggers to avoid clutter during stdio handshake
2136
for noisy in ("httpx", "urllib3"):
2237
try:

UnityMcpBridge/UnityMcpServer~/src/tools/manage_scene.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ def register_manage_scene_tools(mcp: FastMCP):
1212
@mcp.tool()
1313
@telemetry_tool("manage_scene")
1414
def manage_scene(
15-
ctx: Any,
15+
ctx: Context,
1616
action: str,
1717
name: str,
1818
path: str,

0 commit comments

Comments
 (0)