|
14 | 14 | import glob |
15 | 15 | import json |
16 | 16 | import logging |
| 17 | +import os |
| 18 | +import struct |
| 19 | +from datetime import datetime |
17 | 20 | from pathlib import Path |
18 | 21 | import socket |
19 | 22 | from typing import Optional, List |
20 | 23 |
|
| 24 | +from models import UnityInstanceInfo |
| 25 | + |
21 | 26 | logger = logging.getLogger("mcp-for-unity-server") |
22 | 27 |
|
23 | 28 |
|
@@ -56,22 +61,43 @@ def list_candidate_files() -> List[Path]: |
56 | 61 | @staticmethod |
57 | 62 | def _try_probe_unity_mcp(port: int) -> bool: |
58 | 63 | """Quickly check if a MCP for Unity listener is on this port. |
59 | | - Tries a short TCP connect, sends 'ping', expects Unity bridge welcome message. |
| 64 | + Uses Unity's framed protocol: receives handshake, sends framed ping, expects framed pong. |
60 | 65 | """ |
61 | 66 | try: |
62 | 67 | with socket.create_connection(("127.0.0.1", port), PortDiscovery.CONNECT_TIMEOUT) as s: |
63 | 68 | s.settimeout(PortDiscovery.CONNECT_TIMEOUT) |
64 | 69 | try: |
65 | | - s.sendall(b"ping") |
66 | | - data = s.recv(512) |
67 | | - # Check for Unity bridge welcome message format |
68 | | - if data and (b"WELCOME UNITY-MCP" in data or b'"message":"pong"' in data): |
69 | | - return True |
70 | | - except Exception: |
| 70 | + # 1. Receive handshake from Unity |
| 71 | + handshake = s.recv(512) |
| 72 | + if not handshake or b"FRAMING=1" not in handshake: |
| 73 | + # Try legacy mode as fallback |
| 74 | + s.sendall(b"ping") |
| 75 | + data = s.recv(512) |
| 76 | + return data and b'"message":"pong"' in data |
| 77 | + |
| 78 | + # 2. Send framed ping command |
| 79 | + # Frame format: 8-byte length header (big-endian uint64) + payload |
| 80 | + payload = b"ping" |
| 81 | + header = struct.pack('>Q', len(payload)) |
| 82 | + s.sendall(header + payload) |
| 83 | + |
| 84 | + # 3. Receive framed response |
| 85 | + response_header = s.recv(8) |
| 86 | + if len(response_header) != 8: |
| 87 | + return False |
| 88 | + |
| 89 | + response_length = struct.unpack('>Q', response_header)[0] |
| 90 | + if response_length > 10000: # Sanity check |
| 91 | + return False |
| 92 | + |
| 93 | + response = s.recv(response_length) |
| 94 | + return b'"message":"pong"' in response |
| 95 | + except Exception as e: |
| 96 | + logger.debug(f"Port probe failed for {port}: {e}") |
71 | 97 | return False |
72 | | - except Exception: |
| 98 | + except Exception as e: |
| 99 | + logger.debug(f"Connection failed for port {port}: {e}") |
73 | 100 | return False |
74 | | - return False |
75 | 101 |
|
76 | 102 | @staticmethod |
77 | 103 | def _read_latest_status() -> Optional[dict]: |
@@ -158,3 +184,94 @@ def get_port_config() -> Optional[dict]: |
158 | 184 | logger.warning( |
159 | 185 | f"Could not read port configuration {path}: {e}") |
160 | 186 | return None |
| 187 | + |
| 188 | + @staticmethod |
| 189 | + def _extract_project_name(project_path: str) -> str: |
| 190 | + """Extract project name from Assets path. |
| 191 | +
|
| 192 | + Examples: |
| 193 | + /Users/sakura/Projects/MyGame/Assets -> MyGame |
| 194 | + C:\\Projects\\TestProject\\Assets -> TestProject |
| 195 | + """ |
| 196 | + if not project_path: |
| 197 | + return "Unknown" |
| 198 | + |
| 199 | + try: |
| 200 | + # Remove trailing /Assets or \Assets |
| 201 | + path = project_path.rstrip('/\\') |
| 202 | + if path.endswith('Assets'): |
| 203 | + path = path[:-6].rstrip('/\\') |
| 204 | + |
| 205 | + # Get the last directory name |
| 206 | + name = os.path.basename(path) |
| 207 | + return name if name else "Unknown" |
| 208 | + except Exception: |
| 209 | + return "Unknown" |
| 210 | + |
| 211 | + @staticmethod |
| 212 | + def discover_all_unity_instances() -> List[UnityInstanceInfo]: |
| 213 | + """ |
| 214 | + Discover all running Unity Editor instances by scanning status files. |
| 215 | +
|
| 216 | + Returns: |
| 217 | + List of UnityInstanceInfo objects for all discovered instances |
| 218 | + """ |
| 219 | + instances = [] |
| 220 | + base = PortDiscovery.get_registry_dir() |
| 221 | + |
| 222 | + # Scan all status files |
| 223 | + status_pattern = str(base / "unity-mcp-status-*.json") |
| 224 | + status_files = glob.glob(status_pattern) |
| 225 | + |
| 226 | + for status_file_path in status_files: |
| 227 | + try: |
| 228 | + with open(status_file_path, 'r') as f: |
| 229 | + data = json.load(f) |
| 230 | + |
| 231 | + # Extract hash from filename: unity-mcp-status-{hash}.json |
| 232 | + filename = os.path.basename(status_file_path) |
| 233 | + hash_value = filename.replace('unity-mcp-status-', '').replace('.json', '') |
| 234 | + |
| 235 | + # Extract information |
| 236 | + project_path = data.get('project_path', '') |
| 237 | + project_name = PortDiscovery._extract_project_name(project_path) |
| 238 | + port = data.get('unity_port') |
| 239 | + is_reloading = data.get('reloading', False) |
| 240 | + |
| 241 | + # Parse last_heartbeat |
| 242 | + last_heartbeat = None |
| 243 | + heartbeat_str = data.get('last_heartbeat') |
| 244 | + if heartbeat_str: |
| 245 | + try: |
| 246 | + last_heartbeat = datetime.fromisoformat(heartbeat_str.replace('Z', '+00:00')) |
| 247 | + except Exception: |
| 248 | + pass |
| 249 | + |
| 250 | + # Verify port is actually responding |
| 251 | + is_alive = PortDiscovery._try_probe_unity_mcp(port) if isinstance(port, int) else False |
| 252 | + |
| 253 | + if not is_alive: |
| 254 | + logger.debug(f"Instance {project_name}@{hash_value} has heartbeat but port {port} not responding") |
| 255 | + continue |
| 256 | + |
| 257 | + # Create instance info |
| 258 | + instance = UnityInstanceInfo( |
| 259 | + id=f"{project_name}@{hash_value}", |
| 260 | + name=project_name, |
| 261 | + path=project_path, |
| 262 | + hash=hash_value, |
| 263 | + port=port, |
| 264 | + status="reloading" if is_reloading else "running", |
| 265 | + last_heartbeat=last_heartbeat, |
| 266 | + unity_version=data.get('unity_version') # May not be available in current version |
| 267 | + ) |
| 268 | + |
| 269 | + instances.append(instance) |
| 270 | + logger.debug(f"Discovered Unity instance: {instance.id} on port {instance.port}") |
| 271 | + |
| 272 | + except Exception as e: |
| 273 | + logger.debug(f"Failed to parse status file {status_file_path}: {e}") |
| 274 | + continue |
| 275 | + |
| 276 | + logger.info(f"Discovered {len(instances)} Unity instances") |
| 277 | + return instances |
0 commit comments