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.
- cli/__init__.py +3 -0
- cli/commands/__init__.py +3 -0
- cli/commands/animation.py +84 -0
- cli/commands/asset.py +280 -0
- cli/commands/audio.py +125 -0
- cli/commands/batch.py +171 -0
- cli/commands/code.py +182 -0
- cli/commands/component.py +190 -0
- cli/commands/editor.py +447 -0
- cli/commands/gameobject.py +487 -0
- cli/commands/instance.py +93 -0
- cli/commands/lighting.py +123 -0
- cli/commands/material.py +239 -0
- cli/commands/prefab.py +248 -0
- cli/commands/scene.py +231 -0
- cli/commands/script.py +222 -0
- cli/commands/shader.py +226 -0
- cli/commands/texture.py +540 -0
- cli/commands/tool.py +58 -0
- cli/commands/ui.py +258 -0
- cli/commands/vfx.py +421 -0
- cli/main.py +281 -0
- cli/utils/__init__.py +31 -0
- cli/utils/config.py +58 -0
- cli/utils/confirmation.py +37 -0
- cli/utils/connection.py +254 -0
- cli/utils/constants.py +23 -0
- cli/utils/output.py +195 -0
- cli/utils/parsers.py +112 -0
- cli/utils/suggestions.py +34 -0
- core/__init__.py +0 -0
- core/config.py +67 -0
- core/constants.py +4 -0
- core/logging_decorator.py +37 -0
- core/telemetry.py +551 -0
- core/telemetry_decorator.py +164 -0
- main.py +845 -0
- mcpforunityserver-9.4.0b20260203025228.dist-info/METADATA +328 -0
- mcpforunityserver-9.4.0b20260203025228.dist-info/RECORD +105 -0
- mcpforunityserver-9.4.0b20260203025228.dist-info/WHEEL +5 -0
- mcpforunityserver-9.4.0b20260203025228.dist-info/entry_points.txt +3 -0
- mcpforunityserver-9.4.0b20260203025228.dist-info/licenses/LICENSE +21 -0
- mcpforunityserver-9.4.0b20260203025228.dist-info/top_level.txt +7 -0
- models/__init__.py +4 -0
- models/models.py +56 -0
- models/unity_response.py +70 -0
- services/__init__.py +0 -0
- services/api_key_service.py +235 -0
- services/custom_tool_service.py +499 -0
- services/registry/__init__.py +22 -0
- services/registry/resource_registry.py +53 -0
- services/registry/tool_registry.py +51 -0
- services/resources/__init__.py +86 -0
- services/resources/active_tool.py +48 -0
- services/resources/custom_tools.py +57 -0
- services/resources/editor_state.py +304 -0
- services/resources/gameobject.py +243 -0
- services/resources/layers.py +30 -0
- services/resources/menu_items.py +35 -0
- services/resources/prefab.py +191 -0
- services/resources/prefab_stage.py +40 -0
- services/resources/project_info.py +40 -0
- services/resources/selection.py +56 -0
- services/resources/tags.py +31 -0
- services/resources/tests.py +88 -0
- services/resources/unity_instances.py +125 -0
- services/resources/windows.py +48 -0
- services/state/external_changes_scanner.py +245 -0
- services/tools/__init__.py +83 -0
- services/tools/batch_execute.py +93 -0
- services/tools/debug_request_context.py +86 -0
- services/tools/execute_custom_tool.py +43 -0
- services/tools/execute_menu_item.py +32 -0
- services/tools/find_gameobjects.py +110 -0
- services/tools/find_in_file.py +181 -0
- services/tools/manage_asset.py +119 -0
- services/tools/manage_components.py +131 -0
- services/tools/manage_editor.py +64 -0
- services/tools/manage_gameobject.py +260 -0
- services/tools/manage_material.py +111 -0
- services/tools/manage_prefabs.py +209 -0
- services/tools/manage_scene.py +111 -0
- services/tools/manage_script.py +645 -0
- services/tools/manage_scriptable_object.py +87 -0
- services/tools/manage_shader.py +71 -0
- services/tools/manage_texture.py +581 -0
- services/tools/manage_vfx.py +120 -0
- services/tools/preflight.py +110 -0
- services/tools/read_console.py +151 -0
- services/tools/refresh_unity.py +153 -0
- services/tools/run_tests.py +317 -0
- services/tools/script_apply_edits.py +1006 -0
- services/tools/set_active_instance.py +120 -0
- services/tools/utils.py +348 -0
- transport/__init__.py +0 -0
- transport/legacy/port_discovery.py +329 -0
- transport/legacy/stdio_port_registry.py +65 -0
- transport/legacy/unity_connection.py +910 -0
- transport/models.py +68 -0
- transport/plugin_hub.py +787 -0
- transport/plugin_registry.py +182 -0
- transport/unity_instance_middleware.py +262 -0
- transport/unity_transport.py +94 -0
- utils/focus_nudge.py +589 -0
- 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))
|