Skip to content
2 changes: 2 additions & 0 deletions .claude/skills/unity-mcp-skill/references/tools-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions Server/src/cli/CLI_USAGE_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
44 changes: 43 additions & 1 deletion Server/src/cli/commands/editor.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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():
Expand Down
52 changes: 45 additions & 7 deletions Server/src/services/tools/manage_editor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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":
Expand All @@ -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,
Expand All @@ -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,
},
}
137 changes: 137 additions & 0 deletions Server/tests/integration/test_manage_editor_wait.py
Original file line number Diff line number Diff line change
@@ -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
36 changes: 36 additions & 0 deletions Server/tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
1 change: 1 addition & 0 deletions docs/guides/CLI_EXAMPLE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion docs/guides/CLI_USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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` |
Expand Down
2 changes: 2 additions & 0 deletions unity-mcp-skill/references/tools-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down