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
8 changes: 5 additions & 3 deletions .claude/skills/unity-mcp-skill/references/tools-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion MCPForUnity/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion Server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion Server/pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
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
46 changes: 42 additions & 4 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, 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,
Expand All @@ -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,
Expand All @@ -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,
},
}
140 changes: 140 additions & 0 deletions Server/tests/integration/test_manage_editor_wait.py
Original file line number Diff line number Diff line change
@@ -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
36 changes: 36 additions & 0 deletions Server/tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
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 (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
Loading