Skip to content
Merged
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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
[![](https://badge.mcpx.dev?status=on 'MCP Enabled')](https://modelcontextprotocol.io/introduction)
[![](https://img.shields.io/badge/License-MIT-red.svg 'MIT License')](https://opensource.org/licenses/MIT)

**Create your Unity apps with LLMs!** MCP for Unity bridges AI assistants (Claude, Cursor, VS Code, etc.) with your Unity Editor via the [Model Context Protocol](https://modelcontextprotocol.io/introduction). Give your LLM the tools to manage assets, control scenes, edit scripts, and automate tasks.
**Create your Unity apps with LLMs!** MCP for Unity bridges AI assistants (Claude, Claude Code, Cursor, VS Code, etc.) with your Unity Editor via the [Model Context Protocol](https://modelcontextprotocol.io/introduction). Give your LLM the tools to manage assets, control scenes, edit scripts, and automate tasks.

<img alt="MCP for Unity building a scene" src="docs/images/building_scene.gif">

Expand All @@ -25,7 +25,7 @@

* **Unity 2021.3 LTS+** — [Download Unity](https://unity.com/download)
* **Python 3.10+** and **uv** — [Install uv](https://docs.astral.sh/uv/getting-started/installation/)
* **An MCP Client** — [Claude Desktop](https://claude.ai/download) | [Cursor](https://www.cursor.com/en/downloads) | [VS Code Copilot](https://code.visualstudio.com/docs/copilot/overview) | [GitHub Copilot CLI](https://docs.github.com/en/copilot/concepts/agents/about-copilot-cli) | [Windsurf](https://windsurf.com)
* **An MCP Client** — [Claude Desktop](https://claude.ai/download) | [Claude Code](https://docs.anthropic.com/en/docs/claude-code) | [Cursor](https://www.cursor.com/en/downloads) | [VS Code Copilot](https://code.visualstudio.com/docs/copilot/overview) | [GitHub Copilot CLI](https://docs.github.com/en/copilot/concepts/agents/about-copilot-cli) | [Windsurf](https://windsurf.com)

### 1. Install the Unity Package

Expand Down
4 changes: 4 additions & 0 deletions Server/src/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,10 @@ def doRollover(self):
_fh.setLevel(getattr(logging, config.log_level))
logger.addHandler(_fh)
logger.propagate = False # Prevent double logging to root logger
# Add file handler to root logger so __name__-based loggers (e.g. utils.focus_nudge,
# services.tools.run_tests) also write to the log file. Named loggers with
# propagate=False won't double-log.
logging.getLogger().addHandler(_fh)
# Also route telemetry logger to the same rotating file and normal level
try:
tlog = logging.getLogger("unity-mcp-telemetry")
Expand Down
36 changes: 31 additions & 5 deletions Server/src/services/tools/run_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@

logger = logging.getLogger(__name__)

# Strong references to background fire-and-forget tasks to prevent premature GC.
_background_tasks: set[asyncio.Task] = set()


async def _get_unity_project_path(unity_instance: str | None) -> str | None:
"""Get the project root path for a Unity instance (for focus nudging).
Expand Down Expand Up @@ -310,8 +313,31 @@ async def _fetch_status() -> dict[str, Any]:

# No wait_timeout - return immediately (original behavior)
response = await _fetch_status()
if isinstance(response, dict):
if not response.get("success", True):
return MCPResponse(**response)
return GetTestJobResponse(**response)
return MCPResponse(success=False, error=str(response))
if not isinstance(response, dict):
return MCPResponse(success=False, error=str(response))
if not response.get("success", True):
return MCPResponse(**response)

# Fire-and-forget nudge check: even without wait_timeout, clients may poll
# externally. Check if Unity needs a nudge on every call so stalls get
# detected regardless of polling style.
data = response.get("data", {})
status = data.get("status", "")
if status == "running":
progress = data.get("progress", {})
editor_is_focused = progress.get("editor_is_focused", True)
Comment on lines +327 to +328
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

progress = data.get("progress", {}) returns None when the key is present with value null.

dict.get(key, default) only substitutes default when the key is absent; an explicit null/None value is returned as-is. Since TestJobProgress | None = None means Unity can legally send "progress": null (e.g., before the first test starts), line 328 will raise AttributeError: 'NoneType' object has no attribute 'get' in that case.

The identical pattern in the pre-existing wait_timeout path (line 285) has the same bug and should be fixed there too.

🐛 Proposed fix (new path at line 327, and matching fix at line 285)
-        progress = data.get("progress", {})
+        progress = data.get("progress") or {}

Apply the same one-line change to the pre-existing wait_timeout path at line 285.

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

In `@Server/src/services/tools/run_tests.py` around lines 327 - 328, The bug is
that progress = data.get("progress", {}) returns None when the JSON has
"progress": null, causing progress.get(...) to raise; change to use a fallback
that also handles explicit None by replacing the assignment with progress =
data.get("progress") or {} so editor_is_focused =
progress.get("editor_is_focused", True) is safe, and apply the identical pattern
to the existing wait_timeout path (replace whatever assigns wait_timeout =
data.get("wait_timeout", ...) with wait_timeout = data.get("wait_timeout") or
<same-default>) in run_tests.py to avoid AttributeError when the key exists but
is null.

last_update_unix_ms = data.get("last_update_unix_ms")
current_time_ms = int(time.time() * 1000)
if should_nudge(
status=status,
editor_is_focused=editor_is_focused,
last_update_unix_ms=last_update_unix_ms,
current_time_ms=current_time_ms,
):
logger.info(f"Test job {job_id} appears stalled (unfocused Unity), scheduling background nudge...")
project_path = await _get_unity_project_path(unity_instance)
task = asyncio.create_task(nudge_unity_focus(unity_project_path=project_path))
_background_tasks.add(task)
task.add_done_callback(_background_tasks.discard)

return GetTestJobResponse(**response)
104 changes: 104 additions & 0 deletions Server/tests/test_focus_nudge.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
"""Tests for focus_nudge utility — should_nudge() logic and nudge_unity_focus() gating."""

import time
from unittest.mock import patch, AsyncMock

import pytest

from utils.focus_nudge import (
should_nudge,
reset_nudge_backoff,
nudge_unity_focus,
_is_available,
)


class TestShouldNudge:
"""Tests for should_nudge() decision logic."""

def test_returns_false_when_not_running(self):
assert should_nudge(status="succeeded", editor_is_focused=False, last_update_unix_ms=0, current_time_ms=99999) is False

def test_returns_false_when_focused(self):
assert should_nudge(status="running", editor_is_focused=True, last_update_unix_ms=0, current_time_ms=99999) is False

def test_returns_true_when_stalled_and_unfocused(self):
now_ms = int(time.time() * 1000)
stale_ms = now_ms - 5000 # 5s ago
assert should_nudge(status="running", editor_is_focused=False, last_update_unix_ms=stale_ms, current_time_ms=now_ms) is True

def test_returns_false_when_recently_updated(self):
now_ms = int(time.time() * 1000)
recent_ms = now_ms - 1000 # 1s ago (within 3s threshold)
assert should_nudge(status="running", editor_is_focused=False, last_update_unix_ms=recent_ms, current_time_ms=now_ms) is False

def test_returns_true_when_no_updates_yet(self):
"""No last_update_unix_ms means tests might be stuck at start."""
assert should_nudge(status="running", editor_is_focused=False, last_update_unix_ms=None) is True

def test_custom_stall_threshold(self):
now_ms = int(time.time() * 1000)
stale_ms = now_ms - 2000 # 2s ago
# Default threshold (3s) — not stale yet
assert should_nudge(status="running", editor_is_focused=False, last_update_unix_ms=stale_ms, current_time_ms=now_ms) is False
# Custom threshold (1s) — stale
assert should_nudge(status="running", editor_is_focused=False, last_update_unix_ms=stale_ms, current_time_ms=now_ms, stall_threshold_ms=1000) is True

def test_returns_false_for_failed_status(self):
assert should_nudge(status="failed", editor_is_focused=False, last_update_unix_ms=0, current_time_ms=99999) is False

def test_returns_false_for_cancelled_status(self):
assert should_nudge(status="cancelled", editor_is_focused=False, last_update_unix_ms=0, current_time_ms=99999) is False


class TestResetNudgeBackoff:
"""Tests for reset_nudge_backoff() state management."""

def test_resets_consecutive_nudges(self):
import utils.focus_nudge as fn
fn._consecutive_nudges = 5
reset_nudge_backoff()
assert fn._consecutive_nudges == 0

def test_updates_last_progress_time(self):
import utils.focus_nudge as fn
old_time = fn._last_progress_time
reset_nudge_backoff()
assert fn._last_progress_time >= old_time


class TestNudgeUnityFocus:
"""Tests for nudge_unity_focus() gating logic."""

@pytest.mark.asyncio
async def test_skips_when_not_available(self):
with patch("utils.focus_nudge._is_available", return_value=False):
result = await nudge_unity_focus(force=True)
assert result is False

@pytest.mark.asyncio
async def test_skips_when_unity_already_focused(self):
from utils.focus_nudge import _FrontmostAppInfo
with patch("utils.focus_nudge._is_available", return_value=True), \
patch("utils.focus_nudge._get_frontmost_app", return_value=_FrontmostAppInfo(name="Unity")):
result = await nudge_unity_focus(force=True)
assert result is False

@pytest.mark.asyncio
async def test_skips_when_frontmost_app_unknown(self):
with patch("utils.focus_nudge._is_available", return_value=True), \
patch("utils.focus_nudge._get_frontmost_app", return_value=None):
result = await nudge_unity_focus(force=True)
assert result is False

@pytest.mark.asyncio
async def test_rate_limited_by_backoff(self):
import utils.focus_nudge as fn
from utils.focus_nudge import _FrontmostAppInfo
# Simulate a very recent nudge
fn._last_nudge_time = time.monotonic()
fn._consecutive_nudges = 0
with patch("utils.focus_nudge._is_available", return_value=True), \
patch("utils.focus_nudge._get_frontmost_app", return_value=_FrontmostAppInfo(name="Terminal")):
result = await nudge_unity_focus(force=False)
assert result is False
Original file line number Diff line number Diff line change
Expand Up @@ -120,11 +120,10 @@ public IEnumerator NewClient_WhileOldClientStillConnected_ClosesStaleClient()
string handshake2 = ReadLine(stream2, ReadTimeoutMs);
Assert.That(handshake2, Does.Contain("FRAMING=1"), "Second client should receive handshake");

// Wait a few frames for stale client cleanup
for (int i = 0; i < 5; i++)
yield return null;

// Second client should work — stale first client was closed
// Stale-client cleanup runs synchronously in HandleClientAsync before
// the read loop, so by the time we read the handshake it's already done.
// No yield needed — yielding here creates a window for the MCP Python
// server to reconnect and close our test client as stale.
SendFrame(stream2, Encoding.UTF8.GetBytes("ping"));
byte[] pong2Bytes = ReadFrame(stream2, ReadTimeoutMs);
Assert.That(Encoding.UTF8.GetString(pong2Bytes), Does.Contain("pong"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -886,6 +886,8 @@ public void ModifyContents_ComponentProperties_ReturnsErrorForInvalidType()
}
}

// Note: root rename is NOT tested here because LoadAssetAtPath<GameObject> returns
// the asset filename as .name for prefab roots, so rename assertions always fail.
[Test]
public void ModifyContents_ComponentProperties_CombinesWithOtherModifications()
{
Expand All @@ -898,7 +900,6 @@ public void ModifyContents_ComponentProperties_CombinesWithOtherModifications()
["action"] = "modify_contents",
["prefabPath"] = prefabPath,
["position"] = new JArray(5f, 10f, 15f),
["name"] = "RenamedWithProps",
["componentProperties"] = new JObject
{
["Rigidbody"] = new JObject { ["mass"] = 25f }
Expand All @@ -908,7 +909,6 @@ public void ModifyContents_ComponentProperties_CombinesWithOtherModifications()
Assert.IsTrue(result.Value<bool>("success"), $"Expected success but got: {result}");

GameObject reloaded = AssetDatabase.LoadAssetAtPath<GameObject>(prefabPath);
Assert.AreEqual("RenamedWithProps", reloaded.name);
Assert.AreEqual(new Vector3(5f, 10f, 15f), reloaded.transform.localPosition);
Assert.AreEqual(25f, reloaded.GetComponent<Rigidbody>().mass, 0.01f);
}
Expand Down