Skip to content

Commit 0c52c1c

Browse files
authored
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
1 parent ad848f0 commit 0c52c1c

File tree

12 files changed

+218
-95
lines changed

12 files changed

+218
-95
lines changed

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,3 +36,7 @@ CONTRIBUTING.md.meta
3636
.DS_Store*
3737
# Unity test project lock files
3838
TestProjects/UnityMCPTests/Packages/packages-lock.json
39+
40+
# Backup artifacts
41+
*.backup
42+
*.backup.meta

TestProjects/UnityMCPTests/Assets/Editor.meta

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
1-
using System.Collections;
2-
using System.Collections.Generic;
31
using UnityEngine;
2+
using System.Collections;
43

54
public class Hello : MonoBehaviour
65
{
6+
7+
// Use this for initialization
78
void Start()
89
{
910
Debug.Log("Hello World");
1011
}
11-
}
12+
13+
14+
15+
}

TestProjects/UnityMCPTests/Assets/Scripts/Hello.cs.meta

Lines changed: 1 addition & 10 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

UnityMcpBridge/Editor/MCPForUnityBridge.cs

Lines changed: 33 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ private static void LogBreadcrumb(string stage)
4848
{
4949
if (IsDebugEnabled())
5050
{
51-
Debug.Log($"<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>: [{stage}]");
51+
McpLog.Info($"[{stage}]", always: false);
5252
}
5353
}
5454

@@ -230,7 +230,10 @@ public static void Start()
230230
// Don't restart if already running on a working port
231231
if (isRunning && listener != null)
232232
{
233-
Debug.Log($"<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>: MCPForUnityBridge already running on port {currentUnityPort}");
233+
if (IsDebugEnabled())
234+
{
235+
Debug.Log($"<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>: MCPForUnityBridge already running on port {currentUnityPort}");
236+
}
234237
return;
235238
}
236239

@@ -348,7 +351,7 @@ public static void Stop()
348351
listener?.Stop();
349352
listener = null;
350353
EditorApplication.update -= ProcessCommands;
351-
Debug.Log("<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>: MCPForUnityBridge stopped.");
354+
if (IsDebugEnabled()) Debug.Log("<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>: MCPForUnityBridge stopped.");
352355
}
353356
catch (Exception ex)
354357
{
@@ -389,7 +392,7 @@ private static async Task ListenerLoop()
389392
{
390393
if (isRunning)
391394
{
392-
Debug.LogError($"Listener error: {ex.Message}");
395+
if (IsDebugEnabled()) Debug.LogError($"Listener error: {ex.Message}");
393396
}
394397
}
395398
}
@@ -403,8 +406,11 @@ private static async Task HandleClientAsync(TcpClient client)
403406
// Framed I/O only; legacy mode removed
404407
try
405408
{
406-
var ep = client.Client?.RemoteEndPoint?.ToString() ?? "unknown";
407-
Debug.Log($"<b><color=#2EA3FF>UNITY-MCP</color></b>: Client connected {ep}");
409+
if (IsDebugEnabled())
410+
{
411+
var ep = client.Client?.RemoteEndPoint?.ToString() ?? "unknown";
412+
Debug.Log($"<b><color=#2EA3FF>UNITY-MCP</color></b>: Client connected {ep}");
413+
}
408414
}
409415
catch { }
410416
// Strict framing: always require FRAMING=1 and frame all I/O
@@ -423,11 +429,11 @@ private static async Task HandleClientAsync(TcpClient client)
423429
#else
424430
await stream.WriteAsync(handshakeBytes, 0, handshakeBytes.Length, cts.Token).ConfigureAwait(false);
425431
#endif
426-
Debug.Log("<b><color=#2EA3FF>UNITY-MCP</color></b>: Sent handshake FRAMING=1 (strict)");
432+
if (IsDebugEnabled()) MCPForUnity.Editor.Helpers.McpLog.Info("Sent handshake FRAMING=1 (strict)", always: false);
427433
}
428434
catch (Exception ex)
429435
{
430-
Debug.LogWarning($"<b><color=#2EA3FF>UNITY-MCP</color></b>: Handshake failed: {ex.Message}");
436+
if (IsDebugEnabled()) MCPForUnity.Editor.Helpers.McpLog.Warn($"Handshake failed: {ex.Message}");
431437
return; // abort this client
432438
}
433439

@@ -440,8 +446,11 @@ private static async Task HandleClientAsync(TcpClient client)
440446

441447
try
442448
{
443-
var preview = commandText.Length > 120 ? commandText.Substring(0, 120) + "…" : commandText;
444-
Debug.Log($"<b><color=#2EA3FF>UNITY-MCP</color></b>: recv framed: {preview}");
449+
if (IsDebugEnabled())
450+
{
451+
var preview = commandText.Length > 120 ? commandText.Substring(0, 120) + "…" : commandText;
452+
MCPForUnity.Editor.Helpers.McpLog.Info($"recv framed: {preview}", always: false);
453+
}
445454
}
446455
catch { }
447456
string commandId = Guid.NewGuid().ToString();
@@ -470,7 +479,20 @@ private static async Task HandleClientAsync(TcpClient client)
470479
}
471480
catch (Exception ex)
472481
{
473-
Debug.LogError($"Client handler error: {ex.Message}");
482+
// Treat common disconnects/timeouts as benign; only surface hard errors
483+
string msg = ex.Message ?? string.Empty;
484+
bool isBenign =
485+
msg.IndexOf("Connection closed before reading expected bytes", StringComparison.OrdinalIgnoreCase) >= 0
486+
|| msg.IndexOf("Read timed out", StringComparison.OrdinalIgnoreCase) >= 0
487+
|| ex is System.IO.IOException;
488+
if (isBenign)
489+
{
490+
if (IsDebugEnabled()) MCPForUnity.Editor.Helpers.McpLog.Info($"Client handler: {msg}", always: false);
491+
}
492+
else
493+
{
494+
MCPForUnity.Editor.Helpers.McpLog.Error($"Client handler error: {msg}");
495+
}
474496
break;
475497
}
476498
}

UnityMcpBridge/Editor/Tools/ExecuteMenuItem.cs

Lines changed: 20 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,8 @@ private static object ExecuteItem(JObject @params)
6868
{
6969
// Try both naming conventions: snake_case and camelCase
7070
string menuPath = @params["menu_path"]?.ToString() ?? @params["menuPath"]?.ToString();
71+
// Optional future param retained for API compatibility; not used in synchronous mode
72+
// int timeoutMs = Math.Max(0, (@params["timeout_ms"]?.ToObject<int>() ?? 2000));
7173

7274
// string alias = @params["alias"]?.ToString(); // TODO: Implement alias mapping based on refactor plan requirements.
7375
// JObject parameters = @params["parameters"] as JObject; // TODO: Investigate parameter passing (often not directly supported by ExecuteMenuItem).
@@ -94,42 +96,29 @@ private static object ExecuteItem(JObject @params)
9496

9597
try
9698
{
97-
// Attempt to execute the menu item on the main thread using delayCall for safety.
98-
EditorApplication.delayCall += () =>
99-
{
100-
try
101-
{
102-
bool executed = EditorApplication.ExecuteMenuItem(menuPath);
103-
// Log potential failure inside the delayed call.
104-
if (!executed)
105-
{
106-
Debug.LogError(
107-
$"[ExecuteMenuItem] Failed to find or execute menu item via delayCall: '{menuPath}'. It might be invalid, disabled, or context-dependent."
108-
);
109-
}
110-
}
111-
catch (Exception delayEx)
112-
{
113-
Debug.LogError(
114-
$"[ExecuteMenuItem] Exception during delayed execution of '{menuPath}': {delayEx}"
115-
);
116-
}
117-
};
99+
// Trace incoming execute requests
100+
Debug.Log($"[ExecuteMenuItem] Request to execute menu: '{menuPath}'");
118101

119-
// Report attempt immediately, as execution is delayed.
120-
return Response.Success(
121-
$"Attempted to execute menu item: '{menuPath}'. Check Unity logs for confirmation or errors."
102+
// Execute synchronously. This code runs on the Editor main thread in our bridge path.
103+
bool executed = EditorApplication.ExecuteMenuItem(menuPath);
104+
if (executed)
105+
{
106+
Debug.Log($"[ExecuteMenuItem] Executed successfully: '{menuPath}'");
107+
return Response.Success(
108+
$"Executed menu item: '{menuPath}'",
109+
new { executed = true, menuPath }
110+
);
111+
}
112+
Debug.LogWarning($"[ExecuteMenuItem] Failed (not found/disabled): '{menuPath}'");
113+
return Response.Error(
114+
$"Failed to execute menu item (not found or disabled): '{menuPath}'",
115+
new { executed = false, menuPath }
122116
);
123117
}
124118
catch (Exception e)
125119
{
126-
// Catch errors during setup phase.
127-
Debug.LogError(
128-
$"[ExecuteMenuItem] Failed to setup execution for '{menuPath}': {e}"
129-
);
130-
return Response.Error(
131-
$"Error setting up execution for menu item '{menuPath}': {e.Message}"
132-
);
120+
Debug.LogError($"[ExecuteMenuItem] Error executing '{menuPath}': {e}");
121+
return Response.Error($"Error executing menu item '{menuPath}': {e.Message}");
133122
}
134123
}
135124

UnityMcpBridge/Editor/Tools/ManageScript.cs

Lines changed: 28 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -193,10 +193,10 @@ public static object HandleCommand(JObject @params)
193193
namespaceName
194194
);
195195
case "read":
196-
Debug.LogWarning("manage_script.read is deprecated; prefer resources/read. Serving read for backward compatibility.");
196+
McpLog.Warn("manage_script.read is deprecated; prefer resources/read. Serving read for backward compatibility.");
197197
return ReadScript(fullPath, relativePath);
198198
case "update":
199-
Debug.LogWarning("manage_script.update is deprecated; prefer apply_text_edits. Serving update for backward compatibility.");
199+
McpLog.Warn("manage_script.update is deprecated; prefer apply_text_edits. Serving update for backward compatibility.");
200200
return UpdateScript(fullPath, relativePath, name, contents);
201201
case "delete":
202202
return DeleteScript(fullPath, relativePath);
@@ -356,11 +356,11 @@ string namespaceName
356356
var uri = $"unity://path/{relativePath}";
357357
var ok = Response.Success(
358358
$"Script '{name}.cs' created successfully at '{relativePath}'.",
359-
new { uri, scheduledRefresh = true }
359+
new { uri, scheduledRefresh = false }
360360
);
361361

362-
// Schedule heavy work AFTER replying
363-
ManageScriptRefreshHelpers.ScheduleScriptRefresh(relativePath);
362+
ManageScriptRefreshHelpers.ImportAndRequestCompile(relativePath);
363+
364364
return ok;
365365
}
366366
catch (Exception e)
@@ -650,7 +650,7 @@ private static object ApplyTextEdits(
650650
spans = spans.OrderByDescending(t => t.start).ToList();
651651
for (int i = 1; i < spans.Count; i++)
652652
{
653-
if (spans[i].end > spans[i - 1].start)
653+
if (spans[i].end > spans[i - 1].start)
654654
{
655655
var conflict = new[] { new { startA = spans[i].start, endA = spans[i].end, startB = spans[i - 1].start, endB = spans[i - 1].end } };
656656
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 @@ private static object ApplyTextEdits(
763763
string.Equals(refreshModeFromCaller, "sync", StringComparison.OrdinalIgnoreCase);
764764
if (immediate)
765765
{
766-
EditorApplication.delayCall += () =>
767-
{
768-
AssetDatabase.ImportAsset(
769-
relativePath,
770-
ImportAssetOptions.ForceSynchronousImport | ImportAssetOptions.ForceUpdate
771-
);
766+
McpLog.Info($"[ManageScript] ApplyTextEdits: immediate refresh for '{relativePath}'");
767+
AssetDatabase.ImportAsset(
768+
relativePath,
769+
ImportAssetOptions.ForceSynchronousImport | ImportAssetOptions.ForceUpdate
770+
);
772771
#if UNITY_EDITOR
773-
UnityEditor.Compilation.CompilationPipeline.RequestScriptCompilation();
772+
UnityEditor.Compilation.CompilationPipeline.RequestScriptCompilation();
774773
#endif
775-
};
776774
}
777775
else
778776
{
777+
McpLog.Info($"[ManageScript] ApplyTextEdits: debounced refresh scheduled for '{relativePath}'");
779778
ManageScriptRefreshHelpers.ScheduleScriptRefresh(relativePath);
780779
}
781780

@@ -786,7 +785,8 @@ private static object ApplyTextEdits(
786785
uri = $"unity://path/{relativePath}",
787786
path = relativePath,
788787
editsApplied = spans.Count,
789-
sha256 = newSha
788+
sha256 = newSha,
789+
scheduledRefresh = !immediate
790790
}
791791
);
792792
}
@@ -1326,7 +1326,7 @@ private static object EditScript(
13261326
if (ordered[i].start + ordered[i].length > ordered[i - 1].start)
13271327
{
13281328
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 } };
1329-
return Response.Error("overlap", new { status = "overlap", conflicts = conflict, hint = "Apply in descending order against the same precondition snapshot." });
1329+
return Response.Error("overlap", new { status = "overlap", conflicts = conflict, hint = "Sort ranges descending by start and compute from the same snapshot." });
13301330
}
13311331
}
13321332
return Response.Error("overlap", new { status = "overlap" });
@@ -1421,17 +1421,8 @@ private static object EditScript(
14211421

14221422
if (immediate)
14231423
{
1424-
// Force on main thread
1425-
EditorApplication.delayCall += () =>
1426-
{
1427-
AssetDatabase.ImportAsset(
1428-
relativePath,
1429-
ImportAssetOptions.ForceSynchronousImport | ImportAssetOptions.ForceUpdate
1430-
);
1431-
#if UNITY_EDITOR
1432-
UnityEditor.Compilation.CompilationPipeline.RequestScriptCompilation();
1433-
#endif
1434-
};
1424+
McpLog.Info($"[ManageScript] EditScript: immediate refresh for '{relativePath}'", always: false);
1425+
ManageScriptRefreshHelpers.ImportAndRequestCompile(relativePath);
14351426
}
14361427
else
14371428
{
@@ -2620,5 +2611,15 @@ public static void ScheduleScriptRefresh(string relPath)
26202611
{
26212612
RefreshDebounce.Schedule(relPath, TimeSpan.FromMilliseconds(200));
26222613
}
2614+
2615+
public static void ImportAndRequestCompile(string relPath, bool synchronous = true)
2616+
{
2617+
var opts = ImportAssetOptions.ForceUpdate;
2618+
if (synchronous) opts |= ImportAssetOptions.ForceSynchronousImport;
2619+
AssetDatabase.ImportAsset(relPath, opts);
2620+
#if UNITY_EDITOR
2621+
UnityEditor.Compilation.CompilationPipeline.RequestScriptCompilation();
2622+
#endif
2623+
}
26232624
}
26242625

UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1771,7 +1771,7 @@ private void CheckMcpConfiguration(McpClient mcpClient)
17711771
{
17721772
if (debugLogsEnabled)
17731773
{
1774-
UnityEngine.Debug.Log($"MCP for Unity: Auto-updated MCP config for '{mcpClient.name}' to new path: {pythonDir}");
1774+
MCPForUnity.Editor.Helpers.McpLog.Info($"Auto-updated MCP config for '{mcpClient.name}' to new path: {pythonDir}", always: false);
17751775
}
17761776
mcpClient.SetStatus(McpStatus.Configured);
17771777
}
@@ -1971,7 +1971,7 @@ private void CheckClaudeCodeConfiguration(McpClient mcpClient)
19711971

19721972
if (debugLogsEnabled)
19731973
{
1974-
UnityEngine.Debug.Log($"Checking Claude config at: {configPath}");
1974+
MCPForUnity.Editor.Helpers.McpLog.Info($"Checking Claude config at: {configPath}", always: false);
19751975
}
19761976

19771977
if (!File.Exists(configPath))
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
"""
2+
Deprecated: Sentinel flipping is handled inside Unity via the MCP menu
3+
'MCP/Flip Reload Sentinel'. This module remains only as a compatibility shim.
4+
All functions are no-ops to prevent accidental external writes.
5+
"""
6+
7+
def flip_reload_sentinel(*args, **kwargs) -> str:
8+
return "reload_sentinel.py is deprecated; use execute_menu_item → 'MCP/Flip Reload Sentinel'"

0 commit comments

Comments
 (0)