From 8ae595d2f4f2525b0e44ece948883ea37138add4 Mon Sep 17 00:00:00 2001 From: dsarno Date: Fri, 10 Oct 2025 06:53:03 -0700 Subject: [PATCH 01/33] Update github-repo-stats.yml --- .github/workflows/github-repo-stats.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/github-repo-stats.yml b/.github/workflows/github-repo-stats.yml index fda0851b2..6eb1eb724 100644 --- a/.github/workflows/github-repo-stats.yml +++ b/.github/workflows/github-repo-stats.yml @@ -1,10 +1,10 @@ name: github-repo-stats on: - schedule: + # schedule: # Run this once per day, towards the end of the day for keeping the most # recent data point most meaningful (hours are interpreted in UTC). - - cron: "0 23 * * *" + #- cron: "0 23 * * *" workflow_dispatch: # Allow for running this manually. jobs: From 74d35d371a28b2d86cb7722e28017b29be053efd Mon Sep 17 00:00:00 2001 From: David Sarno Date: Thu, 30 Oct 2025 19:28:51 -0700 Subject: [PATCH 02/33] Server: refine shutdown logic per bot feedback\n- Parameterize _force_exit(code) and use timers with args\n- Consistent behavior on BrokenPipeError (no immediate exit)\n- Exit code 1 on unexpected exceptions\n\nTests: restore telemetry module after disabling to avoid bleed-over --- Server/server.py | 95 +++++++++++++++++++++++++++++++++++++++- Server/test_telemetry.py | 42 +++++++----------- 2 files changed, 110 insertions(+), 27 deletions(-) diff --git a/Server/server.py b/Server/server.py index 11053ac87..3148a563c 100644 --- a/Server/server.py +++ b/Server/server.py @@ -4,6 +4,9 @@ from logging.handlers import RotatingFileHandler import os from contextlib import asynccontextmanager +import sys +import signal +import threading from typing import AsyncIterator, Dict, Any from config import config from tools import register_all_tools @@ -64,6 +67,9 @@ # Global connection state _unity_connection: UnityConnection = None +# Global shutdown coordination +_shutdown_flag = threading.Event() + @asynccontextmanager async def server_lifespan(server: FastMCP) -> AsyncIterator[Dict[str, Any]]: @@ -186,9 +192,96 @@ def _emit_startup(): register_all_resources(mcp) +def _force_exit(code: int = 0): + """Force process exit, bypassing any background threads that might linger.""" + try: + sys.exit(code) + except SystemExit: + os._exit(code) + + +def _signal_handler(signum, frame): + logger.info(f"Received signal {signum}, initiating shutdown...") + _shutdown_flag.set() + threading.Timer(1.0, _force_exit, args=(0,)).start() + + +def _monitor_stdin(): + """Background thread to detect stdio detach (stdin EOF) or parent exit.""" + try: + parent_pid = os.getppid() if hasattr(os, "getppid") else None + while not _shutdown_flag.is_set(): + if _shutdown_flag.wait(0.5): + break + + if parent_pid is not None: + try: + os.kill(parent_pid, 0) + except (ProcessLookupError, OSError): + logger.info(f"Parent process {parent_pid} no longer exists; shutting down") + break + + try: + if sys.stdin.closed: + logger.info("stdin.closed is True; client disconnected") + break + fd = sys.stdin.fileno() + if fd < 0: + logger.info("stdin fd invalid; client disconnected") + break + except (ValueError, OSError, AttributeError): + # Closed pipe or unavailable stdin + break + except Exception: + # Ignore transient errors + pass + + if not _shutdown_flag.is_set(): + logger.info("Client disconnected (stdin or parent), initiating shutdown...") + _shutdown_flag.set() + if not _shutdown_flag.is_set(): + threading.Timer(0.5, _force_exit, args=(0,)).start() + else: + threading.Timer(0.5, _force_exit, args=(0,)).start() + except Exception: + # Never let monitor thread crash the process + pass + + def main(): """Entry point for uvx and console scripts.""" - mcp.run(transport='stdio') + try: + signal.signal(signal.SIGTERM, _signal_handler) + signal.signal(signal.SIGINT, _signal_handler) + if hasattr(signal, "SIGPIPE"): + signal.signal(signal.SIGPIPE, signal.SIG_IGN) + if hasattr(signal, "SIGBREAK"): + signal.signal(signal.SIGBREAK, _signal_handler) + except Exception: + # Signals can fail in some environments + pass + + t = threading.Thread(target=_monitor_stdin, daemon=True) + t.start() + + try: + mcp.run(transport='stdio') + logger.info("FastMCP run() returned (stdin EOF or disconnect)") + except (KeyboardInterrupt, SystemExit): + logger.info("Server interrupted; shutting down") + _shutdown_flag.set() + except BrokenPipeError: + logger.info("Broken pipe; shutting down") + _shutdown_flag.set() + # rely on finally to schedule exit for consistency + except Exception as e: + logger.error(f"Server error: {e}", exc_info=True) + _shutdown_flag.set() + _force_exit(1) + finally: + _shutdown_flag.set() + logger.info("Server main loop exited") + threading.Timer(0.5, _force_exit, args=(0,)).start() # Run the server diff --git a/Server/test_telemetry.py b/Server/test_telemetry.py index 3e4b7ce75..5225ff03d 100644 --- a/Server/test_telemetry.py +++ b/Server/test_telemetry.py @@ -23,8 +23,8 @@ def test_telemetry_basic(): ) pass except ImportError as e: - # Silent failure path for tests - return False + # Fail explicitly when imports are missing + assert False, f"telemetry import failed: {e}" # Test telemetry enabled status _ = is_telemetry_enabled() @@ -37,8 +37,7 @@ def test_telemetry_basic(): }) pass except Exception as e: - # Silent failure path for tests - return False + assert False, f"record_telemetry failed: {e}" # Test milestone recording try: @@ -47,26 +46,23 @@ def test_telemetry_basic(): }) _ = is_first except Exception as e: - # Silent failure path for tests - return False + assert False, f"record_milestone failed: {e}" # Test telemetry collector try: collector = get_telemetry() _ = collector except Exception as e: - # Silent failure path for tests - return False + assert False, f"get_telemetry failed: {e}" + assert True - return True - -def test_telemetry_disabled(): +def test_telemetry_disabled(monkeypatch): """Test telemetry with disabled state""" # Silent for tests # Set environment variable to disable telemetry - os.environ["DISABLE_TELEMETRY"] = "true" + monkeypatch.setenv("DISABLE_TELEMETRY", "true") # Re-import to get fresh config import importlib @@ -77,17 +73,12 @@ def test_telemetry_disabled(): _ = is_telemetry_enabled() - if not is_telemetry_enabled(): - pass - - # Test that records are ignored when disabled - record_telemetry(RecordType.USAGE, {"test": "should_be_ignored"}) - pass - - return True - else: - pass - return False + assert is_telemetry_enabled() is False + # Test that records are ignored when disabled (should not raise) + record_telemetry(RecordType.USAGE, {"test": "should_be_ignored"}) + # Restore module state for subsequent tests + monkeypatch.delenv("DISABLE_TELEMETRY", raising=False) + importlib.reload(telemetry) def test_data_storage(): @@ -114,11 +105,10 @@ def test_data_storage(): else: pass - return True + assert True except Exception as e: - # Silent failure path for tests - return False + assert False, f"data storage test failed: {e}" def main(): From 1bb280ee23af023973bdc7a619adb823ff5e1993 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Thu, 30 Oct 2025 19:33:30 -0700 Subject: [PATCH 03/33] Revert "Server: refine shutdown logic per bot feedback\n- Parameterize _force_exit(code) and use timers with args\n- Consistent behavior on BrokenPipeError (no immediate exit)\n- Exit code 1 on unexpected exceptions\n\nTests: restore telemetry module after disabling to avoid bleed-over" This reverts commit 74d35d371a28b2d86cb7722e28017b29be053efd. --- Server/server.py | 95 +--------------------------------------- Server/test_telemetry.py | 42 +++++++++++------- 2 files changed, 27 insertions(+), 110 deletions(-) diff --git a/Server/server.py b/Server/server.py index 3148a563c..11053ac87 100644 --- a/Server/server.py +++ b/Server/server.py @@ -4,9 +4,6 @@ from logging.handlers import RotatingFileHandler import os from contextlib import asynccontextmanager -import sys -import signal -import threading from typing import AsyncIterator, Dict, Any from config import config from tools import register_all_tools @@ -67,9 +64,6 @@ # Global connection state _unity_connection: UnityConnection = None -# Global shutdown coordination -_shutdown_flag = threading.Event() - @asynccontextmanager async def server_lifespan(server: FastMCP) -> AsyncIterator[Dict[str, Any]]: @@ -192,96 +186,9 @@ def _emit_startup(): register_all_resources(mcp) -def _force_exit(code: int = 0): - """Force process exit, bypassing any background threads that might linger.""" - try: - sys.exit(code) - except SystemExit: - os._exit(code) - - -def _signal_handler(signum, frame): - logger.info(f"Received signal {signum}, initiating shutdown...") - _shutdown_flag.set() - threading.Timer(1.0, _force_exit, args=(0,)).start() - - -def _monitor_stdin(): - """Background thread to detect stdio detach (stdin EOF) or parent exit.""" - try: - parent_pid = os.getppid() if hasattr(os, "getppid") else None - while not _shutdown_flag.is_set(): - if _shutdown_flag.wait(0.5): - break - - if parent_pid is not None: - try: - os.kill(parent_pid, 0) - except (ProcessLookupError, OSError): - logger.info(f"Parent process {parent_pid} no longer exists; shutting down") - break - - try: - if sys.stdin.closed: - logger.info("stdin.closed is True; client disconnected") - break - fd = sys.stdin.fileno() - if fd < 0: - logger.info("stdin fd invalid; client disconnected") - break - except (ValueError, OSError, AttributeError): - # Closed pipe or unavailable stdin - break - except Exception: - # Ignore transient errors - pass - - if not _shutdown_flag.is_set(): - logger.info("Client disconnected (stdin or parent), initiating shutdown...") - _shutdown_flag.set() - if not _shutdown_flag.is_set(): - threading.Timer(0.5, _force_exit, args=(0,)).start() - else: - threading.Timer(0.5, _force_exit, args=(0,)).start() - except Exception: - # Never let monitor thread crash the process - pass - - def main(): """Entry point for uvx and console scripts.""" - try: - signal.signal(signal.SIGTERM, _signal_handler) - signal.signal(signal.SIGINT, _signal_handler) - if hasattr(signal, "SIGPIPE"): - signal.signal(signal.SIGPIPE, signal.SIG_IGN) - if hasattr(signal, "SIGBREAK"): - signal.signal(signal.SIGBREAK, _signal_handler) - except Exception: - # Signals can fail in some environments - pass - - t = threading.Thread(target=_monitor_stdin, daemon=True) - t.start() - - try: - mcp.run(transport='stdio') - logger.info("FastMCP run() returned (stdin EOF or disconnect)") - except (KeyboardInterrupt, SystemExit): - logger.info("Server interrupted; shutting down") - _shutdown_flag.set() - except BrokenPipeError: - logger.info("Broken pipe; shutting down") - _shutdown_flag.set() - # rely on finally to schedule exit for consistency - except Exception as e: - logger.error(f"Server error: {e}", exc_info=True) - _shutdown_flag.set() - _force_exit(1) - finally: - _shutdown_flag.set() - logger.info("Server main loop exited") - threading.Timer(0.5, _force_exit, args=(0,)).start() + mcp.run(transport='stdio') # Run the server diff --git a/Server/test_telemetry.py b/Server/test_telemetry.py index 5225ff03d..3e4b7ce75 100644 --- a/Server/test_telemetry.py +++ b/Server/test_telemetry.py @@ -23,8 +23,8 @@ def test_telemetry_basic(): ) pass except ImportError as e: - # Fail explicitly when imports are missing - assert False, f"telemetry import failed: {e}" + # Silent failure path for tests + return False # Test telemetry enabled status _ = is_telemetry_enabled() @@ -37,7 +37,8 @@ def test_telemetry_basic(): }) pass except Exception as e: - assert False, f"record_telemetry failed: {e}" + # Silent failure path for tests + return False # Test milestone recording try: @@ -46,23 +47,26 @@ def test_telemetry_basic(): }) _ = is_first except Exception as e: - assert False, f"record_milestone failed: {e}" + # Silent failure path for tests + return False # Test telemetry collector try: collector = get_telemetry() _ = collector except Exception as e: - assert False, f"get_telemetry failed: {e}" - assert True + # Silent failure path for tests + return False + return True -def test_telemetry_disabled(monkeypatch): + +def test_telemetry_disabled(): """Test telemetry with disabled state""" # Silent for tests # Set environment variable to disable telemetry - monkeypatch.setenv("DISABLE_TELEMETRY", "true") + os.environ["DISABLE_TELEMETRY"] = "true" # Re-import to get fresh config import importlib @@ -73,12 +77,17 @@ def test_telemetry_disabled(monkeypatch): _ = is_telemetry_enabled() - assert is_telemetry_enabled() is False - # Test that records are ignored when disabled (should not raise) - record_telemetry(RecordType.USAGE, {"test": "should_be_ignored"}) - # Restore module state for subsequent tests - monkeypatch.delenv("DISABLE_TELEMETRY", raising=False) - importlib.reload(telemetry) + if not is_telemetry_enabled(): + pass + + # Test that records are ignored when disabled + record_telemetry(RecordType.USAGE, {"test": "should_be_ignored"}) + pass + + return True + else: + pass + return False def test_data_storage(): @@ -105,10 +114,11 @@ def test_data_storage(): else: pass - assert True + return True except Exception as e: - assert False, f"data storage test failed: {e}" + # Silent failure path for tests + return False def main(): From 9207a0d4e784d86a7f7d3fe55dddc2f2478a1a11 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Mon, 8 Dec 2025 10:55:55 -0800 Subject: [PATCH 04/33] Add fork-only Unity tests workflow and guard upstream run --- .github/workflows/unity-tests.yml | 2 + .../.github/workflows/unity-tests-fork.yml | 197 ++++++++++++++++++ 2 files changed, 199 insertions(+) create mode 100644 TestProjects/UnityMCPTests/.github/workflows/unity-tests-fork.yml diff --git a/.github/workflows/unity-tests.yml b/.github/workflows/unity-tests.yml index 954fff30f..632a1f8ac 100644 --- a/.github/workflows/unity-tests.yml +++ b/.github/workflows/unity-tests.yml @@ -11,6 +11,8 @@ on: jobs: testAllModes: + # Guard: only run on upstream repo; skip on forks + if: github.repository_owner == 'CoplayDev' name: Test in ${{ matrix.testMode }} runs-on: ubuntu-latest strategy: diff --git a/TestProjects/UnityMCPTests/.github/workflows/unity-tests-fork.yml b/TestProjects/UnityMCPTests/.github/workflows/unity-tests-fork.yml new file mode 100644 index 000000000..884a0815c --- /dev/null +++ b/TestProjects/UnityMCPTests/.github/workflows/unity-tests-fork.yml @@ -0,0 +1,197 @@ +name: Unity Tests (fork) + +on: + workflow_dispatch: {} + +permissions: + contents: read + checks: write + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + UNITY_IMAGE: unityci/editor:ubuntu-2021.3.45f2-linux-il2cpp-3 + +jobs: + test-editmode: + name: Test in editmode (fork) + runs-on: ubuntu-latest + timeout-minutes: 90 + + steps: + # ---------- Secrets check ---------- + - name: Detect Unity credentials (outputs) + id: detect + env: + UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }} + UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} + UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} + UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} + run: | + set -e + if [ -n "$UNITY_LICENSE" ]; then echo "unity_ok=true" >> "$GITHUB_OUTPUT"; else echo "unity_ok=false" >> "$GITHUB_OUTPUT"; fi + if [ -n "$UNITY_EMAIL" ] && [ -n "$UNITY_PASSWORD" ]; then echo "ebl_ok=true" >> "$GITHUB_OUTPUT"; else echo "ebl_ok=false" >> "$GITHUB_OUTPUT"; fi + if [ -n "$UNITY_SERIAL" ]; then echo "has_serial=true" >> "$GITHUB_OUTPUT"; else echo "has_serial=false" >> "$GITHUB_OUTPUT"; fi + + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Prepare reports + run: | + set -eux + rm -f reports/*.xml || true + mkdir -p reports + + # ---------- Licensing: allow both ULF and EBL ---------- + - name: Decide license sources + id: lic + shell: bash + env: + UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }} + UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} + UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} + UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} + run: | + set -eu + use_ulf=false; use_ebl=false + [[ -n "${UNITY_LICENSE:-}" ]] && use_ulf=true + [[ -n "${UNITY_EMAIL:-}" && -n "${UNITY_PASSWORD:-}" ]] && use_ebl=true + echo "use_ulf=$use_ulf" >> "$GITHUB_OUTPUT" + echo "use_ebl=$use_ebl" >> "$GITHUB_OUTPUT" + echo "has_serial=$([[ -n "${UNITY_SERIAL:-}" ]] && echo true || echo false)" >> "$GITHUB_OUTPUT" + + - name: Stage Unity .ulf license (from secret) + if: steps.lic.outputs.use_ulf == 'true' + id: ulf + env: + UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }} + shell: bash + run: | + set -eu + mkdir -p "$RUNNER_TEMP/unity-license-ulf" "$RUNNER_TEMP/unity-local/Unity" + f="$RUNNER_TEMP/unity-license-ulf/Unity_lic.ulf" + if printf "%s" "$UNITY_LICENSE" | base64 -d - >/dev/null 2>&1; then + printf "%s" "$UNITY_LICENSE" | base64 -d - > "$f" + else + printf "%s" "$UNITY_LICENSE" > "$f" + fi + chmod 600 "$f" || true + # If someone pasted an entitlement XML into UNITY_LICENSE by mistake, re-home it: + if head -c 100 "$f" | grep -qi '<\?xml'; then + mkdir -p "$RUNNER_TEMP/unity-config/Unity/licenses" + mv "$f" "$RUNNER_TEMP/unity-config/Unity/licenses/UnityEntitlementLicense.xml" + echo "ok=false" >> "$GITHUB_OUTPUT" + elif grep -qi '' "$f"; then + # provide it in the standard local-share path too + cp -f "$f" "$RUNNER_TEMP/unity-local/Unity/Unity_lic.ulf" + echo "ok=true" >> "$GITHUB_OUTPUT" + else + echo "ok=false" >> "$GITHUB_OUTPUT" + fi + + - name: Activate Unity (EBL via container - host-mount) + if: steps.lic.outputs.use_ebl == 'true' + shell: bash + env: + UNITY_IMAGE: ${{ env.UNITY_IMAGE }} + UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} + UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} + UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} + run: | + set -euxo pipefail + mkdir -p "$RUNNER_TEMP/unity-config" "$RUNNER_TEMP/unity-local" + + # Try Pro first if serial is present, otherwise named-user EBL. + docker run --rm --network host \ + -e HOME=/root \ + -e UNITY_EMAIL -e UNITY_PASSWORD -e UNITY_SERIAL \ + -v "$RUNNER_TEMP/unity-config:/root/.config/unity3d" \ + -v "$RUNNER_TEMP/unity-local:/root/.local/share/unity3d" \ + "$UNITY_IMAGE" bash -lc ' + set -euxo pipefail + if [[ -n "${UNITY_SERIAL:-}" ]]; then + /opt/unity/Editor/Unity -batchmode -nographics -logFile - \ + -username "$UNITY_EMAIL" -password "$UNITY_PASSWORD" -serial "$UNITY_SERIAL" -quit || true + else + /opt/unity/Editor/Unity -batchmode -nographics -logFile - \ + -username "$UNITY_EMAIL" -password "$UNITY_PASSWORD" -quit || true + fi + ls -la /root/.config/unity3d/Unity/licenses || true + ' + + # Verify entitlement written to host mount; allow ULF-only runs to proceed + if ! find "$RUNNER_TEMP/unity-config" -type f -iname "*.xml" | grep -q .; then + if [[ "${{ steps.ulf.outputs.ok }}" == "true" ]]; then + echo "EBL entitlement not found; proceeding with ULF-only (ok=true)." + else + echo "No entitlement produced and no valid ULF; cannot continue." >&2 + exit 1 + fi + fi + + # ---------- Warm up project (import Library once) ---------- + - name: Warm up project (import Library once) + if: steps.lic.outputs.use_ulf == 'true' || steps.lic.outputs.use_ebl == 'true' + shell: bash + env: + UNITY_IMAGE: ${{ env.UNITY_IMAGE }} + ULF_OK: ${{ steps.ulf.outputs.ok }} + run: | + set -euxo pipefail + manual_args=() + if [[ "${ULF_OK:-false}" == "true" ]]; then + manual_args=(-manualLicenseFile "/root/.local/share/unity3d/Unity/Unity_lic.ulf") + fi + docker run --rm --network host \ + -e HOME=/root \ + -v "${{ github.workspace }}:/workspace" -w /workspace \ + -v "$RUNNER_TEMP/unity-config:/root/.config/unity3d" \ + -v "$RUNNER_TEMP/unity-local:/root/.local/share/unity3d" \ + "$UNITY_IMAGE" /opt/unity/Editor/Unity -batchmode -nographics -logFile - \ + -projectPath /workspace/TestProjects/UnityMCPTests \ + "${manual_args[@]}" \ + -quit + + # ---------- Run editmode tests ---------- + - name: Run editmode tests (Unity CLI) + if: steps.lic.outputs.use_ulf == 'true' || steps.lic.outputs.use_ebl == 'true' + shell: bash + env: + UNITY_IMAGE: ${{ env.UNITY_IMAGE }} + ULF_OK: ${{ steps.ulf.outputs.ok }} + run: | + set -euxo pipefail + manual_args=() + if [[ "${ULF_OK:-false}" == "true" ]]; then + manual_args=(-manualLicenseFile "/root/.local/share/unity3d/Unity/Unity_lic.ulf") + fi + docker run --rm --network host \ + -e HOME=/root \ + -v "${{ github.workspace }}:/workspace" -w /workspace \ + -v "$RUNNER_TEMP/unity-config:/root/.config/unity3d" \ + -v "$RUNNER_TEMP/unity-local:/root/.local/share/unity3d" \ + "$UNITY_IMAGE" /opt/unity/Editor/Unity -batchmode -nographics -logFile - \ + -projectPath /workspace/TestProjects/UnityMCPTests \ + -runTests \ + -testPlatform editmode \ + -testResults /workspace/reports/editmode-results.xml \ + -testResultsFormatter NUnit \ + "${manual_args[@]}" \ + -quit + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: unity-editmode-results + path: reports + + - name: License diagnostics when missing + if: steps.lic.outputs.use_ulf != 'true' && steps.lic.outputs.use_ebl != 'true' + run: | + echo "::error::No Unity credentials were supplied. Set UNITY_LICENSE or UNITY_EMAIL/UNITY_PASSWORD (and optionally UNITY_SERIAL) secrets in this fork." + From 4f6ea55ac5368a62fd8d39d466e3b08dc98653e7 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Mon, 8 Dec 2025 11:02:11 -0800 Subject: [PATCH 05/33] Move fork Unity tests workflow to root --- .../.github => .github}/workflows/unity-tests-fork.yml | 2 ++ 1 file changed, 2 insertions(+) rename {TestProjects/UnityMCPTests/.github => .github}/workflows/unity-tests-fork.yml (98%) diff --git a/TestProjects/UnityMCPTests/.github/workflows/unity-tests-fork.yml b/.github/workflows/unity-tests-fork.yml similarity index 98% rename from TestProjects/UnityMCPTests/.github/workflows/unity-tests-fork.yml rename to .github/workflows/unity-tests-fork.yml index 884a0815c..e8fdae258 100644 --- a/TestProjects/UnityMCPTests/.github/workflows/unity-tests-fork.yml +++ b/.github/workflows/unity-tests-fork.yml @@ -16,6 +16,8 @@ env: jobs: test-editmode: + # Guard: run only on the fork owner's repo + if: github.repository_owner == 'dsarno' name: Test in editmode (fork) runs-on: ubuntu-latest timeout-minutes: 90 From a5467a3f7359a4a35cc9d9be11d2eac359a2104d Mon Sep 17 00:00:00 2001 From: David Sarno Date: Mon, 8 Dec 2025 11:12:44 -0800 Subject: [PATCH 06/33] Fix MCP server install step in NL suite workflow --- .github/workflows/claude-nl-suite.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 49c6f7f2c..5f93e117a 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -62,9 +62,6 @@ jobs: else echo "No MCP Python deps found (skipping)" fi - else - echo "No MCP Python deps found (skipping)" - fi # --- Licensing: allow both ULF and EBL when available --- - name: Decide license sources From 3f7ee971e823a7229aa38ef58f2d9bb991d532c2 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Mon, 8 Dec 2025 16:37:49 -0800 Subject: [PATCH 07/33] Harden NL suite prompts for deterministic anchors --- .claude/prompts/nl-unity-suite-nl.md | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/.claude/prompts/nl-unity-suite-nl.md b/.claude/prompts/nl-unity-suite-nl.md index 1064e4d32..50ab1e0f0 100644 --- a/.claude/prompts/nl-unity-suite-nl.md +++ b/.claude/prompts/nl-unity-suite-nl.md @@ -103,25 +103,27 @@ STRICT OP GUARDRAILS **Goal**: Demonstrate method replacement operations **Actions**: - Replace `HasTarget()` method body: `public bool HasTarget() { return currentTarget != null; }` -- Insert `PrintSeries()` method after `GetCurrentTarget()`: `public void PrintSeries() { Debug.Log("1,2,3"); }` -- Verify both methods exist and are properly formatted +- Validate. +- Insert `PrintSeries()` method after a unique anchor method. Prefer `GetCurrentTarget()` if unique; otherwise use another unique method such as `ApplyBlend`. Insert: `public void PrintSeries() { Debug.Log("1,2,3"); }` +- Validate that both methods exist and are properly formatted. - Delete `PrintSeries()` method (cleanup for next test) - **Expected final state**: `HasTarget()` modified, file structure intact, no temporary methods ### NL-2. Anchor Comment Insertion (Additive State B) **Goal**: Demonstrate anchor-based insertions above methods **Actions**: -- Use `find_in_file` to locate current position of `Update()` method +- Use `find_in_file` with a tolerant anchor to locate the `Update()` method, e.g. `(?m)^\\s*(?:public|private|protected|internal)?\\s*void\\s+Update\\s*\\(\\s*\\)` +- Expect exactly one match; if multiple, fail clearly rather than guessing. - Insert `// Build marker OK` comment line above `Update()` method - Verify comment exists and `Update()` still functions - **Expected final state**: State A + build marker comment above `Update()` ### NL-3. End-of-Class Content (Additive State C) -**Goal**: Demonstrate end-of-class insertions with smart brace matching +**Goal**: Demonstrate end-of-class insertions without ambiguous anchors **Actions**: -- Match the final class-closing brace by scanning from EOF (e.g., last `^\s*}\s*$`) - or compute via `find_in_file` + ranges; insert immediately before it. -- Insert three comment lines before final class brace: +- Use `find_in_file` to locate brace-only lines (e.g., `(?m)^\\s*}\\s*$`). Select the **last** such line (preferably indentation 0 if multiples). +- Compute an exact insertion point immediately before that last brace using `apply_text_edits` (do not use `anchor_insert` for this step). +- Insert three comment lines before the final class brace: ``` // Tail test A // Tail test B @@ -159,7 +161,7 @@ find_in_file(pattern: "public bool HasTarget\\(\\)") **Anchor-based insertions:** ```json -{"op": "anchor_insert", "anchor": "private void Update\\(\\)", "position": "before", "text": "// comment"} +{"op": "anchor_insert", "anchor": "(?m)^\\s*(?:public|private|protected|internal)?\\s*void\\s+Update\\s*\\(\\s*\\)", "position": "before", "text": "// comment"} ``` --- From 8300d3bcee761d7e2175ff23d1ee8fea7e9e88df Mon Sep 17 00:00:00 2001 From: David Sarno Date: Mon, 8 Dec 2025 16:42:26 -0800 Subject: [PATCH 08/33] update claude haiku version for NL/T tests --- .github/workflows/claude-nl-suite.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 5f93e117a..2409c7f4d 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -395,7 +395,7 @@ jobs: settings: .claude/settings.json allowed_tools: "mcp__unity,Edit(reports/**),MultiEdit(reports/**)" disallowed_tools: "Bash,WebFetch,WebSearch,Task,TodoWrite,NotebookEdit,NotebookRead" - model: claude-3-5-haiku-20241022 + model: claude-haiku-4-5-20251001 append_system_prompt: | You are running the T pass (A–J) only. Output requirements: From ab8e8109e84054e0bde761c108380649ee4a8daf Mon Sep 17 00:00:00 2001 From: David Sarno Date: Mon, 8 Dec 2025 17:00:14 -0800 Subject: [PATCH 09/33] Fix CI: share unity-mcp status dir --- .github/workflows/claude-nl-suite.yml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 2409c7f4d..54cae3f6a 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -198,7 +198,7 @@ jobs: manual_args=(-manualLicenseFile "/root/.local/share/unity3d/Unity/Unity_lic.ulf") fi - mkdir -p "$RUNNER_TEMP/unity-status" + mkdir -p "$HOME/.unity-mcp" docker rm -f unity-mcp >/dev/null 2>&1 || true docker run -d --name unity-mcp --network host \ -e HOME=/root \ @@ -206,7 +206,7 @@ jobs: -e UNITY_MCP_STATUS_DIR=/root/.unity-mcp \ -e UNITY_MCP_BIND_HOST=127.0.0.1 \ -v "${{ github.workspace }}:/workspace" -w /workspace \ - -v "$RUNNER_TEMP/unity-status:/root/.unity-mcp" \ + -v "$HOME/.unity-mcp:/root/.unity-mcp" \ -v "$RUNNER_TEMP/unity-config:/root/.config/unity3d:ro" \ -v "$RUNNER_TEMP/unity-local:/root/.local/share/unity3d:ro" \ "$UNITY_IMAGE" /opt/unity/Editor/Unity -batchmode -nographics -logFile - \ @@ -236,7 +236,7 @@ jobs: logs="$(docker logs unity-mcp 2>&1 || true)" # 1) Primary: status JSON exposes TCP port - port="$(jq -r '.unity_port // empty' "$RUNNER_TEMP"/unity-status/unity-mcp-status-*.json 2>/dev/null | head -n1 || true)" + port="$(jq -r '.unity_port // empty' "$HOME"/.unity-mcp/unity-mcp-status-*.json 2>/dev/null | head -n1 || true)" if [[ -n "${port:-}" ]] && timeout 1 bash -lc "exec 3<>/dev/tcp/127.0.0.1/$port"; then echo "Bridge ready on port $port" exit 0 @@ -287,7 +287,7 @@ jobs: "PYTHONUNBUFFERED": "1", "MCP_LOG_LEVEL": "debug", "UNITY_PROJECT_ROOT": "$GITHUB_WORKSPACE/TestProjects/UnityMCPTests", - "UNITY_MCP_STATUS_DIR": "$RUNNER_TEMP/unity-status", + "UNITY_MCP_STATUS_DIR": "$HOME/.unity-mcp", "UNITY_MCP_HOST": "127.0.0.1" } } @@ -343,11 +343,11 @@ jobs: - name: Verify Unity bridge status/port run: | set -euxo pipefail - ls -la "$RUNNER_TEMP/unity-status" || true - jq -r . "$RUNNER_TEMP"/unity-status/unity-mcp-status-*.json | sed -n '1,80p' || true + ls -la "$HOME/.unity-mcp" || true + jq -r . "$HOME"/.unity-mcp/unity-mcp-status-*.json | sed -n '1,80p' || true shopt -s nullglob - status_files=("$RUNNER_TEMP"/unity-status/unity-mcp-status-*.json) + status_files=("$HOME"/.unity-mcp/unity-mcp-status-*.json) if ((${#status_files[@]})); then port="$(grep -hEo '"unity_port"[[:space:]]*:[[:space:]]*[0-9]+' "${status_files[@]}" \ | sed -E 's/.*: *([0-9]+).*/\1/' | head -n1 || true)" From a22868b78e8906de16a44f3915b57ca3ca2a9797 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Mon, 8 Dec 2025 17:17:18 -0800 Subject: [PATCH 10/33] update yaml --- .github/workflows/claude-nl-suite.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 54cae3f6a..010f39027 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -438,8 +438,8 @@ jobs: settings: .claude/settings.json allowed_tools: "mcp__unity,Edit(reports/**),MultiEdit(reports/**)" disallowed_tools: "Bash,MultiEdit(/!(reports/**)),WebFetch,WebSearch,Task,TodoWrite,NotebookEdit,NotebookRead" - model: claude-3-7-sonnet-20250219 - fallback_model: claude-3-5-haiku-20241022 + model: cclaude-haiku-4-5-20251001 + fallback_model: claude-haiku-4-5-20251001 append_system_prompt: | You are running the T pass only. Output requirements: From 0ec6446ccd320390fa704bc77e4cbf7cf5b5abff Mon Sep 17 00:00:00 2001 From: David Sarno Date: Mon, 8 Dec 2025 17:45:19 -0800 Subject: [PATCH 11/33] Add Unity bridge debug step in CI --- .github/workflows/claude-nl-suite.yml | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 010f39027..84d6d5804 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -269,6 +269,31 @@ jobs: docker logs unity-mcp --tail 200 | sed -E 's/((email|serial|license|password|token)[^[:space:]]*)/[REDACTED]/Ig' exit 1 + # ---------- Debug Unity bridge status ---------- + - name: Debug Unity bridge status + if: always() + shell: bash + run: | + set -euxo pipefail + echo "--- Unity container state ---" + docker inspect -f '{{.State.Status}} {{.State.ExitCode}}' unity-mcp || true + echo "--- Unity container logs (tail 200) ---" + docker logs unity-mcp --tail 200 | sed -E 's/((email|serial|license|password|token)[^[:space:]]*)/[REDACTED]/Ig' || true + echo "--- Container status dir ---" + docker exec unity-mcp ls -la /root/.unity-mcp || true + echo "--- Host status dir ---" + ls -la "$HOME/.unity-mcp" || true + echo "--- Host status file (first 120 lines) ---" + jq -r . "$HOME"/.unity-mcp/unity-mcp-status-*.json | sed -n '1,120p' || true + echo "--- Port probe from host ---" + port="$(jq -r '.unity_port // empty' "$HOME"/.unity-mcp/unity-mcp-status-*.json 2>/dev/null | head -n1 || true)" + echo "unity_port=${port:-}" + if [[ -n "${port:-}" ]]; then + timeout 1 bash -lc "exec 3<>/dev/tcp/127.0.0.1/$port" && echo "TCP OK" || echo "TCP probe failed" + else + echo "No unity_port in status file" + fi + # (moved) — return license after Unity is stopped # ---------- MCP client config ---------- From 3c2aa712366ca0bc447f4aadb9aeda46d6d45cd0 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Mon, 8 Dec 2025 17:47:50 -0800 Subject: [PATCH 12/33] Fail fast when Unity MCP status file missing --- .github/workflows/claude-nl-suite.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 84d6d5804..7ddafdb81 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -293,6 +293,13 @@ jobs: else echo "No unity_port in status file" fi + # Fail fast if no status file was written + shopt -s nullglob + status_files=("$HOME"/.unity-mcp/unity-mcp-status-*.json) + if ((${#status_files[@]} == 0)); then + echo "::error::No Unity MCP status file found; failing fast." + exit 1 + fi # (moved) — return license after Unity is stopped From c72198c1dad0cc91242df2d388daf0677d30bb21 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Mon, 8 Dec 2025 17:54:39 -0800 Subject: [PATCH 13/33] Allow Unity local share writable for MCP status --- .github/workflows/claude-nl-suite.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 7ddafdb81..b3a0944d1 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -208,7 +208,7 @@ jobs: -v "${{ github.workspace }}:/workspace" -w /workspace \ -v "$HOME/.unity-mcp:/root/.unity-mcp" \ -v "$RUNNER_TEMP/unity-config:/root/.config/unity3d:ro" \ - -v "$RUNNER_TEMP/unity-local:/root/.local/share/unity3d:ro" \ + -v "$RUNNER_TEMP/unity-local:/root/.local/share/unity3d" \ "$UNITY_IMAGE" /opt/unity/Editor/Unity -batchmode -nographics -logFile - \ -stackTraceLogType Full \ -projectPath /workspace/TestProjects/UnityMCPTests \ From a2ccc49e94acf1db3a2e1df0da01f73db0d68d61 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Mon, 8 Dec 2025 18:02:23 -0800 Subject: [PATCH 14/33] Mount Unity cache rw and dump Editor log for MCP debug --- .github/workflows/claude-nl-suite.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index b3a0944d1..95dd6a208 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -209,6 +209,7 @@ jobs: -v "$HOME/.unity-mcp:/root/.unity-mcp" \ -v "$RUNNER_TEMP/unity-config:/root/.config/unity3d:ro" \ -v "$RUNNER_TEMP/unity-local:/root/.local/share/unity3d" \ + -v "$RUNNER_TEMP/unity-cache:/root/.cache/unity3d" \ "$UNITY_IMAGE" /opt/unity/Editor/Unity -batchmode -nographics -logFile - \ -stackTraceLogType Full \ -projectPath /workspace/TestProjects/UnityMCPTests \ @@ -293,6 +294,8 @@ jobs: else echo "No unity_port in status file" fi + echo "--- Editor log tail ---" + docker exec unity-mcp tail -n 200 /root/.config/unity3d/Editor.log || true # Fail fast if no status file was written shopt -s nullglob status_files=("$HOME"/.unity-mcp/unity-mcp-status-*.json) From a6be33a40d83ef9827b6720c05732ef5948d6e5f Mon Sep 17 00:00:00 2001 From: David Sarno Date: Mon, 8 Dec 2025 18:11:30 -0800 Subject: [PATCH 15/33] Allow Unity config dir writable for MCP heartbeat/logs --- .github/workflows/claude-nl-suite.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 95dd6a208..12a221089 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -207,7 +207,7 @@ jobs: -e UNITY_MCP_BIND_HOST=127.0.0.1 \ -v "${{ github.workspace }}:/workspace" -w /workspace \ -v "$HOME/.unity-mcp:/root/.unity-mcp" \ - -v "$RUNNER_TEMP/unity-config:/root/.config/unity3d:ro" \ + -v "$RUNNER_TEMP/unity-config:/root/.config/unity3d" \ -v "$RUNNER_TEMP/unity-local:/root/.local/share/unity3d" \ -v "$RUNNER_TEMP/unity-cache:/root/.cache/unity3d" \ "$UNITY_IMAGE" /opt/unity/Editor/Unity -batchmode -nographics -logFile - \ From 98b239ac3108141b58c2bdc4b958df724c6351b6 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Mon, 8 Dec 2025 18:21:35 -0800 Subject: [PATCH 16/33] Write Unity logs to file and list config dir in debug --- .github/workflows/claude-nl-suite.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 12a221089..b9eabc58e 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -210,7 +210,7 @@ jobs: -v "$RUNNER_TEMP/unity-config:/root/.config/unity3d" \ -v "$RUNNER_TEMP/unity-local:/root/.local/share/unity3d" \ -v "$RUNNER_TEMP/unity-cache:/root/.cache/unity3d" \ - "$UNITY_IMAGE" /opt/unity/Editor/Unity -batchmode -nographics -logFile - \ + "$UNITY_IMAGE" /opt/unity/Editor/Unity -batchmode -nographics -logFile /root/.config/unity3d/Editor.log \ -stackTraceLogType Full \ -projectPath /workspace/TestProjects/UnityMCPTests \ "${manual_args[@]}" \ @@ -294,6 +294,8 @@ jobs: else echo "No unity_port in status file" fi + echo "--- Config dir listing ---" + docker exec unity-mcp ls -la /root/.config/unity3d || true echo "--- Editor log tail ---" docker exec unity-mcp tail -n 200 /root/.config/unity3d/Editor.log || true # Fail fast if no status file was written From 74a7c3368c541c65d66fa8f83540298b543f0c37 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Mon, 8 Dec 2025 18:35:35 -0800 Subject: [PATCH 17/33] Use available Anthropic models for T pass --- .github/workflows/claude-nl-suite.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index b9eabc58e..90dbdda52 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -432,7 +432,7 @@ jobs: settings: .claude/settings.json allowed_tools: "mcp__unity,Edit(reports/**),MultiEdit(reports/**)" disallowed_tools: "Bash,WebFetch,WebSearch,Task,TodoWrite,NotebookEdit,NotebookRead" - model: claude-haiku-4-5-20251001 + model: claude-3-7-sonnet-20250219 append_system_prompt: | You are running the T pass (A–J) only. Output requirements: @@ -475,8 +475,8 @@ jobs: settings: .claude/settings.json allowed_tools: "mcp__unity,Edit(reports/**),MultiEdit(reports/**)" disallowed_tools: "Bash,MultiEdit(/!(reports/**)),WebFetch,WebSearch,Task,TodoWrite,NotebookEdit,NotebookRead" - model: cclaude-haiku-4-5-20251001 - fallback_model: claude-haiku-4-5-20251001 + model: claude-3-7-sonnet-20250219 + fallback_model: claude-3-5-haiku-20241022 append_system_prompt: | You are running the T pass only. Output requirements: From d4835c55dae906374163c12d45ff6753e364b364 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Mon, 8 Dec 2025 18:38:38 -0800 Subject: [PATCH 18/33] Use latest claude sonnet/haiku models in workflow --- .github/workflows/claude-nl-suite.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 90dbdda52..285f2722f 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -411,7 +411,7 @@ jobs: settings: .claude/settings.json allowed_tools: "mcp__unity,Edit(reports/**),MultiEdit(reports/**)" disallowed_tools: "Bash,WebFetch,WebSearch,Task,TodoWrite,NotebookEdit,NotebookRead" - model: claude-3-7-sonnet-20250219 + model: claude-sonnet-4-5-20250929 append_system_prompt: | You are running the NL pass only. - Emit exactly NL-0, NL-1, NL-2, NL-3, NL-4. @@ -475,8 +475,8 @@ jobs: settings: .claude/settings.json allowed_tools: "mcp__unity,Edit(reports/**),MultiEdit(reports/**)" disallowed_tools: "Bash,MultiEdit(/!(reports/**)),WebFetch,WebSearch,Task,TodoWrite,NotebookEdit,NotebookRead" - model: claude-3-7-sonnet-20250219 - fallback_model: claude-3-5-haiku-20241022 + model: claude-sonnet-4-5-20250929 + fallback_model: claude-haiku-4-5-20251001 append_system_prompt: | You are running the T pass only. Output requirements: From 9574eb62ce4018a1b268b9cc1705dda9864a173a Mon Sep 17 00:00:00 2001 From: David Sarno Date: Mon, 8 Dec 2025 19:14:51 -0800 Subject: [PATCH 19/33] Fix YAML indentation for MCP preflight step --- .github/workflows/claude-nl-suite.yml | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 285f2722f..00c230cb9 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -397,7 +397,17 @@ jobs: timeout 1 bash -lc "exec 3<>/dev/tcp/127.0.0.1/$port" && echo "TCP OK" fi - # (removed) Revert helper and baseline snapshot are no longer used + - name: Preflight MCP server + run: | + set -euxo pipefail + export PYTHONUNBUFFERED=1 + export MCP_LOG_LEVEL=debug + export UNITY_PROJECT_ROOT="$GITHUB_WORKSPACE/TestProjects/UnityMCPTests" + export UNITY_MCP_STATUS_DIR="$HOME/.unity-mcp" + export UNITY_MCP_HOST=127.0.0.1 + uv run --active --directory Server python server.py --help \ + > /tmp/mcp-preflight.log 2>&1 || { cat /tmp/mcp-preflight.log; exit 1; } + cat /tmp/mcp-preflight.log # ---------- Run suite in two passes ---------- - name: Run Claude NL pass From dfafc7ed1069db18157caa22248c8ef3407ad03e Mon Sep 17 00:00:00 2001 From: David Sarno Date: Mon, 8 Dec 2025 19:22:06 -0800 Subject: [PATCH 20/33] Point MCP server to src/server.py and fix preflight --- .github/workflows/claude-nl-suite.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 00c230cb9..96570fb4d 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -318,7 +318,7 @@ jobs: "mcpServers": { "unity": { "command": "uv", - "args": ["run","--active","--directory","Server","python","server.py"], + "args": ["run","--active","--directory","Server","python","src/server.py"], "transport": { "type": "stdio" }, "env": { "PYTHONUNBUFFERED": "1", @@ -405,7 +405,7 @@ jobs: export UNITY_PROJECT_ROOT="$GITHUB_WORKSPACE/TestProjects/UnityMCPTests" export UNITY_MCP_STATUS_DIR="$HOME/.unity-mcp" export UNITY_MCP_HOST=127.0.0.1 - uv run --active --directory Server python server.py --help \ + uv run --active --directory Server python src/server.py --help \ > /tmp/mcp-preflight.log 2>&1 || { cat /tmp/mcp-preflight.log; exit 1; } cat /tmp/mcp-preflight.log From d144d9c164dac1f5e425b5fc3996549fb6b724a1 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Mon, 8 Dec 2025 19:35:02 -0800 Subject: [PATCH 21/33] another try --- .github/workflows/claude-nl-suite.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 96570fb4d..4ab1b59d5 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -318,7 +318,7 @@ jobs: "mcpServers": { "unity": { "command": "uv", - "args": ["run","--active","--directory","Server","python","src/server.py"], + "args": ["run","--active","--directory","Server","mcp-for-unity","--transport","stdio"], "transport": { "type": "stdio" }, "env": { "PYTHONUNBUFFERED": "1", @@ -405,7 +405,7 @@ jobs: export UNITY_PROJECT_ROOT="$GITHUB_WORKSPACE/TestProjects/UnityMCPTests" export UNITY_MCP_STATUS_DIR="$HOME/.unity-mcp" export UNITY_MCP_HOST=127.0.0.1 - uv run --active --directory Server python src/server.py --help \ + uv run --active --directory Server mcp-for-unity --help \ > /tmp/mcp-preflight.log 2>&1 || { cat /tmp/mcp-preflight.log; exit 1; } cat /tmp/mcp-preflight.log From debfd72771fa788c033f5f54d22e3bfee4ca4835 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Mon, 8 Dec 2025 19:52:18 -0800 Subject: [PATCH 22/33] Add MCP preflight workflow and update NL suite --- .github/workflows/claude-mcp-preflight.yml | 55 ++++++++++++++++++++++ .github/workflows/claude-nl-suite.yml | 20 ++++---- 2 files changed, 66 insertions(+), 9 deletions(-) create mode 100644 .github/workflows/claude-mcp-preflight.yml diff --git a/.github/workflows/claude-mcp-preflight.yml b/.github/workflows/claude-mcp-preflight.yml new file mode 100644 index 000000000..dff69bd7b --- /dev/null +++ b/.github/workflows/claude-mcp-preflight.yml @@ -0,0 +1,55 @@ +name: Claude MCP Preflight (no Unity) + +on: [workflow_dispatch] + +permissions: + contents: read + +jobs: + mcp-preflight: + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: astral-sh/setup-uv@v4 + with: + python-version: "3.11" + + - name: Install MCP server deps + run: | + set -eux + uv venv + echo "VIRTUAL_ENV=$GITHUB_WORKSPACE/.venv" >> "$GITHUB_ENV" + echo "$GITHUB_WORKSPACE/.venv/bin" >> "$GITHUB_PATH" + if [ -f Server/pyproject.toml ]; then + uv pip install -e Server + elif [ -f Server/requirements.txt ]; then + uv pip install -r Server/requirements.txt + else + echo "No MCP Python deps found" >&2 + exit 1 + fi + + - name: Preflight MCP server (stdio) + env: + PYTHONUNBUFFERED: "1" + MCP_LOG_LEVEL: debug + UNITY_PROJECT_ROOT: ${{ github.workspace }}/TestProjects/UnityMCPTests + UNITY_MCP_STATUS_DIR: ${{ github.workspace }}/.unity-mcp-dummy + UNITY_MCP_HOST: 127.0.0.1 + run: | + set -euxo pipefail + mkdir -p "$UNITY_MCP_STATUS_DIR" + # Create a dummy status file with an unreachable port; help should not require it + cat > "$UNITY_MCP_STATUS_DIR/unity-mcp-status-dummy.json" < /tmp/mcp-preflight.log 2>&1 || { cat /tmp/mcp-preflight.log; exit 1; } + cat /tmp/mcp-preflight.log + + diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 4ab1b59d5..b163fe814 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -120,7 +120,7 @@ jobs: UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} run: | - set -euxo pipefail + set -euo pipefail # host dirs to receive the full Unity config and local-share mkdir -p "$RUNNER_TEMP/unity-config" "$RUNNER_TEMP/unity-local" @@ -156,7 +156,7 @@ jobs: # ---------- Warm up project (import Library once) ---------- - name: Warm up project (import Library once) - if: steps.lic.outputs.use_ulf == 'true' || steps.lic.outputs.use_ebl == 'true' + if: steps.detect.outputs.anthropic_ok == 'true' && (steps.lic.outputs.use_ulf == 'true' || steps.lic.outputs.use_ebl == 'true') shell: bash env: UNITY_IMAGE: ${{ env.UNITY_IMAGE }} @@ -172,6 +172,7 @@ jobs: -v "${{ github.workspace }}:/workspace" -w /workspace \ -v "$RUNNER_TEMP/unity-config:/root/.config/unity3d" \ -v "$RUNNER_TEMP/unity-local:/root/.local/share/unity3d" \ + -v "$RUNNER_TEMP/unity-cache:/root/.cache/unity3d" \ "$UNITY_IMAGE" /opt/unity/Editor/Unity -batchmode -nographics -logFile - \ -projectPath /workspace/TestProjects/UnityMCPTests \ "${manual_args[@]}" \ @@ -186,7 +187,7 @@ jobs: # ---------- Start headless Unity (persistent bridge) ---------- - name: Start Unity (persistent bridge) - if: steps.lic.outputs.use_ulf == 'true' || steps.lic.outputs.use_ebl == 'true' + if: steps.detect.outputs.anthropic_ok == 'true' && (steps.lic.outputs.use_ulf == 'true' || steps.lic.outputs.use_ebl == 'true') shell: bash env: UNITY_IMAGE: ${{ env.UNITY_IMAGE }} @@ -218,10 +219,11 @@ jobs: # ---------- Wait for Unity bridge ---------- - name: Wait for Unity bridge (robust) + if: steps.detect.outputs.anthropic_ok == 'true' && (steps.lic.outputs.use_ulf == 'true' || steps.lic.outputs.use_ebl == 'true') shell: bash run: | set -euo pipefail - deadline=$((SECONDS+900)) # 15 min max + deadline=$((SECONDS+600)) # 10 min max fatal_after=$((SECONDS+120)) # give licensing 2 min to settle # Fail fast only if container actually died @@ -272,7 +274,7 @@ jobs: # ---------- Debug Unity bridge status ---------- - name: Debug Unity bridge status - if: always() + if: always() && (steps.lic.outputs.use_ulf == 'true' || steps.lic.outputs.use_ebl == 'true') shell: bash run: | set -euxo pipefail @@ -341,11 +343,11 @@ jobs: "permissions": { "allow": [ "mcp__unity", - "Edit(reports/**)" + "Edit(reports/**)", + "MultiEdit(reports/**)" ], "deny": [ "Bash", - "MultiEdit", "WebFetch", "WebSearch", "Task", @@ -357,7 +359,7 @@ jobs: } JSON - # ---------- Reports & helper ---------- + # ---------- Reports & helper ---------- - name: Prepare reports and dirs run: | set -eux @@ -405,7 +407,7 @@ jobs: export UNITY_PROJECT_ROOT="$GITHUB_WORKSPACE/TestProjects/UnityMCPTests" export UNITY_MCP_STATUS_DIR="$HOME/.unity-mcp" export UNITY_MCP_HOST=127.0.0.1 - uv run --active --directory Server mcp-for-unity --help \ + uv run --active --directory Server mcp-for-unity --transport stdio --help \ > /tmp/mcp-preflight.log 2>&1 || { cat /tmp/mcp-preflight.log; exit 1; } cat /tmp/mcp-preflight.log From 14dcec98445aedc10a63dc0edf57a0192cad8ff0 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Wed, 10 Dec 2025 11:44:55 -0800 Subject: [PATCH 23/33] Fixes to improve CI testing --- .github/workflows/claude-nl-suite.yml | 376 +++++++++++++++--- MCPForUnity/Editor/McpCiBoot.cs | 21 + MCPForUnity/Editor/McpCiBoot.cs.meta | 12 + .../Services/StdioBridgeReloadHandler.cs | 18 +- .../Transport/Transports/StdioBridgeHost.cs | 10 +- Server/src/transport/legacy/port_discovery.py | 18 +- 6 files changed, 394 insertions(+), 61 deletions(-) create mode 100644 MCPForUnity/Editor/McpCiBoot.cs create mode 100644 MCPForUnity/Editor/McpCiBoot.cs.meta diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index b163fe814..71b2549d9 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -14,8 +14,54 @@ env: UNITY_IMAGE: unityci/editor:ubuntu-2021.3.45f2-linux-il2cpp-3 jobs: - nl-suite: + mcp-smoke: runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: astral-sh/setup-uv@v4 + with: + python-version: "3.11" + + - name: Install MCP server deps + run: | + set -eux + uv venv + echo "VIRTUAL_ENV=$GITHUB_WORKSPACE/.venv" >> "$GITHUB_ENV" + echo "$GITHUB_WORKSPACE/.venv/bin" >> "$GITHUB_PATH" + if [ -f Server/pyproject.toml ]; then + uv pip install -e Server + elif [ -f Server/requirements.txt ]; then + uv pip install -r Server/requirements.txt + else + echo "No MCP Python deps found" >&2 + exit 1 + fi + + - name: Preflight MCP server (stdio) + env: + PYTHONUNBUFFERED: "1" + MCP_LOG_LEVEL: debug + UNITY_PROJECT_ROOT: ${{ github.workspace }}/TestProjects/UnityMCPTests + UNITY_MCP_STATUS_DIR: ${{ github.workspace }}/.unity-mcp-dummy + UNITY_MCP_HOST: 127.0.0.1 + UNITY_MCP_SKIP_STARTUP_CONNECT: "1" + run: | + set -euxo pipefail + mkdir -p "$UNITY_MCP_STATUS_DIR" + cat > "$UNITY_MCP_STATUS_DIR/unity-mcp-status-dummy.json" < /tmp/mcp-preflight.log 2>&1 || { cat /tmp/mcp-preflight.log; exit 1; } + cat /tmp/mcp-preflight.log + + nl-suite: + runs-on: ubuntu-24.04 + needs: mcp-smoke timeout-minutes: 60 env: JUNIT_OUT: reports/junit-nl-suite.xml @@ -169,12 +215,12 @@ jobs: fi docker run --rm --network host \ -e HOME=/root \ - -v "${{ github.workspace }}:/workspace" -w /workspace \ + -v "${{ github.workspace }}:${{ github.workspace }}" -w "${{ github.workspace }}" \ -v "$RUNNER_TEMP/unity-config:/root/.config/unity3d" \ -v "$RUNNER_TEMP/unity-local:/root/.local/share/unity3d" \ -v "$RUNNER_TEMP/unity-cache:/root/.cache/unity3d" \ "$UNITY_IMAGE" /opt/unity/Editor/Unity -batchmode -nographics -logFile - \ - -projectPath /workspace/TestProjects/UnityMCPTests \ + -projectPath "${{ github.workspace }}/TestProjects/UnityMCPTests" \ "${manual_args[@]}" \ -quit @@ -182,8 +228,8 @@ jobs: - name: Clean old MCP status run: | set -eux - mkdir -p "$HOME/.unity-mcp" - rm -f "$HOME/.unity-mcp"/unity-mcp-status-*.json || true + mkdir -p "$GITHUB_WORKSPACE/.unity-mcp" + rm -f "$GITHUB_WORKSPACE/.unity-mcp"/unity-mcp-status-*.json || true # ---------- Start headless Unity (persistent bridge) ---------- - name: Start Unity (persistent bridge) @@ -199,23 +245,22 @@ jobs: manual_args=(-manualLicenseFile "/root/.local/share/unity3d/Unity/Unity_lic.ulf") fi - mkdir -p "$HOME/.unity-mcp" + mkdir -p "$GITHUB_WORKSPACE/.unity-mcp" docker rm -f unity-mcp >/dev/null 2>&1 || true docker run -d --name unity-mcp --network host \ -e HOME=/root \ -e UNITY_MCP_ALLOW_BATCH=1 \ - -e UNITY_MCP_STATUS_DIR=/root/.unity-mcp \ + -e UNITY_MCP_STATUS_DIR="${{ github.workspace }}/.unity-mcp" \ -e UNITY_MCP_BIND_HOST=127.0.0.1 \ - -v "${{ github.workspace }}:/workspace" -w /workspace \ - -v "$HOME/.unity-mcp:/root/.unity-mcp" \ + -v "${{ github.workspace }}:${{ github.workspace }}" -w "${{ github.workspace }}" \ -v "$RUNNER_TEMP/unity-config:/root/.config/unity3d" \ -v "$RUNNER_TEMP/unity-local:/root/.local/share/unity3d" \ -v "$RUNNER_TEMP/unity-cache:/root/.cache/unity3d" \ "$UNITY_IMAGE" /opt/unity/Editor/Unity -batchmode -nographics -logFile /root/.config/unity3d/Editor.log \ -stackTraceLogType Full \ - -projectPath /workspace/TestProjects/UnityMCPTests \ + -projectPath "${{ github.workspace }}/TestProjects/UnityMCPTests" \ "${manual_args[@]}" \ - -executeMethod MCPForUnity.Editor.Services.Transport.Transports.StdioBridgeHost.StartAutoConnect + -executeMethod MCPForUnity.Editor.McpCiBoot.StartStdioForCi # ---------- Wait for Unity bridge ---------- - name: Wait for Unity bridge (robust) @@ -239,15 +284,18 @@ jobs: logs="$(docker logs unity-mcp 2>&1 || true)" # 1) Primary: status JSON exposes TCP port - port="$(jq -r '.unity_port // empty' "$HOME"/.unity-mcp/unity-mcp-status-*.json 2>/dev/null | head -n1 || true)" + port="$(jq -r '.unity_port // empty' "$GITHUB_WORKSPACE"/.unity-mcp/unity-mcp-status-*.json 2>/dev/null | head -n1 || true)" if [[ -n "${port:-}" ]] && timeout 1 bash -lc "exec 3<>/dev/tcp/127.0.0.1/$port"; then echo "Bridge ready on port $port" + # Ensure status file is readable by all (Claude container might run as different user) + docker exec unity-mcp chmod -R a+rwx "$GITHUB_WORKSPACE/.unity-mcp" || chmod -R a+rwx "$GITHUB_WORKSPACE/.unity-mcp" || true exit 0 fi # 2) Secondary: log markers if echo "$logs" | grep -qiE "$ok_pat"; then echo "Bridge ready (log markers)" + docker exec unity-mcp chmod -R a+rwx "$GITHUB_WORKSPACE/.unity-mcp" || chmod -R a+rwx "$GITHUB_WORKSPACE/.unity-mcp" || true exit 0 fi @@ -283,13 +331,13 @@ jobs: echo "--- Unity container logs (tail 200) ---" docker logs unity-mcp --tail 200 | sed -E 's/((email|serial|license|password|token)[^[:space:]]*)/[REDACTED]/Ig' || true echo "--- Container status dir ---" - docker exec unity-mcp ls -la /root/.unity-mcp || true + docker exec unity-mcp ls -la "${{ github.workspace }}/.unity-mcp" || true echo "--- Host status dir ---" - ls -la "$HOME/.unity-mcp" || true + ls -la "$GITHUB_WORKSPACE/.unity-mcp" || true echo "--- Host status file (first 120 lines) ---" - jq -r . "$HOME"/.unity-mcp/unity-mcp-status-*.json | sed -n '1,120p' || true + jq -r . "$GITHUB_WORKSPACE"/.unity-mcp/unity-mcp-status-*.json | sed -n '1,120p' || true echo "--- Port probe from host ---" - port="$(jq -r '.unity_port // empty' "$HOME"/.unity-mcp/unity-mcp-status-*.json 2>/dev/null | head -n1 || true)" + port="$(jq -r '.unity_port // empty' "$GITHUB_WORKSPACE"/.unity-mcp/unity-mcp-status-*.json 2>/dev/null | head -n1 || true)" echo "unity_port=${port:-}" if [[ -n "${port:-}" ]]; then timeout 1 bash -lc "exec 3<>/dev/tcp/127.0.0.1/$port" && echo "TCP OK" || echo "TCP probe failed" @@ -302,7 +350,7 @@ jobs: docker exec unity-mcp tail -n 200 /root/.config/unity3d/Editor.log || true # Fail fast if no status file was written shopt -s nullglob - status_files=("$HOME"/.unity-mcp/unity-mcp-status-*.json) + status_files=("$GITHUB_WORKSPACE"/.unity-mcp/unity-mcp-status-*.json) if ((${#status_files[@]} == 0)); then echo "::error::No Unity MCP status file found; failing fast." exit 1 @@ -310,30 +358,6 @@ jobs: # (moved) — return license after Unity is stopped - # ---------- MCP client config ---------- - - name: Write MCP config (.claude/mcp.json) - run: | - set -eux - mkdir -p .claude - cat > .claude/mcp.json </dev/tcp/127.0.0.1/$port" && echo "TCP OK" fi + if ((${#status_files[@]})); then + first_status="${status_files[0]}" + fname="$(basename "$first_status")" + hash_part="${fname%.json}"; hash_part="${hash_part#unity-mcp-status-}" + proj="$(jq -r '.project_name // empty' "$first_status" || true)" + if [[ -n "${proj:-}" && -n "${hash_part:-}" ]]; then + echo "UNITY_MCP_DEFAULT_INSTANCE=${proj}@${hash_part}" >> "$GITHUB_ENV" + echo "Default instance set to ${proj}@${hash_part}" + fi + fi + + # ---------- MCP client config ---------- + - name: Write MCP config (.claude/mcp.json) + run: | + set -eux + mkdir -p .claude + python3 - <<'PY' + import json + import os + from pathlib import Path + + workspace = os.environ["GITHUB_WORKSPACE"] + default_inst = os.environ.get("UNITY_MCP_DEFAULT_INSTANCE", "").strip() + + cfg = { + "mcpServers": { + "unity": { + "command": "uv", + "args": [ + "run", + "--active", + "--directory", + "Server", + "mcp-for-unity", + "--transport", + "stdio", + "--status-dir", + f"{workspace}/.unity-mcp", + ], + "transport": {"type": "stdio"}, + "env": { + "PYTHONUNBUFFERED": "1", + "MCP_LOG_LEVEL": "debug", + "UNITY_PROJECT_ROOT": f"{workspace}/TestProjects/UnityMCPTests", + "UNITY_MCP_STATUS_DIR": f"{workspace}/.unity-mcp", + "UNITY_MCP_HOST": "127.0.0.1", + }, + } + } + } + + if default_inst: + unity = cfg["mcpServers"]["unity"] + unity["env"]["UNITY_MCP_DEFAULT_INSTANCE"] = default_inst + if "--default-instance" not in unity["args"]: + unity["args"] += ["--default-instance", default_inst] + + path = Path(".claude/mcp.json") + path.write_text(json.dumps(cfg, indent=2) + "\n") + print(f"Wrote {path} (UNITY_MCP_DEFAULT_INSTANCE={default_inst or 'unset'})") + PY + + - name: Debug MCP config + run: | + set -eux + echo "=== .claude/mcp.json ===" + cat .claude/mcp.json + echo "" + echo "=== Status dir contents ===" + ls -la "$GITHUB_WORKSPACE/.unity-mcp" || true + echo "" + echo "=== Status file content ===" + cat "$GITHUB_WORKSPACE"/.unity-mcp/unity-mcp-status-*.json 2>/dev/null || echo "(no status files)" + - name: Preflight MCP server + env: + UNITY_MCP_DEFAULT_INSTANCE: ${{ env.UNITY_MCP_DEFAULT_INSTANCE }} run: | set -euxo pipefail export PYTHONUNBUFFERED=1 export MCP_LOG_LEVEL=debug export UNITY_PROJECT_ROOT="$GITHUB_WORKSPACE/TestProjects/UnityMCPTests" - export UNITY_MCP_STATUS_DIR="$HOME/.unity-mcp" + export UNITY_MCP_STATUS_DIR="$GITHUB_WORKSPACE/.unity-mcp" export UNITY_MCP_HOST=127.0.0.1 + if [[ -n "${UNITY_MCP_DEFAULT_INSTANCE:-}" ]]; then + export UNITY_MCP_DEFAULT_INSTANCE + fi + + # Debug: probe Unity's actual ping/pong response + echo "--- Unity ping/pong probe ---" + python3 <<'PY' + import socket, struct, sys + port = 6400 + try: + s = socket.create_connection(("127.0.0.1", port), timeout=2) + s.settimeout(2) + hs = s.recv(512) + print(f"handshake: {hs!r}") + hs_ok = b"FRAMING=1" in hs + print(f"FRAMING=1 present: {hs_ok}") + if hs_ok: + s.sendall(struct.pack(">Q", 4) + b"ping") + hdr = s.recv(8) + print(f"response header len: {len(hdr)}") + if len(hdr) == 8: + length = struct.unpack(">Q", hdr)[0] + resp = s.recv(length) + print(f"response payload: {resp!r}") + pong_check = b'"message":"pong"' + print(f"contains pong_check: {pong_check in resp}") + s.close() + except Exception as e: + print(f"probe error: {e}") + PY + uv run --active --directory Server mcp-for-unity --transport stdio --help \ > /tmp/mcp-preflight.log 2>&1 || { cat /tmp/mcp-preflight.log; exit 1; } cat /tmp/mcp-preflight.log + - name: MCP server settle (retry connect) + env: + UNITY_MCP_DEFAULT_INSTANCE: ${{ env.UNITY_MCP_DEFAULT_INSTANCE }} + run: | + set -euxo pipefail + export PYTHONUNBUFFERED=1 MCP_LOG_LEVEL=debug + export UNITY_PROJECT_ROOT="$GITHUB_WORKSPACE/TestProjects/UnityMCPTests" + export UNITY_MCP_STATUS_DIR="$GITHUB_WORKSPACE/.unity-mcp" + export UNITY_MCP_HOST=127.0.0.1 + if [[ -n "${UNITY_MCP_DEFAULT_INSTANCE:-}" ]]; then + export UNITY_MCP_DEFAULT_INSTANCE + fi + for i in {1..10}; do + if uv run --active --directory Server mcp-for-unity --transport stdio --help >/tmp/mcp-settle.log 2>&1; then + cat /tmp/mcp-settle.log + exit 0 + fi + sleep 2 + done + echo "::error::MCP server did not settle after retries" + cat /tmp/mcp-settle.log || true + exit 1 + + - name: Verify MCP Unity instance + env: + UNITY_MCP_DEFAULT_INSTANCE: ${{ env.UNITY_MCP_DEFAULT_INSTANCE }} + run: | + set -euxo pipefail + export PYTHONUNBUFFERED=1 MCP_LOG_LEVEL=debug + export UNITY_PROJECT_ROOT="$GITHUB_WORKSPACE/TestProjects/UnityMCPTests" + export UNITY_MCP_STATUS_DIR="$GITHUB_WORKSPACE/.unity-mcp" + export UNITY_MCP_HOST=127.0.0.1 + if [[ -n "${UNITY_MCP_DEFAULT_INSTANCE:-}" ]]; then + export UNITY_MCP_DEFAULT_INSTANCE + fi + + # Debug: check what PortDiscovery sees + echo "--- PortDiscovery debug ---" + python3 - <<'PY' + import sys + sys.path.insert(0, "Server/src") + from transport.legacy.port_discovery import PortDiscovery + import json + + print(f"status_dir: {PortDiscovery.get_registry_dir()}") + instances = PortDiscovery.discover_all_unity_instances() + print(f"discover_all_unity_instances: {[{'id':i.id,'port':i.port} for i in instances]}") + print(f"try_probe_direct(6400): {PortDiscovery._try_probe_unity_mcp(6400)}") + print(f"discover_unity_port: {PortDiscovery.discover_unity_port()}") + PY + + python3 - <<'PY' + import json + import subprocess + cmd = [ + "uv", "run", "--active", "--directory", "Server", "python", "-c", + "from transport.legacy.stdio_port_registry import stdio_port_registry; " + "inst = stdio_port_registry.get_instances(force_refresh=True); " + "import json; print(json.dumps([{'id':i.id,'port':i.port} for i in inst]))" + ] + result = subprocess.run(cmd, capture_output=True, text=True) + print(result.stdout.strip()) + if result.returncode != 0: + print(result.stderr) + raise SystemExit(1) + try: + data = json.loads(result.stdout.strip() or "[]") + if not data: + print("::error::No Unity instances discovered by MCP registry") + raise SystemExit(1) + except Exception as e: + print(f"::error::Failed to parse instances: {e}") + raise SystemExit(1) + PY + + - name: Test MCP server with exact Claude args + env: + UNITY_MCP_DEFAULT_INSTANCE: ${{ env.UNITY_MCP_DEFAULT_INSTANCE }} + UNITY_MCP_STATUS_DIR: ${{ github.workspace }}/.unity-mcp + run: | + set -euxo pipefail + echo "=== Testing MCP server startup with --status-dir flag ===" + uv run --active --directory Server python <<'PYTEST' + import os + import sys + import glob + sys.path.insert(0, 'src') + from transport.legacy.port_discovery import PortDiscovery + status_dir = PortDiscovery.get_registry_dir() + print('Status dir:', status_dir) + print('Exists:', status_dir.exists()) + pattern = str(status_dir / 'unity-mcp-status-*.json') + files = glob.glob(pattern) + print('Files:', files) + instances = PortDiscovery.discover_all_unity_instances() + print('Instances:', [i.id for i in instances]) + if not instances: + print('::error::Discovery returned empty list!') + sys.exit(1) + PYTEST + + # ---------- Final Unity check before Claude ---------- + - name: Verify Unity IMMEDIATELY before Claude + run: | + set -euxo pipefail + echo "=== Unity container status ===" + docker inspect -f '{{.State.Status}} {{.State.Running}}' unity-mcp || echo "Container not found!" + + echo "=== Raw socket probe to Unity ===" + # Try raw TCP connect without Python overhead + for host in 127.0.0.1 localhost; do + echo "Probing $host:6400..." + if timeout 2 bash -c "exec 3<>/dev/tcp/$host/6400" 2>/dev/null; then + echo "$host:6400 - SUCCESS" + else + echo "$host:6400 - FAILED" + fi + done + + echo "=== Netstat for port 6400 ===" + docker exec unity-mcp netstat -tlnp 2>/dev/null | grep 6400 || ss -tlnp | grep 6400 || echo "No listener found on 6400" + + echo "=== Python probe with timing ===" + python3 <<'PY' + import socket, time + start = time.time() + for host in ['127.0.0.1', 'localhost']: + try: + s = socket.create_connection((host, 6400), timeout=2) + s.close() + print(f"{host}:6400 OK ({time.time()-start:.2f}s)") + except Exception as e: + print(f"{host}:6400 FAILED: {e} ({time.time()-start:.2f}s)") + PY + # ---------- Run suite in two passes ---------- - name: Run Claude NL pass uses: anthropics/claude-code-base-action@beta if: steps.detect.outputs.anthropic_ok == 'true' && steps.detect.outputs.unity_ok == 'true' continue-on-error: true + env: + UNITY_MCP_DEFAULT_INSTANCE: ${{ env.UNITY_MCP_DEFAULT_INSTANCE }} with: use_node_cache: false prompt_file: .claude/prompts/nl-unity-suite-nl.md @@ -433,10 +701,22 @@ jobs: timeout_minutes: "30" anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + - name: Debug MCP server startup (after NL pass) + if: always() + run: | + set -eux + echo "=== MCP Server Startup Debug Log ===" + cat "$GITHUB_WORKSPACE/.unity-mcp/mcp-server-startup-debug.log" 2>/dev/null || echo "(no debug log found - MCP server may not have started)" + echo "" + echo "=== Status dir after Claude ===" + ls -la "$GITHUB_WORKSPACE/.unity-mcp" || true + - name: Run Claude T pass A-J uses: anthropics/claude-code-base-action@beta if: steps.detect.outputs.anthropic_ok == 'true' && steps.detect.outputs.unity_ok == 'true' continue-on-error: true + env: + UNITY_MCP_DEFAULT_INSTANCE: ${{ env.UNITY_MCP_DEFAULT_INSTANCE }} with: use_node_cache: false prompt_file: .claude/prompts/nl-unity-suite-t.md @@ -444,7 +724,8 @@ jobs: settings: .claude/settings.json allowed_tools: "mcp__unity,Edit(reports/**),MultiEdit(reports/**)" disallowed_tools: "Bash,WebFetch,WebSearch,Task,TodoWrite,NotebookEdit,NotebookRead" - model: claude-3-7-sonnet-20250219 + model: claude-sonnet-4-5-20250929 + fallback_model: claude-haiku-4-5-20251001 append_system_prompt: | You are running the T pass (A–J) only. Output requirements: @@ -982,7 +1263,7 @@ jobs: require_tests: false fail_on_parse_error: true - - name: Upload artifacts (reports + fragments + transcript) + - name: Upload artifacts (reports + fragments + transcript + debug) if: always() uses: actions/upload-artifact@v4 with: @@ -992,6 +1273,7 @@ jobs: ${{ env.MD_OUT }} reports/*_results.xml reports/claude-execution-output.json + ${{ github.workspace }}/.unity-mcp/mcp-server-startup-debug.log retention-days: 7 # ---------- Always stop Unity ---------- diff --git a/MCPForUnity/Editor/McpCiBoot.cs b/MCPForUnity/Editor/McpCiBoot.cs new file mode 100644 index 000000000..c8e8c19ea --- /dev/null +++ b/MCPForUnity/Editor/McpCiBoot.cs @@ -0,0 +1,21 @@ +using System; +using MCPForUnity.Editor.Constants; +using MCPForUnity.Editor.Services.Transport.Transports; +using UnityEditor; + +namespace MCPForUnity.Editor +{ + public static class McpCiBoot + { + public static void StartStdioForCi() + { + try + { + EditorPrefs.SetBool(EditorPrefKeys.UseHttpTransport, false); + } + catch { /* ignore */ } + + StdioBridgeHost.StartAutoConnect(); + } + } +} diff --git a/MCPForUnity/Editor/McpCiBoot.cs.meta b/MCPForUnity/Editor/McpCiBoot.cs.meta new file mode 100644 index 000000000..816f857d0 --- /dev/null +++ b/MCPForUnity/Editor/McpCiBoot.cs.meta @@ -0,0 +1,12 @@ +fileFormatVersion: 2 +guid: ef9dca277ab34ba1b136d8dcd45de948 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: + diff --git a/MCPForUnity/Editor/Services/StdioBridgeReloadHandler.cs b/MCPForUnity/Editor/Services/StdioBridgeReloadHandler.cs index bb70ffb0b..be9f692c4 100644 --- a/MCPForUnity/Editor/Services/StdioBridgeReloadHandler.cs +++ b/MCPForUnity/Editor/Services/StdioBridgeReloadHandler.cs @@ -25,7 +25,10 @@ private static void OnBeforeAssemblyReload() { // Only persist resume intent when stdio is the active transport and the bridge is running. bool useHttp = EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true); - bool isRunning = MCPServiceLocator.TransportManager.IsRunning(TransportMode.Stdio); + // Check both TransportManager AND StdioBridgeHost directly, because CI starts via StdioBridgeHost + // bypassing TransportManager state. + bool isRunning = MCPServiceLocator.TransportManager.IsRunning(TransportMode.Stdio) + || StdioBridgeHost.IsRunning; bool shouldResume = !useHttp && isRunning; if (shouldResume) @@ -34,13 +37,12 @@ private static void OnBeforeAssemblyReload() // Stop only the stdio bridge; leave HTTP untouched if it is running concurrently. var stopTask = MCPServiceLocator.TransportManager.StopAsync(TransportMode.Stdio); - stopTask.ContinueWith(t => - { - if (t.IsFaulted && t.Exception != null) - { - McpLog.Warn($"Error stopping stdio bridge before reload: {t.Exception.GetBaseException()?.Message}"); - } - }, System.Threading.Tasks.TaskScheduler.Default); + + // Wait for stop to complete (which deletes the status file) + try { stopTask.Wait(500); } catch { } + + // Write reloading status so clients don't think we vanished + StdioBridgeHost.WriteHeartbeat(true, "reloading"); } else { diff --git a/MCPForUnity/Editor/Services/Transport/Transports/StdioBridgeHost.cs b/MCPForUnity/Editor/Services/Transport/Transports/StdioBridgeHost.cs index 31de7311e..30e31958e 100644 --- a/MCPForUnity/Editor/Services/Transport/Transports/StdioBridgeHost.cs +++ b/MCPForUnity/Editor/Services/Transport/Transports/StdioBridgeHost.cs @@ -463,8 +463,12 @@ public static void Stop() try { - string statusDir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".unity-mcp"); - string statusFile = Path.Combine(statusDir, $"unity-mcp-status-{ComputeProjectHash(Application.dataPath)}.json"); + string dir = Environment.GetEnvironmentVariable("UNITY_MCP_STATUS_DIR"); + if (string.IsNullOrWhiteSpace(dir)) + { + dir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".unity-mcp"); + } + string statusFile = Path.Combine(dir, $"unity-mcp-status-{ComputeProjectHash(Application.dataPath)}.json"); if (File.Exists(statusFile)) { File.Delete(statusFile); @@ -1011,7 +1015,7 @@ private static bool IsValidJson(string text) } - private static void WriteHeartbeat(bool reloading, string reason = null) + public static void WriteHeartbeat(bool reloading, string reason = null) { try { diff --git a/Server/src/transport/legacy/port_discovery.py b/Server/src/transport/legacy/port_discovery.py index 2143106f8..7a6e2fc31 100644 --- a/Server/src/transport/legacy/port_discovery.py +++ b/Server/src/transport/legacy/port_discovery.py @@ -270,9 +270,21 @@ def discover_all_unity_instances() -> list[UnityInstanceInfo]: port) if isinstance(port, int) else False if not is_alive: - logger.debug( - f"Instance {project_name}@{hash_value} has heartbeat but port {port} not responding") - continue + # If Unity says it's reloading and the status is fresh, don't drop the instance. + freshness = last_heartbeat or file_mtime + now = datetime.now() + if freshness.tzinfo: + from datetime import timezone + now = datetime.now(timezone.utc) + + age_s = (now - freshness).total_seconds() + + if is_reloading and age_s < 60: + pass # keep it, status="reloading" + else: + logger.debug( + f"Instance {project_name}@{hash_value} has heartbeat but port {port} not responding") + continue freshness = last_heartbeat or file_mtime From cb191f8b59f55db00d6531dc8cb7b9818b72b516 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Wed, 10 Dec 2025 11:52:07 -0800 Subject: [PATCH 24/33] Cleanup --- .github/workflows/claude-nl-suite.yml | 102 ++++---------------------- 1 file changed, 13 insertions(+), 89 deletions(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 71b2549d9..6dc52685d 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -14,54 +14,8 @@ env: UNITY_IMAGE: unityci/editor:ubuntu-2021.3.45f2-linux-il2cpp-3 jobs: - mcp-smoke: - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - uses: astral-sh/setup-uv@v4 - with: - python-version: "3.11" - - - name: Install MCP server deps - run: | - set -eux - uv venv - echo "VIRTUAL_ENV=$GITHUB_WORKSPACE/.venv" >> "$GITHUB_ENV" - echo "$GITHUB_WORKSPACE/.venv/bin" >> "$GITHUB_PATH" - if [ -f Server/pyproject.toml ]; then - uv pip install -e Server - elif [ -f Server/requirements.txt ]; then - uv pip install -r Server/requirements.txt - else - echo "No MCP Python deps found" >&2 - exit 1 - fi - - - name: Preflight MCP server (stdio) - env: - PYTHONUNBUFFERED: "1" - MCP_LOG_LEVEL: debug - UNITY_PROJECT_ROOT: ${{ github.workspace }}/TestProjects/UnityMCPTests - UNITY_MCP_STATUS_DIR: ${{ github.workspace }}/.unity-mcp-dummy - UNITY_MCP_HOST: 127.0.0.1 - UNITY_MCP_SKIP_STARTUP_CONNECT: "1" - run: | - set -euxo pipefail - mkdir -p "$UNITY_MCP_STATUS_DIR" - cat > "$UNITY_MCP_STATUS_DIR/unity-mcp-status-dummy.json" < /tmp/mcp-preflight.log 2>&1 || { cat /tmp/mcp-preflight.log; exit 1; } - cat /tmp/mcp-preflight.log - nl-suite: runs-on: ubuntu-24.04 - needs: mcp-smoke timeout-minutes: 60 env: JUNIT_OUT: reports/junit-nl-suite.xml @@ -497,7 +451,7 @@ jobs: echo "=== Status file content ===" cat "$GITHUB_WORKSPACE"/.unity-mcp/unity-mcp-status-*.json 2>/dev/null || echo "(no status files)" - - name: Preflight MCP server + - name: Preflight MCP server (with retries) env: UNITY_MCP_DEFAULT_INSTANCE: ${{ env.UNITY_MCP_DEFAULT_INSTANCE }} run: | @@ -538,34 +492,22 @@ jobs: print(f"probe error: {e}") PY - uv run --active --directory Server mcp-for-unity --transport stdio --help \ - > /tmp/mcp-preflight.log 2>&1 || { cat /tmp/mcp-preflight.log; exit 1; } - cat /tmp/mcp-preflight.log - - - name: MCP server settle (retry connect) - env: - UNITY_MCP_DEFAULT_INSTANCE: ${{ env.UNITY_MCP_DEFAULT_INSTANCE }} - run: | - set -euxo pipefail - export PYTHONUNBUFFERED=1 MCP_LOG_LEVEL=debug - export UNITY_PROJECT_ROOT="$GITHUB_WORKSPACE/TestProjects/UnityMCPTests" - export UNITY_MCP_STATUS_DIR="$GITHUB_WORKSPACE/.unity-mcp" - export UNITY_MCP_HOST=127.0.0.1 - if [[ -n "${UNITY_MCP_DEFAULT_INSTANCE:-}" ]]; then - export UNITY_MCP_DEFAULT_INSTANCE - fi - for i in {1..10}; do - if uv run --active --directory Server mcp-for-unity --transport stdio --help >/tmp/mcp-settle.log 2>&1; then - cat /tmp/mcp-settle.log - exit 0 + attempt=0 + while true; do + attempt=$((attempt+1)) + if uv run --active --directory Server mcp-for-unity --transport stdio --help > /tmp/mcp-preflight.log 2>&1; then + cat /tmp/mcp-preflight.log + break + fi + if [ "$attempt" -ge 5 ]; then + echo "::error::MCP server did not settle after $attempt attempts" + cat /tmp/mcp-preflight.log || true + exit 1 fi sleep 2 done - echo "::error::MCP server did not settle after retries" - cat /tmp/mcp-settle.log || true - exit 1 - - name: Verify MCP Unity instance + - name: Verify MCP Unity instance and Claude args env: UNITY_MCP_DEFAULT_INSTANCE: ${{ env.UNITY_MCP_DEFAULT_INSTANCE }} run: | @@ -617,12 +559,6 @@ jobs: raise SystemExit(1) PY - - name: Test MCP server with exact Claude args - env: - UNITY_MCP_DEFAULT_INSTANCE: ${{ env.UNITY_MCP_DEFAULT_INSTANCE }} - UNITY_MCP_STATUS_DIR: ${{ github.workspace }}/.unity-mcp - run: | - set -euxo pipefail echo "=== Testing MCP server startup with --status-dir flag ===" uv run --active --directory Server python <<'PYTEST' import os @@ -1182,18 +1118,6 @@ jobs: md_out.write_text('\n'.join(lines), encoding='utf-8') PY - - name: "Debug: list report files" - if: always() - shell: bash - run: | - set -eux - ls -la reports || true - shopt -s nullglob - for f in reports/*.xml; do - echo "===== $f =====" - head -n 40 "$f" || true - done - # ---------- Collect execution transcript (if present) ---------- - name: Collect action execution transcript if: always() From 8406b6aad0fa2724561f3d79a0e8da05081503f6 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Wed, 10 Dec 2025 12:01:55 -0800 Subject: [PATCH 25/33] fixes --- .github/workflows/claude-nl-suite.yml | 4 ++-- Server/src/transport/legacy/port_discovery.py | 5 ++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 6dc52685d..e370464ea 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -660,8 +660,8 @@ jobs: settings: .claude/settings.json allowed_tools: "mcp__unity,Edit(reports/**),MultiEdit(reports/**)" disallowed_tools: "Bash,WebFetch,WebSearch,Task,TodoWrite,NotebookEdit,NotebookRead" - model: claude-sonnet-4-5-20250929 - fallback_model: claude-haiku-4-5-20251001 + model: claude-haiku-4-5-20251001 + fallback_model: claude-sonnet-4-5-20250929 append_system_prompt: | You are running the T pass (A–J) only. Output requirements: diff --git a/Server/src/transport/legacy/port_discovery.py b/Server/src/transport/legacy/port_discovery.py index 7a6e2fc31..a3b50bd75 100644 --- a/Server/src/transport/legacy/port_discovery.py +++ b/Server/src/transport/legacy/port_discovery.py @@ -34,10 +34,13 @@ class PortDiscovery: @staticmethod def get_registry_path() -> Path: """Get the path to the port registry file""" - return Path.home() / ".unity-mcp" / PortDiscovery.REGISTRY_FILE + return PortDiscovery.get_registry_dir() / PortDiscovery.REGISTRY_FILE @staticmethod def get_registry_dir() -> Path: + env_dir = os.environ.get("UNITY_MCP_STATUS_DIR") + if env_dir: + return Path(env_dir) return Path.home() / ".unity-mcp" @staticmethod From 482abc5d1a1e379482fe4f509f9168a757114200 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Wed, 10 Dec 2025 12:42:03 -0800 Subject: [PATCH 26/33] diag --- .github/workflows/claude-nl-suite.yml | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index e370464ea..d583d571c 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -404,7 +404,6 @@ jobs: cfg = { "mcpServers": { "unity": { - "command": "uv", "args": [ "run", "--active", @@ -428,15 +427,33 @@ jobs: } } + unity = cfg["mcpServers"]["unity"] if default_inst: - unity = cfg["mcpServers"]["unity"] unity["env"]["UNITY_MCP_DEFAULT_INSTANCE"] = default_inst if "--default-instance" not in unity["args"]: unity["args"] += ["--default-instance", default_inst] + runner_script = Path(".claude/run-unity-mcp.sh") + workspace_path = Path(workspace) + uv_candidate = workspace_path / ".venv" / "bin" / "uv" + uv_cmd = uv_candidate.as_posix() if uv_candidate.exists() else "uv" + script = f'''#!/usr/bin/env bash +set -euo pipefail +LOG="{workspace}/.unity-mcp/mcp-server-startup-debug.log" +mkdir -p "$(dirname "$LOG")" +echo "" >> "$LOG" +echo "[ $(date -Iseconds) ] Starting unity MCP server" >> "$LOG" +exec >> "$LOG" 2>&1 +exec {uv_cmd} "$@" +''' + runner_script.write_text(script) + runner_script.chmod(0o755) + + unity["command"] = runner_script.resolve().as_posix() + path = Path(".claude/mcp.json") path.write_text(json.dumps(cfg, indent=2) + "\n") - print(f"Wrote {path} (UNITY_MCP_DEFAULT_INSTANCE={default_inst or 'unset'})") + print(f"Wrote {path} and {runner_script} (UNITY_MCP_DEFAULT_INSTANCE={default_inst or 'unset'})") PY - name: Debug MCP config From 7fbc0d2ef2a1330fb46f07fe83ffa511fb69b990 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Wed, 10 Dec 2025 13:00:18 -0800 Subject: [PATCH 27/33] fix yaml --- .github/workflows/claude-nl-suite.yml | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index d583d571c..2ebf9022b 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -396,6 +396,7 @@ jobs: python3 - <<'PY' import json import os + import textwrap from pathlib import Path workspace = os.environ["GITHUB_WORKSPACE"] @@ -437,15 +438,16 @@ jobs: workspace_path = Path(workspace) uv_candidate = workspace_path / ".venv" / "bin" / "uv" uv_cmd = uv_candidate.as_posix() if uv_candidate.exists() else "uv" - script = f'''#!/usr/bin/env bash -set -euo pipefail -LOG="{workspace}/.unity-mcp/mcp-server-startup-debug.log" -mkdir -p "$(dirname "$LOG")" -echo "" >> "$LOG" -echo "[ $(date -Iseconds) ] Starting unity MCP server" >> "$LOG" -exec >> "$LOG" 2>&1 -exec {uv_cmd} "$@" -''' + script = textwrap.dedent(f"""\ + #!/usr/bin/env bash + set -euo pipefail + LOG="{workspace}/.unity-mcp/mcp-server-startup-debug.log" + mkdir -p "$(dirname "$LOG")" + echo "" >> "$LOG" + echo "[ $(date -Iseconds) ] Starting unity MCP server" >> "$LOG" + exec >> "$LOG" 2>&1 + exec {uv_cmd} "$@" + """) runner_script.write_text(script) runner_script.chmod(0o755) From 4bcb4479c278627c2f8290aeac321274bbf0ecfa Mon Sep 17 00:00:00 2001 From: David Sarno Date: Wed, 10 Dec 2025 13:09:53 -0800 Subject: [PATCH 28/33] fix status dir --- .github/workflows/claude-nl-suite.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 2ebf9022b..0cf49e8c2 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -413,8 +413,6 @@ jobs: "mcp-for-unity", "--transport", "stdio", - "--status-dir", - f"{workspace}/.unity-mcp", ], "transport": {"type": "stdio"}, "env": { From efdfd80f333330017cfbe69c649d63a69c2755d5 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Wed, 10 Dec 2025 13:21:13 -0800 Subject: [PATCH 29/33] Fix YAML / printing to stdout --> stderr --- .github/workflows/claude-nl-suite.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 0cf49e8c2..9d0dc339d 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -443,8 +443,8 @@ jobs: mkdir -p "$(dirname "$LOG")" echo "" >> "$LOG" echo "[ $(date -Iseconds) ] Starting unity MCP server" >> "$LOG" - exec >> "$LOG" 2>&1 - exec {uv_cmd} "$@" + # Redirect stderr to log, keep stdout for MCP communication + exec {uv_cmd} "$@" 2>> "$LOG" """) runner_script.write_text(script) runner_script.chmod(0o755) From fc2013b465e3850fd113da6b02d2978c42a5e6fa Mon Sep 17 00:00:00 2001 From: David Sarno Date: Wed, 10 Dec 2025 14:14:49 -0800 Subject: [PATCH 30/33] find in file fixes. --- .github/workflows/claude-nl-suite.yml | 3 +- Server/src/services/tools/find_in_file.py | 185 ++++++++++++++++++ .../UnityMCPTests/Assets/Scripts/Editor.meta | 8 - 3 files changed, 187 insertions(+), 9 deletions(-) create mode 100644 Server/src/services/tools/find_in_file.py delete mode 100644 TestProjects/UnityMCPTests/Assets/Scripts/Editor.meta diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 9d0dc339d..2c7b2ebab 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -644,7 +644,8 @@ jobs: settings: .claude/settings.json allowed_tools: "mcp__unity,Edit(reports/**),MultiEdit(reports/**)" disallowed_tools: "Bash,WebFetch,WebSearch,Task,TodoWrite,NotebookEdit,NotebookRead" - model: claude-sonnet-4-5-20250929 + model: claude-haiku-4-5-20251001 + fallback_model: claude-sonnet-4-5-20250929 append_system_prompt: | You are running the NL pass only. - Emit exactly NL-0, NL-1, NL-2, NL-3, NL-4. diff --git a/Server/src/services/tools/find_in_file.py b/Server/src/services/tools/find_in_file.py new file mode 100644 index 000000000..4ceab92fc --- /dev/null +++ b/Server/src/services/tools/find_in_file.py @@ -0,0 +1,185 @@ +import base64 +import os +import re +from typing import Annotated, Any +from urllib.parse import unquote, urlparse + +from fastmcp import Context + +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 + + +def _split_uri(uri: str) -> tuple[str, str]: + """Split an incoming URI or path into (name, directory) suitable for Unity. + + Rules: + - unity://path/Assets/... → keep as Assets-relative (after decode/normalize) + - file://... → percent-decode, normalize, strip host and leading slashes, + then, if any 'Assets' segment exists, return path relative to that 'Assets' root. + Otherwise, fall back to original name/dir behavior. + - plain paths → decode/normalize separators; if they contain an 'Assets' segment, + return relative to 'Assets'. + """ + raw_path: str + if uri.startswith("unity://path/"): + raw_path = uri[len("unity://path/"):] + elif uri.startswith("file://"): + parsed = urlparse(uri) + host = (parsed.netloc or "").strip() + p = parsed.path or "" + # UNC: file://server/share/... -> //server/share/... + if host and host.lower() != "localhost": + p = f"//{host}{p}" + # Use percent-decoded path, preserving leading slashes + raw_path = unquote(p) + else: + raw_path = uri + + # Percent-decode any residual encodings and normalize separators + raw_path = unquote(raw_path).replace("\\", "/") + # Strip leading slash only for Windows drive-letter forms like "/C:/..." + if os.name == "nt" and len(raw_path) >= 3 and raw_path[0] == "/" and raw_path[2] == ":": + raw_path = raw_path[1:] + + # Normalize path (collapse ../, ./) + norm = os.path.normpath(raw_path).replace("\\", "/") + + # If an 'Assets' segment exists, compute path relative to it (case-insensitive) + parts = [p for p in norm.split("/") if p not in ("", ".")] + idx = next((i for i, seg in enumerate(parts) + if seg.lower() == "assets"), None) + assets_rel = "/".join(parts[idx:]) if idx is not None else None + + effective_path = assets_rel if assets_rel else norm + # For POSIX absolute paths outside Assets, drop the leading '/' + # to return a clean relative-like directory (e.g., '/tmp' -> 'tmp'). + if effective_path.startswith("/"): + effective_path = effective_path[1:] + + name = os.path.splitext(os.path.basename(effective_path))[0] + directory = os.path.dirname(effective_path) + return name, directory + + +@mcp_for_unity_tool(description="Searches a file with a regex pattern and returns line numbers and excerpts.") +async def find_in_file( + ctx: Context, + uri: Annotated[str, "The resource URI to search under Assets/ or file path form supported by read_resource"], + pattern: Annotated[str, "The regex pattern to search for"], + project_root: Annotated[str | None, "Optional project root path"] = None, + max_results: Annotated[int, "Cap results to avoid huge payloads"] = 200, + ignore_case: Annotated[bool | str | None, "Case insensitive search"] = True, +) -> dict[str, Any]: + unity_instance = get_unity_instance_from_context(ctx) + await ctx.info( + f"Processing find_in_file: {uri} (unity_instance={unity_instance or 'default'})") + + name, directory = _split_uri(uri) + + # 1. Read file content via Unity + read_resp = await send_with_unity_instance( + async_send_command_with_retry, + unity_instance, + "manage_script", + { + "action": "read", + "name": name, + "path": directory, + }, + ) + + if not isinstance(read_resp, dict) or not read_resp.get("success"): + return read_resp if isinstance(read_resp, dict) else {"success": False, "message": str(read_resp)} + + data = read_resp.get("data", {}) + contents = data.get("contents") + if not contents and data.get("contentsEncoded"): + try: + contents = base64.b64decode(data.get("encodedContents", "").encode( + "utf-8")).decode("utf-8", "replace") + except Exception: + contents = contents or "" + + if contents is None: + return {"success": False, "message": "Could not read file content."} + + # 2. Perform regex search + flags = re.MULTILINE + # Handle ignore_case which can be boolean or string from some clients + ic = ignore_case + if isinstance(ic, str): + ic = ic.lower() in ("true", "1", "yes") + if ic: + flags |= re.IGNORECASE + + try: + regex = re.compile(pattern, flags) + except re.error as e: + return {"success": False, "message": f"Invalid regex pattern: {e}"} + + matches = [] + lines = contents.splitlines() + + # Helper to map index to line number + def get_line_number(index, content_lines): + # This is a bit slow for large files if we do it for every match, + # but robust. + # Better: iterate matches and count newlines? + # Or just search line by line? + # Searching line by line is safer for line-based results, but regex might span lines. + pass + + # If the regex is not multiline specific (doesn't contain \n literal match logic), + # we could iterate lines. But users might use multiline regexes. + # Let's search the whole content and map back to lines. + + found = list(regex.finditer(contents)) + + results = [] + count = 0 + + for m in found: + if count >= max_results: + break + + start_idx = m.start() + end_idx = m.end() + + # Calculate line number + # Count newlines up to start_idx + line_num = contents.count('\n', 0, start_idx) + 1 + + # Get line content for excerpt + # Find start of line + line_start = contents.rfind('\n', 0, start_idx) + 1 + # Find end of line + line_end = contents.find('\n', start_idx) + if line_end == -1: + line_end = len(contents) + + line_content = contents[line_start:line_end] + + # Create excerpt + # We can just return the line content as excerpt + + results.append({ + "line": line_num, + "content": line_content.strip(), # detailed match info? + "match": m.group(0), + "start": start_idx, + "end": end_idx + }) + count += 1 + + return { + "success": True, + "data": { + "matches": results, + "count": len(results), + "total_matches": len(found) + } + } + diff --git a/TestProjects/UnityMCPTests/Assets/Scripts/Editor.meta b/TestProjects/UnityMCPTests/Assets/Scripts/Editor.meta deleted file mode 100644 index 65220d07a..000000000 --- a/TestProjects/UnityMCPTests/Assets/Scripts/Editor.meta +++ /dev/null @@ -1,8 +0,0 @@ -fileFormatVersion: 2 -guid: d6cd845e48d9e4d558d50f7a50149682 -folderAsset: yes -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: From 0e2006730b073b4de03406f89dc24b3cbc611f98 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Wed, 10 Dec 2025 14:37:30 -0800 Subject: [PATCH 31/33] fixes to find_in_file and CI report format error --- .github/workflows/claude-nl-suite.yml | 8 ++++---- Server/src/services/tools/find_in_file.py | 15 ++------------- 2 files changed, 6 insertions(+), 17 deletions(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 2c7b2ebab..54faaa98c 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -816,10 +816,10 @@ jobs: def id_from_filename(p: Path): n = p.name - m = re.match(r'NL(\d+)_results\.xml$', n, re.I) + m = re.match(r'NL-?(\d+)_results\.xml$', n, re.I) if m: return f"NL-{int(m.group(1))}" - m = re.match(r'T([A-J])_results\.xml$', n, re.I) + m = re.match(r'T-?([A-J])_results\.xml$', n, re.I) if m: return f"T-{m.group(1).upper()}" return None @@ -863,10 +863,10 @@ jobs: seen = set() def id_from_filename(p: Path): n = p.name - m = re.match(r'NL(\d+)_results\.xml$', n, re.I) + m = re.match(r'NL-?(\d+)_results\.xml$', n, re.I) if m: return f"NL-{int(m.group(1))}" - m = re.match(r'T([A-J])_results\.xml$', n, re.I) + m = re.match(r'T-?([A-J])_results\.xml$', n, re.I) if m: return f"T-{m.group(1).upper()}" return None diff --git a/Server/src/services/tools/find_in_file.py b/Server/src/services/tools/find_in_file.py index 4ceab92fc..600540c25 100644 --- a/Server/src/services/tools/find_in_file.py +++ b/Server/src/services/tools/find_in_file.py @@ -73,6 +73,7 @@ async def find_in_file( max_results: Annotated[int, "Cap results to avoid huge payloads"] = 200, ignore_case: Annotated[bool | str | None, "Case insensitive search"] = True, ) -> dict[str, Any]: + # project_root is currently unused but kept for interface consistency unity_instance = get_unity_instance_from_context(ctx) await ctx.info( f"Processing find_in_file: {uri} (unity_instance={unity_instance or 'default'})") @@ -100,7 +101,7 @@ async def find_in_file( try: contents = base64.b64decode(data.get("encodedContents", "").encode( "utf-8")).decode("utf-8", "replace") - except Exception: + except (ValueError, TypeError, base64.binascii.Error): contents = contents or "" if contents is None: @@ -120,18 +121,6 @@ async def find_in_file( except re.error as e: return {"success": False, "message": f"Invalid regex pattern: {e}"} - matches = [] - lines = contents.splitlines() - - # Helper to map index to line number - def get_line_number(index, content_lines): - # This is a bit slow for large files if we do it for every match, - # but robust. - # Better: iterate matches and count newlines? - # Or just search line by line? - # Searching line by line is safer for line-based results, but regex might span lines. - pass - # If the regex is not multiline specific (doesn't contain \n literal match logic), # we could iterate lines. But users might use multiline regexes. # Let's search the whole content and map back to lines. From 9ec42fb1a01eb01eb462e1d35b5b1d05db6f1d81 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Wed, 10 Dec 2025 14:46:49 -0800 Subject: [PATCH 32/33] Only run the stats on the CoPlay main repo, not forks. --- .github/workflows/github-repo-stats.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/github-repo-stats.yml b/.github/workflows/github-repo-stats.yml index d61f894b9..344b4df97 100644 --- a/.github/workflows/github-repo-stats.yml +++ b/.github/workflows/github-repo-stats.yml @@ -9,6 +9,7 @@ on: jobs: j1: + if: github.repository == 'CoplayDev/unity-mcp' name: github-repo-stats runs-on: ubuntu-latest steps: From 03f5cc78cd9d3eb827181bd90dcd58728532223f Mon Sep 17 00:00:00 2001 From: David Sarno Date: Wed, 10 Dec 2025 14:51:54 -0800 Subject: [PATCH 33/33] Coderabbit fixes. --- Server/src/services/tools/find_in_file.py | 4 ++-- Server/src/services/tools/manage_script.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Server/src/services/tools/find_in_file.py b/Server/src/services/tools/find_in_file.py index 600540c25..4f9254d11 100644 --- a/Server/src/services/tools/find_in_file.py +++ b/Server/src/services/tools/find_in_file.py @@ -97,7 +97,7 @@ async def find_in_file( data = read_resp.get("data", {}) contents = data.get("contents") - if not contents and data.get("contentsEncoded"): + if not contents and data.get("contentsEncoded") and data.get("encodedContents"): try: contents = base64.b64decode(data.get("encodedContents", "").encode( "utf-8")).decode("utf-8", "replace") @@ -105,7 +105,7 @@ async def find_in_file( contents = contents or "" if contents is None: - return {"success": False, "message": "Could not read file content."} + return {"success": False, "message": "Could not read file content."} # 2. Perform regex search flags = re.MULTILINE diff --git a/Server/src/services/tools/manage_script.py b/Server/src/services/tools/manage_script.py index 41148682b..8cbc1e9ea 100644 --- a/Server/src/services/tools/manage_script.py +++ b/Server/src/services/tools/manage_script.py @@ -121,7 +121,7 @@ def _needs_normalization(arr: list[dict[str, Any]]) -> bool: return read_resp if isinstance(read_resp, dict) else {"success": False, "message": str(read_resp)} data = read_resp.get("data", {}) contents = data.get("contents") - if not contents and data.get("contentsEncoded"): + if not contents and data.get("contentsEncoded") and data.get("encodedContents"): try: contents = base64.b64decode(data.get("encodedContents", "").encode( "utf-8")).decode("utf-8", "replace")