Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions README-DEV.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,59 @@ To find it reliably:

Note: In recent builds, the Python server sources are also bundled inside the package under `UnityMcpServer~/src`. This is handy for local testing or pointing MCP clients directly at the packaged server.

## MCP Bridge Stress Test

An on-demand stress utility exercises the MCP bridge with multiple concurrent clients while triggering real script reloads via immediate script edits (no menu calls required).

### Script
- `tools/stress_mcp.py`

### What it does
- Starts N TCP clients against the Unity MCP bridge (default port auto-discovered from `~/.unity-mcp/unity-mcp-status-*.json`).
- Sends lightweight framed `ping` keepalives to maintain concurrency.
- In parallel, appends a unique marker comment to a target C# file using `manage_script.apply_text_edits` with:
- `options.refresh = "immediate"` to force an import/compile immediately (triggers domain reload), and
- `precondition_sha256` computed from the current file contents to avoid drift.
- Uses EOF insertion to avoid header/`using`-guard edits.

### Usage (local)
```bash
# Recommended: use the included large script in the test project
python3 tools/stress_mcp.py \
--duration 60 \
--clients 8 \
--unity-file "TestProjects/UnityMCPTests/Assets/Scripts/LongUnityScriptClaudeTest.cs"
```

Flags:
- `--project` Unity project path (auto-detected to the included test project by default)
- `--unity-file` C# file to edit (defaults to the long test script)
- `--clients` number of concurrent clients (default 10)
- `--duration` seconds to run (default 60)

### Expected outcome
- No Unity Editor crashes during reload churn
- Immediate reloads after each applied edit (no `Assets/Refresh` menu calls)
- Some transient disconnects or a few failed calls may occur during domain reload; the tool retries and continues
- JSON summary printed at the end, e.g.:
- `{"port": 6400, "stats": {"pings": 28566, "applies": 69, "disconnects": 0, "errors": 0}}`

### Notes and troubleshooting
- Immediate vs debounced:
- The tool sets `options.refresh = "immediate"` so changes compile instantly. If you only need churn (not per-edit confirmation), switch to debounced to reduce mid-reload failures.
- Precondition required:
- `apply_text_edits` requires `precondition_sha256` on larger files. The tool reads the file first to compute the SHA.
- Edit location:
- To avoid header guards or complex ranges, the tool appends a one-line marker at EOF each cycle.
- Read API:
- The bridge currently supports `manage_script.read` for file reads. You may see a deprecation warning; it's harmless for this internal tool.
- Transient failures:
- Occasional `apply_errors` often indicate the connection reloaded mid-reply. Edits still typically apply; the loop continues on the next iteration.

### CI guidance
- Keep this out of default PR CI due to Unity/editor requirements and runtime variability.
- Optionally run it as a manual workflow or nightly job on a Unity-capable runner.

## CI Test Workflow (GitHub Actions)

We provide a CI job to run a Natural Language Editing mini-suite against the Unity test project. It spins up a headless Unity container and connects via the MCP bridge.
Expand Down
75 changes: 60 additions & 15 deletions UnityMcpBridge/Editor/MCPForUnityBridge.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ public static partial class MCPForUnityBridge
private static bool isRunning = false;
private static readonly object lockObj = new();
private static readonly object startStopLock = new();
private static CancellationTokenSource cts;
private static Task listenerTask;
private static int processingCommands = 0;
private static bool initScheduled = false;
private static bool ensureUpdateHooked = false;
private static bool isStarting = false;
Expand Down Expand Up @@ -319,8 +322,17 @@ public static void Start()
string platform = Application.platform.ToString();
string serverVer = ReadInstalledServerVersionSafe();
Debug.Log($"<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>: MCPForUnityBridge started on port {currentUnityPort}. (OS={platform}, server={serverVer})");
Task.Run(ListenerLoop);
// Start background listener with cooperative cancellation
cts = new CancellationTokenSource();
listenerTask = Task.Run(() => ListenerLoopAsync(cts.Token));
EditorApplication.update += ProcessCommands;
// Ensure lifecycle events are (re)subscribed in case Stop() removed them earlier in-domain
try { AssemblyReloadEvents.beforeAssemblyReload -= OnBeforeAssemblyReload; } catch { }
try { AssemblyReloadEvents.beforeAssemblyReload += OnBeforeAssemblyReload; } catch { }
try { AssemblyReloadEvents.afterAssemblyReload -= OnAfterAssemblyReload; } catch { }
try { AssemblyReloadEvents.afterAssemblyReload += OnAfterAssemblyReload; } catch { }
try { EditorApplication.quitting -= Stop; } catch { }
try { EditorApplication.quitting += Stop; } catch { }
// Write initial heartbeat immediately
heartbeatSeq++;
WriteHeartbeat(false, "ready");
Expand All @@ -335,6 +347,7 @@ public static void Start()

public static void Stop()
{
Task toWait = null;
lock (startStopLock)
{
if (!isRunning)
Expand All @@ -346,23 +359,43 @@ public static void Stop()
{
// Mark as stopping early to avoid accept logging during disposal
isRunning = false;
// Mark heartbeat one last time before stopping
WriteHeartbeat(false, "stopped");
listener?.Stop();

// Quiesce background listener quickly
var cancel = cts;
cts = null;
try { cancel?.Cancel(); } catch { }

try { listener?.Stop(); } catch { }
listener = null;
EditorApplication.update -= ProcessCommands;
if (IsDebugEnabled()) Debug.Log("<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>: MCPForUnityBridge stopped.");

// Capture background task to wait briefly outside the lock
toWait = listenerTask;
listenerTask = null;
}
catch (Exception ex)
{
Debug.LogError($"Error stopping MCPForUnityBridge: {ex.Message}");
}
}

// Give the background loop a short window to exit without blocking the editor
if (toWait != null)
{
try { toWait.Wait(100); } catch { }
}

// Now unhook editor events safely
try { EditorApplication.update -= ProcessCommands; } catch { }
try { AssemblyReloadEvents.beforeAssemblyReload -= OnBeforeAssemblyReload; } catch { }
try { AssemblyReloadEvents.afterAssemblyReload -= OnAfterAssemblyReload; } catch { }
try { EditorApplication.quitting -= Stop; } catch { }

if (IsDebugEnabled()) Debug.Log("<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>: MCPForUnityBridge stopped.");
}

private static async Task ListenerLoop()
private static async Task ListenerLoopAsync(CancellationToken token)
{
while (isRunning)
while (isRunning && !token.IsCancellationRequested)
{
try
{
Expand All @@ -378,27 +411,31 @@ private static async Task ListenerLoop()
client.ReceiveTimeout = 60000; // 60 seconds

// Fire and forget each client connection
_ = HandleClientAsync(client);
_ = Task.Run(() => HandleClientAsync(client, token), token);
}
catch (ObjectDisposedException)
{
// Listener was disposed during stop/reload; exit quietly
if (!isRunning)
if (!isRunning || token.IsCancellationRequested)
{
break;
}
}
catch (OperationCanceledException)
{
break;
}
catch (Exception ex)
{
if (isRunning)
if (isRunning && !token.IsCancellationRequested)
{
if (IsDebugEnabled()) Debug.LogError($"Listener error: {ex.Message}");
}
}
}
}

private static async Task HandleClientAsync(TcpClient client)
private static async Task HandleClientAsync(TcpClient client, CancellationToken token)
{
using (client)
using (NetworkStream stream = client.GetStream())
Expand Down Expand Up @@ -437,7 +474,7 @@ private static async Task HandleClientAsync(TcpClient client)
return; // abort this client
}

while (isRunning)
while (isRunning && !token.IsCancellationRequested)
{
try
{
Expand Down Expand Up @@ -624,6 +661,10 @@ private static void WriteUInt64BigEndian(byte[] dest, ulong value)

private static void ProcessCommands()
{
if (!isRunning) return;
if (Interlocked.Exchange(ref processingCommands, 1) == 1) return; // reentrancy guard
try
{
// Heartbeat without holding the queue lock
double now = EditorApplication.timeSinceStartup;
if (now >= nextHeartbeatAt)
Expand Down Expand Up @@ -734,6 +775,11 @@ private static void ProcessCommands()
// Remove quickly under lock
lock (lockObj) { commandQueue.Remove(id); }
}
}
finally
{
Interlocked.Exchange(ref processingCommands, 0);
}
}

// Helper method to check if a string is valid JSON
Expand Down Expand Up @@ -865,8 +911,7 @@ private static void OnBeforeAssemblyReload()
{
// Stop cleanly before reload so sockets close and clients see 'reloading'
try { Stop(); } catch { }
WriteHeartbeat(true, "reloading");
LogBreadcrumb("Reload");
// Avoid file I/O or heavy work here
}

private static void OnAfterAssemblyReload()
Expand Down
7 changes: 4 additions & 3 deletions UnityMcpBridge/Editor/Tools/ExecuteMenuItem.cs
Original file line number Diff line number Diff line change
Expand Up @@ -96,14 +96,15 @@ private static object ExecuteItem(JObject @params)

try
{
// Trace incoming execute requests
Debug.Log($"[ExecuteMenuItem] Request to execute menu: '{menuPath}'");
// Trace incoming execute requests (debug-gated)
McpLog.Info($"[ExecuteMenuItem] Request to execute menu: '{menuPath}'", always: false);

// 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}'");
// Success trace (debug-gated)
McpLog.Info($"[ExecuteMenuItem] Executed successfully: '{menuPath}'", always: false);
return Response.Success(
$"Executed menu item: '{menuPath}'",
new { executed = true, menuPath }
Expand Down
Loading