From 82b1b732e2dac82ddf3bf5f4a2a0b9db285b70fd Mon Sep 17 00:00:00 2001 From: Shutong Wu <51266340+Scriptwonder@users.noreply.github.com> Date: Tue, 28 Jul 2026 12:05:53 -0400 Subject: [PATCH 1/5] fix: correct inverted run_command arguments in the camera CLI All 18 call sites passed run_command(config, "manage_camera", params) while the signature is run_command(tool, params, config), so the entire `unity-mcp camera` command group was dead at beta HEAD. test_cli.py asserted against call_args[0][2], which encoded the bug rather than catching it; it now asserts the tool name at [0][0] and params at [0][1]. --- Server/src/cli/commands/camera.py | 36 +++++++++++++++---------------- Server/tests/test_cli.py | 3 ++- 2 files changed, 20 insertions(+), 19 deletions(-) diff --git a/Server/src/cli/commands/camera.py b/Server/src/cli/commands/camera.py index 5f4f82718..a1188049e 100644 --- a/Server/src/cli/commands/camera.py +++ b/Server/src/cli/commands/camera.py @@ -51,7 +51,7 @@ def ping(): unity-mcp camera ping """ config = get_config() - result = run_command(config, "manage_camera", {"action": "ping"}) + result = run_command("manage_camera", {"action": "ping"}, config) format_output(result, config) @@ -65,7 +65,7 @@ def list_cameras(): unity-mcp camera list """ config = get_config() - result = run_command(config, "manage_camera", {"action": "list_cameras"}) + result = run_command("manage_camera", {"action": "list_cameras"}, config) format_output(result, config) @@ -79,7 +79,7 @@ def brain_status(): unity-mcp camera brain-status """ config = get_config() - result = run_command(config, "manage_camera", {"action": "get_brain_status"}) + result = run_command("manage_camera", {"action": "get_brain_status"}, config) format_output(result, config) @@ -125,7 +125,7 @@ def create(name, preset, follow, look_at, priority, fov): if props: params["properties"] = props - result = run_command(config, "manage_camera", params) + result = run_command("manage_camera", params, config) format_output(result, config) @@ -155,7 +155,7 @@ def ensure_brain(camera_ref, blend_style, blend_duration): if props: params["properties"] = props - result = run_command(config, "manage_camera", params) + result = run_command("manage_camera", params, config) format_output(result, config) @@ -189,7 +189,7 @@ def set_target(target, search_method, follow, look_at): "searchMethod": search_method, "properties": props if props else None, }) - result = run_command(config, "manage_camera", params) + result = run_command("manage_camera", params, config) format_output(result, config) @@ -228,7 +228,7 @@ def set_lens(target, search_method, fov, near, far, ortho_size, dutch): "searchMethod": search_method, "properties": props if props else None, }) - result = run_command(config, "manage_camera", params) + result = run_command("manage_camera", params, config) format_output(result, config) @@ -251,7 +251,7 @@ def set_priority(target, search_method, priority): "searchMethod": search_method, "properties": {"priority": priority}, }) - result = run_command(config, "manage_camera", params) + result = run_command("manage_camera", params, config) format_output(result, config) @@ -286,7 +286,7 @@ def set_body(target, search_method, body_type, props): "searchMethod": search_method, "properties": properties if properties else None, }) - result = run_command(config, "manage_camera", params) + result = run_command("manage_camera", params, config) format_output(result, config) @@ -316,7 +316,7 @@ def set_aim(target, search_method, aim_type, props): "searchMethod": search_method, "properties": properties if properties else None, }) - result = run_command(config, "manage_camera", params) + result = run_command("manage_camera", params, config) format_output(result, config) @@ -346,7 +346,7 @@ def set_noise(target, search_method, amplitude, frequency): "searchMethod": search_method, "properties": props if props else None, }) - result = run_command(config, "manage_camera", params) + result = run_command("manage_camera", params, config) format_output(result, config) @@ -379,7 +379,7 @@ def add_extension(target, extension_type, search_method, props): "searchMethod": search_method, "properties": properties, }) - result = run_command(config, "manage_camera", params) + result = run_command("manage_camera", params, config) format_output(result, config) @@ -402,7 +402,7 @@ def remove_extension(target, extension_type, search_method): "searchMethod": search_method, "properties": {"extensionType": extension_type}, }) - result = run_command(config, "manage_camera", params) + result = run_command("manage_camera", params, config) format_output(result, config) @@ -432,7 +432,7 @@ def set_blend(style, duration): if props: params["properties"] = props - result = run_command(config, "manage_camera", params) + result = run_command("manage_camera", params, config) format_output(result, config) @@ -453,7 +453,7 @@ def force_camera(target, search_method): "target": target, "searchMethod": search_method, }) - result = run_command(config, "manage_camera", params) + result = run_command("manage_camera", params, config) format_output(result, config) @@ -467,7 +467,7 @@ def release_override(): unity-mcp camera release """ config = get_config() - result = run_command(config, "manage_camera", {"action": "release_override"}) + result = run_command("manage_camera", {"action": "release_override"}, config) format_output(result, config) @@ -523,7 +523,7 @@ def screenshot(camera_ref, file_name, super_size, include_image, max_resolution, params["viewTarget"] = view_target if output_folder: params["outputFolder"] = output_folder - result = run_command(config, "manage_camera", params) + result = run_command("manage_camera", params, config) format_output(result, config) @@ -550,5 +550,5 @@ def screenshot_multiview(max_resolution, view_target, output_folder): params["viewTarget"] = view_target if output_folder: params["outputFolder"] = output_folder - result = run_command(config, "manage_camera", params) + result = run_command("manage_camera", params, config) format_output(result, config) diff --git a/Server/tests/test_cli.py b/Server/tests/test_cli.py index ccaebaa26..8cba40794 100644 --- a/Server/tests/test_cli.py +++ b/Server/tests/test_cli.py @@ -470,7 +470,8 @@ def test_camera_screenshot_scene_view(self, runner, mock_unity_response): ]) assert result.exit_code == 0 mock_run.assert_called_once() - params = mock_run.call_args[0][2] + assert mock_run.call_args[0][0] == "manage_camera" + params = mock_run.call_args[0][1] assert params["captureSource"] == "scene_view" assert params["viewTarget"] == "Canvas" assert params["includeImage"] is True From 2b2ca8a6f32a141954b6433d91ae87a62a8430ff Mon Sep 17 00:00:00 2001 From: Shutong Wu <51266340+Scriptwonder@users.noreply.github.com> Date: Tue, 28 Jul 2026 12:05:53 -0400 Subject: [PATCH 2/5] feat: add a clear_stuck escape hatch to run_tests (#1272) A test job orphaned by a domain reload leaves TestRunStatus pinned with a CurrentJobId that blocks every subsequent run, and there was no way to clear it from the client side. Add clear_stuck to the run_tests MCP tool and --clear-stuck to the editor CLI. Both short-circuit ahead of the init_timeout validation and preflight, because neither applies to clearing and preflight's requires_no_tests gate would reject the very call that exists to release it. --- Server/src/cli/commands/editor.py | 13 ++- Server/src/services/tools/run_tests.py | 21 ++++- .../tests/integration/test_run_tests_async.py | 84 +++++++++++++++++++ 3 files changed, 115 insertions(+), 3 deletions(-) diff --git a/Server/src/cli/commands/editor.py b/Server/src/cli/commands/editor.py index 8b7746657..5b0dce795 100644 --- a/Server/src/cli/commands/editor.py +++ b/Server/src/cli/commands/editor.py @@ -325,8 +325,13 @@ def execute_menu(menu_path: str): is_flag=True, help="Include details for failed/skipped tests only." ) +@click.option( + "--clear-stuck", + is_flag=True, + help="Clear an orphaned running job that is blocking new runs, instead of starting a run." +) @handle_unity_errors -def run_tests(mode: str, async_mode: bool, wait: Optional[int], details: bool, failed_only: bool): +def run_tests(mode: str, async_mode: bool, wait: Optional[int], details: bool, failed_only: bool, clear_stuck: bool): """Run Unity tests. \b @@ -335,9 +340,15 @@ def run_tests(mode: str, async_mode: bool, wait: Optional[int], details: bool, f unity-mcp editor tests --mode PlayMode unity-mcp editor tests --async unity-mcp editor tests --wait 60 --failed-only + unity-mcp editor tests --clear-stuck """ config = get_config() + if clear_stuck: + result = run_command("run_tests", {"clear_stuck": True}, config) + click.echo(format_output(result, config.format)) + return + params: dict[str, Any] = {"mode": mode} if wait is not None: params["wait_timeout"] = wait diff --git a/Server/src/services/tools/run_tests.py b/Server/src/services/tools/run_tests.py index 0426e63b5..803554baa 100644 --- a/Server/src/services/tools/run_tests.py +++ b/Server/src/services/tools/run_tests.py @@ -170,12 +170,29 @@ async def run_tests( init_timeout: Annotated[int | None, "Initialization timeout in milliseconds. PlayMode tests may need longer " "due to domain reload (default: 15000). Recommended: 120000 for PlayMode."] = None, + clear_stuck: Annotated[bool, + "Clear an orphaned running job instead of starting a run. Use when a job " + "was lost to a domain reload and is blocking every subsequent run."] = False, ) -> RunTestsStartResponse | MCPResponse: + unity_instance = await get_unity_instance_from_context(ctx) + + # Runs before both the init_timeout check and preflight on purpose: neither is relevant to + # clearing, and requires_no_tests would reject the very call that exists to clear the + # orphaned job blocking it. + if clear_stuck: + response = await unity_transport.send_with_unity_instance( + async_send_command_with_retry, + unity_instance, + "run_tests", + {"clear_stuck": True}, + ) + if isinstance(response, dict): + return MCPResponse(**response) + return MCPResponse(success=False, error=str(response)) + if init_timeout is not None and init_timeout <= 0: return MCPResponse(success=False, error="init_timeout must be a positive integer (milliseconds) or None") - unity_instance = await get_unity_instance_from_context(ctx) - gate = await preflight(ctx, requires_no_tests=True, wait_for_no_compile=True, refresh_if_dirty=True) if isinstance(gate, MCPResponse): return gate diff --git a/Server/tests/integration/test_run_tests_async.py b/Server/tests/integration/test_run_tests_async.py index a8098ea6c..79bd24af7 100644 --- a/Server/tests/integration/test_run_tests_async.py +++ b/Server/tests/integration/test_run_tests_async.py @@ -93,6 +93,90 @@ async def test_run_tests_rejects_zero_init_timeout(): assert "init_timeout" in resp.error +@pytest.mark.asyncio +async def test_run_tests_clear_stuck_forwards_only_the_flag(monkeypatch): + from services.tools.run_tests import run_tests + + captured = {} + + async def fake_send_with_unity_instance(send_fn, unity_instance, command_type, params, **kwargs): + captured["command_type"] = command_type + captured["params"] = params + return {"success": True, "message": "Stuck job cleared.", "data": {"cleared": True}} + + import services.tools.run_tests as mod + monkeypatch.setattr( + mod.unity_transport, "send_with_unity_instance", fake_send_with_unity_instance) + + resp = await run_tests(DummyContext(), clear_stuck=True) + + # C# reads @params["clear_stuck"] verbatim (RunTests.cs:23), so the key must stay snake_case. + assert captured["command_type"] == "run_tests" + assert captured["params"] == {"clear_stuck": True} + assert resp.success is True + assert resp.data == {"cleared": True} + + +@pytest.mark.asyncio +async def test_run_tests_clear_stuck_bypasses_preflight(monkeypatch): + """#1272: preflight(requires_no_tests=True) would reject the call that clears the job blocking it.""" + from services.tools.run_tests import run_tests + + async def fake_send_with_unity_instance(send_fn, unity_instance, command_type, params, **kwargs): + return {"success": True, "message": "Stuck job cleared.", "data": {"cleared": True}} + + async def exploding_preflight(*args, **kwargs): + raise AssertionError("clear_stuck must short-circuit before preflight") + + import services.tools.run_tests as mod + monkeypatch.setattr( + mod.unity_transport, "send_with_unity_instance", fake_send_with_unity_instance) + monkeypatch.setattr(mod, "preflight", exploding_preflight) + + resp = await run_tests(DummyContext(), clear_stuck=True) + assert resp.success is True + + +@pytest.mark.asyncio +async def test_run_tests_clear_stuck_ignores_invalid_init_timeout(monkeypatch): + """Recovery must be unconditional: an unrelated bad arg must not block clearing.""" + from services.tools.run_tests import run_tests + + async def fake_send_with_unity_instance(send_fn, unity_instance, command_type, params, **kwargs): + return {"success": True, "message": "Stuck job cleared.", "data": {"cleared": True}} + + import services.tools.run_tests as mod + monkeypatch.setattr( + mod.unity_transport, "send_with_unity_instance", fake_send_with_unity_instance) + + resp = await run_tests(DummyContext(), clear_stuck=True, init_timeout=0) + assert resp.success is True + + +@pytest.mark.asyncio +async def test_run_tests_without_clear_stuck_still_preflights(monkeypatch): + from services.tools.run_tests import run_tests + + calls = [] + + async def fake_send_with_unity_instance(send_fn, unity_instance, command_type, params, **kwargs): + return {"success": True, "data": {"job_id": "abc123", "status": "running", "mode": "EditMode"}} + + async def recording_preflight(*args, **kwargs): + calls.append(kwargs) + return None + + import services.tools.run_tests as mod + monkeypatch.setattr( + mod.unity_transport, "send_with_unity_instance", fake_send_with_unity_instance) + monkeypatch.setattr(mod, "preflight", recording_preflight) + + resp = await run_tests(DummyContext(), mode="EditMode") + assert len(calls) == 1 + assert calls[0]["requires_no_tests"] is True + assert resp.success is True + + @pytest.mark.asyncio async def test_get_test_job_forwards_job_id(monkeypatch): from services.tools.run_tests import get_test_job From 172d3e203baa5314240b3d5686d702257430bbd0 Mon Sep 17 00:00:00 2001 From: Shutong Wu <51266340+Scriptwonder@users.noreply.github.com> Date: Tue, 28 Jul 2026 12:05:53 -0400 Subject: [PATCH 3/5] chore: refresh uv.lock to match the pyproject version The lockfile pinned mcpforunityserver 10.0.0 while pyproject.toml declared 10.1.0, and no workflow runs `uv lock`, so every contributor's first `uv run` dirtied their working tree. --- Server/uv.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Server/uv.lock b/Server/uv.lock index a7e843476..81a005bf9 100644 --- a/Server/uv.lock +++ b/Server/uv.lock @@ -858,7 +858,7 @@ wheels = [ [[package]] name = "mcpforunityserver" -version = "10.0.0" +version = "10.1.0" source = { editable = "." } dependencies = [ { name = "click" }, From a36c9917bce113e409847c0ce599494ce3a8dea4 Mon Sep 17 00:00:00 2001 From: Shutong Wu <51266340+Scriptwonder@users.noreply.github.com> Date: Tue, 28 Jul 2026 13:05:08 -0400 Subject: [PATCH 4/5] docs: regenerate tool reference for run_tests clear_stuck Generated by tools/generate_docs_reference.py; the "Check docs reference is fresh" CI job flagged testing/run_tests.md as stale after clear_stuck was added. --- website/docs/reference/tools/testing/run_tests.md | 1 + 1 file changed, 1 insertion(+) diff --git a/website/docs/reference/tools/testing/run_tests.md b/website/docs/reference/tools/testing/run_tests.md index 07e90da52..1fcf8a1e3 100644 --- a/website/docs/reference/tools/testing/run_tests.md +++ b/website/docs/reference/tools/testing/run_tests.md @@ -26,6 +26,7 @@ Starts a Unity test run asynchronously and returns a job_id immediately. Poll wi | `include_failed_tests` | `bool` | — | Include details for failed/skipped tests only (default: false) | | `include_details` | `bool` | — | Include details for all tests (default: false) | | `init_timeout` | `int \| None` | — | Initialization timeout in milliseconds. PlayMode tests may need longer due to domain reload (default: 15000). Recommended: 120000 for PlayMode. | +| `clear_stuck` | `bool` | — | Clear an orphaned running job instead of starting a run. Use when a job was lost to a domain reload and is blocking every subsequent run. | ## Returns From e2aacdf35d663a709c5f0258445923161d2e6574 Mon Sep 17 00:00:00 2001 From: Shutong Wu <51266340+Scriptwonder@users.noreply.github.com> Date: Tue, 28 Jul 2026 13:09:17 -0400 Subject: [PATCH 5/5] fix: camera CLI discarded its output and ignored --format Follow-up to the inverted-argument fix in this branch: repairing the run_command call order was necessary but not sufficient, and the group was still effectively dead. format_output(data, format_type: str = "text") returns a string. All 18 call sites called format_output(result, config) and dropped the return value, so every `unity-mcp camera` subcommand printed nothing at all. Passing the whole CLIConfig where a format string was expected also meant the branch always fell through to text, silently ignoring --format/UNITY_MCP_FORMAT. Use click.echo(format_output(result, config.format)), matching every other command module. Adds two regression tests: one asserting the group emits non-empty output, one asserting --format json yields parseable JSON. Both fail without this change. Reported by Copilot on #1293. --- Server/src/cli/commands/camera.py | 36 +++++++++++++++---------------- Server/tests/test_cli.py | 17 +++++++++++++++ 2 files changed, 35 insertions(+), 18 deletions(-) diff --git a/Server/src/cli/commands/camera.py b/Server/src/cli/commands/camera.py index a1188049e..e2d7f98c2 100644 --- a/Server/src/cli/commands/camera.py +++ b/Server/src/cli/commands/camera.py @@ -52,7 +52,7 @@ def ping(): """ config = get_config() result = run_command("manage_camera", {"action": "ping"}, config) - format_output(result, config) + click.echo(format_output(result, config.format)) @camera.command("list") @@ -66,7 +66,7 @@ def list_cameras(): """ config = get_config() result = run_command("manage_camera", {"action": "list_cameras"}, config) - format_output(result, config) + click.echo(format_output(result, config.format)) @camera.command("brain-status") @@ -80,7 +80,7 @@ def brain_status(): """ config = get_config() result = run_command("manage_camera", {"action": "get_brain_status"}, config) - format_output(result, config) + click.echo(format_output(result, config.format)) # ============================================================================= @@ -126,7 +126,7 @@ def create(name, preset, follow, look_at, priority, fov): params["properties"] = props result = run_command("manage_camera", params, config) - format_output(result, config) + click.echo(format_output(result, config.format)) @camera.command("ensure-brain") @@ -156,7 +156,7 @@ def ensure_brain(camera_ref, blend_style, blend_duration): params["properties"] = props result = run_command("manage_camera", params, config) - format_output(result, config) + click.echo(format_output(result, config.format)) # ============================================================================= @@ -190,7 +190,7 @@ def set_target(target, search_method, follow, look_at): "properties": props if props else None, }) result = run_command("manage_camera", params, config) - format_output(result, config) + click.echo(format_output(result, config.format)) @camera.command("set-lens") @@ -229,7 +229,7 @@ def set_lens(target, search_method, fov, near, far, ortho_size, dutch): "properties": props if props else None, }) result = run_command("manage_camera", params, config) - format_output(result, config) + click.echo(format_output(result, config.format)) @camera.command("set-priority") @@ -252,7 +252,7 @@ def set_priority(target, search_method, priority): "properties": {"priority": priority}, }) result = run_command("manage_camera", params, config) - format_output(result, config) + click.echo(format_output(result, config.format)) # ============================================================================= @@ -287,7 +287,7 @@ def set_body(target, search_method, body_type, props): "properties": properties if properties else None, }) result = run_command("manage_camera", params, config) - format_output(result, config) + click.echo(format_output(result, config.format)) @camera.command("set-aim") @@ -317,7 +317,7 @@ def set_aim(target, search_method, aim_type, props): "properties": properties if properties else None, }) result = run_command("manage_camera", params, config) - format_output(result, config) + click.echo(format_output(result, config.format)) @camera.command("set-noise") @@ -347,7 +347,7 @@ def set_noise(target, search_method, amplitude, frequency): "properties": props if props else None, }) result = run_command("manage_camera", params, config) - format_output(result, config) + click.echo(format_output(result, config.format)) # ============================================================================= @@ -380,7 +380,7 @@ def add_extension(target, extension_type, search_method, props): "properties": properties, }) result = run_command("manage_camera", params, config) - format_output(result, config) + click.echo(format_output(result, config.format)) @camera.command("remove-extension") @@ -403,7 +403,7 @@ def remove_extension(target, extension_type, search_method): "properties": {"extensionType": extension_type}, }) result = run_command("manage_camera", params, config) - format_output(result, config) + click.echo(format_output(result, config.format)) # ============================================================================= @@ -433,7 +433,7 @@ def set_blend(style, duration): params["properties"] = props result = run_command("manage_camera", params, config) - format_output(result, config) + click.echo(format_output(result, config.format)) @camera.command("force") @@ -454,7 +454,7 @@ def force_camera(target, search_method): "searchMethod": search_method, }) result = run_command("manage_camera", params, config) - format_output(result, config) + click.echo(format_output(result, config.format)) @camera.command("release") @@ -468,7 +468,7 @@ def release_override(): """ config = get_config() result = run_command("manage_camera", {"action": "release_override"}, config) - format_output(result, config) + click.echo(format_output(result, config.format)) # ============================================================================= @@ -524,7 +524,7 @@ def screenshot(camera_ref, file_name, super_size, include_image, max_resolution, if output_folder: params["outputFolder"] = output_folder result = run_command("manage_camera", params, config) - format_output(result, config) + click.echo(format_output(result, config.format)) @camera.command("screenshot-multiview") @@ -551,4 +551,4 @@ def screenshot_multiview(max_resolution, view_target, output_folder): if output_folder: params["outputFolder"] = output_folder result = run_command("manage_camera", params, config) - format_output(result, config) + click.echo(format_output(result, config.format)) diff --git a/Server/tests/test_cli.py b/Server/tests/test_cli.py index 8cba40794..65f64f781 100644 --- a/Server/tests/test_cli.py +++ b/Server/tests/test_cli.py @@ -476,6 +476,23 @@ def test_camera_screenshot_scene_view(self, runner, mock_unity_response): assert params["viewTarget"] == "Canvas" assert params["includeImage"] is True + def test_camera_ping_prints_output(self, runner, mock_unity_response): + """The camera group must actually emit its result. + + Asserting only on run_command's arguments is what let the group ship + while formatting to a discarded string and printing nothing at all. + """ + with patch("cli.commands.camera.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, ["camera", "ping"]) + assert result.exit_code == 0 + assert result.output.strip() != "" + + def test_camera_respects_json_format(self, runner, mock_unity_response): + """--format json must reach format_output, not be swallowed by a config object.""" + with patch("cli.commands.camera.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, ["--format", "json", "camera", "ping"]) + assert result.exit_code == 0 + json.loads(result.output) # =============================================================================