diff --git a/.claude/skills/unity-mcp-skill/references/tools-reference.md b/.claude/skills/unity-mcp-skill/references/tools-reference.md index 601e533a9..4352aa9d6 100644 --- a/.claude/skills/unity-mcp-skill/references/tools-reference.md +++ b/.claude/skills/unity-mcp-skill/references/tools-reference.md @@ -730,9 +730,11 @@ 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="open_prefab_stage", prefab_path="Assets/Prefabs/Enemy.prefab") -manage_editor(action="save_prefab_stage") # Save changes in the open prefab stage -manage_editor(action="close_prefab_stage") # Exit prefab editing mode back to main scene +manage_editor(action="wait_for_compilation", timeout=30) # Wait for scripts to compile (timeout clamps to 1-120s) + +manage_prefabs(action="open_prefab_stage", prefab_path="Assets/Prefabs/Enemy.prefab") +manage_prefabs(action="save_prefab_stage") # Save changes in the open prefab stage +manage_prefabs(action="close_prefab_stage") # Exit prefab editing mode back to main scene # Package deployment (no confirmation dialog — designed for LLM-driven iteration) manage_editor(action="deploy_package") # Copy configured MCPForUnity source into installed package diff --git a/MCPForUnity/package.json b/MCPForUnity/package.json index 19b08b73e..3f22cfd54 100644 --- a/MCPForUnity/package.json +++ b/MCPForUnity/package.json @@ -1,6 +1,6 @@ { "name": "com.coplaydev.unity-mcp", - "version": "9.6.5-beta.5", + "version": "9.6.5", "displayName": "MCP for Unity", "description": "A bridge that connects AI assistants to Unity via the MCP (Model Context Protocol). Allows AI clients like Claude Code, Cursor, and VSCode to directly control your Unity Editor for enhanced development workflows.\n\nFeatures automated setup wizard, cross-platform support, and seamless integration with popular AI development tools.\n\nJoin Our Discord: https://discord.gg/y4p8KfzrN4", "unity": "2021.3", diff --git a/Server/README.md b/Server/README.md index 171663141..18dbb4ab0 100644 --- a/Server/README.md +++ b/Server/README.md @@ -69,7 +69,7 @@ Use this to run the latest released version from the repository. Change the vers "command": "uvx", "args": [ "--from", - "git+https://github.com/CoplayDev/unity-mcp@v9.6.4#subdirectory=Server", + "git+https://github.com/CoplayDev/unity-mcp@v9.6.5#subdirectory=Server", "mcp-for-unity", "--transport", "stdio" diff --git a/Server/pyproject.toml b/Server/pyproject.toml index 116f03120..b23c1c48e 100644 --- a/Server/pyproject.toml +++ b/Server/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mcpforunityserver" -version = "9.6.4" +version = "9.6.5" description = "MCP for Unity Server: A Unity package for Unity Editor integration via the Model Context Protocol (MCP)." readme = "README.md" license = "MIT" diff --git a/Server/src/cli/CLI_USAGE_GUIDE.md b/Server/src/cli/CLI_USAGE_GUIDE.md index a276bd15a..8efff4453 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 31142073b..04e4e178a 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, deploy_package, restore_package, undo, redo. For prefab editing (open/save/close prefab stage), use manage_prefabs. 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, deploy_package, restore_package, undo, redo. For prefab editing (open/save/close prefab stage), use manage_prefabs. 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", "deploy_package", "restore_package", "undo", "redo"], "Get and update the Unity Editor state. 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. For prefab editing (open/save/close prefab stage), use manage_prefabs."], + action: Annotated[Literal["telemetry_status", "telemetry_ping", "wait_for_compilation", "play", "pause", "stop", "set_active_tool", "add_tag", "remove_tag", "add_layer", "remove_layer", "deploy_package", "restore_package", "undo", "redo"], "Get and update the Unity Editor state. 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. For prefab editing (open/save/close prefab stage), use manage_prefabs."], + 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, @@ -37,6 +39,9 @@ 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) + # Prepare parameters, removing None values params = { "action": action, @@ -56,3 +61,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}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..14aa9ac32 --- /dev/null +++ b/Server/tests/integration/test_manage_editor_wait.py @@ -0,0 +1,140 @@ +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 + assert result["message"] == ( + f"Timed out after {result['data']['timeout_seconds']}s waiting for compilation to finish." + ) + + +@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 ccaebaa26..b73b7e944 100644 --- a/Server/tests/test_cli.py +++ b/Server/tests/test_cli.py @@ -1287,6 +1287,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 120.0s 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 120.0s 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/Server/uv.lock b/Server/uv.lock index 953d97186..6df80ce33 100644 --- a/Server/uv.lock +++ b/Server/uv.lock @@ -858,7 +858,7 @@ wheels = [ [[package]] name = "mcpforunityserver" -version = "9.6.4" +version = "9.6.5" source = { editable = "." } dependencies = [ { name = "click" }, diff --git a/docs/guides/CLI_EXAMPLE.md b/docs/guides/CLI_EXAMPLE.md index 6034b5147..174200779 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 062dfb524..16c115b9a 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 @@ -492,7 +495,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`, `modify` | | `material` | `info`, `create`, `set-color`, `set-property`, `assign`, `set-renderer-color` | diff --git a/manifest.json b/manifest.json index b634d643a..c3d36e55b 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": "0.3", "name": "Unity MCP", - "version": "9.6.4", + "version": "9.6.5", "description": "AI-powered Unity Editor automation via MCP - manage GameObjects, scripts, materials, scenes, prefabs, VFX, and run tests", "author": { "name": "Coplay", diff --git a/unity-mcp-skill/references/tools-reference.md b/unity-mcp-skill/references/tools-reference.md index 9bf1d41e5..4352aa9d6 100644 --- a/unity-mcp-skill/references/tools-reference.md +++ b/unity-mcp-skill/references/tools-reference.md @@ -730,6 +730,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_prefabs(action="open_prefab_stage", prefab_path="Assets/Prefabs/Enemy.prefab") manage_prefabs(action="save_prefab_stage") # Save changes in the open prefab stage manage_prefabs(action="close_prefab_stage") # Exit prefab editing mode back to main scene