From 0c52c1c92eb59fc01fa98c93fc56310d6aed7d35 Mon Sep 17 00:00:00 2001 From: dsarno Date: Tue, 2 Sep 2025 09:36:50 -0700 Subject: [PATCH] feat: Automatic Background Compile and Domain Reload for MCP Script Edits and New Script Creation (#248) * Unity MCP: reliable auto-reload via Unity-side sentinel flip; remove Python writes - Add MCP/Flip Reload Sentinel editor menu and flip package sentinel synchronously - Trigger sentinel flip after Create/Update/ApplyTextEdits (sync) in ManageScript - Instrument flip path with Debug.Log for traceability - Remove Python reload_sentinel writes; tools now execute Unity menu instead - Harden reload_sentinel path resolution to project/package - ExecuteMenuItem runs synchronously for deterministic results - Verified MCP edits trigger compile/reload without focus; no Python permission errors * Getting double flips * Fix double reload and ensure accurate batch edits; immediate structured reloads * Remove MCP/Flip Reload Sentinel menu; rely on synchronous import/compile for reloads * Route bridge/editor logs through McpLog and gate behind debug; create path now reloads synchronously * chore: ignore backup artifacts; remove stray ManageScript.cs.backup files * fix span logic * fix: honor UNITY_MCP_STATUS_DIR for sentinel status file lookup (fallback to ~/.unity-mcp) * test: add sentinel test honoring UNITY_MCP_STATUS_DIR; chore: ManageScript overlap check simplification and log consistency * Harden environment path, remove extraneous flip menu test * refactor: centralize import/compile via ManageScriptRefreshHelpers.ImportAndRequestCompile; replace duplicated sequences * feat: add scheduledRefresh flag; standardize logs; gate info and DRY immediate import/compile * chore: remove execute_menu_item sentinel flip from manage_script_edits; rely on import/compile * chore: remove unused MCP reload sentinel mechanism * fix: honor ignore_case for anchor_insert in text conversion path --- .gitignore | 4 + TestProjects/UnityMCPTests/Assets/Editor.meta | 8 ++ .../UnityMCPTests/Assets/Scripts/Hello.cs | 10 ++- .../Assets/Scripts/Hello.cs.meta | 11 +-- UnityMcpBridge/Editor/MCPForUnityBridge.cs | 44 +++++++--- .../Editor/Tools/ExecuteMenuItem.cs | 51 +++++------- UnityMcpBridge/Editor/Tools/ManageScript.cs | 55 +++++++------ .../Editor/Windows/MCPForUnityEditorWindow.cs | 4 +- .../UnityMcpServer~/src/reload_sentinel.py | 8 ++ .../src/tools/manage_script.py | 34 +++++++- .../src/tools/manage_script_edits.py | 82 +++++++++++++++++-- UnityMcpBridge/UnityMcpServer~/src/uv.lock | 2 +- 12 files changed, 218 insertions(+), 95 deletions(-) create mode 100644 TestProjects/UnityMCPTests/Assets/Editor.meta create mode 100644 UnityMcpBridge/UnityMcpServer~/src/reload_sentinel.py diff --git a/.gitignore b/.gitignore index 0e2cbb0..be9fc7e 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,7 @@ CONTRIBUTING.md.meta .DS_Store* # Unity test project lock files TestProjects/UnityMCPTests/Packages/packages-lock.json + +# Backup artifacts +*.backup +*.backup.meta diff --git a/TestProjects/UnityMCPTests/Assets/Editor.meta b/TestProjects/UnityMCPTests/Assets/Editor.meta new file mode 100644 index 0000000..79828f3 --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Editor.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 46421b2ea84fe4b1a903e2483cff3958 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/TestProjects/UnityMCPTests/Assets/Scripts/Hello.cs b/TestProjects/UnityMCPTests/Assets/Scripts/Hello.cs index ab99643..9bab3e3 100644 --- a/TestProjects/UnityMCPTests/Assets/Scripts/Hello.cs +++ b/TestProjects/UnityMCPTests/Assets/Scripts/Hello.cs @@ -1,11 +1,15 @@ -using System.Collections; -using System.Collections.Generic; using UnityEngine; +using System.Collections; public class Hello : MonoBehaviour { + + // Use this for initialization void Start() { Debug.Log("Hello World"); } -} + + + +} \ No newline at end of file diff --git a/TestProjects/UnityMCPTests/Assets/Scripts/Hello.cs.meta b/TestProjects/UnityMCPTests/Assets/Scripts/Hello.cs.meta index 6b1e126..b01fea0 100644 --- a/TestProjects/UnityMCPTests/Assets/Scripts/Hello.cs.meta +++ b/TestProjects/UnityMCPTests/Assets/Scripts/Hello.cs.meta @@ -1,11 +1,2 @@ fileFormatVersion: 2 -guid: bebdf68a6876b425494ee770d20f70ef -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: +guid: bebdf68a6876b425494ee770d20f70ef \ No newline at end of file diff --git a/UnityMcpBridge/Editor/MCPForUnityBridge.cs b/UnityMcpBridge/Editor/MCPForUnityBridge.cs index f90b223..1a17584 100644 --- a/UnityMcpBridge/Editor/MCPForUnityBridge.cs +++ b/UnityMcpBridge/Editor/MCPForUnityBridge.cs @@ -48,7 +48,7 @@ namespace MCPForUnity.Editor { if (IsDebugEnabled()) { - Debug.Log($"MCP-FOR-UNITY: [{stage}]"); + McpLog.Info($"[{stage}]", always: false); } } @@ -230,7 +230,10 @@ namespace MCPForUnity.Editor // Don't restart if already running on a working port if (isRunning && listener != null) { - Debug.Log($"MCP-FOR-UNITY: MCPForUnityBridge already running on port {currentUnityPort}"); + if (IsDebugEnabled()) + { + Debug.Log($"MCP-FOR-UNITY: MCPForUnityBridge already running on port {currentUnityPort}"); + } return; } @@ -348,7 +351,7 @@ namespace MCPForUnity.Editor listener?.Stop(); listener = null; EditorApplication.update -= ProcessCommands; - Debug.Log("MCP-FOR-UNITY: MCPForUnityBridge stopped."); + if (IsDebugEnabled()) Debug.Log("MCP-FOR-UNITY: MCPForUnityBridge stopped."); } catch (Exception ex) { @@ -389,7 +392,7 @@ namespace MCPForUnity.Editor { if (isRunning) { - Debug.LogError($"Listener error: {ex.Message}"); + if (IsDebugEnabled()) Debug.LogError($"Listener error: {ex.Message}"); } } } @@ -403,8 +406,11 @@ namespace MCPForUnity.Editor // Framed I/O only; legacy mode removed try { - var ep = client.Client?.RemoteEndPoint?.ToString() ?? "unknown"; - Debug.Log($"UNITY-MCP: Client connected {ep}"); + if (IsDebugEnabled()) + { + var ep = client.Client?.RemoteEndPoint?.ToString() ?? "unknown"; + Debug.Log($"UNITY-MCP: Client connected {ep}"); + } } catch { } // Strict framing: always require FRAMING=1 and frame all I/O @@ -423,11 +429,11 @@ namespace MCPForUnity.Editor #else await stream.WriteAsync(handshakeBytes, 0, handshakeBytes.Length, cts.Token).ConfigureAwait(false); #endif - Debug.Log("UNITY-MCP: Sent handshake FRAMING=1 (strict)"); + if (IsDebugEnabled()) MCPForUnity.Editor.Helpers.McpLog.Info("Sent handshake FRAMING=1 (strict)", always: false); } catch (Exception ex) { - Debug.LogWarning($"UNITY-MCP: Handshake failed: {ex.Message}"); + if (IsDebugEnabled()) MCPForUnity.Editor.Helpers.McpLog.Warn($"Handshake failed: {ex.Message}"); return; // abort this client } @@ -440,8 +446,11 @@ namespace MCPForUnity.Editor try { - var preview = commandText.Length > 120 ? commandText.Substring(0, 120) + "…" : commandText; - Debug.Log($"UNITY-MCP: recv framed: {preview}"); + if (IsDebugEnabled()) + { + var preview = commandText.Length > 120 ? commandText.Substring(0, 120) + "…" : commandText; + MCPForUnity.Editor.Helpers.McpLog.Info($"recv framed: {preview}", always: false); + } } catch { } string commandId = Guid.NewGuid().ToString(); @@ -470,7 +479,20 @@ namespace MCPForUnity.Editor } catch (Exception ex) { - Debug.LogError($"Client handler error: {ex.Message}"); + // Treat common disconnects/timeouts as benign; only surface hard errors + string msg = ex.Message ?? string.Empty; + bool isBenign = + msg.IndexOf("Connection closed before reading expected bytes", StringComparison.OrdinalIgnoreCase) >= 0 + || msg.IndexOf("Read timed out", StringComparison.OrdinalIgnoreCase) >= 0 + || ex is System.IO.IOException; + if (isBenign) + { + if (IsDebugEnabled()) MCPForUnity.Editor.Helpers.McpLog.Info($"Client handler: {msg}", always: false); + } + else + { + MCPForUnity.Editor.Helpers.McpLog.Error($"Client handler error: {msg}"); + } break; } } diff --git a/UnityMcpBridge/Editor/Tools/ExecuteMenuItem.cs b/UnityMcpBridge/Editor/Tools/ExecuteMenuItem.cs index e51d773..5adb476 100644 --- a/UnityMcpBridge/Editor/Tools/ExecuteMenuItem.cs +++ b/UnityMcpBridge/Editor/Tools/ExecuteMenuItem.cs @@ -68,6 +68,8 @@ namespace MCPForUnity.Editor.Tools { // Try both naming conventions: snake_case and camelCase string menuPath = @params["menu_path"]?.ToString() ?? @params["menuPath"]?.ToString(); + // Optional future param retained for API compatibility; not used in synchronous mode + // int timeoutMs = Math.Max(0, (@params["timeout_ms"]?.ToObject() ?? 2000)); // string alias = @params["alias"]?.ToString(); // TODO: Implement alias mapping based on refactor plan requirements. // JObject parameters = @params["parameters"] as JObject; // TODO: Investigate parameter passing (often not directly supported by ExecuteMenuItem). @@ -94,42 +96,29 @@ namespace MCPForUnity.Editor.Tools try { - // Attempt to execute the menu item on the main thread using delayCall for safety. - EditorApplication.delayCall += () => - { - try - { - bool executed = EditorApplication.ExecuteMenuItem(menuPath); - // Log potential failure inside the delayed call. - if (!executed) - { - Debug.LogError( - $"[ExecuteMenuItem] Failed to find or execute menu item via delayCall: '{menuPath}'. It might be invalid, disabled, or context-dependent." - ); - } - } - catch (Exception delayEx) - { - Debug.LogError( - $"[ExecuteMenuItem] Exception during delayed execution of '{menuPath}': {delayEx}" - ); - } - }; + // Trace incoming execute requests + Debug.Log($"[ExecuteMenuItem] Request to execute menu: '{menuPath}'"); - // Report attempt immediately, as execution is delayed. - return Response.Success( - $"Attempted to execute menu item: '{menuPath}'. Check Unity logs for confirmation or errors." + // Execute synchronously. This code runs on the Editor main thread in our bridge path. + bool executed = EditorApplication.ExecuteMenuItem(menuPath); + if (executed) + { + Debug.Log($"[ExecuteMenuItem] Executed successfully: '{menuPath}'"); + return Response.Success( + $"Executed menu item: '{menuPath}'", + new { executed = true, menuPath } + ); + } + Debug.LogWarning($"[ExecuteMenuItem] Failed (not found/disabled): '{menuPath}'"); + return Response.Error( + $"Failed to execute menu item (not found or disabled): '{menuPath}'", + new { executed = false, menuPath } ); } catch (Exception e) { - // Catch errors during setup phase. - Debug.LogError( - $"[ExecuteMenuItem] Failed to setup execution for '{menuPath}': {e}" - ); - return Response.Error( - $"Error setting up execution for menu item '{menuPath}': {e.Message}" - ); + Debug.LogError($"[ExecuteMenuItem] Error executing '{menuPath}': {e}"); + return Response.Error($"Error executing menu item '{menuPath}': {e.Message}"); } } diff --git a/UnityMcpBridge/Editor/Tools/ManageScript.cs b/UnityMcpBridge/Editor/Tools/ManageScript.cs index 0337f74..7ec6afe 100644 --- a/UnityMcpBridge/Editor/Tools/ManageScript.cs +++ b/UnityMcpBridge/Editor/Tools/ManageScript.cs @@ -193,10 +193,10 @@ namespace MCPForUnity.Editor.Tools namespaceName ); case "read": - Debug.LogWarning("manage_script.read is deprecated; prefer resources/read. Serving read for backward compatibility."); + McpLog.Warn("manage_script.read is deprecated; prefer resources/read. Serving read for backward compatibility."); return ReadScript(fullPath, relativePath); case "update": - Debug.LogWarning("manage_script.update is deprecated; prefer apply_text_edits. Serving update for backward compatibility."); + McpLog.Warn("manage_script.update is deprecated; prefer apply_text_edits. Serving update for backward compatibility."); return UpdateScript(fullPath, relativePath, name, contents); case "delete": return DeleteScript(fullPath, relativePath); @@ -356,11 +356,11 @@ namespace MCPForUnity.Editor.Tools var uri = $"unity://path/{relativePath}"; var ok = Response.Success( $"Script '{name}.cs' created successfully at '{relativePath}'.", - new { uri, scheduledRefresh = true } + new { uri, scheduledRefresh = false } ); - // Schedule heavy work AFTER replying - ManageScriptRefreshHelpers.ScheduleScriptRefresh(relativePath); + ManageScriptRefreshHelpers.ImportAndRequestCompile(relativePath); + return ok; } catch (Exception e) @@ -650,7 +650,7 @@ namespace MCPForUnity.Editor.Tools spans = spans.OrderByDescending(t => t.start).ToList(); for (int i = 1; i < spans.Count; i++) { - if (spans[i].end > spans[i - 1].start) + if (spans[i].end > spans[i - 1].start) { var conflict = new[] { new { startA = spans[i].start, endA = spans[i].end, startB = spans[i - 1].start, endB = spans[i - 1].end } }; return Response.Error("overlap", new { status = "overlap", conflicts = conflict, hint = "Sort ranges descending by start and compute from the same snapshot." }); @@ -763,19 +763,18 @@ namespace MCPForUnity.Editor.Tools string.Equals(refreshModeFromCaller, "sync", StringComparison.OrdinalIgnoreCase); if (immediate) { - EditorApplication.delayCall += () => - { - AssetDatabase.ImportAsset( - relativePath, - ImportAssetOptions.ForceSynchronousImport | ImportAssetOptions.ForceUpdate - ); + McpLog.Info($"[ManageScript] ApplyTextEdits: immediate refresh for '{relativePath}'"); + AssetDatabase.ImportAsset( + relativePath, + ImportAssetOptions.ForceSynchronousImport | ImportAssetOptions.ForceUpdate + ); #if UNITY_EDITOR - UnityEditor.Compilation.CompilationPipeline.RequestScriptCompilation(); + UnityEditor.Compilation.CompilationPipeline.RequestScriptCompilation(); #endif - }; } else { + McpLog.Info($"[ManageScript] ApplyTextEdits: debounced refresh scheduled for '{relativePath}'"); ManageScriptRefreshHelpers.ScheduleScriptRefresh(relativePath); } @@ -786,7 +785,8 @@ namespace MCPForUnity.Editor.Tools uri = $"unity://path/{relativePath}", path = relativePath, editsApplied = spans.Count, - sha256 = newSha + sha256 = newSha, + scheduledRefresh = !immediate } ); } @@ -1326,7 +1326,7 @@ namespace MCPForUnity.Editor.Tools if (ordered[i].start + ordered[i].length > ordered[i - 1].start) { var conflict = new[] { new { startA = ordered[i].start, endA = ordered[i].start + ordered[i].length, startB = ordered[i - 1].start, endB = ordered[i - 1].start + ordered[i - 1].length } }; - return Response.Error("overlap", new { status = "overlap", conflicts = conflict, hint = "Apply in descending order against the same precondition snapshot." }); + return Response.Error("overlap", new { status = "overlap", conflicts = conflict, hint = "Sort ranges descending by start and compute from the same snapshot." }); } } return Response.Error("overlap", new { status = "overlap" }); @@ -1421,17 +1421,8 @@ namespace MCPForUnity.Editor.Tools if (immediate) { - // Force on main thread - EditorApplication.delayCall += () => - { - AssetDatabase.ImportAsset( - relativePath, - ImportAssetOptions.ForceSynchronousImport | ImportAssetOptions.ForceUpdate - ); -#if UNITY_EDITOR - UnityEditor.Compilation.CompilationPipeline.RequestScriptCompilation(); -#endif - }; + McpLog.Info($"[ManageScript] EditScript: immediate refresh for '{relativePath}'", always: false); + ManageScriptRefreshHelpers.ImportAndRequestCompile(relativePath); } else { @@ -2620,5 +2611,15 @@ static class ManageScriptRefreshHelpers { RefreshDebounce.Schedule(relPath, TimeSpan.FromMilliseconds(200)); } + + public static void ImportAndRequestCompile(string relPath, bool synchronous = true) + { + var opts = ImportAssetOptions.ForceUpdate; + if (synchronous) opts |= ImportAssetOptions.ForceSynchronousImport; + AssetDatabase.ImportAsset(relPath, opts); +#if UNITY_EDITOR + UnityEditor.Compilation.CompilationPipeline.RequestScriptCompilation(); +#endif + } } diff --git a/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs b/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs index f9235fd..84113f7 100644 --- a/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs +++ b/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs @@ -1771,7 +1771,7 @@ namespace MCPForUnity.Editor.Windows { if (debugLogsEnabled) { - UnityEngine.Debug.Log($"MCP for Unity: Auto-updated MCP config for '{mcpClient.name}' to new path: {pythonDir}"); + MCPForUnity.Editor.Helpers.McpLog.Info($"Auto-updated MCP config for '{mcpClient.name}' to new path: {pythonDir}", always: false); } mcpClient.SetStatus(McpStatus.Configured); } @@ -1971,7 +1971,7 @@ namespace MCPForUnity.Editor.Windows if (debugLogsEnabled) { - UnityEngine.Debug.Log($"Checking Claude config at: {configPath}"); + MCPForUnity.Editor.Helpers.McpLog.Info($"Checking Claude config at: {configPath}", always: false); } if (!File.Exists(configPath)) diff --git a/UnityMcpBridge/UnityMcpServer~/src/reload_sentinel.py b/UnityMcpBridge/UnityMcpServer~/src/reload_sentinel.py new file mode 100644 index 0000000..e224844 --- /dev/null +++ b/UnityMcpBridge/UnityMcpServer~/src/reload_sentinel.py @@ -0,0 +1,8 @@ +""" +Deprecated: Sentinel flipping is handled inside Unity via the MCP menu +'MCP/Flip Reload Sentinel'. This module remains only as a compatibility shim. +All functions are no-ops to prevent accidental external writes. +""" + +def flip_reload_sentinel(*args, **kwargs) -> str: + return "reload_sentinel.py is deprecated; use execute_menu_item → 'MCP/Flip Reload Sentinel'" diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py index 9aad124..d4e9ad4 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py @@ -306,6 +306,38 @@ def register_manage_script_tools(mcp: FastMCP): data.setdefault("normalizedEdits", normalized_edits) if warnings: data.setdefault("warnings", warnings) + if resp.get("success") and (options or {}).get("force_sentinel_reload"): + # Optional: flip sentinel via menu if explicitly requested + try: + import threading, time, json, glob, os + def _latest_status() -> dict | None: + try: + files = sorted(glob.glob(os.path.expanduser("~/.unity-mcp/unity-mcp-status-*.json")), key=os.path.getmtime, reverse=True) + if not files: + return None + with open(files[0], "r") as f: + return json.loads(f.read()) + except Exception: + return None + + def _flip_async(): + try: + time.sleep(0.1) + st = _latest_status() + if st and st.get("reloading"): + return + send_command_with_retry( + "execute_menu_item", + {"menuPath": "MCP/Flip Reload Sentinel"}, + max_retries=0, + retry_ms=0, + ) + except Exception: + pass + threading.Thread(target=_flip_async, daemon=True).start() + except Exception: + pass + return resp return resp return {"success": False, "message": str(resp)} @@ -457,7 +489,7 @@ def register_manage_script_tools(mcp: FastMCP): "path": path, "edits": edits, "precondition_sha256": sha, - "options": {"refresh": "immediate", "validate": "standard"}, + "options": {"refresh": "debounced", "validate": "standard"}, } # Preflight size vs. default cap (256 KiB) to avoid opaque server errors try: diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py index fc50be3..4ed65de 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py @@ -2,6 +2,7 @@ from mcp.server.fastmcp import FastMCP, Context from typing import Dict, Any, List, Tuple import base64 import re +import os from unity_connection import send_command_with_retry @@ -80,6 +81,37 @@ def _apply_edits_locally(original_text: str, edits: List[Dict[str, Any]]) -> str return text +def _trigger_sentinel_async() -> None: + """Fire the Unity menu flip on a short-lived background thread. + + This avoids blocking the current request or getting stuck during domain reloads + (socket reconnects) when the Editor recompiles. + """ + try: + import threading, time + + def _flip(): + try: + import json, glob, os + # Small delay so write flushes; prefer early flip to avoid editor-focus second reload + time.sleep(0.1) + try: + files = sorted(glob.glob(os.path.expanduser("~/.unity-mcp/unity-mcp-status-*.json")), key=os.path.getmtime, reverse=True) + if files: + with open(files[0], "r") as f: + st = json.loads(f.read()) + if st.get("reloading"): + return + except Exception: + pass + + except Exception: + pass + + threading.Thread(target=_flip, daemon=True).start() + except Exception: + pass + def _infer_class_name(script_name: str) -> str: # Default to script name as class name (common Unity pattern) return (script_name or "").strip() @@ -470,7 +502,7 @@ def register_manage_script_edits_tools(mcp: FastMCP): # If everything is structured (method/class/anchor ops), forward directly to Unity's structured editor. if all_struct: opts2 = dict(options or {}) - # Do not force sequential; allow server default (atomic) unless caller requests otherwise + # For structured edits, prefer immediate refresh to avoid missed reloads when Editor is unfocused opts2.setdefault("refresh", "immediate") params_struct: Dict[str, Any] = { "action": "edit", @@ -482,6 +514,13 @@ def register_manage_script_edits_tools(mcp: FastMCP): "options": opts2, } resp_struct = send_command_with_retry("manage_script", params_struct) + if isinstance(resp_struct, dict) and resp_struct.get("success"): + # Optional: flip sentinel only if explicitly requested + if (options or {}).get("force_sentinel_reload"): + try: + _trigger_sentinel_async() + except Exception: + pass return _with_norm(resp_struct if isinstance(resp_struct, dict) else {"success": False, "message": str(resp_struct)}, normalized_for_echo, routing="structured") # 1) read from Unity @@ -597,18 +636,24 @@ def register_manage_script_edits_tools(mcp: FastMCP): "scriptType": script_type, "edits": at_edits, "precondition_sha256": sha, - "options": {"refresh": "immediate", "validate": (options or {}).get("validate", "standard"), "applyMode": ("atomic" if len(at_edits) > 1 else (options or {}).get("applyMode", "sequential"))} + "options": {"refresh": (options or {}).get("refresh", "debounced"), "validate": (options or {}).get("validate", "standard"), "applyMode": ("atomic" if len(at_edits) > 1 else (options or {}).get("applyMode", "sequential"))} } resp_text = send_command_with_retry("manage_script", params_text) if not (isinstance(resp_text, dict) and resp_text.get("success")): return _with_norm(resp_text if isinstance(resp_text, dict) else {"success": False, "message": str(resp_text)}, normalized_for_echo, routing="mixed/text-first") + # Successful text write; flip sentinel only if explicitly requested + if (options or {}).get("force_sentinel_reload"): + try: + _trigger_sentinel_async() + except Exception: + pass except Exception as e: return _with_norm({"success": False, "message": f"Text edit conversion failed: {e}"}, normalized_for_echo, routing="mixed/text-first") if struct_edits: opts2 = dict(options or {}) - # Let server decide; do not force sequential - opts2.setdefault("refresh", "immediate") + # Prefer debounced background refresh unless explicitly overridden + opts2.setdefault("refresh", "debounced") params_struct: Dict[str, Any] = { "action": "edit", "name": name, @@ -619,6 +664,12 @@ def register_manage_script_edits_tools(mcp: FastMCP): "options": opts2 } resp_struct = send_command_with_retry("manage_script", params_struct) + if isinstance(resp_struct, dict) and resp_struct.get("success"): + if (options or {}).get("force_sentinel_reload"): + try: + _trigger_sentinel_async() + except Exception: + pass return _with_norm(resp_struct if isinstance(resp_struct, dict) else {"success": False, "message": str(resp_struct)}, normalized_for_echo, routing="mixed/text-first") return _with_norm({"success": True, "message": "Applied text edits (no structured ops)"}, normalized_for_echo, routing="mixed/text-first") @@ -648,9 +699,10 @@ def register_manage_script_edits_tools(mcp: FastMCP): if op == "anchor_insert": anchor = e.get("anchor") or "" position = (e.get("position") or "after").lower() - # Early regex compile with helpful errors + # Early regex compile with helpful errors, honoring ignore_case try: - regex_obj = _re.compile(anchor, _re.MULTILINE) + flags = _re.MULTILINE | (_re.IGNORECASE if e.get("ignore_case") else 0) + regex_obj = _re.compile(anchor, flags) except Exception as ex: return _with_norm(_err("bad_regex", f"Invalid anchor regex: {ex}", normalized=normalized_for_echo, routing="text", extra={"hint": "Escape parentheses/braces or use a simpler anchor."}), normalized_for_echo, routing="text") m = regex_obj.search(base_text) @@ -734,12 +786,18 @@ def register_manage_script_edits_tools(mcp: FastMCP): "edits": at_edits, "precondition_sha256": sha, "options": { - "refresh": "immediate", + "refresh": (options or {}).get("refresh", "debounced"), "validate": (options or {}).get("validate", "standard"), "applyMode": ("atomic" if len(at_edits) > 1 else (options or {}).get("applyMode", "sequential")) } } resp = send_command_with_retry("manage_script", params) + if isinstance(resp, dict) and resp.get("success"): + if (options or {}).get("force_sentinel_reload"): + try: + _trigger_sentinel_async() + except Exception: + pass return _with_norm( resp if isinstance(resp, dict) else {"success": False, "message": str(resp)}, normalized_for_echo, @@ -791,7 +849,7 @@ def register_manage_script_edits_tools(mcp: FastMCP): # Default refresh/validate for natural usage on text path as well options = dict(options or {}) options.setdefault("validate", "standard") - options.setdefault("refresh", "immediate") + options.setdefault("refresh", "debounced") import hashlib # Compute the SHA of the current file contents for the precondition @@ -816,10 +874,16 @@ def register_manage_script_edits_tools(mcp: FastMCP): } ], "precondition_sha256": sha, - "options": options or {"validate": "standard", "refresh": "immediate"}, + "options": options or {"validate": "standard", "refresh": "debounced"}, } write_resp = send_command_with_retry("manage_script", params) + if isinstance(write_resp, dict) and write_resp.get("success"): + if (options or {}).get("force_sentinel_reload"): + try: + _trigger_sentinel_async() + except Exception: + pass return _with_norm( write_resp if isinstance(write_resp, dict) else {"success": False, "message": str(write_resp)}, diff --git a/UnityMcpBridge/UnityMcpServer~/src/uv.lock b/UnityMcpBridge/UnityMcpServer~/src/uv.lock index 87a4deb..f5cac0f 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/uv.lock +++ b/UnityMcpBridge/UnityMcpServer~/src/uv.lock @@ -162,7 +162,7 @@ cli = [ [[package]] name = "mcpforunityserver" -version = "3.0.2" +version = "3.1.0" source = { editable = "." } dependencies = [ { name = "httpx" },