From fa3771c3acb8a98cc34219682075df70491e18a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sercan=20Muhlac=C4=B1?= Date: Mon, 23 Feb 2026 12:53:32 +0300 Subject: [PATCH 1/8] feat(manage_editor): add wait_for_compilation action Poll editor_state until compilation and domain reload finish so the AI can wait for script changes to compile instead of using fixed sleep. - manage_editor(action="wait_for_compilation", timeout=30) on server - CLI: unity-mcp editor wait-compile [--timeout N] - Reuses wait_for_editor_ready from refresh_unity; no C# changes Co-authored-by: Cursor --- Server/src/cli/commands/editor.py | 30 ++++ Server/src/services/tools/manage_editor.py | 48 +++++- .../integration/test_manage_editor_wait.py | 137 ++++++++++++++++++ 3 files changed, 208 insertions(+), 7 deletions(-) create mode 100644 Server/tests/integration/test_manage_editor_wait.py diff --git a/Server/src/cli/commands/editor.py b/Server/src/cli/commands/editor.py index 8b7746657..fe9072ce3 100644 --- a/Server/src/cli/commands/editor.py +++ b/Server/src/cli/commands/editor.py @@ -17,6 +17,36 @@ def editor(): pass +@editor.command("wait-compile") +@click.option( + "--timeout", "-t", + type=float, + default=30.0, + help="Max seconds to wait (default: 30)." +) +@handle_unity_errors +def wait_compile(timeout: float): + """Wait for Unity script compilation to finish. + + Polls editor state until compilation and domain reload are complete. + Useful after modifying scripts to ensure changes are compiled before + entering play mode or performing other actions. + + \b + Examples: + unity-mcp editor wait-compile + unity-mcp editor wait-compile --timeout 60 + """ + config = get_config() + result = run_command("manage_editor", {"action": "wait_for_compilation", "timeout": timeout}, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + waited = result.get("data", {}).get("waited_seconds", 0) + print_success(f"Compilation complete (waited {waited}s)") + else: + print_error("Compilation wait timed out") + + @editor.command("play") @handle_unity_errors def play(): diff --git a/Server/src/services/tools/manage_editor.py b/Server/src/services/tools/manage_editor.py index 414e81da3..48006fb77 100644 --- a/Server/src/services/tools/manage_editor.py +++ b/Server/src/services/tools/manage_editor.py @@ -3,21 +3,23 @@ from fastmcp import Context from mcp.types import ToolAnnotations -from services.registry import mcp_for_unity_tool from core.telemetry import is_telemetry_enabled, record_tool_usage +from services.registry import mcp_for_unity_tool from services.tools import get_unity_instance_from_context -from transport.unity_transport import send_with_unity_instance from transport.legacy.unity_connection import async_send_command_with_retry +from transport.unity_transport import send_with_unity_instance @mcp_for_unity_tool( - description="Controls and queries the Unity editor's state and settings. Read-only actions: telemetry_status, telemetry_ping. Modifying actions: play, pause, stop, set_active_tool, add_tag, remove_tag, add_layer, remove_layer, open_prefab_stage, close_prefab_stage, deploy_package, restore_package, undo, redo. open_prefab_stage opens a prefab asset in Unity's prefab editing mode. deploy_package copies the configured MCPForUnity source folder into the project's installed package location (triggers recompile, no confirmation dialog). restore_package reverts to the pre-deployment backup. undo/redo perform Unity editor undo/redo and return the affected group name.", + description="Controls and queries the Unity editor's state and settings. Read-only actions: telemetry_status, telemetry_ping, wait_for_compilation. Modifying actions: play, pause, stop, set_active_tool, add_tag, remove_tag, add_layer, remove_layer, open_prefab_stage, close_prefab_stage, deploy_package, restore_package, undo, redo. open_prefab_stage opens a prefab asset in Unity's prefab editing mode. deploy_package copies the configured MCPForUnity source folder into the project's installed package location (triggers recompile, no confirmation dialog). restore_package reverts to the pre-deployment backup. undo/redo perform Unity editor undo/redo and return the affected group name.", annotations=ToolAnnotations( title="Manage Editor", ), ) async def manage_editor( ctx: Context, - action: Annotated[Literal["telemetry_status", "telemetry_ping", "play", "pause", "stop", "set_active_tool", "add_tag", "remove_tag", "add_layer", "remove_layer", "open_prefab_stage", "close_prefab_stage", "deploy_package", "restore_package", "undo", "redo"], "Get and update the Unity Editor state. open_prefab_stage opens a prefab asset in prefab editing mode; close_prefab_stage exits prefab editing mode and returns to the main scene stage. deploy_package copies the configured MCPForUnity source into the project's package location (triggers recompile). restore_package reverts the last deployment from backup. undo/redo perform editor undo/redo."], + action: Annotated[Literal["telemetry_status", "telemetry_ping", "wait_for_compilation", "play", "pause", "stop", "set_active_tool", "add_tag", "remove_tag", "add_layer", "remove_layer", "open_prefab_stage", "close_prefab_stage", "deploy_package", "restore_package", "undo", "redo"], "Get and update the Unity Editor state. open_prefab_stage opens a prefab asset in prefab editing mode; close_prefab_stage exits prefab editing mode and returns to the main scene stage. deploy_package copies the configured MCPForUnity source into the project's package location (triggers recompile). restore_package reverts the last deployment from backup. undo/redo perform editor undo/redo."], + timeout: Annotated[int | float | None, + "Timeout in seconds for wait_for_compilation (default: 30)."] = None, tool_name: Annotated[str, "Tool name when setting active tool"] | None = None, tag_name: Annotated[str, @@ -29,9 +31,6 @@ async def manage_editor( path: Annotated[str, "Compatibility alias for prefab_path when opening a prefab stage."] | None = None, ) -> dict[str, Any]: - # Get active instance from request state (injected by middleware) - unity_instance = await get_unity_instance_from_context(ctx) - try: # Diagnostics: quick telemetry checks if action == "telemetry_status": @@ -41,12 +40,18 @@ async def manage_editor( record_tool_usage("diagnostic_ping", True, 1.0, None) return {"success": True, "message": "telemetry ping queued"} + if action == "wait_for_compilation": + return await _wait_for_compilation(ctx, timeout) + if prefab_path is not None and path is not None and prefab_path != path: return { "success": False, "message": "Provide only one of prefab_path or path, or ensure both values match.", } + # Get active instance from request state (injected by middleware) + unity_instance = await get_unity_instance_from_context(ctx) + # Prepare parameters, removing None values params = { "action": action, @@ -70,3 +75,32 @@ async def manage_editor( except Exception as e: return {"success": False, "message": f"Python error managing editor: {str(e)}"} + + +async def _wait_for_compilation(ctx: Context, timeout: int | float | None) -> dict[str, Any]: + """Poll editor_state until compilation and domain reload finish.""" + from services.tools.refresh_unity import wait_for_editor_ready + + timeout_s = float(timeout) if timeout is not None else 30.0 + timeout_s = max(1.0, min(timeout_s, 120.0)) + ready, elapsed = await wait_for_editor_ready(ctx, timeout_s=timeout_s) + + if ready: + return { + "success": True, + "message": "Compilation complete. Editor is ready.", + "data": { + "waited_seconds": round(elapsed, 2), + "ready": True, + }, + } + + return { + "success": False, + "message": f"Timed out after {timeout_s:.0f}s waiting for compilation to finish.", + "data": { + "waited_seconds": round(elapsed, 2), + "ready": False, + "timeout_seconds": timeout_s, + }, + } diff --git a/Server/tests/integration/test_manage_editor_wait.py b/Server/tests/integration/test_manage_editor_wait.py new file mode 100644 index 000000000..9a5157c19 --- /dev/null +++ b/Server/tests/integration/test_manage_editor_wait.py @@ -0,0 +1,137 @@ +import asyncio +import os +import time + +import pytest + +from .test_helpers import DummyContext + + +@pytest.mark.asyncio +async def test_wait_for_compilation_returns_immediately_when_ready(monkeypatch): + """If compilation is already done, returns immediately with waited_seconds ~0.""" + from services.tools import manage_editor as mod + from services.tools import refresh_unity as refresh_mod + + monkeypatch.delenv("PYTEST_CURRENT_TEST", raising=False) + + async def fake_get_editor_state(ctx): + return {"data": {"advice": {"ready_for_tools": True, "blocking_reasons": []}}} + + monkeypatch.setattr(refresh_mod.editor_state, "get_editor_state", fake_get_editor_state) + + ctx = DummyContext() + result = await mod._wait_for_compilation(ctx, timeout=10) + assert result["success"] is True + assert result["data"]["ready"] is True + assert result["data"]["waited_seconds"] < 2.0 + + +@pytest.mark.asyncio +async def test_wait_for_compilation_polls_until_ready(monkeypatch): + """Waits while compiling, returns success when compilation finishes.""" + from services.tools import manage_editor as mod + from services.tools import refresh_unity as refresh_mod + + monkeypatch.delenv("PYTEST_CURRENT_TEST", raising=False) + + call_count = 0 + + async def fake_get_editor_state(ctx): + nonlocal call_count + call_count += 1 + if call_count < 3: + return {"data": {"advice": {"ready_for_tools": False, "blocking_reasons": ["compiling"]}}} + return {"data": {"advice": {"ready_for_tools": True, "blocking_reasons": []}}} + + monkeypatch.setattr(refresh_mod.editor_state, "get_editor_state", fake_get_editor_state) + + ctx = DummyContext() + result = await mod._wait_for_compilation(ctx, timeout=10) + assert result["success"] is True + assert result["data"]["ready"] is True + assert call_count >= 3 + + +@pytest.mark.asyncio +async def test_wait_for_compilation_timeout(monkeypatch): + """Returns failure when compilation doesn't finish within timeout.""" + from services.tools import manage_editor as mod + from services.tools import refresh_unity as refresh_mod + + monkeypatch.delenv("PYTEST_CURRENT_TEST", raising=False) + + async def fake_get_editor_state(ctx): + return {"data": {"advice": {"ready_for_tools": False, "blocking_reasons": ["compiling"]}}} + + monkeypatch.setattr(refresh_mod.editor_state, "get_editor_state", fake_get_editor_state) + + ctx = DummyContext() + result = await mod._wait_for_compilation(ctx, timeout=1) + assert result["success"] is False + assert result["data"]["ready"] is False + assert result["data"]["timeout_seconds"] == 1.0 + + +@pytest.mark.asyncio +async def test_wait_for_compilation_default_timeout(monkeypatch): + """None timeout defaults to 30s (clamped).""" + from services.tools import manage_editor as mod + from services.tools import refresh_unity as refresh_mod + + monkeypatch.delenv("PYTEST_CURRENT_TEST", raising=False) + + async def fake_get_editor_state(ctx): + return {"data": {"advice": {"ready_for_tools": True, "blocking_reasons": []}}} + + monkeypatch.setattr(refresh_mod.editor_state, "get_editor_state", fake_get_editor_state) + + ctx = DummyContext() + result = await mod._wait_for_compilation(ctx, timeout=None) + assert result["success"] is True + + +@pytest.mark.asyncio +async def test_wait_for_compilation_via_manage_editor(monkeypatch): + """The action is routed correctly through the main manage_editor function.""" + from services.tools import manage_editor as mod + from services.tools import refresh_unity as refresh_mod + + monkeypatch.delenv("PYTEST_CURRENT_TEST", raising=False) + + async def fake_get_editor_state(ctx): + return {"data": {"advice": {"ready_for_tools": True, "blocking_reasons": []}}} + + monkeypatch.setattr(refresh_mod.editor_state, "get_editor_state", fake_get_editor_state) + + ctx = DummyContext() + result = await mod.manage_editor(ctx, action="wait_for_compilation", timeout=5) + assert result["success"] is True + assert result["data"]["ready"] is True + + +@pytest.mark.asyncio +async def test_wait_for_compilation_domain_reload(monkeypatch): + """Waits through domain_reload blocking reason too.""" + from services.tools import manage_editor as mod + from services.tools import refresh_unity as refresh_mod + + monkeypatch.delenv("PYTEST_CURRENT_TEST", raising=False) + + call_count = 0 + + async def fake_get_editor_state(ctx): + nonlocal call_count + call_count += 1 + if call_count == 1: + return {"data": {"advice": {"ready_for_tools": False, "blocking_reasons": ["compiling"]}}} + if call_count == 2: + return {"data": {"advice": {"ready_for_tools": False, "blocking_reasons": ["domain_reload"]}}} + return {"data": {"advice": {"ready_for_tools": True, "blocking_reasons": []}}} + + monkeypatch.setattr(refresh_mod.editor_state, "get_editor_state", fake_get_editor_state) + + ctx = DummyContext() + result = await mod._wait_for_compilation(ctx, timeout=10) + assert result["success"] is True + assert call_count >= 3 From d77ac452eeddae64003db0b425bfa545a4372268 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sercan=20Muhlac=C4=B1?= Date: Mon, 23 Feb 2026 14:24:34 +0300 Subject: [PATCH 2/8] docs(manage_editor): add wait_for_compilation and CLI wait-compile to references and guides --- .claude/skills/unity-mcp-skill/references/tools-reference.md | 2 ++ Server/src/cli/CLI_USAGE_GUIDE.md | 3 +++ docs/guides/CLI_EXAMPLE.md | 1 + docs/guides/CLI_USAGE.md | 3 +++ unity-mcp-skill/references/tools-reference.md | 2 ++ 5 files changed, 11 insertions(+) diff --git a/.claude/skills/unity-mcp-skill/references/tools-reference.md b/.claude/skills/unity-mcp-skill/references/tools-reference.md index bc100e25a..2fb571e14 100644 --- a/.claude/skills/unity-mcp-skill/references/tools-reference.md +++ b/.claude/skills/unity-mcp-skill/references/tools-reference.md @@ -749,6 +749,8 @@ manage_editor(action="remove_tag", tag_name="OldTag") manage_editor(action="add_layer", layer_name="Projectiles") manage_editor(action="remove_layer", layer_name="OldLayer") +manage_editor(action="wait_for_compilation", timeout=30) # Wait for scripts to compile + manage_editor(action="close_prefab_stage") # Exit prefab editing mode back to main scene # Undo/Redo — returns the affected undo group name diff --git a/Server/src/cli/CLI_USAGE_GUIDE.md b/Server/src/cli/CLI_USAGE_GUIDE.md index 4c6413eae..13e6b20a4 100644 --- a/Server/src/cli/CLI_USAGE_GUIDE.md +++ b/Server/src/cli/CLI_USAGE_GUIDE.md @@ -507,6 +507,9 @@ unity-mcp editor play unity-mcp editor pause unity-mcp editor stop +# Wait for script compilation (optional timeout in seconds) +unity-mcp editor wait-compile [--timeout 30] + # Console unity-mcp editor console # Read console unity-mcp editor console --count 20 # Last 20 entries diff --git a/docs/guides/CLI_EXAMPLE.md b/docs/guides/CLI_EXAMPLE.md index dbddaee81..1bef13edb 100644 --- a/docs/guides/CLI_EXAMPLE.md +++ b/docs/guides/CLI_EXAMPLE.md @@ -45,6 +45,7 @@ unity-mcp instance current # Show current instance **Editor Control** ```bash unity-mcp editor play|pause|stop # Control play mode +unity-mcp editor wait-compile [--timeout N] # Wait for scripts to compile unity-mcp editor console [--clear] # Get/clear console logs unity-mcp editor refresh [--compile] # Refresh assets unity-mcp editor menu "Edit/Project Settings..." # Execute menu item diff --git a/docs/guides/CLI_USAGE.md b/docs/guides/CLI_USAGE.md index 0c610503f..2403baf93 100644 --- a/docs/guides/CLI_USAGE.md +++ b/docs/guides/CLI_USAGE.md @@ -156,6 +156,9 @@ unity-mcp editor play unity-mcp editor pause unity-mcp editor stop +# Wait for compilation +unity-mcp editor wait-compile [--timeout 30] + # Refresh assets unity-mcp editor refresh unity-mcp editor refresh --compile diff --git a/unity-mcp-skill/references/tools-reference.md b/unity-mcp-skill/references/tools-reference.md index acf61a8ad..4ecdf9fe0 100644 --- a/unity-mcp-skill/references/tools-reference.md +++ b/unity-mcp-skill/references/tools-reference.md @@ -706,6 +706,8 @@ manage_editor(action="remove_tag", tag_name="OldTag") manage_editor(action="add_layer", layer_name="Projectiles") manage_editor(action="remove_layer", layer_name="OldLayer") +manage_editor(action="wait_for_compilation", timeout=30) # Wait for scripts to compile + manage_editor(action="open_prefab_stage", prefab_path="Assets/Prefabs/Enemy.prefab") manage_editor(action="close_prefab_stage") # Exit prefab editing mode back to main scene From 99921b3a52d17adac38cf4896c976015d3a5689a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sercan=20Muhlac=C4=B1?= Date: Mon, 23 Feb 2026 14:36:44 +0300 Subject: [PATCH 3/8] Address code review: remove unused constant, surface server error in wait-compile - Remove unused _WAIT_FOR_COMPILATION_ACTIONS from manage_editor.py - Use result.get('message', ...) in editor wait-compile so non-timeout errors (e.g. Python/connectivity) are reported accurately Co-authored-by: Cursor --- Server/src/cli/commands/editor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Server/src/cli/commands/editor.py b/Server/src/cli/commands/editor.py index fe9072ce3..eee0dca45 100644 --- a/Server/src/cli/commands/editor.py +++ b/Server/src/cli/commands/editor.py @@ -44,7 +44,7 @@ def wait_compile(timeout: float): waited = result.get("data", {}).get("waited_seconds", 0) print_success(f"Compilation complete (waited {waited}s)") else: - print_error("Compilation wait timed out") + print_error(result.get("message", "Compilation wait timed out")) @editor.command("play") From 16ed5d8db8c35d9dfb91f7b304c7c38ecf96385f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sercan=20Muhlac=C4=B1?= Date: Mon, 23 Feb 2026 14:37:10 +0300 Subject: [PATCH 4/8] docs: add wait-compile to editor subcommands in CLI reference table Co-authored-by: Cursor --- docs/guides/CLI_USAGE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guides/CLI_USAGE.md b/docs/guides/CLI_USAGE.md index 2403baf93..30e19a1ae 100644 --- a/docs/guides/CLI_USAGE.md +++ b/docs/guides/CLI_USAGE.md @@ -488,7 +488,7 @@ unity-mcp raw manage_packages '{"action": "list_packages"}' | `component` | `add`, `remove`, `set`, `modify` | | `script` | `create`, `read`, `delete`, `edit`, `validate` | | `shader` | `create`, `read`, `update`, `delete` | -| `editor` | `play`, `pause`, `stop`, `refresh`, `console`, `menu`, `tool`, `add-tag`, `remove-tag`, `add-layer`, `remove-layer`, `tests`, `poll-test`, `custom-tool` | +| `editor` | `play`, `pause`, `stop`, `wait-compile`, `refresh`, `console`, `menu`, `tool`, `add-tag`, `remove-tag`, `add-layer`, `remove-layer`, `tests`, `poll-test`, `custom-tool` | | `asset` | `search`, `info`, `create`, `delete`, `duplicate`, `move`, `rename`, `import`, `mkdir` | | `prefab` | `open`, `close`, `save`, `create` | | `material` | `info`, `create`, `set-color`, `set-property`, `assign`, `set-renderer-color` | From 3e5c14aa22b8758edc4af8cc5ec84080f6d08855 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sercan=20Muhlac=C4=B1?= Date: Mon, 23 Feb 2026 14:45:55 +0300 Subject: [PATCH 5/8] cli: pass transport timeout for editor wait-compile so long waits are not cut off Co-authored-by: Cursor --- Server/src/cli/commands/editor.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Server/src/cli/commands/editor.py b/Server/src/cli/commands/editor.py index eee0dca45..4d08acb83 100644 --- a/Server/src/cli/commands/editor.py +++ b/Server/src/cli/commands/editor.py @@ -38,7 +38,9 @@ def wait_compile(timeout: float): unity-mcp editor wait-compile --timeout 60 """ config = get_config() - result = run_command("manage_editor", {"action": "wait_for_compilation", "timeout": timeout}, config) + # Ensure the transport timeout outlasts the compilation wait (add a small buffer). + transport_timeout = int(timeout) + 10 + result = run_command("manage_editor", {"action": "wait_for_compilation", "timeout": timeout}, config, timeout=transport_timeout) click.echo(format_output(result, config.format)) if result.get("success"): waited = result.get("data", {}).get("waited_seconds", 0) From a6d83845674826b4644ae58e13fd619c86359077 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sercan=20Muhlac=C4=B1?= Date: Tue, 24 Mar 2026 12:53:04 +0300 Subject: [PATCH 6/8] fix(manage_editor): surface wait timeout bounds Document the 1-120 second wait_for_compilation timeout clamp in the tool, CLI, and references so callers know larger values are not honored. Round the CLI transport timeout up before adding its buffer so fractional waits are covered precisely. Made-with: Cursor --- .../unity-mcp-skill/references/tools-reference.md | 2 +- Server/src/cli/CLI_USAGE_GUIDE.md | 2 +- Server/src/cli/commands/editor.py | 11 +++++++---- Server/src/services/tools/manage_editor.py | 10 +++++++--- docs/guides/CLI_EXAMPLE.md | 2 +- docs/guides/CLI_USAGE.md | 2 +- unity-mcp-skill/references/tools-reference.md | 2 +- 7 files changed, 19 insertions(+), 12 deletions(-) diff --git a/.claude/skills/unity-mcp-skill/references/tools-reference.md b/.claude/skills/unity-mcp-skill/references/tools-reference.md index 2fb571e14..3b77cee97 100644 --- a/.claude/skills/unity-mcp-skill/references/tools-reference.md +++ b/.claude/skills/unity-mcp-skill/references/tools-reference.md @@ -749,7 +749,7 @@ manage_editor(action="remove_tag", tag_name="OldTag") manage_editor(action="add_layer", layer_name="Projectiles") manage_editor(action="remove_layer", layer_name="OldLayer") -manage_editor(action="wait_for_compilation", timeout=30) # Wait for scripts to compile +manage_editor(action="wait_for_compilation", timeout=30) # Wait for scripts to compile (timeout clamps to 1-120s) manage_editor(action="close_prefab_stage") # Exit prefab editing mode back to main scene diff --git a/Server/src/cli/CLI_USAGE_GUIDE.md b/Server/src/cli/CLI_USAGE_GUIDE.md index 13e6b20a4..b4b43df6c 100644 --- a/Server/src/cli/CLI_USAGE_GUIDE.md +++ b/Server/src/cli/CLI_USAGE_GUIDE.md @@ -507,7 +507,7 @@ unity-mcp editor play unity-mcp editor pause unity-mcp editor stop -# Wait for script compilation (optional timeout in seconds) +# Wait for script compilation (timeout clamps to 1-120 seconds) unity-mcp editor wait-compile [--timeout 30] # Console diff --git a/Server/src/cli/commands/editor.py b/Server/src/cli/commands/editor.py index 4d08acb83..81b31de6c 100644 --- a/Server/src/cli/commands/editor.py +++ b/Server/src/cli/commands/editor.py @@ -1,8 +1,10 @@ """Editor CLI commands.""" +import math import sys +from typing import Any, Optional + import click -from typing import Optional, Any from cli.utils.config import get_config from cli.utils.output import format_output, print_error, print_success, print_info @@ -22,7 +24,7 @@ def editor(): "--timeout", "-t", type=float, default=30.0, - help="Max seconds to wait (default: 30)." + help="Max seconds to wait (default: 30, clamped to 1-120)." ) @handle_unity_errors def wait_compile(timeout: float): @@ -30,7 +32,8 @@ def wait_compile(timeout: float): Polls editor state until compilation and domain reload are complete. Useful after modifying scripts to ensure changes are compiled before - entering play mode or performing other actions. + entering play mode or performing other actions. Timeout values are + clamped to the inclusive range 1-120 seconds. \b Examples: @@ -39,7 +42,7 @@ def wait_compile(timeout: float): """ config = get_config() # Ensure the transport timeout outlasts the compilation wait (add a small buffer). - transport_timeout = int(timeout) + 10 + transport_timeout = math.ceil(timeout) + 10 result = run_command("manage_editor", {"action": "wait_for_compilation", "timeout": timeout}, config, timeout=transport_timeout) click.echo(format_output(result, config.format)) if result.get("success"): diff --git a/Server/src/services/tools/manage_editor.py b/Server/src/services/tools/manage_editor.py index 48006fb77..8fb6cc9c5 100644 --- a/Server/src/services/tools/manage_editor.py +++ b/Server/src/services/tools/manage_editor.py @@ -10,7 +10,7 @@ from transport.unity_transport import send_with_unity_instance @mcp_for_unity_tool( - description="Controls and queries the Unity editor's state and settings. Read-only actions: telemetry_status, telemetry_ping, wait_for_compilation. Modifying actions: play, pause, stop, set_active_tool, add_tag, remove_tag, add_layer, remove_layer, open_prefab_stage, close_prefab_stage, deploy_package, restore_package, undo, redo. open_prefab_stage opens a prefab asset in Unity's prefab editing mode. deploy_package copies the configured MCPForUnity source folder into the project's installed package location (triggers recompile, no confirmation dialog). restore_package reverts to the pre-deployment backup. undo/redo perform Unity editor undo/redo and return the affected group name.", + description="Controls and queries the Unity editor's state and settings. Read-only actions: telemetry_status, telemetry_ping, wait_for_compilation. wait_for_compilation polls until compilation and domain reload finish; its timeout is clamped to 1-120 seconds (default 30). Modifying actions: play, pause, stop, set_active_tool, add_tag, remove_tag, add_layer, remove_layer, open_prefab_stage, close_prefab_stage, deploy_package, restore_package, undo, redo. open_prefab_stage opens a prefab asset in Unity's prefab editing mode. deploy_package copies the configured MCPForUnity source folder into the project's installed package location (triggers recompile, no confirmation dialog). restore_package reverts to the pre-deployment backup. undo/redo perform Unity editor undo/redo and return the affected group name.", annotations=ToolAnnotations( title="Manage Editor", ), @@ -19,7 +19,7 @@ async def manage_editor( ctx: Context, action: Annotated[Literal["telemetry_status", "telemetry_ping", "wait_for_compilation", "play", "pause", "stop", "set_active_tool", "add_tag", "remove_tag", "add_layer", "remove_layer", "open_prefab_stage", "close_prefab_stage", "deploy_package", "restore_package", "undo", "redo"], "Get and update the Unity Editor state. open_prefab_stage opens a prefab asset in prefab editing mode; close_prefab_stage exits prefab editing mode and returns to the main scene stage. deploy_package copies the configured MCPForUnity source into the project's package location (triggers recompile). restore_package reverts the last deployment from backup. undo/redo perform editor undo/redo."], timeout: Annotated[int | float | None, - "Timeout in seconds for wait_for_compilation (default: 30)."] = None, + "Timeout in seconds for wait_for_compilation (default: 30, clamped to 1-120)."] = None, tool_name: Annotated[str, "Tool name when setting active tool"] | None = None, tag_name: Annotated[str, @@ -78,7 +78,11 @@ async def manage_editor( async def _wait_for_compilation(ctx: Context, timeout: int | float | None) -> dict[str, Any]: - """Poll editor_state until compilation and domain reload finish.""" + """Poll editor_state until compilation and domain reload finish. + + The timeout is clamped to the inclusive range [1.0, 120.0] seconds to + keep waits bounded in the Unity editor. + """ from services.tools.refresh_unity import wait_for_editor_ready timeout_s = float(timeout) if timeout is not None else 30.0 diff --git a/docs/guides/CLI_EXAMPLE.md b/docs/guides/CLI_EXAMPLE.md index 1bef13edb..c2e2cbbf0 100644 --- a/docs/guides/CLI_EXAMPLE.md +++ b/docs/guides/CLI_EXAMPLE.md @@ -45,7 +45,7 @@ unity-mcp instance current # Show current instance **Editor Control** ```bash unity-mcp editor play|pause|stop # Control play mode -unity-mcp editor wait-compile [--timeout N] # Wait for scripts to compile +unity-mcp editor wait-compile [--timeout N] # Wait for scripts to compile (1-120s clamp) unity-mcp editor console [--clear] # Get/clear console logs unity-mcp editor refresh [--compile] # Refresh assets unity-mcp editor menu "Edit/Project Settings..." # Execute menu item diff --git a/docs/guides/CLI_USAGE.md b/docs/guides/CLI_USAGE.md index 30e19a1ae..a284ed267 100644 --- a/docs/guides/CLI_USAGE.md +++ b/docs/guides/CLI_USAGE.md @@ -156,7 +156,7 @@ unity-mcp editor play unity-mcp editor pause unity-mcp editor stop -# Wait for compilation +# Wait for compilation (timeout clamps to 1-120 seconds) unity-mcp editor wait-compile [--timeout 30] # Refresh assets diff --git a/unity-mcp-skill/references/tools-reference.md b/unity-mcp-skill/references/tools-reference.md index 4ecdf9fe0..fd4618be2 100644 --- a/unity-mcp-skill/references/tools-reference.md +++ b/unity-mcp-skill/references/tools-reference.md @@ -706,7 +706,7 @@ manage_editor(action="remove_tag", tag_name="OldTag") manage_editor(action="add_layer", layer_name="Projectiles") manage_editor(action="remove_layer", layer_name="OldLayer") -manage_editor(action="wait_for_compilation", timeout=30) # Wait for scripts to compile +manage_editor(action="wait_for_compilation", timeout=30) # Wait for scripts to compile (timeout clamps to 1-120s) manage_editor(action="open_prefab_stage", prefab_path="Assets/Prefabs/Enemy.prefab") manage_editor(action="close_prefab_stage") # Exit prefab editing mode back to main scene From 4a3c89975647b26c33c7effaa6259972f6954913 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sercan=20Muhlac=C4=B1?= Date: Tue, 24 Mar 2026 13:09:11 +0300 Subject: [PATCH 7/8] fix(cli): clamp wait-compile timeout before sending Clamp the wait-compile timeout on the CLI before deriving the transport timeout so invalid or excessive values never reach the request layer. Exit with a non-zero status when compilation waiting fails so shell pipelines stop on timeout. Made-with: Cursor --- Server/src/cli/commands/editor.py | 11 +++++++++-- Server/tests/test_cli.py | 33 +++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/Server/src/cli/commands/editor.py b/Server/src/cli/commands/editor.py index 81b31de6c..33f553344 100644 --- a/Server/src/cli/commands/editor.py +++ b/Server/src/cli/commands/editor.py @@ -41,15 +41,22 @@ def wait_compile(timeout: float): unity-mcp editor wait-compile --timeout 60 """ config = get_config() + effective_timeout = max(1.0, min(timeout, 120.0)) # Ensure the transport timeout outlasts the compilation wait (add a small buffer). - transport_timeout = math.ceil(timeout) + 10 - result = run_command("manage_editor", {"action": "wait_for_compilation", "timeout": timeout}, config, timeout=transport_timeout) + transport_timeout = math.ceil(effective_timeout) + 10 + result = run_command( + "manage_editor", + {"action": "wait_for_compilation", "timeout": effective_timeout}, + config, + timeout=transport_timeout, + ) click.echo(format_output(result, config.format)) if result.get("success"): waited = result.get("data", {}).get("waited_seconds", 0) print_success(f"Compilation complete (waited {waited}s)") else: print_error(result.get("message", "Compilation wait timed out")) + sys.exit(1) @editor.command("play") diff --git a/Server/tests/test_cli.py b/Server/tests/test_cli.py index 976b03cad..e2643974e 100644 --- a/Server/tests/test_cli.py +++ b/Server/tests/test_cli.py @@ -1079,6 +1079,39 @@ def test_batch_run_file(self, runner, tmp_path, mock_unity_response): class TestEditorEnhancedCommands: """Tests for new editor subcommands.""" + def test_editor_wait_compile_clamps_timeout_for_request_and_transport(self, runner, mock_config): + """Test wait-compile clamps timeout before calling the server.""" + wait_response = { + "success": True, + "data": {"waited_seconds": 120.0}, + } + with patch("cli.commands.editor.get_config", return_value=mock_config): + with patch("cli.commands.editor.run_command", return_value=wait_response) as mock_run: + result = runner.invoke(cli, ["editor", "wait-compile", "--timeout", "500"]) + + assert result.exit_code == 0 + mock_run.assert_called_once() + args = mock_run.call_args + assert args[0][0] == "manage_editor" + assert args[0][1] == { + "action": "wait_for_compilation", + "timeout": 120.0, + } + assert args[1]["timeout"] == 130 + + def test_editor_wait_compile_returns_nonzero_on_failure(self, runner, mock_config): + """Test wait-compile exits with code 1 when the wait fails.""" + wait_response = { + "success": False, + "message": "Timed out after 120s waiting for compilation to finish.", + } + with patch("cli.commands.editor.get_config", return_value=mock_config): + with patch("cli.commands.editor.run_command", return_value=wait_response): + result = runner.invoke(cli, ["editor", "wait-compile", "--timeout", "-5"]) + + assert result.exit_code == 1 + assert "Timed out after 120s waiting for compilation to finish." in result.output + def test_editor_refresh(self, runner, mock_unity_response): """Test editor refresh.""" with patch("cli.commands.editor.run_command", return_value=mock_unity_response): From f4f3c55a888565b85f6d6963e04efae3ce437419 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sercan=20Muhlac=C4=B1?= Date: Tue, 24 Mar 2026 13:16:21 +0300 Subject: [PATCH 8/8] test(cli): cover wait-compile timeout clamping in failure path Assert that negative wait-compile timeouts are clamped before dispatch so the CLI test verifies the outgoing request is sanitized as well as returning a non-zero exit code. Made-with: Cursor --- Server/tests/test_cli.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Server/tests/test_cli.py b/Server/tests/test_cli.py index e2643974e..0efed6517 100644 --- a/Server/tests/test_cli.py +++ b/Server/tests/test_cli.py @@ -1106,11 +1106,14 @@ def test_editor_wait_compile_returns_nonzero_on_failure(self, runner, mock_confi "message": "Timed out after 120s waiting for compilation to finish.", } with patch("cli.commands.editor.get_config", return_value=mock_config): - with patch("cli.commands.editor.run_command", return_value=wait_response): + with patch("cli.commands.editor.run_command", return_value=wait_response) as mock_run: result = runner.invoke(cli, ["editor", "wait-compile", "--timeout", "-5"]) assert result.exit_code == 1 assert "Timed out after 120s waiting for compilation to finish." in result.output + mock_run.assert_called_once() + args = mock_run.call_args + assert args[0][1]["timeout"] == 1.0 def test_editor_refresh(self, runner, mock_unity_response): """Test editor refresh."""