Skip to content

Commit 07b3583

Browse files
committed
Bridge: deferred init, stop-before-reload, breadcrumb logs; stable rebinds.
Editor: auto-rewrite MCP client config when package path changes. Server: heartbeat-aware retries, structured {state: reloading, retry_after_ms}, single auto-retry across tools; guard empty calls. Repo: remove global *~ ignore (was hiding UnityMcpServer~), track tilde server folder (Unity still excludes it from assemblies).
1 parent 32f513f commit 07b3583

19 files changed

+1742
-10
lines changed

UnityMcpBridge/Editor/UnityMcpBridge.cs

Lines changed: 132 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,31 @@ public static partial class UnityMcpBridge
2424
private static readonly object lockObj = new();
2525
private static readonly object startStopLock = new();
2626
private static bool initScheduled = false;
27+
private static bool ensureUpdateHooked = false;
28+
private static bool isStarting = false;
29+
private static double nextStartAt = 0.0f;
2730
private static double nextHeartbeatAt = 0.0f;
31+
private static int heartbeatSeq = 0;
2832
private static Dictionary<
2933
string,
3034
(string commandJson, TaskCompletionSource<string> tcs)
3135
> commandQueue = new();
3236
private static int currentUnityPort = 6400; // Dynamic port, starts with default
3337
private static bool isAutoConnectMode = false;
38+
39+
// Debug helpers
40+
private static bool IsDebugEnabled()
41+
{
42+
try { return EditorPrefs.GetBool("UnityMCP.DebugLogs", false); } catch { return false; }
43+
}
44+
45+
private static void LogBreadcrumb(string stage)
46+
{
47+
if (IsDebugEnabled())
48+
{
49+
Debug.Log($"<b><color=#2EA3FF>UNITY-MCP</color></b>: [{stage}]");
50+
}
51+
}
3452

3553
public static bool IsRunning => isRunning;
3654
public static int GetCurrentPort() => currentUnityPort;
@@ -78,11 +96,24 @@ public static bool FolderExists(string path)
7896

7997
static UnityMcpBridge()
8098
{
81-
// Immediate start for minimal downtime, plus quit hook
82-
Start();
99+
// Skip bridge in headless/batch environments (CI/builds)
100+
if (Application.isBatchMode)
101+
{
102+
return;
103+
}
104+
// Defer start until the editor is idle and not compiling
105+
ScheduleInitRetry();
106+
// Add a safety net update hook in case delayCall is missed during reload churn
107+
if (!ensureUpdateHooked)
108+
{
109+
ensureUpdateHooked = true;
110+
EditorApplication.update += EnsureStartedOnEditorIdle;
111+
}
83112
EditorApplication.quitting += Stop;
84113
AssemblyReloadEvents.beforeAssemblyReload += OnBeforeAssemblyReload;
85114
AssemblyReloadEvents.afterAssemblyReload += OnAfterAssemblyReload;
115+
// Also coalesce play mode transitions into a deferred init
116+
EditorApplication.playModeStateChanged += _ => ScheduleInitRetry();
86117
}
87118

88119
/// <summary>
@@ -94,7 +125,7 @@ private static void InitializeAfterCompilation()
94125
initScheduled = false;
95126

96127
// Play-mode friendly: allow starting in play mode; only defer while compiling
97-
if (EditorApplication.isCompiling)
128+
if (IsCompiling())
98129
{
99130
ScheduleInitRetry();
100131
return;
@@ -118,9 +149,77 @@ private static void ScheduleInitRetry()
118149
return;
119150
}
120151
initScheduled = true;
152+
// Debounce: start ~200ms after the last trigger
153+
nextStartAt = EditorApplication.timeSinceStartup + 0.20f;
154+
// Ensure the update pump is active
155+
if (!ensureUpdateHooked)
156+
{
157+
ensureUpdateHooked = true;
158+
EditorApplication.update += EnsureStartedOnEditorIdle;
159+
}
160+
// Keep the original delayCall as a secondary path
121161
EditorApplication.delayCall += InitializeAfterCompilation;
122162
}
123163

164+
// Safety net: ensure the bridge starts shortly after domain reload when editor is idle
165+
private static void EnsureStartedOnEditorIdle()
166+
{
167+
// Do nothing while compiling
168+
if (IsCompiling())
169+
{
170+
return;
171+
}
172+
173+
// If already running, remove the hook
174+
if (isRunning)
175+
{
176+
EditorApplication.update -= EnsureStartedOnEditorIdle;
177+
ensureUpdateHooked = false;
178+
return;
179+
}
180+
181+
// Debounced start: wait until the scheduled time
182+
if (nextStartAt > 0 && EditorApplication.timeSinceStartup < nextStartAt)
183+
{
184+
return;
185+
}
186+
187+
if (isStarting)
188+
{
189+
return;
190+
}
191+
192+
isStarting = true;
193+
// Attempt start; if it succeeds, remove the hook to avoid overhead
194+
Start();
195+
isStarting = false;
196+
if (isRunning)
197+
{
198+
EditorApplication.update -= EnsureStartedOnEditorIdle;
199+
ensureUpdateHooked = false;
200+
}
201+
}
202+
203+
// Helper to check compilation status across Unity versions
204+
private static bool IsCompiling()
205+
{
206+
if (EditorApplication.isCompiling)
207+
{
208+
return true;
209+
}
210+
try
211+
{
212+
System.Type pipeline = System.Type.GetType("UnityEditor.Compilation.CompilationPipeline, UnityEditor");
213+
var prop = pipeline?.GetProperty("isCompiling", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static);
214+
if (prop != null)
215+
{
216+
return (bool)prop.GetValue(null);
217+
}
218+
}
219+
catch { }
220+
return false;
221+
}
222+
124223
public static void Start()
125224
{
126225
lock (startStopLock)
@@ -140,6 +239,9 @@ public static void Start()
140239
// Always consult PortManager first so we prefer the persisted project port
141240
currentUnityPort = PortManager.GetPortWithFallback();
142241

242+
// Breadcrumb: Start
243+
LogBreadcrumb("Start");
244+
143245
const int maxImmediateRetries = 3;
144246
const int retrySleepMs = 75;
145247
int attempt = 0;
@@ -153,6 +255,13 @@ public static void Start()
153255
SocketOptionName.ReuseAddress,
154256
true
155257
);
258+
#if UNITY_EDITOR_WIN
259+
try
260+
{
261+
listener.ExclusiveAddressUse = false;
262+
}
263+
catch { }
264+
#endif
156265
// Minimize TIME_WAIT by sending RST on close
157266
try
158267
{
@@ -180,6 +289,13 @@ public static void Start()
180289
SocketOptionName.ReuseAddress,
181290
true
182291
);
292+
#if UNITY_EDITOR_WIN
293+
try
294+
{
295+
listener.ExclusiveAddressUse = false;
296+
}
297+
catch { }
298+
#endif
183299
try
184300
{
185301
listener.Server.LingerState = new LingerOption(true, 0);
@@ -198,7 +314,8 @@ public static void Start()
198314
Task.Run(ListenerLoop);
199315
EditorApplication.update += ProcessCommands;
200316
// Write initial heartbeat immediately
201-
WriteHeartbeat(false);
317+
heartbeatSeq++;
318+
WriteHeartbeat(false, "ready");
202319
nextHeartbeatAt = EditorApplication.timeSinceStartup + 0.5f;
203320
}
204321
catch (SocketException ex)
@@ -571,16 +688,22 @@ private static string GetParamsSummary(JObject @params)
571688
// Heartbeat/status helpers
572689
private static void OnBeforeAssemblyReload()
573690
{
574-
WriteHeartbeat(true);
691+
// Stop cleanly before reload so sockets close and clients see 'reloading'
692+
try { Stop(); } catch { }
693+
WriteHeartbeat(true, "reloading");
694+
LogBreadcrumb("Reload");
575695
}
576696

577697
private static void OnAfterAssemblyReload()
578698
{
579699
// Will be overwritten by Start(), but mark as alive quickly
580-
WriteHeartbeat(false);
700+
WriteHeartbeat(false, "idle");
701+
LogBreadcrumb("Idle");
702+
// Schedule a safe restart after reload to avoid races during compilation
703+
ScheduleInitRetry();
581704
}
582705

583-
private static void WriteHeartbeat(bool reloading)
706+
private static void WriteHeartbeat(bool reloading, string reason = null)
584707
{
585708
try
586709
{
@@ -591,6 +714,8 @@ private static void WriteHeartbeat(bool reloading)
591714
{
592715
unity_port = currentUnityPort,
593716
reloading,
717+
reason = reason ?? (reloading ? "reloading" : "ready"),
718+
seq = heartbeatSeq,
594719
project_path = Application.dataPath,
595720
last_heartbeat = DateTime.UtcNow.ToString("O")
596721
};

UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1311,14 +1311,38 @@ private void CheckMcpConfiguration(McpClient mcpClient)
13111311
// Common logic for checking configuration status
13121312
if (configExists)
13131313
{
1314-
if (pythonDir != null &&
1315-
Array.Exists(args, arg => arg.Contains(pythonDir, StringComparison.Ordinal)))
1314+
bool matches = pythonDir != null && Array.Exists(args, arg => arg.Contains(pythonDir, StringComparison.Ordinal));
1315+
if (matches)
13161316
{
13171317
mcpClient.SetStatus(McpStatus.Configured);
13181318
}
13191319
else
13201320
{
1321-
mcpClient.SetStatus(McpStatus.IncorrectPath);
1321+
// Attempt auto-rewrite once if the package path changed
1322+
try
1323+
{
1324+
string rewriteResult = WriteToConfig(pythonDir, configPath, mcpClient);
1325+
if (rewriteResult == "Configured successfully")
1326+
{
1327+
if (debugLogsEnabled)
1328+
{
1329+
UnityEngine.Debug.Log($"UnityMCP: Auto-updated MCP config for '{mcpClient.name}' to new path: {pythonDir}");
1330+
}
1331+
mcpClient.SetStatus(McpStatus.Configured);
1332+
}
1333+
else
1334+
{
1335+
mcpClient.SetStatus(McpStatus.IncorrectPath);
1336+
}
1337+
}
1338+
catch (Exception ex)
1339+
{
1340+
mcpClient.SetStatus(McpStatus.IncorrectPath);
1341+
if (debugLogsEnabled)
1342+
{
1343+
UnityEngine.Debug.LogWarning($"UnityMCP: Auto-config rewrite failed for '{mcpClient.name}': {ex.Message}");
1344+
}
1345+
}
13221346
}
13231347
}
13241348
else
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
FROM python:3.12-slim
2+
3+
# Install required system dependencies
4+
RUN apt-get update && apt-get install -y --no-install-recommends \
5+
git \
6+
&& rm -rf /var/lib/apt/lists/*
7+
8+
# Set working directory
9+
WORKDIR /app
10+
11+
# Install uv package manager
12+
RUN pip install uv
13+
14+
# Copy required files
15+
COPY config.py /app/
16+
COPY server.py /app/
17+
COPY unity_connection.py /app/
18+
COPY pyproject.toml /app/
19+
COPY __init__.py /app/
20+
COPY tools/ /app/tools/
21+
22+
# Install dependencies using uv
23+
RUN uv pip install --system -e .
24+
25+
26+
# Command to run the server
27+
CMD ["uv", "run", "server.py"]
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
"""
2+
Unity MCP Server package.
3+
"""
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
"""
2+
Configuration settings for the Unity MCP Server.
3+
This file contains all configurable parameters for the server.
4+
"""
5+
6+
from dataclasses import dataclass
7+
8+
@dataclass
9+
class ServerConfig:
10+
"""Main configuration class for the MCP server."""
11+
12+
# Network settings
13+
unity_host: str = "localhost"
14+
unity_port: int = 6400
15+
mcp_port: int = 6500
16+
17+
# Connection settings
18+
connection_timeout: float = 60.0 # default steady-state timeout; retries use shorter timeouts
19+
buffer_size: int = 16 * 1024 * 1024 # 16MB buffer
20+
21+
# Logging settings
22+
log_level: str = "INFO"
23+
log_format: str = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
24+
25+
# Server settings
26+
max_retries: int = 10
27+
retry_delay: float = 0.25
28+
29+
# Create a global config instance
30+
config = ServerConfig()

0 commit comments

Comments
 (0)