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
main
dsarno 2025-09-02 09:36:50 -07:00 committed by GitHub
parent ad848f06df
commit 0c52c1c92e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 218 additions and 95 deletions

4
.gitignore vendored
View File

@ -36,3 +36,7 @@ CONTRIBUTING.md.meta
.DS_Store* .DS_Store*
# Unity test project lock files # Unity test project lock files
TestProjects/UnityMCPTests/Packages/packages-lock.json TestProjects/UnityMCPTests/Packages/packages-lock.json
# Backup artifacts
*.backup
*.backup.meta

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 46421b2ea84fe4b1a903e2483cff3958
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -1,11 +1,15 @@
using System.Collections;
using System.Collections.Generic;
using UnityEngine; using UnityEngine;
using System.Collections;
public class Hello : MonoBehaviour public class Hello : MonoBehaviour
{ {
// Use this for initialization
void Start() void Start()
{ {
Debug.Log("Hello World"); Debug.Log("Hello World");
} }
} }

View File

@ -1,11 +1,2 @@
fileFormatVersion: 2 fileFormatVersion: 2
guid: bebdf68a6876b425494ee770d20f70ef guid: bebdf68a6876b425494ee770d20f70ef
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -48,7 +48,7 @@ namespace MCPForUnity.Editor
{ {
if (IsDebugEnabled()) if (IsDebugEnabled())
{ {
Debug.Log($"<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>: [{stage}]"); McpLog.Info($"[{stage}]", always: false);
} }
} }
@ -229,8 +229,11 @@ namespace MCPForUnity.Editor
{ {
// Don't restart if already running on a working port // Don't restart if already running on a working port
if (isRunning && listener != null) if (isRunning && listener != null)
{
if (IsDebugEnabled())
{ {
Debug.Log($"<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>: MCPForUnityBridge already running on port {currentUnityPort}"); Debug.Log($"<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>: MCPForUnityBridge already running on port {currentUnityPort}");
}
return; return;
} }
@ -348,7 +351,7 @@ namespace MCPForUnity.Editor
listener?.Stop(); listener?.Stop();
listener = null; listener = null;
EditorApplication.update -= ProcessCommands; EditorApplication.update -= ProcessCommands;
Debug.Log("<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>: MCPForUnityBridge stopped."); if (IsDebugEnabled()) Debug.Log("<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>: MCPForUnityBridge stopped.");
} }
catch (Exception ex) catch (Exception ex)
{ {
@ -389,7 +392,7 @@ namespace MCPForUnity.Editor
{ {
if (isRunning) if (isRunning)
{ {
Debug.LogError($"Listener error: {ex.Message}"); if (IsDebugEnabled()) Debug.LogError($"Listener error: {ex.Message}");
} }
} }
} }
@ -402,10 +405,13 @@ namespace MCPForUnity.Editor
{ {
// Framed I/O only; legacy mode removed // Framed I/O only; legacy mode removed
try try
{
if (IsDebugEnabled())
{ {
var ep = client.Client?.RemoteEndPoint?.ToString() ?? "unknown"; var ep = client.Client?.RemoteEndPoint?.ToString() ?? "unknown";
Debug.Log($"<b><color=#2EA3FF>UNITY-MCP</color></b>: Client connected {ep}"); Debug.Log($"<b><color=#2EA3FF>UNITY-MCP</color></b>: Client connected {ep}");
} }
}
catch { } catch { }
// Strict framing: always require FRAMING=1 and frame all I/O // Strict framing: always require FRAMING=1 and frame all I/O
try try
@ -423,11 +429,11 @@ namespace MCPForUnity.Editor
#else #else
await stream.WriteAsync(handshakeBytes, 0, handshakeBytes.Length, cts.Token).ConfigureAwait(false); await stream.WriteAsync(handshakeBytes, 0, handshakeBytes.Length, cts.Token).ConfigureAwait(false);
#endif #endif
Debug.Log("<b><color=#2EA3FF>UNITY-MCP</color></b>: Sent handshake FRAMING=1 (strict)"); if (IsDebugEnabled()) MCPForUnity.Editor.Helpers.McpLog.Info("Sent handshake FRAMING=1 (strict)", always: false);
} }
catch (Exception ex) catch (Exception ex)
{ {
Debug.LogWarning($"<b><color=#2EA3FF>UNITY-MCP</color></b>: Handshake failed: {ex.Message}"); if (IsDebugEnabled()) MCPForUnity.Editor.Helpers.McpLog.Warn($"Handshake failed: {ex.Message}");
return; // abort this client return; // abort this client
} }
@ -439,9 +445,12 @@ namespace MCPForUnity.Editor
string commandText = await ReadFrameAsUtf8Async(stream, FrameIOTimeoutMs); string commandText = await ReadFrameAsUtf8Async(stream, FrameIOTimeoutMs);
try try
{
if (IsDebugEnabled())
{ {
var preview = commandText.Length > 120 ? commandText.Substring(0, 120) + "…" : commandText; var preview = commandText.Length > 120 ? commandText.Substring(0, 120) + "…" : commandText;
Debug.Log($"<b><color=#2EA3FF>UNITY-MCP</color></b>: recv framed: {preview}"); MCPForUnity.Editor.Helpers.McpLog.Info($"recv framed: {preview}", always: false);
}
} }
catch { } catch { }
string commandId = Guid.NewGuid().ToString(); string commandId = Guid.NewGuid().ToString();
@ -470,7 +479,20 @@ namespace MCPForUnity.Editor
} }
catch (Exception ex) 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; break;
} }
} }

View File

@ -68,6 +68,8 @@ namespace MCPForUnity.Editor.Tools
{ {
// Try both naming conventions: snake_case and camelCase // Try both naming conventions: snake_case and camelCase
string menuPath = @params["menu_path"]?.ToString() ?? @params["menuPath"]?.ToString(); 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<int>() ?? 2000));
// string alias = @params["alias"]?.ToString(); // TODO: Implement alias mapping based on refactor plan requirements. // 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). // 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 try
{ {
// Attempt to execute the menu item on the main thread using delayCall for safety. // Trace incoming execute requests
EditorApplication.delayCall += () => Debug.Log($"[ExecuteMenuItem] Request to execute menu: '{menuPath}'");
{
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}"
);
}
};
// Report attempt immediately, as execution is delayed. // 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( return Response.Success(
$"Attempted to execute menu item: '{menuPath}'. Check Unity logs for confirmation or errors." $"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 (Exception e)
{ {
// Catch errors during setup phase. Debug.LogError($"[ExecuteMenuItem] Error executing '{menuPath}': {e}");
Debug.LogError( return Response.Error($"Error executing menu item '{menuPath}': {e.Message}");
$"[ExecuteMenuItem] Failed to setup execution for '{menuPath}': {e}"
);
return Response.Error(
$"Error setting up execution for menu item '{menuPath}': {e.Message}"
);
} }
} }

View File

@ -193,10 +193,10 @@ namespace MCPForUnity.Editor.Tools
namespaceName namespaceName
); );
case "read": 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); return ReadScript(fullPath, relativePath);
case "update": 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); return UpdateScript(fullPath, relativePath, name, contents);
case "delete": case "delete":
return DeleteScript(fullPath, relativePath); return DeleteScript(fullPath, relativePath);
@ -356,11 +356,11 @@ namespace MCPForUnity.Editor.Tools
var uri = $"unity://path/{relativePath}"; var uri = $"unity://path/{relativePath}";
var ok = Response.Success( var ok = Response.Success(
$"Script '{name}.cs' created successfully at '{relativePath}'.", $"Script '{name}.cs' created successfully at '{relativePath}'.",
new { uri, scheduledRefresh = true } new { uri, scheduledRefresh = false }
); );
// Schedule heavy work AFTER replying ManageScriptRefreshHelpers.ImportAndRequestCompile(relativePath);
ManageScriptRefreshHelpers.ScheduleScriptRefresh(relativePath);
return ok; return ok;
} }
catch (Exception e) catch (Exception e)
@ -763,8 +763,7 @@ namespace MCPForUnity.Editor.Tools
string.Equals(refreshModeFromCaller, "sync", StringComparison.OrdinalIgnoreCase); string.Equals(refreshModeFromCaller, "sync", StringComparison.OrdinalIgnoreCase);
if (immediate) if (immediate)
{ {
EditorApplication.delayCall += () => McpLog.Info($"[ManageScript] ApplyTextEdits: immediate refresh for '{relativePath}'");
{
AssetDatabase.ImportAsset( AssetDatabase.ImportAsset(
relativePath, relativePath,
ImportAssetOptions.ForceSynchronousImport | ImportAssetOptions.ForceUpdate ImportAssetOptions.ForceSynchronousImport | ImportAssetOptions.ForceUpdate
@ -772,10 +771,10 @@ namespace MCPForUnity.Editor.Tools
#if UNITY_EDITOR #if UNITY_EDITOR
UnityEditor.Compilation.CompilationPipeline.RequestScriptCompilation(); UnityEditor.Compilation.CompilationPipeline.RequestScriptCompilation();
#endif #endif
};
} }
else else
{ {
McpLog.Info($"[ManageScript] ApplyTextEdits: debounced refresh scheduled for '{relativePath}'");
ManageScriptRefreshHelpers.ScheduleScriptRefresh(relativePath); ManageScriptRefreshHelpers.ScheduleScriptRefresh(relativePath);
} }
@ -786,7 +785,8 @@ namespace MCPForUnity.Editor.Tools
uri = $"unity://path/{relativePath}", uri = $"unity://path/{relativePath}",
path = relativePath, path = relativePath,
editsApplied = spans.Count, 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) 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 } }; 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" }); return Response.Error("overlap", new { status = "overlap" });
@ -1421,17 +1421,8 @@ namespace MCPForUnity.Editor.Tools
if (immediate) if (immediate)
{ {
// Force on main thread McpLog.Info($"[ManageScript] EditScript: immediate refresh for '{relativePath}'", always: false);
EditorApplication.delayCall += () => ManageScriptRefreshHelpers.ImportAndRequestCompile(relativePath);
{
AssetDatabase.ImportAsset(
relativePath,
ImportAssetOptions.ForceSynchronousImport | ImportAssetOptions.ForceUpdate
);
#if UNITY_EDITOR
UnityEditor.Compilation.CompilationPipeline.RequestScriptCompilation();
#endif
};
} }
else else
{ {
@ -2620,5 +2611,15 @@ static class ManageScriptRefreshHelpers
{ {
RefreshDebounce.Schedule(relPath, TimeSpan.FromMilliseconds(200)); 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
}
} }

View File

@ -1771,7 +1771,7 @@ namespace MCPForUnity.Editor.Windows
{ {
if (debugLogsEnabled) 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); mcpClient.SetStatus(McpStatus.Configured);
} }
@ -1971,7 +1971,7 @@ namespace MCPForUnity.Editor.Windows
if (debugLogsEnabled) 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)) if (!File.Exists(configPath))

View File

@ -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'"

View File

@ -306,6 +306,38 @@ def register_manage_script_tools(mcp: FastMCP):
data.setdefault("normalizedEdits", normalized_edits) data.setdefault("normalizedEdits", normalized_edits)
if warnings: if warnings:
data.setdefault("warnings", 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 resp
return {"success": False, "message": str(resp)} return {"success": False, "message": str(resp)}
@ -457,7 +489,7 @@ def register_manage_script_tools(mcp: FastMCP):
"path": path, "path": path,
"edits": edits, "edits": edits,
"precondition_sha256": sha, "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 # Preflight size vs. default cap (256 KiB) to avoid opaque server errors
try: try:

View File

@ -2,6 +2,7 @@ from mcp.server.fastmcp import FastMCP, Context
from typing import Dict, Any, List, Tuple from typing import Dict, Any, List, Tuple
import base64 import base64
import re import re
import os
from unity_connection import send_command_with_retry 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 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: def _infer_class_name(script_name: str) -> str:
# Default to script name as class name (common Unity pattern) # Default to script name as class name (common Unity pattern)
return (script_name or "").strip() 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 everything is structured (method/class/anchor ops), forward directly to Unity's structured editor.
if all_struct: if all_struct:
opts2 = dict(options or {}) 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") opts2.setdefault("refresh", "immediate")
params_struct: Dict[str, Any] = { params_struct: Dict[str, Any] = {
"action": "edit", "action": "edit",
@ -482,6 +514,13 @@ def register_manage_script_edits_tools(mcp: FastMCP):
"options": opts2, "options": opts2,
} }
resp_struct = send_command_with_retry("manage_script", params_struct) 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") 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 # 1) read from Unity
@ -597,18 +636,24 @@ def register_manage_script_edits_tools(mcp: FastMCP):
"scriptType": script_type, "scriptType": script_type,
"edits": at_edits, "edits": at_edits,
"precondition_sha256": sha, "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) resp_text = send_command_with_retry("manage_script", params_text)
if not (isinstance(resp_text, dict) and resp_text.get("success")): 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") 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: except Exception as e:
return _with_norm({"success": False, "message": f"Text edit conversion failed: {e}"}, normalized_for_echo, routing="mixed/text-first") return _with_norm({"success": False, "message": f"Text edit conversion failed: {e}"}, normalized_for_echo, routing="mixed/text-first")
if struct_edits: if struct_edits:
opts2 = dict(options or {}) opts2 = dict(options or {})
# Let server decide; do not force sequential # Prefer debounced background refresh unless explicitly overridden
opts2.setdefault("refresh", "immediate") opts2.setdefault("refresh", "debounced")
params_struct: Dict[str, Any] = { params_struct: Dict[str, Any] = {
"action": "edit", "action": "edit",
"name": name, "name": name,
@ -619,6 +664,12 @@ def register_manage_script_edits_tools(mcp: FastMCP):
"options": opts2 "options": opts2
} }
resp_struct = send_command_with_retry("manage_script", params_struct) 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(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") 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": if op == "anchor_insert":
anchor = e.get("anchor") or "" anchor = e.get("anchor") or ""
position = (e.get("position") or "after").lower() position = (e.get("position") or "after").lower()
# Early regex compile with helpful errors # Early regex compile with helpful errors, honoring ignore_case
try: 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: 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") 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) m = regex_obj.search(base_text)
@ -734,12 +786,18 @@ def register_manage_script_edits_tools(mcp: FastMCP):
"edits": at_edits, "edits": at_edits,
"precondition_sha256": sha, "precondition_sha256": sha,
"options": { "options": {
"refresh": "immediate", "refresh": (options or {}).get("refresh", "debounced"),
"validate": (options or {}).get("validate", "standard"), "validate": (options or {}).get("validate", "standard"),
"applyMode": ("atomic" if len(at_edits) > 1 else (options or {}).get("applyMode", "sequential")) "applyMode": ("atomic" if len(at_edits) > 1 else (options or {}).get("applyMode", "sequential"))
} }
} }
resp = send_command_with_retry("manage_script", params) 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( return _with_norm(
resp if isinstance(resp, dict) else {"success": False, "message": str(resp)}, resp if isinstance(resp, dict) else {"success": False, "message": str(resp)},
normalized_for_echo, 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 # Default refresh/validate for natural usage on text path as well
options = dict(options or {}) options = dict(options or {})
options.setdefault("validate", "standard") options.setdefault("validate", "standard")
options.setdefault("refresh", "immediate") options.setdefault("refresh", "debounced")
import hashlib import hashlib
# Compute the SHA of the current file contents for the precondition # 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, "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) 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( return _with_norm(
write_resp if isinstance(write_resp, dict) write_resp if isinstance(write_resp, dict)
else {"success": False, "message": str(write_resp)}, else {"success": False, "message": str(write_resp)},

View File

@ -162,7 +162,7 @@ cli = [
[[package]] [[package]]
name = "mcpforunityserver" name = "mcpforunityserver"
version = "3.0.2" version = "3.1.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "httpx" }, { name = "httpx" },