|
| 1 | +name: Claude NL/T No Unity (fake instance) |
| 2 | + |
| 3 | +on: [workflow_dispatch] |
| 4 | + |
| 5 | +permissions: |
| 6 | + contents: read |
| 7 | + checks: write |
| 8 | + |
| 9 | +concurrency: |
| 10 | + group: ${{ github.workflow }}-${{ github.ref }} |
| 11 | + cancel-in-progress: true |
| 12 | + |
| 13 | +jobs: |
| 14 | + no-unity: |
| 15 | + runs-on: ubuntu-24.04 # Ubuntu 24.04 required for Claude Code installer |
| 16 | + timeout-minutes: 20 |
| 17 | + steps: |
| 18 | + # ---------- Secrets check ---------- |
| 19 | + - name: Detect secrets (outputs) |
| 20 | + id: detect |
| 21 | + env: |
| 22 | + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} |
| 23 | + run: | |
| 24 | + set -e |
| 25 | + if [ -n "$ANTHROPIC_API_KEY" ]; then echo "anthropic_ok=true" >> "$GITHUB_OUTPUT"; else echo "anthropic_ok=false" >> "$GITHUB_OUTPUT"; fi |
| 26 | +
|
| 27 | + - uses: actions/checkout@v4 |
| 28 | + with: |
| 29 | + fetch-depth: 0 |
| 30 | + |
| 31 | + - uses: astral-sh/setup-uv@v4 |
| 32 | + with: |
| 33 | + python-version: "3.11" |
| 34 | + |
| 35 | + - name: Install MCP server deps |
| 36 | + run: | |
| 37 | + set -eux |
| 38 | + uv venv |
| 39 | + echo "VIRTUAL_ENV=$GITHUB_WORKSPACE/.venv" >> "$GITHUB_ENV" |
| 40 | + echo "$GITHUB_WORKSPACE/.venv/bin" >> "$GITHUB_PATH" |
| 41 | + if [ -f Server/pyproject.toml ]; then |
| 42 | + uv pip install -e Server |
| 43 | + elif [ -f Server/requirements.txt ]; then |
| 44 | + uv pip install -r Server/requirements.txt |
| 45 | + else |
| 46 | + echo "No MCP Python deps found" >&2 |
| 47 | + exit 1 |
| 48 | + fi |
| 49 | +
|
| 50 | + - name: Create fake Unity instance (status + listener) |
| 51 | + env: |
| 52 | + UNITY_PORT: 6400 |
| 53 | + run: | |
| 54 | + set -euxo pipefail |
| 55 | + mkdir -p "$GITHUB_WORKSPACE/.unity-mcp" |
| 56 | +
|
| 57 | + HASH="$(python3 <<'PY' |
| 58 | + import hashlib, os |
| 59 | + path = os.path.join(os.environ['GITHUB_WORKSPACE'], 'TestProjects/UnityMCPTests/Assets') |
| 60 | + print(hashlib.sha1(path.encode()).hexdigest()[:8]) |
| 61 | + PY |
| 62 | + )" |
| 63 | + export HASH |
| 64 | + echo "UNITY_MCP_DEFAULT_INSTANCE=UnityMCPTests@${HASH}" >> "$GITHUB_ENV" |
| 65 | +
|
| 66 | + python3 <<'PY' |
| 67 | + import json, os, time, pathlib |
| 68 | + ws = os.environ["GITHUB_WORKSPACE"] |
| 69 | + h = os.environ["HASH"] |
| 70 | + port = int(os.environ.get("UNITY_PORT", "6400")) |
| 71 | + status = { |
| 72 | + "unity_port": port, |
| 73 | + "reloading": False, |
| 74 | + "reason": "ready", |
| 75 | + "seq": 1, |
| 76 | + "project_path": f"{ws}/TestProjects/UnityMCPTests/Assets", |
| 77 | + "project_name": "UnityMCPTests", |
| 78 | + "unity_version": "fake-0.0.0", |
| 79 | + "last_heartbeat": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), |
| 80 | + "instance_id": f"UnityMCPTests@{h}", |
| 81 | + } |
| 82 | + status_path = pathlib.Path(ws) / ".unity-mcp" / f"unity-mcp-status-{h}.json" |
| 83 | + status_path.write_text(json.dumps(status, indent=2) + "\n", encoding="utf-8") |
| 84 | + # Also mirror to home registry dir to satisfy any default lookups |
| 85 | + home_dir = pathlib.Path.home() / ".unity-mcp" |
| 86 | + home_dir.mkdir(parents=True, exist_ok=True) |
| 87 | + home_path = home_dir / f"unity-mcp-status-{h}.json" |
| 88 | + home_path.write_text(json.dumps(status, indent=2) + "\n", encoding="utf-8") |
| 89 | + # Optional port registry (legacy helpers) |
| 90 | + port_file = home_dir / f"unity-mcp-port-{h}.json" |
| 91 | + port_file.write_text(json.dumps({"unity_port": port}) + "\n", encoding="utf-8") |
| 92 | + PY |
| 93 | +
|
| 94 | + cat > /tmp/fake-unity-listener.py <<'PY' |
| 95 | + import socket, struct, os |
| 96 | + host = "0.0.0.0" |
| 97 | + port = int(os.environ.get("UNITY_PORT", "6400")) |
| 98 | + handshake = b"UnityMCP Ready\nFRAMING=1\n\n" |
| 99 | + def recv_exact(conn, n): |
| 100 | + data = b"" |
| 101 | + while len(data) < n: |
| 102 | + chunk = conn.recv(n - len(data)) |
| 103 | + if not chunk: |
| 104 | + return None |
| 105 | + data += chunk |
| 106 | + return data |
| 107 | + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) |
| 108 | + s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) |
| 109 | + s.bind((host, port)) |
| 110 | + s.listen(5) |
| 111 | + while True: |
| 112 | + conn, _ = s.accept() |
| 113 | + try: |
| 114 | + conn.sendall(handshake) |
| 115 | + header = recv_exact(conn, 8) |
| 116 | + if not header: |
| 117 | + # Legacy probe path: expect plain "ping" and reply JSON pong |
| 118 | + data = conn.recv(512) |
| 119 | + if data == b"ping": |
| 120 | + conn.sendall(b'{"message":"pong"}') |
| 121 | + conn.close(); continue |
| 122 | + length = struct.unpack('>Q', header)[0] |
| 123 | + payload = recv_exact(conn, length) or b"" |
| 124 | + if payload == b"ping": |
| 125 | + resp = b'{"message":"pong"}' |
| 126 | + conn.sendall(struct.pack('>Q', len(resp)) + resp) |
| 127 | + finally: |
| 128 | + conn.close() |
| 129 | + PY |
| 130 | + nohup python3 /tmp/fake-unity-listener.py >/tmp/fake-unity-listener.log 2>&1 & |
| 131 | +
|
| 132 | + # Give listener a moment to bind before verification |
| 133 | + sleep 1 |
| 134 | +
|
| 135 | + # Self-check: handshake + framed ping/pong against fake listener |
| 136 | + python3 <<'PY' |
| 137 | + import socket, struct, time, os, sys |
| 138 | + port = int(os.environ.get("UNITY_PORT", "6400")) |
| 139 | + s = socket.create_connection(("127.0.0.1", port), timeout=2) |
| 140 | + s.settimeout(2) |
| 141 | + handshake = s.recv(512) |
| 142 | + if b"FRAMING=1" not in handshake: |
| 143 | + print("::error::Fake listener handshake missing FRAMING=1") |
| 144 | + sys.exit(1) |
| 145 | + payload = b"ping" |
| 146 | + s.sendall(struct.pack(">Q", len(payload)) + payload) |
| 147 | + header = s.recv(8) |
| 148 | + if len(header) != 8: |
| 149 | + print("::error::Fake listener missing pong header") |
| 150 | + sys.exit(1) |
| 151 | + length = struct.unpack(">Q", header)[0] |
| 152 | + resp = s.recv(length) |
| 153 | + if b"pong" not in resp: |
| 154 | + print(f"::error::Fake listener pong mismatch: {resp!r}") |
| 155 | + sys.exit(1) |
| 156 | + s.close() |
| 157 | + PY |
| 158 | +
|
| 159 | + chmod -R a+rwx "$GITHUB_WORKSPACE/.unity-mcp" |
| 160 | +
|
| 161 | + - name: Write MCP config (.claude/mcp.json) |
| 162 | + run: | |
| 163 | + set -eux |
| 164 | + mkdir -p .claude |
| 165 | + python - <<'PY' |
| 166 | + import json |
| 167 | + import os |
| 168 | + from pathlib import Path |
| 169 | +
|
| 170 | + workspace = os.environ["GITHUB_WORKSPACE"] |
| 171 | + default_inst = os.environ.get("UNITY_MCP_DEFAULT_INSTANCE", "").strip() |
| 172 | +
|
| 173 | + cfg = { |
| 174 | + "mcpServers": { |
| 175 | + "unity": { |
| 176 | + "command": "uv", |
| 177 | + "args": [ |
| 178 | + "run", |
| 179 | + "--active", |
| 180 | + "--directory", |
| 181 | + "Server", |
| 182 | + "mcp-for-unity", |
| 183 | + "--transport", |
| 184 | + "stdio", |
| 185 | + "--status-dir", |
| 186 | + f"{workspace}/.unity-mcp", |
| 187 | + ], |
| 188 | + "transport": {"type": "stdio"}, |
| 189 | + "env": { |
| 190 | + "PYTHONUNBUFFERED": "1", |
| 191 | + "MCP_LOG_LEVEL": "debug", |
| 192 | + "UNITY_PROJECT_ROOT": f"{workspace}/TestProjects/UnityMCPTests", |
| 193 | + "UNITY_MCP_STATUS_DIR": f"{workspace}/.unity-mcp", |
| 194 | + "UNITY_MCP_HOST": "127.0.0.1", |
| 195 | + "UNITY_MCP_SKIP_STARTUP_CONNECT": "1", |
| 196 | + }, |
| 197 | + } |
| 198 | + } |
| 199 | + } |
| 200 | +
|
| 201 | + if default_inst: |
| 202 | + unity = cfg["mcpServers"]["unity"] |
| 203 | + unity["env"]["UNITY_MCP_DEFAULT_INSTANCE"] = default_inst |
| 204 | + if "--default-instance" not in unity["args"]: |
| 205 | + unity["args"] += ["--default-instance", default_inst] |
| 206 | +
|
| 207 | + path = Path(".claude/mcp.json") |
| 208 | + path.write_text(json.dumps(cfg, indent=2) + "\n") |
| 209 | + print(f"Wrote {path} (UNITY_MCP_DEFAULT_INSTANCE={default_inst or 'unset'})") |
| 210 | + PY |
| 211 | +
|
| 212 | + - name: Pin Claude tool permissions (.claude/settings.json) |
| 213 | + run: | |
| 214 | + set -eux |
| 215 | + mkdir -p .claude |
| 216 | + cat > .claude/settings.json <<'JSON' |
| 217 | + { |
| 218 | + "permissions": { |
| 219 | + "allow": [ |
| 220 | + "mcp__unity", |
| 221 | + "Edit(reports/**)", |
| 222 | + "MultiEdit(reports/**)" |
| 223 | + ], |
| 224 | + "deny": [ |
| 225 | + "Bash", |
| 226 | + "WebFetch", |
| 227 | + "WebSearch", |
| 228 | + "Task", |
| 229 | + "TodoWrite", |
| 230 | + "NotebookEdit", |
| 231 | + "NotebookRead" |
| 232 | + ] |
| 233 | + } |
| 234 | + } |
| 235 | + JSON |
| 236 | +
|
| 237 | + - name: Verify MCP fake Unity instance |
| 238 | + env: |
| 239 | + PYTHONUNBUFFERED: "1" |
| 240 | + MCP_LOG_LEVEL: debug |
| 241 | + UNITY_PROJECT_ROOT: ${{ github.workspace }}/TestProjects/UnityMCPTests |
| 242 | + UNITY_MCP_STATUS_DIR: ${{ github.workspace }}/.unity-mcp |
| 243 | + UNITY_MCP_HOST: 127.0.0.1 |
| 244 | + UNITY_MCP_DEFAULT_INSTANCE: ${{ env.UNITY_MCP_DEFAULT_INSTANCE }} |
| 245 | + run: | |
| 246 | + set -euxo pipefail |
| 247 | + ls -la "$UNITY_MCP_STATUS_DIR" |
| 248 | + jq -r . "$UNITY_MCP_STATUS_DIR"/unity-mcp-status-*.json |
| 249 | + echo "--- Listener process ---" |
| 250 | + pgrep -fl fake-unity-listener || true |
| 251 | + echo "--- Socket listeners ---" |
| 252 | + ss -ltnp || true |
| 253 | + echo "--- Fake listener log ---" |
| 254 | + cat /tmp/fake-unity-listener.log || true |
| 255 | +
|
| 256 | + # Extra probe from this step |
| 257 | + python3 <<'PY' |
| 258 | + import socket, struct, sys, os |
| 259 | + port = int(os.environ.get("UNITY_PORT", "6400")) |
| 260 | + s = socket.create_connection(("127.0.0.1", port), timeout=2) |
| 261 | + s.settimeout(2) |
| 262 | + hs = s.recv(512) |
| 263 | + print("probe-handshake:", hs) |
| 264 | + s.sendall(struct.pack(">Q", 4) + b"ping") |
| 265 | + hdr = s.recv(8) |
| 266 | + print("probe-hdr-len:", len(hdr), "data:", hdr) |
| 267 | + if len(hdr) == 8: |
| 268 | + ln = struct.unpack(">Q", hdr)[0] |
| 269 | + resp = s.recv(ln) |
| 270 | + print("probe-resp:", resp) |
| 271 | + s.close() |
| 272 | + PY |
| 273 | + echo "--- Port discovery debug ---" |
| 274 | + python3 <<'PY' |
| 275 | + import glob, json, os, socket, struct |
| 276 | + from pathlib import Path |
| 277 | + status_dir = Path(os.environ["UNITY_MCP_STATUS_DIR"]) |
| 278 | + files = sorted(glob.glob(str(status_dir / "unity-mcp-status-*.json"))) |
| 279 | + print("status_dir", status_dir) |
| 280 | + print("files", files) |
| 281 | + for f in files: |
| 282 | + try: |
| 283 | + data = json.loads(Path(f).read_text()) |
| 284 | + port = data.get("unity_port") |
| 285 | + print(f"file={f} port={port}") |
| 286 | + if isinstance(port, int): |
| 287 | + s = socket.create_connection(("127.0.0.1", port), timeout=2) |
| 288 | + hs = s.recv(512) |
| 289 | + s.sendall(struct.pack(">Q", 4) + b"ping") |
| 290 | + hdr = s.recv(8) |
| 291 | + resp = s.recv(struct.unpack(">Q", hdr)[0]) if len(hdr) == 8 else b"" |
| 292 | + print("probe", f, "hs_ok", b"FRAMING=1" in hs, "hdr_len", len(hdr), "resp", resp) |
| 293 | + s.close() |
| 294 | + except Exception as e: |
| 295 | + print("probe error", f, e) |
| 296 | + PY |
| 297 | +
|
| 298 | + echo "--- PortDiscovery.discover_all_unity_instances ---" |
| 299 | + python3 - <<'PY' |
| 300 | + from transport.legacy.port_discovery import PortDiscovery |
| 301 | + inst = PortDiscovery.discover_all_unity_instances() |
| 302 | + print(inst) |
| 303 | + print("try_probe_direct", PortDiscovery._try_probe_unity_mcp(6400)) |
| 304 | + print("discover_unity_port", PortDiscovery.discover_unity_port()) |
| 305 | + PY |
| 306 | +
|
| 307 | + python - <<'PY' |
| 308 | + import json |
| 309 | + import subprocess |
| 310 | + cmd = [ |
| 311 | + "uv", "run", "--active", "--directory", "Server", "python", "-c", |
| 312 | + "from transport.legacy.stdio_port_registry import stdio_port_registry; " |
| 313 | + "inst = stdio_port_registry.get_instances(force_refresh=True); " |
| 314 | + "import json; print(json.dumps([{'id':i.id,'port':i.port} for i in inst]))" |
| 315 | + ] |
| 316 | + result = subprocess.run(cmd, capture_output=True, text=True) |
| 317 | + print(result.stdout.strip()) |
| 318 | + if result.returncode != 0: |
| 319 | + print(result.stderr) |
| 320 | + raise SystemExit(1) |
| 321 | + try: |
| 322 | + data = json.loads(result.stdout.strip() or "[]") |
| 323 | + if not data: |
| 324 | + print("::error::No Unity instances discovered by MCP registry") |
| 325 | + raise SystemExit(1) |
| 326 | + except Exception as e: |
| 327 | + print(f"::error::Failed to parse instances: {e}") |
| 328 | + raise SystemExit(1) |
| 329 | + PY |
| 330 | +
|
| 331 | + - name: Claude smoke (fake Unity) |
| 332 | + uses: anthropics/claude-code-base-action@beta |
| 333 | + if: steps.detect.outputs.anthropic_ok == 'true' |
| 334 | + continue-on-error: true |
| 335 | + env: |
| 336 | + UNITY_MCP_DEFAULT_INSTANCE: ${{ env.UNITY_MCP_DEFAULT_INSTANCE }} |
| 337 | + with: |
| 338 | + use_node_cache: false |
| 339 | + prompt: | |
| 340 | + You are running against a fake Unity MCP instance for connectivity debug. |
| 341 | + - Call one unity tool (any) or report the registry instances you see. |
| 342 | + - If tools fail to connect, print the exact error and stop. |
| 343 | + - Do not write or edit files. |
| 344 | + mcp_config: .claude/mcp.json |
| 345 | + settings: .claude/settings.json |
| 346 | + allowed_tools: "mcp__unity" |
| 347 | + disallowed_tools: "Edit(reports/**),MultiEdit(reports/**),Bash,WebFetch,WebSearch,Task,TodoWrite,NotebookEdit,NotebookRead" |
| 348 | + max_turns: 1 |
| 349 | + timeout_minutes: "5" |
| 350 | + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} |
| 351 | + |
| 352 | + - name: Debug MCP server startup (after Claude) |
| 353 | + if: always() |
| 354 | + run: | |
| 355 | + set -eux |
| 356 | + echo "=== MCP Server Startup Debug Log ===" |
| 357 | + cat "$GITHUB_WORKSPACE/.unity-mcp/mcp-server-startup-debug.log" 2>/dev/null || echo "(no debug log found)" |
| 358 | + echo "" |
| 359 | + echo "=== Fake listener log ===" |
| 360 | + cat /tmp/fake-unity-listener.log 2>/dev/null || echo "(no listener log)" |
| 361 | +
|
0 commit comments