Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -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
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 (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
Expand Down
32 changes: 32 additions & 0 deletions Server/src/cli/commands/editor.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,38 @@ 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()
# 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)
print_success(f"Compilation complete (waited {waited}s)")
else:
print_error(result.get("message", "Compilation wait timed out"))
Comment on lines +45 to +49
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

waited_seconds: null from the server bypasses the default and produces a malformed message.

dict.get(key, default) only applies the default when the key is absent. If the server ever returns "waited_seconds": null, the expression result.get("data", {}).get("waited_seconds", 0) evaluates to None, and the success message becomes "Compilation complete (waited Nones)".

Additionally, a raw float like 2.3456 would render verbosely in the output string.

🛡️ Proposed fix
-        waited = result.get("data", {}).get("waited_seconds", 0)
-        print_success(f"Compilation complete (waited {waited}s)")
+        waited = result.get("data", {}).get("waited_seconds") or 0
+        print_success(f"Compilation complete (waited {waited:.1f}s)")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Server/src/cli/commands/editor.py` around lines 45 - 49, The success message
breaks when "waited_seconds" is present but null (producing None) and shows
verbose raw floats; change how waited is obtained and formatted in
Server/src/cli/commands/editor.py: replace result.get("data",
{}).get("waited_seconds", 0) with a retrieval that coerces null to 0 (e.g.,
waited = result.get("data", {}).get("waited_seconds") or 0) and then format the
value before passing to print_success (e.g., round or format to 2 decimal places
with f-string) so print_success(f"Compilation complete (waited {formatted}s)")
always receives a sane string; keep references to result, waited, and
print_success.



@editor.command("play")
@handle_unity_errors
def play():
Expand Down
47 changes: 40 additions & 7 deletions Server/src/services/tools/manage_editor.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import time
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Remove unused start variable and the now-dead import time.

start = time.monotonic() (Line 76) is flagged by Ruff (F841) and has no effect — elapsed is already returned directly by wait_for_editor_ready. Once Line 76 is removed, the import time at Line 1 becomes unused as well.

🧹 Proposed fix
-import time
 from typing import Annotated, Any, Literal
     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)

Also applies to: 76-76

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Server/src/services/tools/manage_editor.py` at line 1, Remove the dead start
= time.monotonic() assignment and the now-unused import time in
manage_editor.py: delete the start variable (the one set immediately before
calling wait_for_editor_ready) since elapsed is returned directly by
wait_for_editor_ready, and remove the top-level import time because it becomes
unused after dropping that assignment.

from typing import Annotated, Any, Literal

from fastmcp import Context
Expand All @@ -12,37 +13,40 @@


@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,
"Tag name when adding and removing tags"] | None = None,
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,
Expand All @@ -52,13 +56,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,
},
}
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
2 changes: 1 addition & 1 deletion Server/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

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
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 @@ -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
Expand Down Expand Up @@ -338,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` |
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 @@ -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
Expand Down