diff --git a/.claude/skills/unity-mcp-skill/references/tools-reference.md b/.claude/skills/unity-mcp-skill/references/tools-reference.md index bc100e25a..3b77cee97 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 (timeout clamps to 1-120s) + 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..b4b43df6c 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 (timeout clamps to 1-120 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/Server/src/cli/commands/editor.py b/Server/src/cli/commands/editor.py index 8b7746657..33f553344 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 @@ -17,6 +19,46 @@ def editor(): pass +@editor.command("wait-compile") +@click.option( + "--timeout", "-t", + type=float, + default=30.0, + help="Max seconds to wait (default: 30, clamped to 1-120)." +) +@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. Timeout values are + clamped to the inclusive range 1-120 seconds. + + \b + Examples: + unity-mcp editor wait-compile + 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(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") @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..8fb6cc9c5 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. 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", ), ) 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, clamped to 1-120)."] = 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,36 @@ 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. + + 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 + 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 diff --git a/Server/tests/test_cli.py b/Server/tests/test_cli.py index 976b03cad..0efed6517 100644 --- a/Server/tests/test_cli.py +++ b/Server/tests/test_cli.py @@ -1079,6 +1079,42 @@ 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) 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.""" with patch("cli.commands.editor.run_command", return_value=mock_unity_response): diff --git a/docs/guides/CLI_EXAMPLE.md b/docs/guides/CLI_EXAMPLE.md index dbddaee81..c2e2cbbf0 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 (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 0c610503f..a284ed267 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 (timeout clamps to 1-120 seconds) +unity-mcp editor wait-compile [--timeout 30] + # Refresh assets unity-mcp editor refresh unity-mcp editor refresh --compile @@ -485,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` | diff --git a/unity-mcp-skill/references/tools-reference.md b/unity-mcp-skill/references/tools-reference.md index acf61a8ad..fd4618be2 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 (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