mcpforunityserver 9.3.0b20260129104751__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 (103) 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 +258 -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 +52 -0
  33. core/logging_decorator.py +37 -0
  34. core/telemetry.py +551 -0
  35. core/telemetry_decorator.py +164 -0
  36. main.py +713 -0
  37. mcpforunityserver-9.3.0b20260129104751.dist-info/METADATA +216 -0
  38. mcpforunityserver-9.3.0b20260129104751.dist-info/RECORD +103 -0
  39. mcpforunityserver-9.3.0b20260129104751.dist-info/WHEEL +5 -0
  40. mcpforunityserver-9.3.0b20260129104751.dist-info/entry_points.txt +3 -0
  41. mcpforunityserver-9.3.0b20260129104751.dist-info/licenses/LICENSE +21 -0
  42. mcpforunityserver-9.3.0b20260129104751.dist-info/top_level.txt +7 -0
  43. models/__init__.py +4 -0
  44. models/models.py +56 -0
  45. models/unity_response.py +47 -0
  46. services/__init__.py +0 -0
  47. services/custom_tool_service.py +499 -0
  48. services/registry/__init__.py +22 -0
  49. services/registry/resource_registry.py +53 -0
  50. services/registry/tool_registry.py +51 -0
  51. services/resources/__init__.py +86 -0
  52. services/resources/active_tool.py +47 -0
  53. services/resources/custom_tools.py +57 -0
  54. services/resources/editor_state.py +304 -0
  55. services/resources/gameobject.py +243 -0
  56. services/resources/layers.py +29 -0
  57. services/resources/menu_items.py +34 -0
  58. services/resources/prefab.py +191 -0
  59. services/resources/prefab_stage.py +39 -0
  60. services/resources/project_info.py +39 -0
  61. services/resources/selection.py +55 -0
  62. services/resources/tags.py +30 -0
  63. services/resources/tests.py +87 -0
  64. services/resources/unity_instances.py +122 -0
  65. services/resources/windows.py +47 -0
  66. services/state/external_changes_scanner.py +245 -0
  67. services/tools/__init__.py +83 -0
  68. services/tools/batch_execute.py +93 -0
  69. services/tools/debug_request_context.py +86 -0
  70. services/tools/execute_custom_tool.py +43 -0
  71. services/tools/execute_menu_item.py +32 -0
  72. services/tools/find_gameobjects.py +110 -0
  73. services/tools/find_in_file.py +181 -0
  74. services/tools/manage_asset.py +119 -0
  75. services/tools/manage_components.py +131 -0
  76. services/tools/manage_editor.py +64 -0
  77. services/tools/manage_gameobject.py +260 -0
  78. services/tools/manage_material.py +111 -0
  79. services/tools/manage_prefabs.py +174 -0
  80. services/tools/manage_scene.py +111 -0
  81. services/tools/manage_script.py +645 -0
  82. services/tools/manage_scriptable_object.py +87 -0
  83. services/tools/manage_shader.py +71 -0
  84. services/tools/manage_texture.py +581 -0
  85. services/tools/manage_vfx.py +120 -0
  86. services/tools/preflight.py +110 -0
  87. services/tools/read_console.py +151 -0
  88. services/tools/refresh_unity.py +153 -0
  89. services/tools/run_tests.py +317 -0
  90. services/tools/script_apply_edits.py +1006 -0
  91. services/tools/set_active_instance.py +117 -0
  92. services/tools/utils.py +348 -0
  93. transport/__init__.py +0 -0
  94. transport/legacy/port_discovery.py +329 -0
  95. transport/legacy/stdio_port_registry.py +65 -0
  96. transport/legacy/unity_connection.py +888 -0
  97. transport/models.py +63 -0
  98. transport/plugin_hub.py +585 -0
  99. transport/plugin_registry.py +126 -0
  100. transport/unity_instance_middleware.py +232 -0
  101. transport/unity_transport.py +63 -0
  102. utils/focus_nudge.py +589 -0
  103. utils/module_discovery.py +55 -0
@@ -0,0 +1,888 @@
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
+ if not self.sock and not self.connect():
310
+ raise ConnectionError("Could not connect to Unity")
311
+
312
+ # Build payload
313
+ if command_type == 'ping':
314
+ payload = b'ping'
315
+ else:
316
+ payload = json.dumps({
317
+ 'type': command_type,
318
+ 'params': params,
319
+ }).encode('utf-8')
320
+
321
+ # Send/receive are serialized to protect the shared socket
322
+ with self._io_lock:
323
+ mode = 'framed' if self.use_framing else 'legacy'
324
+ with contextlib.suppress(Exception):
325
+ logger.debug(
326
+ f"send {len(payload)} bytes; mode={mode}; head={payload[:32].decode('utf-8', 'ignore')}")
327
+ if self.use_framing:
328
+ header = struct.pack('>Q', len(payload))
329
+ self.sock.sendall(header)
330
+ self.sock.sendall(payload)
331
+ else:
332
+ self.sock.sendall(payload)
333
+
334
+ # During retry bursts use a short receive timeout and ensure restoration
335
+ restore_timeout = None
336
+ if attempt > 0 and last_short_timeout is None:
337
+ restore_timeout = self.sock.gettimeout()
338
+ self.sock.settimeout(1.0)
339
+ try:
340
+ response_data = self.receive_full_response(self.sock)
341
+ with contextlib.suppress(Exception):
342
+ logger.debug(
343
+ f"recv {len(response_data)} bytes; mode={mode}")
344
+ finally:
345
+ if restore_timeout is not None:
346
+ self.sock.settimeout(restore_timeout)
347
+ last_short_timeout = None
348
+
349
+ # Parse
350
+ if command_type == 'ping':
351
+ resp = json.loads(response_data.decode('utf-8'))
352
+ if resp.get('status') == 'success' and resp.get('result', {}).get('message') == 'pong':
353
+ return {"message": "pong"}
354
+ raise Exception("Ping unsuccessful")
355
+
356
+ resp = json.loads(response_data.decode('utf-8'))
357
+ if resp.get('status') == 'error':
358
+ err = resp.get('error') or resp.get(
359
+ 'message', 'Unknown Unity error')
360
+ raise Exception(err)
361
+ return resp.get('result', {})
362
+ except Exception as e:
363
+ logger.warning(
364
+ f"Unity communication attempt {attempt+1} failed: {e}")
365
+ try:
366
+ if self.sock:
367
+ self.sock.close()
368
+ finally:
369
+ self.sock = None
370
+
371
+ # Re-discover the port for this specific instance
372
+ try:
373
+ new_port: int | None = None
374
+ if self.instance_id:
375
+ # Try to rediscover the specific instance via shared registry
376
+ refreshed_instance = stdio_port_registry.get_instance(
377
+ self.instance_id)
378
+ if refreshed_instance and isinstance(refreshed_instance.port, int):
379
+ new_port = refreshed_instance.port
380
+ logger.debug(
381
+ f"Rediscovered instance {self.instance_id} on port {new_port}")
382
+ else:
383
+ logger.warning(
384
+ f"Instance {self.instance_id} not found during reconnection; falling back to port scan",
385
+ )
386
+
387
+ # Fallback to registry default if instance-specific discovery failed
388
+ if new_port is None:
389
+ new_port = stdio_port_registry.get_port(
390
+ self.instance_id)
391
+ logger.info(
392
+ f"Using Unity port from stdio_port_registry: {new_port}")
393
+
394
+ if new_port != self.port:
395
+ logger.info(
396
+ f"Unity port changed {self.port} -> {new_port}")
397
+ self.port = new_port
398
+ except Exception as de:
399
+ logger.debug(f"Port discovery failed: {de}")
400
+
401
+ if attempt < attempts:
402
+ # Heartbeat-aware, jittered backoff
403
+ status = read_status_file(target_hash)
404
+ # Base exponential backoff
405
+ backoff = base_backoff * (2 ** attempt)
406
+ # Decorrelated jitter multiplier
407
+ jitter = random.uniform(0.1, 0.3)
408
+
409
+ # Fast‑retry for transient socket failures
410
+ fast_error = isinstance(
411
+ e, (ConnectionRefusedError, ConnectionResetError, TimeoutError))
412
+ if not fast_error:
413
+ try:
414
+ err_no = getattr(e, 'errno', None)
415
+ fast_error = err_no in (
416
+ errno.ECONNREFUSED, errno.ECONNRESET, errno.ETIMEDOUT)
417
+ except Exception:
418
+ pass
419
+
420
+ # Cap backoff depending on state
421
+ if status and status.get('reloading'):
422
+ cap = 0.8
423
+ elif fast_error:
424
+ cap = 0.25
425
+ else:
426
+ cap = 3.0
427
+
428
+ sleep_s = min(cap, jitter * (2 ** attempt))
429
+ time.sleep(sleep_s)
430
+ continue
431
+ raise
432
+
433
+
434
+ # -----------------------------
435
+ # Connection Pool for Multiple Unity Instances
436
+ # -----------------------------
437
+
438
+ class UnityConnectionPool:
439
+ """Manages connections to multiple Unity Editor instances"""
440
+
441
+ def __init__(self):
442
+ self._connections: dict[str, UnityConnection] = {}
443
+ self._known_instances: dict[str, UnityInstanceInfo] = {}
444
+ self._last_full_scan: float = 0
445
+ self._scan_interval: float = 5.0 # Cache for 5 seconds
446
+ self._pool_lock = threading.Lock()
447
+ self._default_instance_id: str | None = None
448
+
449
+ # Check for default instance from environment
450
+ env_default = os.environ.get("UNITY_MCP_DEFAULT_INSTANCE", "").strip()
451
+ if env_default:
452
+ self._default_instance_id = env_default
453
+ logger.info(
454
+ f"Default Unity instance set from environment: {env_default}")
455
+
456
+ def discover_all_instances(self, force_refresh: bool = False) -> list[UnityInstanceInfo]:
457
+ """
458
+ Discover all running Unity Editor instances.
459
+
460
+ Args:
461
+ force_refresh: If True, bypass cache and scan immediately
462
+
463
+ Returns:
464
+ List of UnityInstanceInfo objects
465
+ """
466
+ now = time.time()
467
+
468
+ # Return cached results if valid
469
+ if not force_refresh and (now - self._last_full_scan) < self._scan_interval:
470
+ logger.debug(
471
+ f"Returning cached Unity instances (age: {now - self._last_full_scan:.1f}s)")
472
+ return list(self._known_instances.values())
473
+
474
+ # Scan for instances
475
+ logger.debug("Scanning for Unity instances...")
476
+ instances = PortDiscovery.discover_all_unity_instances()
477
+
478
+ # Update cache
479
+ with self._pool_lock:
480
+ self._known_instances = {inst.id: inst for inst in instances}
481
+ self._last_full_scan = now
482
+
483
+ logger.info(
484
+ f"Found {len(instances)} Unity instances: {[inst.id for inst in instances]}")
485
+ return instances
486
+
487
+ def _resolve_instance_id(self, instance_identifier: str | None, instances: list[UnityInstanceInfo]) -> UnityInstanceInfo:
488
+ """
489
+ Resolve an instance identifier to a specific Unity instance.
490
+
491
+ Args:
492
+ instance_identifier: User-provided identifier (name, hash, name@hash, path, port, or None)
493
+ instances: List of available instances
494
+
495
+ Returns:
496
+ Resolved UnityInstanceInfo
497
+
498
+ Raises:
499
+ ConnectionError: If instance cannot be resolved
500
+ """
501
+ if not instances:
502
+ raise ConnectionError(
503
+ "No Unity Editor instances found. Please ensure Unity is running with MCP for Unity bridge."
504
+ )
505
+
506
+ # Use default instance if no identifier provided
507
+ if instance_identifier is None:
508
+ if self._default_instance_id:
509
+ instance_identifier = self._default_instance_id
510
+ logger.debug(f"Using default instance: {instance_identifier}")
511
+ else:
512
+ # Use the most recently active instance
513
+ # Instances with no heartbeat (None) should be sorted last (use 0 as sentinel)
514
+ sorted_instances = sorted(
515
+ instances,
516
+ key=lambda inst: inst.last_heartbeat.timestamp() if inst.last_heartbeat else 0.0,
517
+ reverse=True,
518
+ )
519
+ logger.info(
520
+ f"No instance specified, using most recent: {sorted_instances[0].id}")
521
+ return sorted_instances[0]
522
+
523
+ identifier = instance_identifier.strip()
524
+
525
+ # Try exact ID match first
526
+ for inst in instances:
527
+ if inst.id == identifier:
528
+ return inst
529
+
530
+ # Try project name match
531
+ name_matches = [inst for inst in instances if inst.name == identifier]
532
+ if len(name_matches) == 1:
533
+ return name_matches[0]
534
+ elif len(name_matches) > 1:
535
+ # Multiple projects with same name - return helpful error
536
+ suggestions = [
537
+ {
538
+ "id": inst.id,
539
+ "path": inst.path,
540
+ "port": inst.port,
541
+ "suggest": f"Use unity_instance='{inst.id}'"
542
+ }
543
+ for inst in name_matches
544
+ ]
545
+ raise ConnectionError(
546
+ f"Project name '{identifier}' matches {len(name_matches)} instances. "
547
+ f"Please use the full format (e.g., '{name_matches[0].id}'). "
548
+ f"Available instances: {suggestions}"
549
+ )
550
+
551
+ # Try hash match
552
+ hash_matches = [inst for inst in instances if inst.hash ==
553
+ identifier or inst.hash.startswith(identifier)]
554
+ if len(hash_matches) == 1:
555
+ return hash_matches[0]
556
+ elif len(hash_matches) > 1:
557
+ raise ConnectionError(
558
+ f"Hash '{identifier}' matches multiple instances: {[inst.id for inst in hash_matches]}"
559
+ )
560
+
561
+ # Try composite format: Name@Hash or Name@Port
562
+ if "@" in identifier:
563
+ name_part, hint_part = identifier.split("@", 1)
564
+ composite_matches = [
565
+ inst for inst in instances
566
+ if inst.name == name_part and (
567
+ inst.hash.startswith(hint_part) or str(
568
+ inst.port) == hint_part
569
+ )
570
+ ]
571
+ if len(composite_matches) == 1:
572
+ return composite_matches[0]
573
+
574
+ # Try port match (as string)
575
+ try:
576
+ port_num = int(identifier)
577
+ port_matches = [
578
+ inst for inst in instances if inst.port == port_num]
579
+ if len(port_matches) == 1:
580
+ return port_matches[0]
581
+ except ValueError:
582
+ pass
583
+
584
+ # Try path match
585
+ path_matches = [inst for inst in instances if inst.path == identifier]
586
+ if len(path_matches) == 1:
587
+ return path_matches[0]
588
+
589
+ # Nothing matched
590
+ available_ids = [inst.id for inst in instances]
591
+ raise ConnectionError(
592
+ f"Unity instance '{identifier}' not found. "
593
+ f"Available instances: {available_ids}. "
594
+ f"Check mcpforunity://instances resource for all instances."
595
+ )
596
+
597
+ def get_connection(self, instance_identifier: str | None = None) -> UnityConnection:
598
+ """
599
+ Get or create a connection to a Unity instance.
600
+
601
+ Args:
602
+ instance_identifier: Optional identifier (name, hash, name@hash, etc.)
603
+ If None, uses default or most recent instance
604
+
605
+ Returns:
606
+ UnityConnection to the specified instance
607
+
608
+ Raises:
609
+ ConnectionError: If instance cannot be found or connected
610
+ """
611
+ # Refresh instance list if cache expired
612
+ instances = self.discover_all_instances()
613
+
614
+ # Resolve identifier to specific instance
615
+ target = self._resolve_instance_id(instance_identifier, instances)
616
+
617
+ # Return existing connection or create new one
618
+ with self._pool_lock:
619
+ if target.id not in self._connections:
620
+ logger.info(
621
+ f"Creating new connection to Unity instance: {target.id} (port {target.port})")
622
+ conn = UnityConnection(port=target.port, instance_id=target.id)
623
+ if not conn.connect():
624
+ raise ConnectionError(
625
+ f"Failed to connect to Unity instance '{target.id}' on port {target.port}. "
626
+ f"Ensure the Unity Editor is running."
627
+ )
628
+ self._connections[target.id] = conn
629
+ else:
630
+ # Update existing connection with instance_id and port if changed
631
+ conn = self._connections[target.id]
632
+ conn.instance_id = target.id
633
+ if conn.port != target.port:
634
+ logger.info(
635
+ f"Updating cached port for {target.id}: {conn.port} -> {target.port}")
636
+ conn.port = target.port
637
+ logger.debug(f"Reusing existing connection to: {target.id}")
638
+
639
+ return self._connections[target.id]
640
+
641
+ def disconnect_all(self):
642
+ """Disconnect all active connections"""
643
+ with self._pool_lock:
644
+ for instance_id, conn in self._connections.items():
645
+ try:
646
+ logger.info(
647
+ f"Disconnecting from Unity instance: {instance_id}")
648
+ conn.disconnect()
649
+ except Exception:
650
+ logger.exception(f"Error disconnecting from {instance_id}")
651
+ self._connections.clear()
652
+
653
+
654
+ # Global Unity connection pool
655
+ _unity_connection_pool: UnityConnectionPool | None = None
656
+ _pool_init_lock = threading.Lock()
657
+
658
+
659
+ def get_unity_connection_pool() -> UnityConnectionPool:
660
+ """Get or create the global Unity connection pool"""
661
+ global _unity_connection_pool
662
+
663
+ if _unity_connection_pool is not None:
664
+ return _unity_connection_pool
665
+
666
+ with _pool_init_lock:
667
+ if _unity_connection_pool is not None:
668
+ return _unity_connection_pool
669
+
670
+ logger.info("Initializing Unity connection pool")
671
+ _unity_connection_pool = UnityConnectionPool()
672
+ return _unity_connection_pool
673
+
674
+
675
+ # Backwards compatibility: keep old single-connection function
676
+ def get_unity_connection(instance_identifier: str | None = None) -> UnityConnection:
677
+ """Retrieve or establish a Unity connection.
678
+
679
+ Args:
680
+ instance_identifier: Optional identifier for specific Unity instance.
681
+ If None, uses default or most recent instance.
682
+
683
+ Returns:
684
+ UnityConnection to the specified or default Unity instance
685
+
686
+ Note: This function now uses the connection pool internally.
687
+ """
688
+ pool = get_unity_connection_pool()
689
+ return pool.get_connection(instance_identifier)
690
+
691
+
692
+ # -----------------------------
693
+ # Centralized retry helpers
694
+ # -----------------------------
695
+
696
+ def _extract_response_reason(resp: object) -> str | None:
697
+ """Extract a normalized (lowercase) reason string from a response.
698
+
699
+ Returns lowercase reason values to enable case-insensitive comparisons
700
+ by callers (e.g. _is_reloading_response, refresh_unity).
701
+ """
702
+ if isinstance(resp, MCPResponse):
703
+ data = getattr(resp, "data", None)
704
+ if isinstance(data, dict):
705
+ reason = data.get("reason")
706
+ if isinstance(reason, str):
707
+ return reason.lower()
708
+ message_text = f"{resp.message or ''} {resp.error or ''}".lower()
709
+ if "reload" in message_text:
710
+ return "reloading"
711
+ return None
712
+
713
+ if isinstance(resp, dict):
714
+ if resp.get("state") == "reloading":
715
+ return "reloading"
716
+ data = resp.get("data")
717
+ if isinstance(data, dict):
718
+ reason = data.get("reason")
719
+ if isinstance(reason, str):
720
+ return reason.lower()
721
+ message_text = (resp.get("message") or resp.get("error") or "").lower()
722
+ if "reload" in message_text:
723
+ return "reloading"
724
+ return None
725
+
726
+ return None
727
+
728
+
729
+ def _is_reloading_response(resp: object) -> bool:
730
+ """Return True if the Unity response indicates the editor is reloading.
731
+
732
+ Supports both raw dict payloads from Unity and MCPResponse objects returned
733
+ by preflight checks or transport helpers.
734
+ """
735
+ return _extract_response_reason(resp) == "reloading"
736
+
737
+
738
+ def send_command_with_retry(
739
+ command_type: str,
740
+ params: dict[str, Any],
741
+ *,
742
+ instance_id: str | None = None,
743
+ max_retries: int | None = None,
744
+ retry_ms: int | None = None,
745
+ retry_on_reload: bool = True
746
+ ) -> dict[str, Any] | MCPResponse:
747
+ """Send a command to a Unity instance, waiting politely through Unity reloads.
748
+
749
+ Args:
750
+ command_type: The command type to send
751
+ params: Command parameters
752
+ instance_id: Optional Unity instance identifier (name, hash, name@hash, etc.)
753
+ max_retries: Maximum number of retries for reload states
754
+ retry_ms: Delay between retries in milliseconds
755
+ retry_on_reload: If False, don't retry when Unity is reloading (for commands
756
+ that trigger compilation/reload and shouldn't be re-sent)
757
+
758
+ Returns:
759
+ Response dictionary or MCPResponse from Unity
760
+
761
+ Uses config.reload_retry_ms and config.reload_max_retries by default. Preserves the
762
+ structured failure if retries are exhausted.
763
+ """
764
+ conn = get_unity_connection(instance_id)
765
+ if max_retries is None:
766
+ max_retries = getattr(config, "reload_max_retries", 40)
767
+ if retry_ms is None:
768
+ retry_ms = getattr(config, "reload_retry_ms", 250)
769
+ try:
770
+ max_wait_s = float(os.environ.get(
771
+ "UNITY_MCP_RELOAD_MAX_WAIT_S", "2.0"))
772
+ except ValueError as e:
773
+ raw_val = os.environ.get("UNITY_MCP_RELOAD_MAX_WAIT_S", "2.0")
774
+ logger.warning(
775
+ "Invalid UNITY_MCP_RELOAD_MAX_WAIT_S=%r, using default 2.0: %s",
776
+ raw_val, e)
777
+ max_wait_s = 2.0
778
+ # Clamp to [0, 30] to prevent misconfiguration from causing excessive waits
779
+ max_wait_s = max(0.0, min(max_wait_s, 30.0))
780
+
781
+ # If retry_on_reload=False, disable connection-level retries too (issue #577)
782
+ # Commands that trigger compilation/reload shouldn't retry on disconnect
783
+ send_max_attempts = None if retry_on_reload else 0
784
+
785
+ response = conn.send_command(
786
+ command_type, params, max_attempts=send_max_attempts)
787
+ retries = 0
788
+ wait_started = None
789
+ reason = _extract_response_reason(response)
790
+ while retry_on_reload and _is_reloading_response(response) and retries < max_retries:
791
+ if wait_started is None:
792
+ wait_started = time.monotonic()
793
+ logger.debug(
794
+ "Unity reload wait started: command=%s instance=%s reason=%s max_wait_s=%.2f",
795
+ command_type,
796
+ instance_id or "default",
797
+ reason or "reloading",
798
+ max_wait_s,
799
+ )
800
+ if max_wait_s <= 0:
801
+ break
802
+ elapsed = time.monotonic() - wait_started
803
+ if elapsed >= max_wait_s:
804
+ break
805
+ delay_ms = retry_ms
806
+ if isinstance(response, dict):
807
+ retry_after = response.get("retry_after_ms")
808
+ if retry_after is None and isinstance(response.get("data"), dict):
809
+ retry_after = response["data"].get("retry_after_ms")
810
+ if retry_after is not None:
811
+ delay_ms = int(retry_after)
812
+ sleep_ms = max(50, min(int(delay_ms), 250))
813
+ logger.debug(
814
+ "Unity reload wait retry: command=%s instance=%s reason=%s retry_after_ms=%s sleep_ms=%s",
815
+ command_type,
816
+ instance_id or "default",
817
+ reason or "reloading",
818
+ delay_ms,
819
+ sleep_ms,
820
+ )
821
+ time.sleep(max(0.0, sleep_ms / 1000.0))
822
+ retries += 1
823
+ response = conn.send_command(command_type, params)
824
+ reason = _extract_response_reason(response)
825
+
826
+ if wait_started is not None:
827
+ waited = time.monotonic() - wait_started
828
+ if _is_reloading_response(response):
829
+ logger.debug(
830
+ "Unity reload wait exceeded budget: command=%s instance=%s waited_s=%.3f",
831
+ command_type,
832
+ instance_id or "default",
833
+ waited,
834
+ )
835
+ return MCPResponse(
836
+ success=False,
837
+ error="Unity is reloading; please retry",
838
+ hint="retry",
839
+ data={
840
+ "reason": "reloading",
841
+ "retry_after_ms": min(250, max(50, retry_ms)),
842
+ },
843
+ )
844
+ logger.debug(
845
+ "Unity reload wait completed: command=%s instance=%s waited_s=%.3f",
846
+ command_type,
847
+ instance_id or "default",
848
+ waited,
849
+ )
850
+ return response
851
+
852
+
853
+ async def async_send_command_with_retry(
854
+ command_type: str,
855
+ params: dict[str, Any],
856
+ *,
857
+ instance_id: str | None = None,
858
+ loop=None,
859
+ max_retries: int | None = None,
860
+ retry_ms: int | None = None,
861
+ retry_on_reload: bool = True
862
+ ) -> dict[str, Any] | MCPResponse:
863
+ """Async wrapper that runs the blocking retry helper in a thread pool.
864
+
865
+ Args:
866
+ command_type: The command type to send
867
+ params: Command parameters
868
+ instance_id: Optional Unity instance identifier
869
+ loop: Optional asyncio event loop
870
+ max_retries: Maximum number of retries for reload states
871
+ retry_ms: Delay between retries in milliseconds
872
+ retry_on_reload: If False, don't retry when Unity is reloading
873
+
874
+ Returns:
875
+ Response dictionary or MCPResponse on error
876
+ """
877
+ try:
878
+ import asyncio # local import to avoid mandatory asyncio dependency for sync callers
879
+ if loop is None:
880
+ loop = asyncio.get_running_loop()
881
+ return await loop.run_in_executor(
882
+ None,
883
+ lambda: send_command_with_retry(
884
+ command_type, params, instance_id=instance_id, max_retries=max_retries,
885
+ retry_ms=retry_ms, retry_on_reload=retry_on_reload),
886
+ )
887
+ except Exception as e:
888
+ return MCPResponse(success=False, error=str(e))