From 0c0211262aa5e0532241834841a4ebe0573496f3 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/5] 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 | 49 ++++++- .../integration/test_manage_editor_wait.py | 137 ++++++++++++++++++ Server/uv.lock | 2 +- 4 files changed, 210 insertions(+), 8 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 30c462c04..73eda0161 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 51480b75d..8cd9d133b 100644 --- a/Server/src/services/tools/manage_editor.py +++ b/Server/src/services/tools/manage_editor.py @@ -1,3 +1,4 @@ +import time from typing import Annotated, Any, Literal from fastmcp import Context @@ -10,18 +11,22 @@ from transport.legacy.unity_connection import async_send_command_with_retry from services.tools.utils import coerce_bool +_WAIT_FOR_COMPILATION_ACTIONS = frozenset({"wait_for_compilation"}) + @mcp_for_unity_tool( - description="Controls and queries the Unity editor's state and settings. Tip: pass booleans as true/false; if your client only sends strings, 'true'/'false' are accepted. Read-only actions: telemetry_status, telemetry_ping. Modifying actions: play, pause, stop, set_active_tool, add_tag, remove_tag, add_layer, remove_layer.", + description="Controls and queries the Unity editor's state and settings. Tip: pass booleans as true/false; if your client only sends strings, 'true'/'false' are accepted. 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.", 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"], "Get and update the Unity Editor state."], + action: Annotated[Literal["telemetry_status", "telemetry_ping", "wait_for_compilation", "play", "pause", "stop", "set_active_tool", "add_tag", "remove_tag", "add_layer", "remove_layer"], "Get and update the Unity Editor state."], wait_for_completion: Annotated[bool | str, "Optional. If True, waits for certain actions (accepts true/false or 'true'/'false')"] | None = None, + 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,20 +34,21 @@ async def manage_editor( layer_name: Annotated[str, "Layer name when adding and removing layers"] | None = None, ) -> dict[str, Any]: - # Get active instance from request state (injected by middleware) unity_instance = get_unity_instance_from_context(ctx) wait_for_completion = coerce_bool(wait_for_completion) try: - # Diagnostics: quick telemetry checks if action == "telemetry_status": return {"success": True, "telemetry_enabled": is_telemetry_enabled()} if action == "telemetry_ping": record_tool_usage("diagnostic_ping", True, 1.0, None) return {"success": True, "message": "telemetry ping queued"} - # Prepare parameters, removing None values + + if action == "wait_for_compilation": + return await _wait_for_compilation(ctx, timeout) + params = { "action": action, "waitForCompletion": wait_for_completion, @@ -52,13 +58,42 @@ async def manage_editor( } params = {k: v for k, v in params.items() if v is not None} - # Send command using centralized retry helper with instance routing response = await send_with_unity_instance(async_send_command_with_retry, unity_instance, "manage_editor", params) - # Preserve structured failure data; unwrap success into a friendlier shape if isinstance(response, dict) and response.get("success"): return {"success": True, "message": response.get("message", "Editor operation successful."), "data": response.get("data")} return response if isinstance(response, dict) else {"success": False, "message": str(response)} 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)) + + start = time.monotonic() + 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 diff --git a/Server/uv.lock b/Server/uv.lock index 46e8a45a7..d2f1c8da7 100644 --- a/Server/uv.lock +++ b/Server/uv.lock @@ -912,7 +912,7 @@ wheels = [ [[package]] name = "mcpforunityserver" -version = "9.4.6" +version = "9.4.7" source = { editable = "." } dependencies = [ { name = "click" }, From a0dec49f61ae561a5e8154aaed310996ab36b6cc 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/5] 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 0cb251155..97d0be508 100644 --- a/.claude/skills/unity-mcp-skill/references/tools-reference.md +++ b/.claude/skills/unity-mcp-skill/references/tools-reference.md @@ -505,6 +505,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 ``` ### execute_menu_item diff --git a/Server/src/cli/CLI_USAGE_GUIDE.md b/Server/src/cli/CLI_USAGE_GUIDE.md index 576196477..78754169d 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 ab3198ce3..0003d34b4 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 8ea77e7f1..3e8037044 100644 --- a/docs/guides/CLI_USAGE.md +++ b/docs/guides/CLI_USAGE.md @@ -150,6 +150,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 0cb251155..97d0be508 100644 --- a/unity-mcp-skill/references/tools-reference.md +++ b/unity-mcp-skill/references/tools-reference.md @@ -505,6 +505,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 ``` ### execute_menu_item From fd9f96d91a49002caefcee1ea2ff17e70df5d82e 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/5] 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 +- Server/src/services/tools/manage_editor.py | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/Server/src/cli/commands/editor.py b/Server/src/cli/commands/editor.py index 73eda0161..c06719879 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") diff --git a/Server/src/services/tools/manage_editor.py b/Server/src/services/tools/manage_editor.py index 8cd9d133b..d72117cd9 100644 --- a/Server/src/services/tools/manage_editor.py +++ b/Server/src/services/tools/manage_editor.py @@ -11,8 +11,6 @@ from transport.legacy.unity_connection import async_send_command_with_retry from services.tools.utils import coerce_bool -_WAIT_FOR_COMPILATION_ACTIONS = frozenset({"wait_for_compilation"}) - @mcp_for_unity_tool( description="Controls and queries the Unity editor's state and settings. Tip: pass booleans as true/false; if your client only sends strings, 'true'/'false' are accepted. 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.", From ed7418ab80e6945877466721914ef65e3e0802a3 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/5] 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 3e8037044..ec19b6c60 100644 --- a/docs/guides/CLI_USAGE.md +++ b/docs/guides/CLI_USAGE.md @@ -341,7 +341,7 @@ unity-mcp raw read_console '{"count": 20}' | `script` | `create`, `read`, `delete`, `edit`, `validate` | | `code` | `read`, `search` | | `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 651e806f1d4988bef2e274bf8ab9573cfd2cd2fb 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/5] 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 c06719879..c9aec9ea0 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)