mcpforunityserver 9.4.0b20260203025228__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. cli/__init__.py +3 -0
  2. cli/commands/__init__.py +3 -0
  3. cli/commands/animation.py +84 -0
  4. cli/commands/asset.py +280 -0
  5. cli/commands/audio.py +125 -0
  6. cli/commands/batch.py +171 -0
  7. cli/commands/code.py +182 -0
  8. cli/commands/component.py +190 -0
  9. cli/commands/editor.py +447 -0
  10. cli/commands/gameobject.py +487 -0
  11. cli/commands/instance.py +93 -0
  12. cli/commands/lighting.py +123 -0
  13. cli/commands/material.py +239 -0
  14. cli/commands/prefab.py +248 -0
  15. cli/commands/scene.py +231 -0
  16. cli/commands/script.py +222 -0
  17. cli/commands/shader.py +226 -0
  18. cli/commands/texture.py +540 -0
  19. cli/commands/tool.py +58 -0
  20. cli/commands/ui.py +258 -0
  21. cli/commands/vfx.py +421 -0
  22. cli/main.py +281 -0
  23. cli/utils/__init__.py +31 -0
  24. cli/utils/config.py +58 -0
  25. cli/utils/confirmation.py +37 -0
  26. cli/utils/connection.py +254 -0
  27. cli/utils/constants.py +23 -0
  28. cli/utils/output.py +195 -0
  29. cli/utils/parsers.py +112 -0
  30. cli/utils/suggestions.py +34 -0
  31. core/__init__.py +0 -0
  32. core/config.py +67 -0
  33. core/constants.py +4 -0
  34. core/logging_decorator.py +37 -0
  35. core/telemetry.py +551 -0
  36. core/telemetry_decorator.py +164 -0
  37. main.py +845 -0
  38. mcpforunityserver-9.4.0b20260203025228.dist-info/METADATA +328 -0
  39. mcpforunityserver-9.4.0b20260203025228.dist-info/RECORD +105 -0
  40. mcpforunityserver-9.4.0b20260203025228.dist-info/WHEEL +5 -0
  41. mcpforunityserver-9.4.0b20260203025228.dist-info/entry_points.txt +3 -0
  42. mcpforunityserver-9.4.0b20260203025228.dist-info/licenses/LICENSE +21 -0
  43. mcpforunityserver-9.4.0b20260203025228.dist-info/top_level.txt +7 -0
  44. models/__init__.py +4 -0
  45. models/models.py +56 -0
  46. models/unity_response.py +70 -0
  47. services/__init__.py +0 -0
  48. services/api_key_service.py +235 -0
  49. services/custom_tool_service.py +499 -0
  50. services/registry/__init__.py +22 -0
  51. services/registry/resource_registry.py +53 -0
  52. services/registry/tool_registry.py +51 -0
  53. services/resources/__init__.py +86 -0
  54. services/resources/active_tool.py +48 -0
  55. services/resources/custom_tools.py +57 -0
  56. services/resources/editor_state.py +304 -0
  57. services/resources/gameobject.py +243 -0
  58. services/resources/layers.py +30 -0
  59. services/resources/menu_items.py +35 -0
  60. services/resources/prefab.py +191 -0
  61. services/resources/prefab_stage.py +40 -0
  62. services/resources/project_info.py +40 -0
  63. services/resources/selection.py +56 -0
  64. services/resources/tags.py +31 -0
  65. services/resources/tests.py +88 -0
  66. services/resources/unity_instances.py +125 -0
  67. services/resources/windows.py +48 -0
  68. services/state/external_changes_scanner.py +245 -0
  69. services/tools/__init__.py +83 -0
  70. services/tools/batch_execute.py +93 -0
  71. services/tools/debug_request_context.py +86 -0
  72. services/tools/execute_custom_tool.py +43 -0
  73. services/tools/execute_menu_item.py +32 -0
  74. services/tools/find_gameobjects.py +110 -0
  75. services/tools/find_in_file.py +181 -0
  76. services/tools/manage_asset.py +119 -0
  77. services/tools/manage_components.py +131 -0
  78. services/tools/manage_editor.py +64 -0
  79. services/tools/manage_gameobject.py +260 -0
  80. services/tools/manage_material.py +111 -0
  81. services/tools/manage_prefabs.py +209 -0
  82. services/tools/manage_scene.py +111 -0
  83. services/tools/manage_script.py +645 -0
  84. services/tools/manage_scriptable_object.py +87 -0
  85. services/tools/manage_shader.py +71 -0
  86. services/tools/manage_texture.py +581 -0
  87. services/tools/manage_vfx.py +120 -0
  88. services/tools/preflight.py +110 -0
  89. services/tools/read_console.py +151 -0
  90. services/tools/refresh_unity.py +153 -0
  91. services/tools/run_tests.py +317 -0
  92. services/tools/script_apply_edits.py +1006 -0
  93. services/tools/set_active_instance.py +120 -0
  94. services/tools/utils.py +348 -0
  95. transport/__init__.py +0 -0
  96. transport/legacy/port_discovery.py +329 -0
  97. transport/legacy/stdio_port_registry.py +65 -0
  98. transport/legacy/unity_connection.py +910 -0
  99. transport/models.py +68 -0
  100. transport/plugin_hub.py +787 -0
  101. transport/plugin_registry.py +182 -0
  102. transport/unity_instance_middleware.py +262 -0
  103. transport/unity_transport.py +94 -0
  104. utils/focus_nudge.py +589 -0
  105. utils/module_discovery.py +55 -0
@@ -0,0 +1,910 @@
1
+ from core.config import config
2
+ import contextlib
3
+ from dataclasses import dataclass
4
+ import errno
5
+ import json
6
+ import logging
7
+ import os
8
+ from pathlib import Path
9
+ from transport.legacy.port_discovery import PortDiscovery
10
+ import random
11
+ import socket
12
+ import struct
13
+ import threading
14
+ import time
15
+ from typing import Any
16
+
17
+ from models.models import MCPResponse, UnityInstanceInfo
18
+ from transport.legacy.stdio_port_registry import stdio_port_registry
19
+
20
+
21
+ logger = logging.getLogger("mcp-for-unity-server")
22
+
23
+ # Module-level lock to guard global connection initialization
24
+ _connection_lock = threading.Lock()
25
+
26
+ # Maximum allowed framed payload size (64 MiB)
27
+ FRAMED_MAX = 64 * 1024 * 1024
28
+
29
+
30
+ @dataclass
31
+ class UnityConnection:
32
+ """Manages the socket connection to the Unity Editor."""
33
+ host: str = config.unity_host
34
+ port: int = None # Will be set dynamically
35
+ sock: socket.socket = None # Socket for Unity communication
36
+ use_framing: bool = False # Negotiated per-connection
37
+ instance_id: str | None = None # Instance identifier for reconnection
38
+
39
+ def __post_init__(self):
40
+ """Set port from discovery if not explicitly provided"""
41
+ if self.port is None:
42
+ self.port = stdio_port_registry.get_port(self.instance_id)
43
+ self._io_lock = threading.Lock()
44
+ self._conn_lock = threading.Lock()
45
+
46
+ def _prepare_socket(self, sock: socket.socket) -> None:
47
+ try:
48
+ sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
49
+ except OSError as exc:
50
+ logger.debug(f"Unable to set TCP_NODELAY: {exc}")
51
+
52
+ def connect(self) -> bool:
53
+ """Establish a connection to the Unity Editor."""
54
+ if self.sock:
55
+ return True
56
+ with self._conn_lock:
57
+ if self.sock:
58
+ return True
59
+ try:
60
+ # Bounded connect to avoid indefinite blocking
61
+ connect_timeout = float(
62
+ getattr(config, "connection_timeout", 1.0))
63
+ # We trust config.unity_host (default 127.0.0.1) but future improvements
64
+ # could dynamically prefer 'localhost' depending on OS resolver behavior.
65
+ self.sock = socket.create_connection(
66
+ (self.host, self.port), connect_timeout)
67
+ self._prepare_socket(self.sock)
68
+ logger.debug(f"Connected to Unity at {self.host}:{self.port}")
69
+
70
+ # Strict handshake: require FRAMING=1
71
+ try:
72
+ require_framing = getattr(config, "require_framing", True)
73
+ handshake_timeout = float(
74
+ getattr(config, "handshake_timeout", 1.0))
75
+ self.sock.settimeout(handshake_timeout)
76
+ buf = bytearray()
77
+ deadline = time.monotonic() + handshake_timeout
78
+ while time.monotonic() < deadline and len(buf) < 512:
79
+ try:
80
+ chunk = self.sock.recv(256)
81
+ if not chunk:
82
+ break
83
+ buf.extend(chunk)
84
+ if b"\n" in buf:
85
+ break
86
+ except socket.timeout:
87
+ break
88
+ text = bytes(buf).decode('ascii', errors='ignore').strip()
89
+
90
+ if 'FRAMING=1' in text:
91
+ self.use_framing = True
92
+ logger.debug(
93
+ 'MCP for Unity handshake received: FRAMING=1 (strict)')
94
+ else:
95
+ if require_framing:
96
+ # Best-effort plain-text advisory for legacy peers
97
+ with contextlib.suppress(Exception):
98
+ self.sock.sendall(
99
+ b'MCP for Unity requires FRAMING=1\n')
100
+ raise ConnectionError(
101
+ f'MCP for Unity requires FRAMING=1, got: {text!r}')
102
+ else:
103
+ self.use_framing = False
104
+ logger.warning(
105
+ 'MCP for Unity handshake missing FRAMING=1; proceeding in legacy mode by configuration')
106
+ finally:
107
+ self.sock.settimeout(config.connection_timeout)
108
+ return True
109
+ except Exception as e:
110
+ logger.error(f"Failed to connect to Unity: {str(e)}")
111
+ try:
112
+ if self.sock:
113
+ self.sock.close()
114
+ except Exception:
115
+ pass
116
+ self.sock = None
117
+ return False
118
+
119
+ def disconnect(self):
120
+ """Close the connection to the Unity Editor."""
121
+ if self.sock:
122
+ try:
123
+ self.sock.close()
124
+ except Exception as e:
125
+ logger.error(f"Error disconnecting from Unity: {str(e)}")
126
+ finally:
127
+ self.sock = None
128
+
129
+ def _read_exact(self, sock: socket.socket, count: int) -> bytes:
130
+ data = bytearray()
131
+ while len(data) < count:
132
+ chunk = sock.recv(count - len(data))
133
+ if not chunk:
134
+ raise ConnectionError(
135
+ "Connection closed before reading expected bytes")
136
+ data.extend(chunk)
137
+ return bytes(data)
138
+
139
+ def receive_full_response(self, sock, buffer_size=config.buffer_size) -> bytes:
140
+ """Receive a complete response from Unity, handling chunked data."""
141
+ if self.use_framing:
142
+ # Heartbeat semantics: the Unity editor emits zero-length frames while
143
+ # a long-running command is still executing. We tolerate a bounded
144
+ # number of these frames (or a small time window) before surfacing a
145
+ # timeout to the caller so tools can retry or fail gracefully.
146
+ heartbeat_limit = getattr(config, 'max_heartbeat_frames', 16)
147
+ heartbeat_window = getattr(config, 'heartbeat_timeout', 2.0)
148
+ heartbeat_started = time.monotonic()
149
+ heartbeat_count = 0
150
+ try:
151
+ while True:
152
+ header = self._read_exact(sock, 8)
153
+ payload_len = struct.unpack('>Q', header)[0]
154
+ if payload_len == 0:
155
+ heartbeat_count += 1
156
+ logger.debug(
157
+ f"Received heartbeat frame #{heartbeat_count}")
158
+ if heartbeat_count >= heartbeat_limit or (time.monotonic() - heartbeat_started) > heartbeat_window:
159
+ raise TimeoutError(
160
+ "Unity sent heartbeat frames without payload within configured threshold"
161
+ )
162
+ continue
163
+ if payload_len > FRAMED_MAX:
164
+ raise ValueError(
165
+ f"Invalid framed length: {payload_len}")
166
+ payload = self._read_exact(sock, payload_len)
167
+ logger.debug(
168
+ f"Received framed response ({len(payload)} bytes)")
169
+ return payload
170
+ except socket.timeout as exc:
171
+ logger.warning("Socket timeout during framed receive")
172
+ raise TimeoutError("Timeout receiving Unity response") from exc
173
+ except TimeoutError:
174
+ raise
175
+ except Exception as exc:
176
+ logger.error(f"Error during framed receive: {exc}")
177
+ raise
178
+
179
+ chunks = []
180
+ # Respect the socket's currently configured timeout
181
+ try:
182
+ while True:
183
+ chunk = sock.recv(buffer_size)
184
+ if not chunk:
185
+ if not chunks:
186
+ raise Exception(
187
+ "Connection closed before receiving data")
188
+ break
189
+ chunks.append(chunk)
190
+
191
+ # Process the data received so far
192
+ data = b''.join(chunks)
193
+ decoded_data = data.decode('utf-8')
194
+
195
+ # Check if we've received a complete response
196
+ try:
197
+ # Special case for ping-pong
198
+ if decoded_data.strip().startswith('{"status":"success","result":{"message":"pong"'):
199
+ logger.debug("Received ping response")
200
+ return data
201
+
202
+ # Handle escaped quotes in the content
203
+ if '"content":' in decoded_data:
204
+ # Find the content field and its value
205
+ content_start = decoded_data.find('"content":') + 9
206
+ content_end = decoded_data.rfind('"', content_start)
207
+ if content_end > content_start:
208
+ # Replace escaped quotes in content with regular quotes
209
+ content = decoded_data[content_start:content_end]
210
+ content = content.replace('\\"', '"')
211
+ decoded_data = decoded_data[:content_start] + \
212
+ content + decoded_data[content_end:]
213
+
214
+ # Validate JSON format
215
+ json.loads(decoded_data)
216
+
217
+ # If we get here, we have valid JSON
218
+ logger.info(
219
+ f"Received complete response ({len(data)} bytes)")
220
+ return data
221
+ except json.JSONDecodeError:
222
+ # We haven't received a complete valid JSON response yet
223
+ continue
224
+ except Exception as e:
225
+ logger.warning(
226
+ f"Error processing response chunk: {str(e)}")
227
+ # Continue reading more chunks as this might not be the complete response
228
+ continue
229
+ except socket.timeout:
230
+ logger.warning("Socket timeout during receive")
231
+ raise Exception("Timeout receiving Unity response")
232
+ except Exception as e:
233
+ logger.error(f"Error during receive: {str(e)}")
234
+ raise
235
+
236
+ def send_command(self, command_type: str, params: dict[str, Any] = None, max_attempts: int | None = None) -> dict[str, Any]:
237
+ """Send a command with retry/backoff and port rediscovery. Pings only when requested.
238
+
239
+ Args:
240
+ command_type: The Unity command to send
241
+ params: Command parameters
242
+ max_attempts: Maximum retry attempts (None = use config default, 0 = no retries)
243
+ """
244
+ # Defensive guard: catch empty/placeholder invocations early
245
+ if not command_type:
246
+ raise ValueError("MCP call missing command_type")
247
+ if params is None:
248
+ return MCPResponse(success=False, error="MCP call received with no parameters (client placeholder?)")
249
+ attempts = max(config.max_retries,
250
+ 5) if max_attempts is None else max_attempts
251
+ base_backoff = max(0.5, config.retry_delay)
252
+
253
+ def read_status_file(target_hash: str | None = None) -> dict | None:
254
+ try:
255
+ base_path = Path.home().joinpath('.unity-mcp')
256
+ status_files = sorted(
257
+ base_path.glob('unity-mcp-status-*.json'),
258
+ key=lambda p: p.stat().st_mtime,
259
+ reverse=True,
260
+ )
261
+ if not status_files:
262
+ return None
263
+ if target_hash:
264
+ for status_path in status_files:
265
+ if status_path.stem.endswith(target_hash):
266
+ with status_path.open('r') as f:
267
+ return json.load(f)
268
+ # Fallback: return most recent regardless of hash
269
+ with status_files[0].open('r') as f:
270
+ return json.load(f)
271
+ except FileNotFoundError:
272
+ logger.debug(
273
+ "Unity status file disappeared before it could be read")
274
+ return None
275
+ except json.JSONDecodeError as exc:
276
+ logger.warning(f"Malformed Unity status file: {exc}")
277
+ return None
278
+ except OSError as exc:
279
+ logger.warning(f"Failed to read Unity status file: {exc}")
280
+ return None
281
+ except Exception as exc:
282
+ logger.debug(f"Preflight status check failed: {exc}")
283
+ return None
284
+
285
+ last_short_timeout = None
286
+
287
+ # Extract hash suffix from instance id (e.g., Project@hash)
288
+ target_hash: str | None = None
289
+ if self.instance_id and '@' in self.instance_id:
290
+ maybe_hash = self.instance_id.split('@', 1)[1].strip()
291
+ if maybe_hash:
292
+ target_hash = maybe_hash
293
+
294
+ # Preflight: if Unity reports reloading, return a structured hint so clients can retry politely
295
+ try:
296
+ status = read_status_file(target_hash)
297
+ if status and (status.get('reloading') or status.get('reason') == 'reloading'):
298
+ return MCPResponse(
299
+ success=False,
300
+ error="Unity is reloading; please retry",
301
+ hint="retry",
302
+ )
303
+ except Exception as exc:
304
+ logger.debug(f"Preflight status check failed: {exc}")
305
+
306
+ for attempt in range(attempts + 1):
307
+ try:
308
+ # Ensure connected (handshake occurs within connect())
309
+ t_conn_start = time.time()
310
+ if not self.sock and not self.connect():
311
+ raise ConnectionError("Could not connect to Unity")
312
+ logger.info("[TIMING-STDIO] connect took %.3fs command=%s", time.time() - t_conn_start, command_type)
313
+
314
+ # Build payload
315
+ if command_type == 'ping':
316
+ payload = b'ping'
317
+ else:
318
+ payload = json.dumps({
319
+ 'type': command_type,
320
+ 'params': params,
321
+ }).encode('utf-8')
322
+
323
+ # Send/receive are serialized to protect the shared socket
324
+ with self._io_lock:
325
+ mode = 'framed' if self.use_framing else 'legacy'
326
+ with contextlib.suppress(Exception):
327
+ logger.debug(
328
+ f"send {len(payload)} bytes; mode={mode}; head={payload[:32].decode('utf-8', 'ignore')}")
329
+ t_send_start = time.time()
330
+ if self.use_framing:
331
+ header = struct.pack('>Q', len(payload))
332
+ self.sock.sendall(header)
333
+ self.sock.sendall(payload)
334
+ else:
335
+ self.sock.sendall(payload)
336
+ logger.info("[TIMING-STDIO] sendall took %.3fs command=%s", time.time() - t_send_start, command_type)
337
+
338
+ # During retry bursts use a short receive timeout and ensure restoration
339
+ restore_timeout = None
340
+ if attempt > 0 and last_short_timeout is None:
341
+ restore_timeout = self.sock.gettimeout()
342
+ self.sock.settimeout(1.0)
343
+ try:
344
+ t_recv_start = time.time()
345
+ response_data = self.receive_full_response(self.sock)
346
+ logger.info("[TIMING-STDIO] receive took %.3fs command=%s len=%d", time.time() - t_recv_start, command_type, len(response_data))
347
+ with contextlib.suppress(Exception):
348
+ logger.debug(
349
+ f"recv {len(response_data)} bytes; mode={mode}")
350
+ finally:
351
+ if restore_timeout is not None:
352
+ self.sock.settimeout(restore_timeout)
353
+ last_short_timeout = None
354
+
355
+ # Parse
356
+ if command_type == 'ping':
357
+ resp = json.loads(response_data.decode('utf-8'))
358
+ if resp.get('status') == 'success' and resp.get('result', {}).get('message') == 'pong':
359
+ return {"message": "pong"}
360
+ raise Exception("Ping unsuccessful")
361
+
362
+ resp = json.loads(response_data.decode('utf-8'))
363
+ if resp.get('status') == 'error':
364
+ err = resp.get('error') or resp.get(
365
+ 'message', 'Unknown Unity error')
366
+ raise Exception(err)
367
+ return resp.get('result', {})
368
+ except Exception as e:
369
+ logger.warning(
370
+ f"Unity communication attempt {attempt+1} failed: {e}")
371
+ try:
372
+ if self.sock:
373
+ self.sock.close()
374
+ finally:
375
+ self.sock = None
376
+
377
+ # Re-discover the port for this specific instance
378
+ try:
379
+ new_port: int | None = None
380
+ if self.instance_id:
381
+ # Try to rediscover the specific instance via shared registry
382
+ refreshed_instance = stdio_port_registry.get_instance(
383
+ self.instance_id)
384
+ if refreshed_instance and isinstance(refreshed_instance.port, int):
385
+ new_port = refreshed_instance.port
386
+ logger.debug(
387
+ f"Rediscovered instance {self.instance_id} on port {new_port}")
388
+ else:
389
+ logger.warning(
390
+ f"Instance {self.instance_id} not found during reconnection; falling back to port scan",
391
+ )
392
+
393
+ # Fallback to registry default if instance-specific discovery failed
394
+ if new_port is None:
395
+ new_port = stdio_port_registry.get_port(
396
+ self.instance_id)
397
+ logger.info(
398
+ f"Using Unity port from stdio_port_registry: {new_port}")
399
+
400
+ if new_port != self.port:
401
+ logger.info(
402
+ f"Unity port changed {self.port} -> {new_port}")
403
+ self.port = new_port
404
+ except Exception as de:
405
+ logger.debug(f"Port discovery failed: {de}")
406
+
407
+ if attempt < attempts:
408
+ # Heartbeat-aware, jittered backoff
409
+ status = read_status_file(target_hash)
410
+ # Base exponential backoff
411
+ backoff = base_backoff * (2 ** attempt)
412
+ # Decorrelated jitter multiplier
413
+ jitter = random.uniform(0.1, 0.3)
414
+
415
+ # Fast‑retry for transient socket failures
416
+ fast_error = isinstance(
417
+ e, (ConnectionRefusedError, ConnectionResetError, TimeoutError))
418
+ if not fast_error:
419
+ try:
420
+ err_no = getattr(e, 'errno', None)
421
+ fast_error = err_no in (
422
+ errno.ECONNREFUSED, errno.ECONNRESET, errno.ETIMEDOUT)
423
+ except Exception:
424
+ pass
425
+
426
+ # Cap backoff depending on state
427
+ if status and status.get('reloading'):
428
+ # Domain reload can take 10-20s; use longer waits
429
+ cap = 5.0
430
+ elif fast_error:
431
+ cap = 0.25
432
+ else:
433
+ cap = 3.0
434
+
435
+ sleep_s = min(cap, jitter * (2 ** attempt))
436
+ time.sleep(sleep_s)
437
+ continue
438
+ raise
439
+
440
+
441
+ # -----------------------------
442
+ # Connection Pool for Multiple Unity Instances
443
+ # -----------------------------
444
+
445
+ class UnityConnectionPool:
446
+ """Manages connections to multiple Unity Editor instances"""
447
+
448
+ def __init__(self):
449
+ self._connections: dict[str, UnityConnection] = {}
450
+ self._known_instances: dict[str, UnityInstanceInfo] = {}
451
+ self._last_full_scan: float = 0
452
+ self._scan_interval: float = 5.0 # Cache for 5 seconds
453
+ self._pool_lock = threading.Lock()
454
+ self._default_instance_id: str | None = None
455
+
456
+ # Check for default instance from environment
457
+ env_default = os.environ.get("UNITY_MCP_DEFAULT_INSTANCE", "").strip()
458
+ if env_default:
459
+ self._default_instance_id = env_default
460
+ logger.info(
461
+ f"Default Unity instance set from environment: {env_default}")
462
+
463
+ def discover_all_instances(self, force_refresh: bool = False) -> list[UnityInstanceInfo]:
464
+ """
465
+ Discover all running Unity Editor instances.
466
+
467
+ Args:
468
+ force_refresh: If True, bypass cache and scan immediately
469
+
470
+ Returns:
471
+ List of UnityInstanceInfo objects
472
+ """
473
+ now = time.time()
474
+
475
+ # Return cached results if valid
476
+ if not force_refresh and (now - self._last_full_scan) < self._scan_interval:
477
+ logger.debug(
478
+ f"Returning cached Unity instances (age: {now - self._last_full_scan:.1f}s)")
479
+ return list(self._known_instances.values())
480
+
481
+ # Scan for instances
482
+ logger.debug("Scanning for Unity instances...")
483
+ instances = PortDiscovery.discover_all_unity_instances()
484
+
485
+ # Update cache
486
+ with self._pool_lock:
487
+ self._known_instances = {inst.id: inst for inst in instances}
488
+ self._last_full_scan = now
489
+
490
+ logger.info(
491
+ f"Found {len(instances)} Unity instances: {[inst.id for inst in instances]}")
492
+ return instances
493
+
494
+ def _resolve_instance_id(self, instance_identifier: str | None, instances: list[UnityInstanceInfo]) -> UnityInstanceInfo:
495
+ """
496
+ Resolve an instance identifier to a specific Unity instance.
497
+
498
+ Args:
499
+ instance_identifier: User-provided identifier (name, hash, name@hash, path, port, or None)
500
+ instances: List of available instances
501
+
502
+ Returns:
503
+ Resolved UnityInstanceInfo
504
+
505
+ Raises:
506
+ ConnectionError: If instance cannot be resolved
507
+ """
508
+ if not instances:
509
+ raise ConnectionError(
510
+ "No Unity Editor instances found. Please ensure Unity is running with MCP for Unity bridge."
511
+ )
512
+
513
+ # Use default instance if no identifier provided
514
+ if instance_identifier is None:
515
+ if self._default_instance_id:
516
+ instance_identifier = self._default_instance_id
517
+ logger.debug(f"Using default instance: {instance_identifier}")
518
+ else:
519
+ # Use the most recently active instance
520
+ # Instances with no heartbeat (None) should be sorted last (use 0 as sentinel)
521
+ sorted_instances = sorted(
522
+ instances,
523
+ key=lambda inst: inst.last_heartbeat.timestamp() if inst.last_heartbeat else 0.0,
524
+ reverse=True,
525
+ )
526
+ logger.info(
527
+ f"No instance specified, using most recent: {sorted_instances[0].id}")
528
+ return sorted_instances[0]
529
+
530
+ identifier = instance_identifier.strip()
531
+
532
+ # Try exact ID match first
533
+ for inst in instances:
534
+ if inst.id == identifier:
535
+ return inst
536
+
537
+ # Try project name match
538
+ name_matches = [inst for inst in instances if inst.name == identifier]
539
+ if len(name_matches) == 1:
540
+ return name_matches[0]
541
+ elif len(name_matches) > 1:
542
+ # Multiple projects with same name - return helpful error
543
+ suggestions = [
544
+ {
545
+ "id": inst.id,
546
+ "path": inst.path,
547
+ "port": inst.port,
548
+ "suggest": f"Use unity_instance='{inst.id}'"
549
+ }
550
+ for inst in name_matches
551
+ ]
552
+ raise ConnectionError(
553
+ f"Project name '{identifier}' matches {len(name_matches)} instances. "
554
+ f"Please use the full format (e.g., '{name_matches[0].id}'). "
555
+ f"Available instances: {suggestions}"
556
+ )
557
+
558
+ # Try hash match
559
+ hash_matches = [inst for inst in instances if inst.hash ==
560
+ identifier or inst.hash.startswith(identifier)]
561
+ if len(hash_matches) == 1:
562
+ return hash_matches[0]
563
+ elif len(hash_matches) > 1:
564
+ raise ConnectionError(
565
+ f"Hash '{identifier}' matches multiple instances: {[inst.id for inst in hash_matches]}"
566
+ )
567
+
568
+ # Try composite format: Name@Hash or Name@Port
569
+ if "@" in identifier:
570
+ name_part, hint_part = identifier.split("@", 1)
571
+ composite_matches = [
572
+ inst for inst in instances
573
+ if inst.name == name_part and (
574
+ inst.hash.startswith(hint_part) or str(
575
+ inst.port) == hint_part
576
+ )
577
+ ]
578
+ if len(composite_matches) == 1:
579
+ return composite_matches[0]
580
+
581
+ # Try port match (as string)
582
+ try:
583
+ port_num = int(identifier)
584
+ port_matches = [
585
+ inst for inst in instances if inst.port == port_num]
586
+ if len(port_matches) == 1:
587
+ return port_matches[0]
588
+ except ValueError:
589
+ pass
590
+
591
+ # Try path match
592
+ path_matches = [inst for inst in instances if inst.path == identifier]
593
+ if len(path_matches) == 1:
594
+ return path_matches[0]
595
+
596
+ # Nothing matched
597
+ available_ids = [inst.id for inst in instances]
598
+ raise ConnectionError(
599
+ f"Unity instance '{identifier}' not found. "
600
+ f"Available instances: {available_ids}. "
601
+ f"Check mcpforunity://instances resource for all instances."
602
+ )
603
+
604
+ def get_connection(self, instance_identifier: str | None = None) -> UnityConnection:
605
+ """
606
+ Get or create a connection to a Unity instance.
607
+
608
+ Args:
609
+ instance_identifier: Optional identifier (name, hash, name@hash, etc.)
610
+ If None, uses default or most recent instance
611
+
612
+ Returns:
613
+ UnityConnection to the specified instance
614
+
615
+ Raises:
616
+ ConnectionError: If instance cannot be found or connected
617
+ """
618
+ # Refresh instance list if cache expired
619
+ instances = self.discover_all_instances()
620
+
621
+ # Resolve identifier to specific instance
622
+ target = self._resolve_instance_id(instance_identifier, instances)
623
+
624
+ # Return existing connection or create new one
625
+ with self._pool_lock:
626
+ if target.id not in self._connections:
627
+ logger.info(
628
+ f"Creating new connection to Unity instance: {target.id} (port {target.port})")
629
+ conn = UnityConnection(port=target.port, instance_id=target.id)
630
+ if not conn.connect():
631
+ raise ConnectionError(
632
+ f"Failed to connect to Unity instance '{target.id}' on port {target.port}. "
633
+ f"Ensure the Unity Editor is running."
634
+ )
635
+ self._connections[target.id] = conn
636
+ else:
637
+ # Update existing connection with instance_id and port if changed
638
+ conn = self._connections[target.id]
639
+ conn.instance_id = target.id
640
+ if conn.port != target.port:
641
+ logger.info(
642
+ f"Updating cached port for {target.id}: {conn.port} -> {target.port}")
643
+ conn.port = target.port
644
+ logger.debug(f"Reusing existing connection to: {target.id}")
645
+
646
+ return self._connections[target.id]
647
+
648
+ def disconnect_all(self):
649
+ """Disconnect all active connections"""
650
+ with self._pool_lock:
651
+ for instance_id, conn in self._connections.items():
652
+ try:
653
+ logger.info(
654
+ f"Disconnecting from Unity instance: {instance_id}")
655
+ conn.disconnect()
656
+ except Exception:
657
+ logger.exception(f"Error disconnecting from {instance_id}")
658
+ self._connections.clear()
659
+
660
+
661
+ # Global Unity connection pool
662
+ _unity_connection_pool: UnityConnectionPool | None = None
663
+ _pool_init_lock = threading.Lock()
664
+
665
+
666
+ def get_unity_connection_pool() -> UnityConnectionPool:
667
+ """Get or create the global Unity connection pool"""
668
+ global _unity_connection_pool
669
+
670
+ if _unity_connection_pool is not None:
671
+ return _unity_connection_pool
672
+
673
+ with _pool_init_lock:
674
+ if _unity_connection_pool is not None:
675
+ return _unity_connection_pool
676
+
677
+ logger.info("Initializing Unity connection pool")
678
+ _unity_connection_pool = UnityConnectionPool()
679
+ return _unity_connection_pool
680
+
681
+
682
+ # Backwards compatibility: keep old single-connection function
683
+ def get_unity_connection(instance_identifier: str | None = None) -> UnityConnection:
684
+ """Retrieve or establish a Unity connection.
685
+
686
+ Args:
687
+ instance_identifier: Optional identifier for specific Unity instance.
688
+ If None, uses default or most recent instance.
689
+
690
+ Returns:
691
+ UnityConnection to the specified or default Unity instance
692
+
693
+ Note: This function now uses the connection pool internally.
694
+ """
695
+ pool = get_unity_connection_pool()
696
+ return pool.get_connection(instance_identifier)
697
+
698
+
699
+ # -----------------------------
700
+ # Centralized retry helpers
701
+ # -----------------------------
702
+
703
+ def _extract_response_reason(resp: object) -> str | None:
704
+ """Extract a normalized (lowercase) reason string from a response.
705
+
706
+ Returns lowercase reason values to enable case-insensitive comparisons
707
+ by callers (e.g. _is_reloading_response, refresh_unity).
708
+ """
709
+ if isinstance(resp, MCPResponse):
710
+ data = getattr(resp, "data", None)
711
+ if isinstance(data, dict):
712
+ reason = data.get("reason")
713
+ if isinstance(reason, str):
714
+ return reason.lower()
715
+ message_text = f"{resp.message or ''} {resp.error or ''}".lower()
716
+ if "reload" in message_text:
717
+ return "reloading"
718
+ return None
719
+
720
+ if isinstance(resp, dict):
721
+ if resp.get("state") == "reloading":
722
+ return "reloading"
723
+ data = resp.get("data")
724
+ if isinstance(data, dict):
725
+ reason = data.get("reason")
726
+ if isinstance(reason, str):
727
+ return reason.lower()
728
+ message_text = (resp.get("message") or resp.get("error") or "").lower()
729
+ if "reload" in message_text:
730
+ return "reloading"
731
+ return None
732
+
733
+ return None
734
+
735
+
736
+ def _is_reloading_response(resp: object) -> bool:
737
+ """Return True if the Unity response indicates the editor is reloading.
738
+
739
+ Supports both raw dict payloads from Unity and MCPResponse objects returned
740
+ by preflight checks or transport helpers.
741
+ """
742
+ return _extract_response_reason(resp) == "reloading"
743
+
744
+
745
+ def send_command_with_retry(
746
+ command_type: str,
747
+ params: dict[str, Any],
748
+ *,
749
+ instance_id: str | None = None,
750
+ max_retries: int | None = None,
751
+ retry_ms: int | None = None,
752
+ retry_on_reload: bool = True
753
+ ) -> dict[str, Any] | MCPResponse:
754
+ """Send a command to a Unity instance, waiting politely through Unity reloads.
755
+
756
+ Args:
757
+ command_type: The command type to send
758
+ params: Command parameters
759
+ instance_id: Optional Unity instance identifier (name, hash, name@hash, etc.)
760
+ max_retries: Maximum number of retries for reload states
761
+ retry_ms: Delay between retries in milliseconds
762
+ retry_on_reload: If False, don't retry when Unity is reloading (for commands
763
+ that trigger compilation/reload and shouldn't be re-sent)
764
+
765
+ Returns:
766
+ Response dictionary or MCPResponse from Unity
767
+
768
+ Uses config.reload_retry_ms and config.reload_max_retries by default. Preserves the
769
+ structured failure if retries are exhausted.
770
+ """
771
+ t_retry_start = time.time()
772
+ logger.info("[TIMING-STDIO] send_command_with_retry START command=%s", command_type)
773
+ t_get_conn = time.time()
774
+ conn = get_unity_connection(instance_id)
775
+ logger.info("[TIMING-STDIO] get_unity_connection took %.3fs command=%s", time.time() - t_get_conn, command_type)
776
+ if max_retries is None:
777
+ max_retries = getattr(config, "reload_max_retries", 40)
778
+ if retry_ms is None:
779
+ retry_ms = getattr(config, "reload_retry_ms", 250)
780
+ # Default to 20s to handle domain reloads (which can take 10-20s after tests or script changes).
781
+ #
782
+ # NOTE: This wait can impact agentic workflows where domain reloads happen
783
+ # frequently (e.g., after test runs, script compilation). The 20s default
784
+ # balances handling slow reloads vs. avoiding unnecessary delays.
785
+ #
786
+ # TODO: Make this more deterministic by detecting Unity's actual reload state
787
+ # rather than blindly waiting up to 20s. See Issue #657.
788
+ #
789
+ # Configurable via: UNITY_MCP_RELOAD_MAX_WAIT_S (default: 20.0, max: 20.0)
790
+ try:
791
+ max_wait_s = float(os.environ.get(
792
+ "UNITY_MCP_RELOAD_MAX_WAIT_S", "20.0"))
793
+ except ValueError as e:
794
+ raw_val = os.environ.get("UNITY_MCP_RELOAD_MAX_WAIT_S", "20.0")
795
+ logger.warning(
796
+ "Invalid UNITY_MCP_RELOAD_MAX_WAIT_S=%r, using default 20.0: %s",
797
+ raw_val, e)
798
+ max_wait_s = 20.0
799
+ # Clamp to [0, 20] to prevent misconfiguration from causing excessive waits
800
+ max_wait_s = max(0.0, min(max_wait_s, 20.0))
801
+
802
+ # If retry_on_reload=False, disable connection-level retries too (issue #577)
803
+ # Commands that trigger compilation/reload shouldn't retry on disconnect
804
+ send_max_attempts = None if retry_on_reload else 0
805
+
806
+ response = conn.send_command(
807
+ command_type, params, max_attempts=send_max_attempts)
808
+ retries = 0
809
+ wait_started = None
810
+ reason = _extract_response_reason(response)
811
+ while retry_on_reload and _is_reloading_response(response) and retries < max_retries:
812
+ if wait_started is None:
813
+ wait_started = time.monotonic()
814
+ logger.debug(
815
+ "Unity reload wait started: command=%s instance=%s reason=%s max_wait_s=%.2f",
816
+ command_type,
817
+ instance_id or "default",
818
+ reason or "reloading",
819
+ max_wait_s,
820
+ )
821
+ if max_wait_s <= 0:
822
+ break
823
+ elapsed = time.monotonic() - wait_started
824
+ if elapsed >= max_wait_s:
825
+ break
826
+ delay_ms = retry_ms
827
+ if isinstance(response, dict):
828
+ retry_after = response.get("retry_after_ms")
829
+ if retry_after is None and isinstance(response.get("data"), dict):
830
+ retry_after = response["data"].get("retry_after_ms")
831
+ if retry_after is not None:
832
+ delay_ms = int(retry_after)
833
+ sleep_ms = max(50, min(int(delay_ms), 250))
834
+ logger.debug(
835
+ "Unity reload wait retry: command=%s instance=%s reason=%s retry_after_ms=%s sleep_ms=%s",
836
+ command_type,
837
+ instance_id or "default",
838
+ reason or "reloading",
839
+ delay_ms,
840
+ sleep_ms,
841
+ )
842
+ time.sleep(max(0.0, sleep_ms / 1000.0))
843
+ retries += 1
844
+ response = conn.send_command(command_type, params)
845
+ reason = _extract_response_reason(response)
846
+
847
+ if wait_started is not None:
848
+ waited = time.monotonic() - wait_started
849
+ if _is_reloading_response(response):
850
+ logger.debug(
851
+ "Unity reload wait exceeded budget: command=%s instance=%s waited_s=%.3f",
852
+ command_type,
853
+ instance_id or "default",
854
+ waited,
855
+ )
856
+ return MCPResponse(
857
+ success=False,
858
+ error="Unity is reloading; please retry",
859
+ hint="retry",
860
+ data={
861
+ "reason": "reloading",
862
+ "retry_after_ms": min(250, max(50, retry_ms)),
863
+ },
864
+ )
865
+ logger.debug(
866
+ "Unity reload wait completed: command=%s instance=%s waited_s=%.3f",
867
+ command_type,
868
+ instance_id or "default",
869
+ waited,
870
+ )
871
+ logger.info("[TIMING-STDIO] send_command_with_retry DONE total=%.3fs command=%s", time.time() - t_retry_start, command_type)
872
+ return response
873
+
874
+
875
+ async def async_send_command_with_retry(
876
+ command_type: str,
877
+ params: dict[str, Any],
878
+ *,
879
+ instance_id: str | None = None,
880
+ loop=None,
881
+ max_retries: int | None = None,
882
+ retry_ms: int | None = None,
883
+ retry_on_reload: bool = True
884
+ ) -> dict[str, Any] | MCPResponse:
885
+ """Async wrapper that runs the blocking retry helper in a thread pool.
886
+
887
+ Args:
888
+ command_type: The command type to send
889
+ params: Command parameters
890
+ instance_id: Optional Unity instance identifier
891
+ loop: Optional asyncio event loop
892
+ max_retries: Maximum number of retries for reload states
893
+ retry_ms: Delay between retries in milliseconds
894
+ retry_on_reload: If False, don't retry when Unity is reloading
895
+
896
+ Returns:
897
+ Response dictionary or MCPResponse on error
898
+ """
899
+ try:
900
+ import asyncio # local import to avoid mandatory asyncio dependency for sync callers
901
+ if loop is None:
902
+ loop = asyncio.get_running_loop()
903
+ return await loop.run_in_executor(
904
+ None,
905
+ lambda: send_command_with_retry(
906
+ command_type, params, instance_id=instance_id, max_retries=max_retries,
907
+ retry_ms=retry_ms, retry_on_reload=retry_on_reload),
908
+ )
909
+ except Exception as e:
910
+ return MCPResponse(success=False, error=str(e))