Skip to main content
Glama
claude-nl-suite.yml52.2 kB
name: Claude NL/T Full Suite (Unity live) 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: nl-suite: runs-on: ubuntu-24.04 timeout-minutes: 60 env: JUNIT_OUT: reports/junit-nl-suite.xml MD_OUT: reports/junit-nl-suite.md steps: # ---------- Secrets check ---------- - name: Detect secrets (outputs) id: detect env: UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }} UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} run: | set -e if [ -n "$ANTHROPIC_API_KEY" ]; then echo "anthropic_ok=true" >> "$GITHUB_OUTPUT"; else echo "anthropic_ok=false" >> "$GITHUB_OUTPUT"; fi if [ -n "$UNITY_LICENSE" ] || { [ -n "$UNITY_EMAIL" ] && [ -n "$UNITY_PASSWORD" ]; }; then echo "unity_ok=true" >> "$GITHUB_OUTPUT" else echo "unity_ok=false" >> "$GITHUB_OUTPUT" fi - uses: actions/checkout@v4 with: fetch-depth: 0 # ---------- Python env for MCP server (uv) ---------- - uses: astral-sh/setup-uv@v4 with: python-version: "3.11" - name: Install MCP server 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 (skipping)" fi # --- Licensing: allow both ULF and EBL when available --- - 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 '<Signature>' "$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 # --- Activate via EBL inside the same Unity image (writes host-side entitlement) --- - 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 -euo pipefail # host dirs to receive the full Unity config and local-share 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 # EBL entitlement is already written directly to $RUNNER_TEMP/unity-config by the activation step # ---------- Warm up project (import Library once) ---------- - name: Warm up project (import Library once) 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 }} 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 }}:${{ 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 "${{ github.workspace }}/TestProjects/UnityMCPTests" \ "${manual_args[@]}" \ -quit # ---------- Clean old MCP status ---------- - name: Clean old MCP status run: | set -eux 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) 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 }} 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 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="${{ github.workspace }}/.unity-mcp" \ -e UNITY_MCP_BIND_HOST=127.0.0.1 \ -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 "${{ github.workspace }}/TestProjects/UnityMCPTests" \ "${manual_args[@]}" \ -executeMethod MCPForUnity.Editor.McpCiBoot.StartStdioForCi # ---------- 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+600)) # 10 min max fatal_after=$((SECONDS+120)) # give licensing 2 min to settle # Fail fast only if container actually died st="$(docker inspect -f '{{.State.Status}} {{.State.ExitCode}}' unity-mcp 2>/dev/null || true)" case "$st" in exited*|dead*) docker logs unity-mcp --tail 200 | sed -E 's/((email|serial|license|password|token)[^[:space:]]*)/[REDACTED]/Ig'; exit 1;; esac # Patterns ok_pat='(Bridge|MCP(For)?Unity|AutoConnect).*(listening|ready|started|port|bound)' # Only truly fatal signals; allow transient "Licensing::..." chatter license_fatal='No valid Unity|License is not active|cannot load ULF|Signature element not found|Token not found|0 entitlement|Entitlement.*(failed|denied)|License (activation|return|renewal).*(failed|expired|denied)' while [ $SECONDS -lt $deadline ]; do logs="$(docker logs unity-mcp 2>&1 || true)" # 1) Primary: status JSON exposes TCP port 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 # Only treat license failures as fatal *after* warm-up if [ $SECONDS -ge $fatal_after ] && echo "$logs" | grep -qiE "$license_fatal"; then echo "::error::Fatal licensing signal detected after warm-up" echo "$logs" | tail -n 200 | sed -E 's/((email|serial|license|password|token)[^[:space:]]*)/[REDACTED]/Ig' exit 1 fi # If the container dies mid-wait, bail st="$(docker inspect -f '{{.State.Status}}' unity-mcp 2>/dev/null || true)" if [[ "$st" != "running" ]]; then echo "::error::Unity container exited during wait"; docker logs unity-mcp --tail 200 | sed -E 's/((email|serial|license|password|token)[^[:space:]]*)/[REDACTED]/Ig' exit 1 fi sleep 2 done echo "::error::Bridge not ready before deadline" 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() && (steps.lic.outputs.use_ulf == 'true' || steps.lic.outputs.use_ebl == 'true') 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 "${{ github.workspace }}/.unity-mcp" || true echo "--- Host status dir ---" ls -la "$GITHUB_WORKSPACE/.unity-mcp" || true echo "--- Host status file (first 120 lines) ---" 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' "$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" 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 shopt -s nullglob 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 fi # (moved) — return license after Unity is stopped - name: Pin Claude tool permissions (.claude/settings.json) run: | set -eux mkdir -p .claude cat > .claude/settings.json <<'JSON' { "permissions": { "allow": [ "mcp__unity", "Edit(reports/**)", "MultiEdit(reports/**)" ], "deny": [ "Bash", "WebFetch", "WebSearch", "Task", "TodoWrite", "NotebookEdit", "NotebookRead" ] } } JSON # ---------- Reports & helper ---------- - name: Prepare reports and dirs run: | set -eux rm -f reports/*.xml reports/*.md || true mkdir -p reports reports/_snapshots reports/_staging - name: Create report skeletons run: | set -eu cat > "$JUNIT_OUT" <<'XML' <?xml version="1.0" encoding="UTF-8"?> <testsuites><testsuite name="UnityMCP.NL-T" tests="1" failures="1" errors="0" skipped="0" time="0"> <testcase name="NL-Suite.Bootstrap" classname="UnityMCP.NL-T"> <failure message="bootstrap">Bootstrap placeholder; suite will append real tests.</failure> </testcase> </testsuite></testsuites> XML printf '# Unity NL/T Editing Suite Test Results\n\n' > "$MD_OUT" - name: Verify Unity bridge status/port run: | set -euxo pipefail ls -la "$GITHUB_WORKSPACE/.unity-mcp" || true jq -r . "$GITHUB_WORKSPACE"/.unity-mcp/unity-mcp-status-*.json | sed -n '1,80p' || true shopt -s nullglob status_files=("$GITHUB_WORKSPACE"/.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)" else port="" fi echo "unity_port=$port" if [[ -n "$port" ]]; then timeout 1 bash -lc "exec 3<>/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 import textwrap from pathlib import Path workspace = os.environ["GITHUB_WORKSPACE"] default_inst = os.environ.get("UNITY_MCP_DEFAULT_INSTANCE", "").strip() cfg = { "mcpServers": { "unity": { "args": [ "run", "--active", "--directory", "Server", "mcp-for-unity", "--transport", "stdio", ], "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", }, } } } unity = cfg["mcpServers"]["unity"] if default_inst: 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 = 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" # Redirect stderr to log, keep stdout for MCP communication exec {uv_cmd} "$@" 2>> "$LOG" """) 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} and {runner_script} (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 (with retries) 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="$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 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 - name: Verify MCP Unity instance and Claude args 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 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 mcp_config: .claude/mcp.json 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 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. - Write each to reports/${ID}_results.xml. - Prefer a single MultiEdit(reports/**) batch. Do not emit any T-* tests. - Stop after NL-4_results.xml is written. 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 mcp_config: .claude/mcp.json 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 fallback_model: claude-sonnet-4-5-20250929 append_system_prompt: | You are running the T pass (A–J) only. Output requirements: - Emit exactly 10 test fragments: T-A, T-B, T-C, T-D, T-E, T-F, T-G, T-H, T-I, T-J. - Write each fragment to reports/${ID}_results.xml (e.g., T-A_results.xml). - Prefer a single MultiEdit(reports/**) call that writes all ten files in one batch. - If MultiEdit is not used, emit individual writes for any missing IDs until all ten exist. - Do not emit any NL-* fragments. Stop condition: - After T-J_results.xml is written, stop. timeout_minutes: "30" anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} # (moved) Assert T coverage after staged fragments are promoted - name: Check T coverage incomplete (pre-retry) id: t_cov if: always() shell: bash run: | set -euo pipefail missing=() for id in T-A T-B T-C T-D T-E T-F T-G T-H T-I T-J; do if [[ ! -s "reports/${id}_results.xml" && ! -s "reports/_staging/${id}_results.xml" ]]; then missing+=("$id") fi done echo "missing=${#missing[@]}" >> "$GITHUB_OUTPUT" if (( ${#missing[@]} )); then echo "list=${missing[*]}" >> "$GITHUB_OUTPUT" fi - name: Retry T pass (Sonnet) if incomplete if: steps.t_cov.outputs.missing != '0' uses: anthropics/claude-code-base-action@beta with: use_node_cache: false prompt_file: .claude/prompts/nl-unity-suite-t.md mcp_config: .claude/mcp.json 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-sonnet-4-5-20250929 fallback_model: claude-haiku-4-5-20251001 append_system_prompt: | You are running the T pass only. Output requirements: - Emit exactly 10 test fragments: T-A, T-B, T-C, T-D, T-E, T-F, T-G, T-H, T-I, T-J. - Write each fragment to reports/${ID}_results.xml (e.g., T-A_results.xml). - Prefer a single MultiEdit(reports/**) call that writes all ten files in one batch. - If MultiEdit is not used, emit individual writes for any missing IDs until all ten exist. - Do not emit any NL-* fragments. Stop condition: - After T-J_results.xml is written, stop. timeout_minutes: "30" anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} - name: Re-assert T coverage (post-retry) if: always() shell: bash run: | set -euo pipefail missing=() for id in T-A T-B T-C T-D T-E T-F T-G T-H T-I T-J; do [[ -s "reports/${id}_results.xml" ]] || missing+=("$id") done if (( ${#missing[@]} )); then echo "::error::Still missing T fragments: ${missing[*]}" exit 1 fi # (kept) Finalize staged report fragments (promote to reports/) # (removed duplicate) Finalize staged report fragments - name: Assert T coverage (after promotion) if: always() shell: bash run: | set -euo pipefail missing=() for id in T-A T-B T-C T-D T-E T-F T-G T-H T-I T-J; do if [[ ! -s "reports/${id}_results.xml" ]]; then # Accept staged fragment as present [[ -s "reports/_staging/${id}_results.xml" ]] || missing+=("$id") fi done if (( ${#missing[@]} )); then echo "::error::Missing T fragments: ${missing[*]}" exit 1 fi - name: Canonicalize testcase names (NL/T prefixes) if: always() shell: bash run: | python3 - <<'PY' from pathlib import Path import xml.etree.ElementTree as ET, re, os RULES = [ ("NL-0", r"\b(NL-0|Baseline|State\s*Capture)\b"), ("NL-1", r"\b(NL-1|Core\s*Method)\b"), ("NL-2", r"\b(NL-2|Anchor|Build\s*marker)\b"), ("NL-3", r"\b(NL-3|End[-\s]*of[-\s]*Class\s*Content|Tail\s*test\s*[ABC])\b"), ("NL-4", r"\b(NL-4|Console|Unity\s*console)\b"), ("T-A", r"\b(T-?A|Temporary\s*Helper)\b"), ("T-B", r"\b(T-?B|Method\s*Body\s*Interior)\b"), ("T-C", r"\b(T-?C|Different\s*Method\s*Interior|ApplyBlend)\b"), ("T-D", r"\b(T-?D|End[-\s]*of[-\s]*Class\s*Helper|TestHelper)\b"), ("T-E", r"\b(T-?E|Method\s*Evolution|Counter|IncrementCounter)\b"), ("T-F", r"\b(T-?F|Atomic\s*Multi[-\s]*Edit)\b"), ("T-G", r"\b(T-?G|Path\s*Normalization)\b"), ("T-H", r"\b(T-?H|Validation\s*on\s*Modified)\b"), ("T-I", r"\b(T-?I|Failure\s*Surface)\b"), ("T-J", r"\b(T-?J|Idempotenc(y|e))\b"), ] def canon_name(name: str) -> str: n = name or "" for tid, pat in RULES: if re.search(pat, n, flags=re.I): # If it already starts with the correct format, leave it alone if re.match(rf'^\s*{re.escape(tid)}\s*[—–-]', n, flags=re.I): return n.strip() # If it has a different separator, extract title and reformat title_match = re.search(rf'{re.escape(tid)}\s*[:.\-–—]\s*(.+)', n, flags=re.I) if title_match: title = title_match.group(1).strip() return f"{tid} — {title}" # Otherwise, just return the canonical ID return tid return n def id_from_filename(p: Path): n = p.name 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) if m: return f"T-{m.group(1).upper()}" return None frags = list(sorted(Path("reports").glob("*_results.xml"))) for frag in frags: try: tree = ET.parse(frag); root = tree.getroot() except Exception: continue if root.tag != "testcase": continue file_id = id_from_filename(frag) old = root.get("name") or "" # Prefer filename-derived ID; if name doesn't start with it, override if file_id: # Respect file's ID (prevents T-D being renamed to NL-3 by loose patterns) title = re.sub(r'^\s*(NL-\d+|T-[A-Z])\s*[—–:\-]\s*', '', old).strip() new = f"{file_id} — {title}" if title else file_id else: new = canon_name(old) if new != old and new: root.set("name", new) tree.write(frag, encoding="utf-8", xml_declaration=False) print(f'canon: {frag.name}: "{old}" -> "{new}"') # Note: Do not auto-relable fragments. We rely on per-test strict emission # and the backfill step to surface missing tests explicitly. PY - name: Backfill missing NL/T tests (fail placeholders) if: always() shell: bash run: | python3 - <<'PY' from pathlib import Path import xml.etree.ElementTree as ET import re DESIRED = ["NL-0","NL-1","NL-2","NL-3","NL-4","T-A","T-B","T-C","T-D","T-E","T-F","T-G","T-H","T-I","T-J"] seen = set() def id_from_filename(p: Path): n = p.name 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) if m: return f"T-{m.group(1).upper()}" return None for p in Path("reports").glob("*_results.xml"): try: r = ET.parse(p).getroot() except Exception: continue # Count by filename id primarily; fall back to testcase name if needed fid = id_from_filename(p) if fid in DESIRED: seen.add(fid) continue if r.tag == "testcase": name = (r.get("name") or "").strip() for d in DESIRED: if name.startswith(d): seen.add(d) break Path("reports").mkdir(parents=True, exist_ok=True) for d in DESIRED: if d in seen: continue frag = Path(f"reports/{d}_results.xml") tc = ET.Element("testcase", {"classname":"UnityMCP.NL-T", "name": d}) fail = ET.SubElement(tc, "failure", {"message":"not produced"}) fail.text = "The agent did not emit a fragment for this test." ET.ElementTree(tc).write(frag, encoding="utf-8", xml_declaration=False) print(f"backfill: {d}") PY - name: "Debug: list testcase names" if: always() run: | python3 - <<'PY' from pathlib import Path import xml.etree.ElementTree as ET for p in sorted(Path('reports').glob('*_results.xml')): try: r = ET.parse(p).getroot() if r.tag == 'testcase': print(f"{p.name}: {(r.get('name') or '').strip()}") except Exception: pass PY # ---------- Merge testcase fragments into JUnit ---------- - name: Normalize/assemble JUnit in-place (single file) if: always() shell: bash run: | python3 - <<'PY' from pathlib import Path import xml.etree.ElementTree as ET import re, os def localname(tag: str) -> str: return tag.rsplit('}', 1)[-1] if '}' in tag else tag src = Path(os.environ.get('JUNIT_OUT', 'reports/junit-nl-suite.xml')) if not src.exists(): raise SystemExit(0) tree = ET.parse(src) root = tree.getroot() suite = root.find('./*') if localname(root.tag) == 'testsuites' else root if suite is None: raise SystemExit(0) def id_from_filename(p: Path): n = p.name 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) if m: return f"T-{m.group(1).upper()}" return None def id_from_system_out(tc): so = tc.find('system-out') if so is not None and so.text: m = re.search(r'\b(NL-\d+|T-[A-Z])\b', so.text) if m: return m.group(1) return None fragments = sorted(Path('reports').glob('*_results.xml')) added = 0 renamed = 0 for frag in fragments: tcs = [] try: froot = ET.parse(frag).getroot() if localname(froot.tag) == 'testcase': tcs = [froot] else: tcs = list(froot.findall('.//testcase')) except Exception: txt = Path(frag).read_text(encoding='utf-8', errors='replace') # Extract all testcase nodes from raw text nodes = re.findall(r'<testcase[\s\S]*?</testcase>', txt, flags=re.DOTALL) for m in nodes: try: tcs.append(ET.fromstring(m)) except Exception: pass # Guard: keep only the first testcase from each fragment if len(tcs) > 1: tcs = tcs[:1] test_id = id_from_filename(frag) for tc in tcs: current_name = tc.get('name') or '' tid = test_id or id_from_system_out(tc) # Enforce filename-derived ID as prefix; repair names if needed if tid and not re.match(r'^\s*(NL-\d+|T-[A-Z])\b', current_name): title = current_name.strip() new_name = f'{tid} — {title}' if title else tid tc.set('name', new_name) elif tid and not re.match(rf'^\s*{re.escape(tid)}\b', current_name): # Replace any wrong leading ID with the correct one title = re.sub(r'^\s*(NL-\d+|T-[A-Z])\s*[—–:\-]\s*', '', current_name).strip() new_name = f'{tid} — {title}' if title else tid tc.set('name', new_name) renamed += 1 suite.append(tc) added += 1 if added: # Drop bootstrap placeholder and recompute counts for tc in list(suite.findall('.//testcase')): if (tc.get('name') or '') == 'NL-Suite.Bootstrap': suite.remove(tc) testcases = suite.findall('.//testcase') failures_cnt = sum(1 for tc in testcases if (tc.find('failure') is not None or tc.find('error') is not None)) suite.set('tests', str(len(testcases))) suite.set('failures', str(failures_cnt)) suite.set('errors', '0') suite.set('skipped', '0') tree.write(src, encoding='utf-8', xml_declaration=True) print(f"Appended {added} testcase(s); renamed {renamed} to canonical NL/T names.") PY # ---------- Markdown summary from JUnit ---------- - name: Build markdown summary from JUnit if: always() shell: bash run: | python3 - <<'PY' import xml.etree.ElementTree as ET from pathlib import Path import os, html, re def localname(tag: str) -> str: return tag.rsplit('}', 1)[-1] if '}' in tag else tag src = Path(os.environ.get('JUNIT_OUT', 'reports/junit-nl-suite.xml')) md_out = Path(os.environ.get('MD_OUT', 'reports/junit-nl-suite.md')) md_out.parent.mkdir(parents=True, exist_ok=True) if not src.exists(): md_out.write_text("# Unity NL/T Editing Suite Test Results\n\n(No JUnit found)\n", encoding='utf-8') raise SystemExit(0) tree = ET.parse(src) root = tree.getroot() suite = root.find('./*') if localname(root.tag) == 'testsuites' else root cases = [] if suite is None else list(suite.findall('.//testcase')) def id_from_case(tc): n = (tc.get('name') or '') m = re.match(r'\s*(NL-\d+|T-[A-Z])\b', n) if m: return m.group(1) so = tc.find('system-out') if so is not None and so.text: m = re.search(r'\b(NL-\d+|T-[A-Z])\b', so.text) if m: return m.group(1) return None id_status = {} name_map = {} for tc in cases: tid = id_from_case(tc) ok = (tc.find('failure') is None and tc.find('error') is None) if tid and tid not in id_status: id_status[tid] = ok name_map[tid] = (tc.get('name') or tid) desired = ['NL-0','NL-1','NL-2','NL-3','NL-4','T-A','T-B','T-C','T-D','T-E','T-F','T-G','T-H','T-I','T-J'] total = len(cases) failures = sum(1 for tc in cases if (tc.find('failure') is not None or tc.find('error') is not None)) passed = total - failures lines = [] lines += [ '# Unity NL/T Editing Suite Test Results', '', f'Totals: {passed} passed, {failures} failed, {total} total', '', '## Test Checklist' ] for p in desired: st = id_status.get(p, None) lines.append(f"- [x] {p}" if st is True else (f"- [ ] {p} (fail)" if st is False else f"- [ ] {p} (not run)")) lines.append('') lines.append('## Test Details') def order_key(n: str): if n.startswith('NL-'): try: return (0, int(n.split('-')[1])) except: return (0, 999) if n.startswith('T-') and len(n) > 2: return (1, ord(n[2])) return (2, n) MAX_CHARS = 2000 seen = set() for tid in sorted(id_status.keys(), key=order_key): seen.add(tid) tc = next((c for c in cases if (id_from_case(c) == tid)), None) if not tc: continue title = name_map.get(tid, tid) status_badge = "PASS" if id_status[tid] else "FAIL" lines.append(f"### {title} — {status_badge}") so = tc.find('system-out') text = '' if so is None or so.text is None else html.unescape(so.text.replace('\r\n','\n')) if text.strip(): t = text.strip() if len(t) > MAX_CHARS: t = t[:MAX_CHARS] + "\n…(truncated)" fence = '```' if '```' not in t else '````' lines += [fence, t, fence] else: lines.append('(no system-out)') node = tc.find('failure') or tc.find('error') if node is not None: msg = (node.get('message') or '').strip() body = (node.text or '').strip() if msg: lines.append(f"- Message: {msg}") if body: lines.append(f"- Detail: {body.splitlines()[0][:500]}") lines.append('') for tc in cases: if id_from_case(tc) in seen: continue title = tc.get('name') or '(unnamed)' status_badge = "PASS" if (tc.find('failure') is None and tc.find('error') is None) else "FAIL" lines.append(f"### {title} — {status_badge}") lines.append('(unmapped test id)') lines.append('') md_out.write_text('\n'.join(lines), encoding='utf-8') PY # ---------- Collect execution transcript (if present) ---------- - name: Collect action execution transcript if: always() shell: bash run: | set -eux if [ -f "$RUNNER_TEMP/claude-execution-output.json" ]; then cp "$RUNNER_TEMP/claude-execution-output.json" reports/claude-execution-output.json elif [ -f "/home/runner/work/_temp/claude-execution-output.json" ]; then cp "/home/runner/work/_temp/claude-execution-output.json" reports/claude-execution-output.json fi - name: Sanitize markdown (normalize newlines) if: always() run: | set -eu python3 - <<'PY' from pathlib import Path rp=Path('reports'); rp.mkdir(parents=True, exist_ok=True) for p in rp.glob('*.md'): b=p.read_bytes().replace(b'\x00', b'') s=b.decode('utf-8','replace').replace('\r\n','\n') p.write_text(s, encoding='utf-8', newline='\n') PY - name: NL/T details -> Job Summary if: always() run: | echo "## Unity NL/T Editing Suite — Summary" >> $GITHUB_STEP_SUMMARY python3 - <<'PY' >> $GITHUB_STEP_SUMMARY from pathlib import Path p = Path('reports/junit-nl-suite.md') if p.exists(): text = p.read_bytes().decode('utf-8', 'replace') MAX = 65000 print(text[:MAX]) if len(text) > MAX: print("\n\n_…truncated; full report in artifacts._") else: print("_No markdown report found._") PY - name: Fallback JUnit if missing if: always() run: | set -eu mkdir -p reports if [ ! -f "$JUNIT_OUT" ]; then printf '%s\n' \ '<?xml version="1.0" encoding="UTF-8"?>' \ '<testsuite name="UnityMCP.NL-T" tests="1" failures="1" time="0">' \ ' <testcase classname="UnityMCP.NL-T" name="NL-Suite.Execution" time="0.0">' \ ' <failure><![CDATA[No JUnit was produced by the NL suite step. See the step logs.]]></failure>' \ ' </testcase>' \ '</testsuite>' \ > "$JUNIT_OUT" fi - name: Publish JUnit report if: always() uses: mikepenz/action-junit-report@v5 with: report_paths: "${{ env.JUNIT_OUT }}" include_passed: true detailed_summary: true annotate_notice: true require_tests: false fail_on_parse_error: true - name: Upload artifacts (reports + fragments + transcript + debug) if: always() uses: actions/upload-artifact@v4 with: name: claude-nl-suite-artifacts path: | ${{ env.JUNIT_OUT }} ${{ 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 ---------- - name: Stop Unity if: always() run: | docker logs --tail 400 unity-mcp | sed -E 's/((email|serial|license|password|token)[^[:space:]]*)/[REDACTED]/ig' || true docker rm -f unity-mcp || true - name: Return Pro license (if used) if: always() && steps.lic.outputs.use_ebl == 'true' && steps.lic.outputs.has_serial == 'true' uses: game-ci/unity-return-license@v2 continue-on-error: true env: UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }}

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/CoplayDev/unity-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server