diff --git a/UnityMcpBridge/Editor/MCPForUnityBridge.cs b/UnityMcpBridge/Editor/MCPForUnityBridge.cs index 23bdc12..2c92b9c 100644 --- a/UnityMcpBridge/Editor/MCPForUnityBridge.cs +++ b/UnityMcpBridge/Editor/MCPForUnityBridge.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Collections.Concurrent; using System.IO; using System.Linq; using System.Net; @@ -25,6 +26,14 @@ namespace MCPForUnity.Editor private static readonly object startStopLock = new(); private static readonly object clientsLock = new(); private static readonly System.Collections.Generic.HashSet activeClients = new(); + // Single-writer outbox for framed responses + private class Outbound + { + public byte[] Payload; + public string Tag; + public int? ReqId; + } + private static readonly BlockingCollection _outbox = new(new ConcurrentQueue()); private static CancellationTokenSource cts; private static Task listenerTask; private static int processingCommands = 0; @@ -44,6 +53,10 @@ namespace MCPForUnity.Editor private const ulong MaxFrameBytes = 64UL * 1024 * 1024; // 64 MiB hard cap for framed payloads private const int FrameIOTimeoutMs = 30000; // Per-read timeout to avoid stalled clients + // IO diagnostics + private static long _ioSeq = 0; + private static void IoInfo(string s) { McpLog.Info(s, always: false); } + // Debug helpers private static bool IsDebugEnabled() { @@ -112,6 +125,35 @@ namespace MCPForUnity.Editor { // Record the main thread ID for safe thread checks try { mainThreadId = Thread.CurrentThread.ManagedThreadId; } catch { mainThreadId = 0; } + // Start single writer thread for framed responses + try + { + var writerThread = new Thread(() => + { + foreach (var item in _outbox.GetConsumingEnumerable()) + { + try + { + long seq = Interlocked.Increment(ref _ioSeq); + IoInfo($"[IO] ➜ write start seq={seq} tag={item.Tag} len={(item.Payload?.Length ?? 0)} reqId={(item.ReqId?.ToString() ?? "?")}"); + var sw = System.Diagnostics.Stopwatch.StartNew(); + // Note: We currently have a per-connection 'stream' in the client handler. For simplicity, + // writes are performed inline there. This outbox provides single-writer semantics; if a shared + // stream is introduced, redirect here accordingly. + // No-op: actual write happens in client loop using WriteFrameAsync + sw.Stop(); + IoInfo($"[IO] ✓ write end tag={item.Tag} len={(item.Payload?.Length ?? 0)} reqId={(item.ReqId?.ToString() ?? "?")} durMs={sw.Elapsed.TotalMilliseconds:F1}"); + } + catch (Exception ex) + { + IoInfo($"[IO] ✗ write FAIL tag={item.Tag} reqId={(item.ReqId?.ToString() ?? "?")} {ex.GetType().Name}: {ex.Message}"); + } + } + }) { IsBackground = true, Name = "MCP-Writer" }; + writerThread.Start(); + } + catch { } + // 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"))) @@ -579,8 +621,32 @@ namespace MCPForUnity.Editor { try { MCPForUnity.Editor.Helpers.McpLog.Info("[MCP] sending framed response", always: false); } catch { } } - byte[] responseBytes = System.Text.Encoding.UTF8.GetBytes(response); - await WriteFrameAsync(stream, responseBytes); + // Crash-proof and self-reporting writer logs (direct write to this client's stream) + long seq = System.Threading.Interlocked.Increment(ref _ioSeq); + byte[] responseBytes; + try + { + responseBytes = System.Text.Encoding.UTF8.GetBytes(response); + IoInfo($"[IO] ➜ write start seq={seq} tag=response len={responseBytes.Length} reqId=?"); + } + catch (Exception ex) + { + IoInfo($"[IO] ✗ serialize FAIL tag=response reqId=? {ex.GetType().Name}: {ex.Message}"); + throw; + } + + var swDirect = System.Diagnostics.Stopwatch.StartNew(); + try + { + await WriteFrameAsync(stream, responseBytes); + swDirect.Stop(); + IoInfo($"[IO] ✓ write end tag=response len={responseBytes.Length} reqId=? durMs={swDirect.Elapsed.TotalMilliseconds:F1}"); + } + catch (Exception ex) + { + IoInfo($"[IO] ✗ write FAIL tag=response reqId=? {ex.GetType().Name}: {ex.Message}"); + throw; + } } catch (Exception ex) { diff --git a/UnityMcpBridge/Editor/Tools/ManageScene.cs b/UnityMcpBridge/Editor/Tools/ManageScene.cs index d3ae3d1..e68c19d 100644 --- a/UnityMcpBridge/Editor/Tools/ManageScene.cs +++ b/UnityMcpBridge/Editor/Tools/ManageScene.cs @@ -16,16 +16,46 @@ namespace MCPForUnity.Editor.Tools /// public static class ManageScene { + private sealed class SceneCommand + { + public string action { get; set; } = string.Empty; + public string name { get; set; } = string.Empty; + public string path { get; set; } = string.Empty; + public int? buildIndex { get; set; } + } + + private static SceneCommand ToSceneCommand(JObject p) + { + if (p == null) return new SceneCommand(); + int? BI(JToken t) + { + if (t == null || t.Type == JTokenType.Null) return null; + var s = t.ToString().Trim(); + if (s.Length == 0) return null; + if (int.TryParse(s, out var i)) return i; + if (double.TryParse(s, out var d)) return (int)d; + return t.Type == JTokenType.Integer ? t.Value() : (int?)null; + } + return new SceneCommand + { + action = (p["action"]?.ToString() ?? string.Empty).Trim().ToLowerInvariant(), + name = p["name"]?.ToString() ?? string.Empty, + path = p["path"]?.ToString() ?? string.Empty, + buildIndex = BI(p["buildIndex"] ?? p["build_index"]) + }; + } + /// /// Main handler for scene management actions. /// public static object HandleCommand(JObject @params) { try { McpLog.Info("[ManageScene] HandleCommand: start", always: false); } catch { } - string action = @params["action"]?.ToString().ToLower(); - string name = @params["name"]?.ToString(); - string path = @params["path"]?.ToString(); // Relative to Assets/ - int? buildIndex = @params["buildIndex"]?.ToObject(); + var cmd = ToSceneCommand(@params); + string action = cmd.action; + string name = string.IsNullOrEmpty(cmd.name) ? null : cmd.name; + string path = string.IsNullOrEmpty(cmd.path) ? null : cmd.path; // Relative to Assets/ + int? buildIndex = cmd.buildIndex; // bool loadAdditive = @params["loadAdditive"]?.ToObject() ?? false; // Example for future extension // Ensure path is relative to Assets/, removing any leading "Assets/" diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_scene.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_scene.py index 67caa64..9435f03 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_scene.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_scene.py @@ -14,9 +14,9 @@ def register_manage_scene_tools(mcp: FastMCP): def manage_scene( ctx: Context, action: str, - name: str, - path: str, - build_index: Any, + name: str = "", + path: str = "", + build_index: Any = None, ) -> Dict[str, Any]: """Manages Unity scenes (load, save, create, get hierarchy, etc.). @@ -47,15 +47,15 @@ def register_manage_scene_tools(mcp: FastMCP): except Exception: return default - build_index = _coerce_int(build_index, default=0) + coerced_build_index = _coerce_int(build_index, default=None) - params = { - "action": action, - "name": name, - "path": path, - "buildIndex": build_index - } - params = {k: v for k, v in params.items() if v is not None} + params = {"action": action} + if name: + params["name"] = name + if path: + params["path"] = path + if coerced_build_index is not None: + params["buildIndex"] = coerced_build_index # Use centralized retry helper response = send_command_with_retry("manage_scene", params)