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,787 @@
1
+ """WebSocket hub for Unity plugin communication."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ import os
8
+ import time
9
+ import uuid
10
+ from typing import Any, ClassVar
11
+
12
+ from starlette.endpoints import WebSocketEndpoint
13
+ from starlette.websockets import WebSocket
14
+
15
+ from core.config import config
16
+ from core.constants import API_KEY_HEADER
17
+ from models.models import MCPResponse
18
+ from transport.plugin_registry import PluginRegistry
19
+ from services.api_key_service import ApiKeyService
20
+ from transport.models import (
21
+ WelcomeMessage,
22
+ RegisteredMessage,
23
+ ExecuteCommandMessage,
24
+ PingMessage,
25
+ RegisterMessage,
26
+ RegisterToolsMessage,
27
+ PongMessage,
28
+ CommandResultMessage,
29
+ SessionList,
30
+ SessionDetails,
31
+ )
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+
36
+ class PluginDisconnectedError(RuntimeError):
37
+ """Raised when a plugin WebSocket disconnects while commands are in flight."""
38
+
39
+
40
+ class NoUnitySessionError(RuntimeError):
41
+ """Raised when no Unity plugins are available."""
42
+
43
+
44
+ class InstanceSelectionRequiredError(RuntimeError):
45
+ """Raised when the caller must explicitly select a Unity instance."""
46
+
47
+ _SELECTION_REQUIRED = (
48
+ "Unity instance selection is required. "
49
+ "Call set_active_instance with Name@hash from mcpforunity://instances."
50
+ )
51
+ _MULTIPLE_INSTANCES = (
52
+ "Multiple Unity instances are connected. "
53
+ "Call set_active_instance with Name@hash from mcpforunity://instances."
54
+ )
55
+
56
+ def __init__(self, message: str | None = None):
57
+ super().__init__(message or self._SELECTION_REQUIRED)
58
+
59
+
60
+ class PluginHub(WebSocketEndpoint):
61
+ """Manages persistent WebSocket connections to Unity plugins."""
62
+
63
+ encoding = "json"
64
+ KEEP_ALIVE_INTERVAL = 15
65
+ SERVER_TIMEOUT = 30
66
+ COMMAND_TIMEOUT = 30
67
+ # Server-side ping interval (seconds) - how often to send pings to Unity
68
+ PING_INTERVAL = 10
69
+ # Max time (seconds) to wait for pong before considering connection dead
70
+ PING_TIMEOUT = 20
71
+ # Timeout (seconds) for fast-fail commands like ping/read_console/get_editor_state.
72
+ # Keep short so MCP clients aren't blocked during Unity compilation/reload/unfocused throttling.
73
+ FAST_FAIL_TIMEOUT = 2.0
74
+ # Fast-path commands should never block the client for long; return a retry hint instead.
75
+ # This helps avoid the Cursor-side ~30s tool-call timeout when Unity is compiling/reloading
76
+ # or is throttled while unfocused.
77
+ _FAST_FAIL_COMMANDS: set[str] = {
78
+ "read_console", "get_editor_state", "ping"}
79
+
80
+ _registry: PluginRegistry | None = None
81
+ _connections: dict[str, WebSocket] = {}
82
+ # command_id -> {"future": Future, "session_id": str}
83
+ _pending: dict[str, dict[str, Any]] = {}
84
+ _lock: asyncio.Lock | None = None
85
+ _loop: asyncio.AbstractEventLoop | None = None
86
+ # session_id -> last pong timestamp (monotonic)
87
+ _last_pong: ClassVar[dict[str, float]] = {}
88
+ # session_id -> ping task
89
+ _ping_tasks: ClassVar[dict[str, asyncio.Task]] = {}
90
+
91
+ @classmethod
92
+ def configure(
93
+ cls,
94
+ registry: PluginRegistry,
95
+ loop: asyncio.AbstractEventLoop | None = None,
96
+ ) -> None:
97
+ cls._registry = registry
98
+ cls._loop = loop or asyncio.get_running_loop()
99
+ # Ensure coordination primitives are bound to the configured loop
100
+ cls._lock = asyncio.Lock()
101
+
102
+ @classmethod
103
+ def is_configured(cls) -> bool:
104
+ return cls._registry is not None and cls._lock is not None
105
+
106
+ async def on_connect(self, websocket: WebSocket) -> None:
107
+ # Validate API key in remote-hosted mode (fail closed)
108
+ if config.http_remote_hosted:
109
+ if not ApiKeyService.is_initialized():
110
+ logger.debug(
111
+ "WebSocket connection rejected: auth service not initialized")
112
+ await websocket.close(code=1013, reason="Try again later")
113
+ return
114
+
115
+ api_key = websocket.headers.get(API_KEY_HEADER)
116
+
117
+ if not api_key:
118
+ logger.debug("WebSocket connection rejected: API key required")
119
+ await websocket.close(code=4401, reason="API key required")
120
+ return
121
+
122
+ service = ApiKeyService.get_instance()
123
+ result = await service.validate(api_key)
124
+
125
+ if not result.valid:
126
+ # Transient auth failures are retryable (1013)
127
+ if result.error and any(
128
+ indicator in result.error.lower()
129
+ for indicator in ("unavailable", "timeout", "service error")
130
+ ):
131
+ logger.debug(
132
+ "WebSocket connection rejected: auth service unavailable")
133
+ await websocket.close(code=1013, reason="Try again later")
134
+ return
135
+
136
+ logger.debug("WebSocket connection rejected: invalid API key")
137
+ await websocket.close(code=4403, reason="Invalid API key")
138
+ return
139
+
140
+ # Both valid and user_id must be present to accept
141
+ if not result.user_id:
142
+ logger.debug(
143
+ "WebSocket connection rejected: validated key missing user_id")
144
+ await websocket.close(code=4403, reason="Invalid API key")
145
+ return
146
+
147
+ # Store user_id in websocket state for later use during registration
148
+ websocket.state.user_id = result.user_id
149
+ websocket.state.api_key_metadata = result.metadata
150
+
151
+ await websocket.accept()
152
+ msg = WelcomeMessage(
153
+ serverTimeout=self.SERVER_TIMEOUT,
154
+ keepAliveInterval=self.KEEP_ALIVE_INTERVAL,
155
+ )
156
+ await websocket.send_json(msg.model_dump())
157
+
158
+ async def on_receive(self, websocket: WebSocket, data: Any) -> None:
159
+ if not isinstance(data, dict):
160
+ logger.warning(f"Received non-object payload from plugin: {data}")
161
+ return
162
+
163
+ message_type = data.get("type")
164
+ try:
165
+ if message_type == "register":
166
+ await self._handle_register(websocket, RegisterMessage(**data))
167
+ elif message_type == "register_tools":
168
+ await self._handle_register_tools(websocket, RegisterToolsMessage(**data))
169
+ elif message_type == "pong":
170
+ await self._handle_pong(PongMessage(**data))
171
+ elif message_type == "command_result":
172
+ await self._handle_command_result(CommandResultMessage(**data))
173
+ else:
174
+ logger.debug(f"Ignoring plugin message: {data}")
175
+ except Exception as e:
176
+ logger.error(f"Error handling message type {message_type}: {e}")
177
+
178
+ async def on_disconnect(self, websocket: WebSocket, close_code: int) -> None:
179
+ cls = type(self)
180
+ lock = cls._lock
181
+ if lock is None:
182
+ return
183
+ async with lock:
184
+ session_id = next(
185
+ (sid for sid, ws in cls._connections.items() if ws is websocket), None)
186
+ if session_id:
187
+ cls._connections.pop(session_id, None)
188
+ # Stop the ping loop for this session
189
+ ping_task = cls._ping_tasks.pop(session_id, None)
190
+ if ping_task and not ping_task.done():
191
+ ping_task.cancel()
192
+ # Clean up last pong tracking
193
+ cls._last_pong.pop(session_id, None)
194
+ # Fail-fast any in-flight commands for this session to avoid waiting for COMMAND_TIMEOUT.
195
+ pending_ids = [
196
+ command_id
197
+ for command_id, entry in cls._pending.items()
198
+ if entry.get("session_id") == session_id
199
+ ]
200
+ if pending_ids:
201
+ logger.debug(f"Cancelling {len(pending_ids)} pending commands for disconnected session")
202
+ for command_id in pending_ids:
203
+ entry = cls._pending.get(command_id)
204
+ future = entry.get("future") if isinstance(
205
+ entry, dict) else None
206
+ if future and not future.done():
207
+ future.set_exception(
208
+ PluginDisconnectedError(
209
+ f"Unity plugin session {session_id} disconnected while awaiting command_result"
210
+ )
211
+ )
212
+ if cls._registry:
213
+ await cls._registry.unregister(session_id)
214
+ logger.info(
215
+ f"Plugin session {session_id} disconnected ({close_code})")
216
+
217
+ # ------------------------------------------------------------------
218
+ # Public API
219
+ # ------------------------------------------------------------------
220
+ @classmethod
221
+ async def send_command(cls, session_id: str, command_type: str, params: dict[str, Any]) -> dict[str, Any]:
222
+ websocket = await cls._get_connection(session_id)
223
+ command_id = str(uuid.uuid4())
224
+ future: asyncio.Future = asyncio.get_running_loop().create_future()
225
+ # Compute a per-command timeout:
226
+ # - fast-path commands: short timeout (encourage retry)
227
+ # - long-running commands: allow caller to request a longer timeout via params
228
+ unity_timeout_s = float(cls.COMMAND_TIMEOUT)
229
+ server_wait_s = float(cls.COMMAND_TIMEOUT)
230
+ if command_type in cls._FAST_FAIL_COMMANDS:
231
+ fast_timeout = float(cls.FAST_FAIL_TIMEOUT)
232
+ unity_timeout_s = fast_timeout
233
+ server_wait_s = fast_timeout
234
+ else:
235
+ # Common tools pass a requested timeout in seconds (e.g., timeout_seconds=900).
236
+ requested = None
237
+ try:
238
+ if isinstance(params, dict):
239
+ requested = params.get("timeout_seconds", None)
240
+ if requested is None:
241
+ requested = params.get("timeoutSeconds", None)
242
+ except Exception:
243
+ requested = None
244
+
245
+ if requested is not None:
246
+ try:
247
+ requested_s = float(requested)
248
+ # Clamp to a sane upper bound to avoid accidental infinite hangs.
249
+ requested_s = max(1.0, min(requested_s, 60.0 * 60.0))
250
+ unity_timeout_s = max(unity_timeout_s, requested_s)
251
+ # Give the server a small cushion beyond the Unity-side timeout to account for transport overhead.
252
+ server_wait_s = max(server_wait_s, requested_s + 5.0)
253
+ except Exception:
254
+ pass
255
+
256
+ lock = cls._lock
257
+ if lock is None:
258
+ raise RuntimeError("PluginHub not configured")
259
+
260
+ async with lock:
261
+ if command_id in cls._pending:
262
+ raise RuntimeError(
263
+ f"Duplicate command id generated: {command_id}")
264
+ cls._pending[command_id] = {
265
+ "future": future, "session_id": session_id}
266
+
267
+ try:
268
+ msg = ExecuteCommandMessage(
269
+ id=command_id,
270
+ name=command_type,
271
+ params=params,
272
+ timeout=unity_timeout_s,
273
+ )
274
+ try:
275
+ await websocket.send_json(msg.model_dump())
276
+ except Exception as exc:
277
+ # If send fails (socket already closing), fail the future so callers don't hang.
278
+ if not future.done():
279
+ future.set_exception(exc)
280
+ raise
281
+ try:
282
+ result = await asyncio.wait_for(future, timeout=server_wait_s)
283
+ return result
284
+ except PluginDisconnectedError as exc:
285
+ return MCPResponse(success=False, error=str(exc), hint="retry").model_dump()
286
+ except asyncio.TimeoutError:
287
+ if command_type in cls._FAST_FAIL_COMMANDS:
288
+ return MCPResponse(
289
+ success=False,
290
+ error=f"Unity did not respond to '{command_type}' within {server_wait_s:.1f}s; please retry",
291
+ hint="retry",
292
+ ).model_dump()
293
+ raise
294
+ finally:
295
+ async with lock:
296
+ cls._pending.pop(command_id, None)
297
+
298
+ @classmethod
299
+ async def get_sessions(cls, user_id: str | None = None) -> SessionList:
300
+ """Get all active plugin sessions.
301
+
302
+ Args:
303
+ user_id: If provided (remote-hosted mode), only return sessions for this user.
304
+ """
305
+ if cls._registry is None:
306
+ return SessionList(sessions={})
307
+ sessions = await cls._registry.list_sessions(user_id=user_id)
308
+ return SessionList(
309
+ sessions={
310
+ session_id: SessionDetails(
311
+ project=session.project_name,
312
+ hash=session.project_hash,
313
+ unity_version=session.unity_version,
314
+ connected_at=session.connected_at.isoformat(),
315
+ )
316
+ for session_id, session in sessions.items()
317
+ }
318
+ )
319
+
320
+ @classmethod
321
+ async def get_tools_for_project(cls, project_hash: str) -> list[Any]:
322
+ """Retrieve tools registered for a active project hash."""
323
+ if cls._registry is None:
324
+ return []
325
+
326
+ session_id = await cls._registry.get_session_id_by_hash(project_hash)
327
+ if not session_id:
328
+ return []
329
+
330
+ session = await cls._registry.get_session(session_id)
331
+ if not session:
332
+ return []
333
+
334
+ return list(session.tools.values())
335
+
336
+ @classmethod
337
+ async def get_tool_definition(cls, project_hash: str, tool_name: str) -> Any | None:
338
+ """Retrieve a specific tool definition for an active project hash."""
339
+ if cls._registry is None:
340
+ return None
341
+
342
+ session_id = await cls._registry.get_session_id_by_hash(project_hash)
343
+ if not session_id:
344
+ return None
345
+
346
+ session = await cls._registry.get_session(session_id)
347
+ if not session:
348
+ return None
349
+
350
+ return session.tools.get(tool_name)
351
+
352
+ # ------------------------------------------------------------------
353
+ # Internal helpers
354
+ # ------------------------------------------------------------------
355
+ async def _handle_register(self, websocket: WebSocket, payload: RegisterMessage) -> None:
356
+ cls = type(self)
357
+ registry = cls._registry
358
+ lock = cls._lock
359
+ if registry is None or lock is None:
360
+ await websocket.close(code=1011)
361
+ raise RuntimeError("PluginHub not configured")
362
+
363
+ project_name = payload.project_name
364
+ project_hash = payload.project_hash
365
+ unity_version = payload.unity_version
366
+ project_path = payload.project_path
367
+
368
+ if not project_hash:
369
+ await websocket.close(code=4400)
370
+ raise ValueError(
371
+ "Plugin registration missing project_hash")
372
+
373
+ # Get user_id from websocket state (set during API key validation)
374
+ user_id = getattr(websocket.state, "user_id", None)
375
+
376
+ session_id = str(uuid.uuid4())
377
+ # Inform the plugin of its assigned session ID
378
+ response = RegisteredMessage(session_id=session_id)
379
+ await websocket.send_json(response.model_dump())
380
+
381
+ session = await registry.register(session_id, project_name, project_hash, unity_version, project_path, user_id=user_id)
382
+ async with lock:
383
+ cls._connections[session.session_id] = websocket
384
+ # Initialize last pong time and start ping loop for this session
385
+ cls._last_pong[session_id] = time.monotonic()
386
+ # Cancel any existing ping task for this session (shouldn't happen, but be safe)
387
+ old_task = cls._ping_tasks.pop(session_id, None)
388
+ if old_task and not old_task.done():
389
+ old_task.cancel()
390
+ # Start the server-side ping loop
391
+ ping_task = asyncio.create_task(cls._ping_loop(session_id, websocket))
392
+ cls._ping_tasks[session_id] = ping_task
393
+
394
+ if user_id:
395
+ logger.info(f"Plugin registered: {project_name} ({project_hash}) for user {user_id}")
396
+ else:
397
+ logger.info(f"Plugin registered: {project_name} ({project_hash})")
398
+
399
+ async def _handle_register_tools(self, websocket: WebSocket, payload: RegisterToolsMessage) -> None:
400
+ cls = type(self)
401
+ registry = cls._registry
402
+ lock = cls._lock
403
+ if registry is None or lock is None:
404
+ return
405
+
406
+ # Find session_id for this websocket
407
+ async with lock:
408
+ session_id = next(
409
+ (sid for sid, ws in cls._connections.items() if ws is websocket), None)
410
+
411
+ if not session_id:
412
+ logger.warning("Received register_tools from unknown connection")
413
+ return
414
+
415
+ await registry.register_tools_for_session(session_id, payload.tools)
416
+ logger.info(
417
+ f"Registered {len(payload.tools)} tools for session {session_id}")
418
+
419
+ try:
420
+ from services.custom_tool_service import CustomToolService
421
+
422
+ service = CustomToolService.get_instance()
423
+ service.register_global_tools(payload.tools)
424
+ except RuntimeError as exc:
425
+ logger.debug(
426
+ "Skipping global custom tool registration: CustomToolService not initialized yet (%s)",
427
+ exc,
428
+ )
429
+ except Exception as exc:
430
+ logger.warning(
431
+ "Unexpected error during global custom tool registration; "
432
+ "custom tools may not be available globally",
433
+ exc_info=exc,
434
+ )
435
+
436
+ async def _handle_command_result(self, payload: CommandResultMessage) -> None:
437
+ cls = type(self)
438
+ lock = cls._lock
439
+ if lock is None:
440
+ return
441
+ command_id = payload.id
442
+ result = payload.result
443
+
444
+ if not command_id:
445
+ logger.warning(f"Command result missing id: {payload}")
446
+ return
447
+
448
+ async with lock:
449
+ entry = cls._pending.get(command_id)
450
+ future = entry.get("future") if isinstance(entry, dict) else None
451
+ if future and not future.done():
452
+ future.set_result(result)
453
+
454
+ async def _handle_pong(self, payload: PongMessage) -> None:
455
+ cls = type(self)
456
+ registry = cls._registry
457
+ lock = cls._lock
458
+ if registry is None:
459
+ return
460
+ session_id = payload.session_id
461
+ if session_id:
462
+ await registry.touch(session_id)
463
+ # Record last pong time for staleness detection (under lock for consistency)
464
+ if lock is not None:
465
+ async with lock:
466
+ cls._last_pong[session_id] = time.monotonic()
467
+
468
+ @classmethod
469
+ async def _ping_loop(cls, session_id: str, websocket: WebSocket) -> None:
470
+ """Server-initiated ping loop to detect dead connections.
471
+
472
+ Sends periodic pings to the Unity client. If no pong is received within
473
+ PING_TIMEOUT seconds, the connection is considered dead and closed.
474
+ This helps detect connections that die silently (e.g., Windows OSError 64).
475
+ """
476
+ logger.debug(f"[Ping] Starting ping loop for session {session_id}")
477
+ try:
478
+ while True:
479
+ await asyncio.sleep(cls.PING_INTERVAL)
480
+
481
+ # Check if we're still supposed to be running and get last pong time (under lock)
482
+ lock = cls._lock
483
+ if lock is None:
484
+ break
485
+ async with lock:
486
+ if session_id not in cls._connections:
487
+ logger.debug(f"[Ping] Session {session_id} no longer in connections, stopping ping loop")
488
+ break
489
+ # Read last pong time under lock for consistency
490
+ last_pong = cls._last_pong.get(session_id, 0)
491
+
492
+ # Check staleness: has it been too long since we got a pong?
493
+ elapsed = time.monotonic() - last_pong
494
+ if elapsed > cls.PING_TIMEOUT:
495
+ logger.warning(
496
+ f"[Ping] Session {session_id} stale: no pong for {elapsed:.1f}s "
497
+ f"(timeout={cls.PING_TIMEOUT}s). Closing connection."
498
+ )
499
+ try:
500
+ await websocket.close(code=1001) # Going away
501
+ except Exception as close_ex:
502
+ logger.debug(f"[Ping] Error closing stale websocket: {close_ex}")
503
+ break
504
+
505
+ # Send a ping to the client
506
+ try:
507
+ ping_msg = PingMessage()
508
+ await websocket.send_json(ping_msg.model_dump())
509
+ logger.debug(f"[Ping] Sent ping to session {session_id}")
510
+ except Exception as send_ex:
511
+ # Send failed - connection is dead
512
+ logger.warning(
513
+ f"[Ping] Failed to send ping to session {session_id}: {send_ex}. "
514
+ "Connection likely dead."
515
+ )
516
+ try:
517
+ await websocket.close(code=1006) # Abnormal closure
518
+ except Exception:
519
+ pass
520
+ break
521
+
522
+ except asyncio.CancelledError:
523
+ logger.debug(f"[Ping] Ping loop cancelled for session {session_id}")
524
+ except Exception as ex:
525
+ logger.warning(f"[Ping] Ping loop error for session {session_id}: {ex}")
526
+ finally:
527
+ logger.debug(f"[Ping] Ping loop ended for session {session_id}")
528
+
529
+ @classmethod
530
+ async def _get_connection(cls, session_id: str) -> WebSocket:
531
+ lock = cls._lock
532
+ if lock is None:
533
+ raise RuntimeError("PluginHub not configured")
534
+ async with lock:
535
+ websocket = cls._connections.get(session_id)
536
+ if websocket is None:
537
+ raise RuntimeError(f"Plugin session {session_id} not connected")
538
+ return websocket
539
+
540
+ # ------------------------------------------------------------------
541
+ # Session resolution helpers
542
+ # ------------------------------------------------------------------
543
+ @classmethod
544
+ async def _resolve_session_id(cls, unity_instance: str | None, user_id: str | None = None) -> str:
545
+ """Resolve a project hash (Unity instance id) to an active plugin session.
546
+
547
+ During Unity domain reloads the plugin's WebSocket session is torn down
548
+ and reconnected shortly afterwards. Instead of failing immediately when
549
+ no sessions are available, we wait for a bounded period for a plugin
550
+ to reconnect so in-flight MCP calls can succeed transparently.
551
+
552
+ Args:
553
+ unity_instance: Target instance (Name@hash or hash)
554
+ user_id: User ID from API key validation (for remote-hosted mode session isolation)
555
+ """
556
+ if cls._registry is None:
557
+ raise RuntimeError("Plugin registry not configured")
558
+
559
+ # Bound waiting for Unity sessions. Default to 20s to handle domain reloads
560
+ # (which can take 10-20s after test runs or script changes).
561
+ #
562
+ # NOTE: This wait can impact agentic workflows where domain reloads happen
563
+ # frequently (e.g., after test runs, script compilation). The 20s default
564
+ # balances handling slow reloads vs. avoiding unnecessary delays.
565
+ #
566
+ # TODO: Make this more deterministic by detecting Unity's actual reload state
567
+ # (e.g., via status file, heartbeat, or explicit "reloading" signal from Unity)
568
+ # rather than blindly waiting up to 20s. See Issue #657.
569
+ #
570
+ # Configurable via: UNITY_MCP_SESSION_RESOLVE_MAX_WAIT_S (default: 20.0, max: 20.0)
571
+ try:
572
+ max_wait_s = float(
573
+ os.environ.get("UNITY_MCP_SESSION_RESOLVE_MAX_WAIT_S", "20.0"))
574
+ except ValueError as e:
575
+ raw_val = os.environ.get(
576
+ "UNITY_MCP_SESSION_RESOLVE_MAX_WAIT_S", "20.0")
577
+ logger.warning(
578
+ "Invalid UNITY_MCP_SESSION_RESOLVE_MAX_WAIT_S=%r, using default 20.0: %s",
579
+ raw_val, e)
580
+ max_wait_s = 20.0
581
+ # Clamp to [0, 20] to prevent misconfiguration from causing excessive waits
582
+ max_wait_s = max(0.0, min(max_wait_s, 20.0))
583
+ retry_ms = float(getattr(config, "reload_retry_ms", 250))
584
+ sleep_seconds = max(0.05, min(0.25, retry_ms / 1000.0))
585
+
586
+ # Allow callers to provide either just the hash or Name@hash
587
+ target_hash: str | None = None
588
+ if unity_instance:
589
+ if "@" in unity_instance:
590
+ _, _, suffix = unity_instance.rpartition("@")
591
+ target_hash = suffix or None
592
+ else:
593
+ target_hash = unity_instance
594
+
595
+ async def _try_once() -> tuple[str | None, int, bool]:
596
+ explicit_required = config.http_remote_hosted
597
+ # Prefer a specific Unity instance if one was requested
598
+ if target_hash:
599
+ # In remote-hosted mode with user_id, use user-scoped lookup
600
+ if config.http_remote_hosted and user_id:
601
+ session_id = await cls._registry.get_session_id_by_hash(target_hash, user_id)
602
+ sessions = await cls._registry.list_sessions(user_id=user_id)
603
+ else:
604
+ session_id = await cls._registry.get_session_id_by_hash(target_hash)
605
+ sessions = await cls._registry.list_sessions(user_id=user_id)
606
+ return session_id, len(sessions), explicit_required
607
+
608
+ # No target provided: determine if we can auto-select
609
+ # In remote-hosted mode, filter sessions by user_id
610
+ sessions = await cls._registry.list_sessions(user_id=user_id)
611
+ count = len(sessions)
612
+ if count == 0:
613
+ return None, count, explicit_required
614
+ if explicit_required:
615
+ return None, count, explicit_required
616
+ if count == 1:
617
+ return next(iter(sessions.keys())), count, explicit_required
618
+ # Multiple sessions but no explicit target is ambiguous
619
+ return None, count, explicit_required
620
+
621
+ session_id, session_count, explicit_required = await _try_once()
622
+ if session_id is None and explicit_required and not target_hash and session_count > 0:
623
+ raise InstanceSelectionRequiredError()
624
+ deadline = time.monotonic() + max_wait_s
625
+ wait_started = None
626
+
627
+ # If there is no active plugin yet (e.g., Unity starting up or reloading),
628
+ # wait politely for a session to appear before surfacing an error.
629
+ while session_id is None and time.monotonic() < deadline:
630
+ if not target_hash and session_count > 1:
631
+ raise InstanceSelectionRequiredError(
632
+ InstanceSelectionRequiredError._MULTIPLE_INSTANCES)
633
+ if session_id is None and explicit_required and not target_hash and session_count > 0:
634
+ raise InstanceSelectionRequiredError()
635
+ if wait_started is None:
636
+ wait_started = time.monotonic()
637
+ logger.debug(
638
+ "No plugin session available (instance=%s); waiting up to %.2fs",
639
+ unity_instance or "default",
640
+ max_wait_s,
641
+ )
642
+ await asyncio.sleep(sleep_seconds)
643
+ session_id, session_count, explicit_required = await _try_once()
644
+
645
+ if session_id is not None and wait_started is not None:
646
+ logger.debug(
647
+ "Plugin session restored after %.3fs (instance=%s)",
648
+ time.monotonic() - wait_started,
649
+ unity_instance or "default",
650
+ )
651
+ if session_id is None and not target_hash and session_count > 1:
652
+ raise InstanceSelectionRequiredError(
653
+ InstanceSelectionRequiredError._MULTIPLE_INSTANCES)
654
+
655
+ if session_id is None and explicit_required and not target_hash and session_count > 0:
656
+ raise InstanceSelectionRequiredError()
657
+
658
+ if session_id is None:
659
+ logger.warning(
660
+ "No Unity plugin reconnected within %.2fs (instance=%s)",
661
+ max_wait_s,
662
+ unity_instance or "default",
663
+ )
664
+ # At this point we've given the plugin ample time to reconnect; surface
665
+ # a clear error so the client can prompt the user to open Unity.
666
+ raise NoUnitySessionError(
667
+ "No Unity plugins are currently connected")
668
+
669
+ return session_id
670
+
671
+ @classmethod
672
+ async def send_command_for_instance(
673
+ cls,
674
+ unity_instance: str | None,
675
+ command_type: str,
676
+ params: dict[str, Any],
677
+ user_id: str | None = None,
678
+ ) -> dict[str, Any]:
679
+ """Send a command to a Unity instance.
680
+
681
+ Args:
682
+ unity_instance: Target instance (Name@hash or hash)
683
+ command_type: Command type to execute
684
+ params: Command parameters
685
+ user_id: User ID for session isolation in remote-hosted mode
686
+ """
687
+ try:
688
+ session_id = await cls._resolve_session_id(unity_instance, user_id=user_id)
689
+ except NoUnitySessionError:
690
+ logger.debug(
691
+ "Unity session unavailable; returning retry: command=%s instance=%s",
692
+ command_type,
693
+ unity_instance or "default",
694
+ )
695
+ return MCPResponse(
696
+ success=False,
697
+ error="Unity session not available; please retry",
698
+ hint="retry",
699
+ data={"reason": "no_unity_session", "retry_after_ms": 250},
700
+ ).model_dump()
701
+
702
+ # During domain reload / immediate reconnect windows, the plugin may be connected but not yet
703
+ # ready to process execute commands on the Unity main thread (which can be further delayed when
704
+ # the Unity Editor is unfocused). For fast-path commands, we do a bounded readiness probe using
705
+ # a main-thread ping command (handled by TransportCommandDispatcher) rather than waiting on
706
+ # register_tools (which can be delayed by EditorApplication.delayCall).
707
+ if command_type in cls._FAST_FAIL_COMMANDS and command_type != "ping":
708
+ try:
709
+ max_wait_s = float(os.environ.get(
710
+ "UNITY_MCP_SESSION_READY_WAIT_SECONDS", "6"))
711
+ except ValueError as e:
712
+ raw_val = os.environ.get(
713
+ "UNITY_MCP_SESSION_READY_WAIT_SECONDS", "6")
714
+ logger.warning(
715
+ "Invalid UNITY_MCP_SESSION_READY_WAIT_SECONDS=%r, using default 6.0: %s",
716
+ raw_val, e)
717
+ max_wait_s = 6.0
718
+ max_wait_s = max(0.0, min(max_wait_s, 20.0))
719
+ if max_wait_s > 0:
720
+ deadline = time.monotonic() + max_wait_s
721
+ while time.monotonic() < deadline:
722
+ try:
723
+ probe = await cls.send_command(session_id, "ping", {})
724
+ except Exception:
725
+ probe = None
726
+
727
+ # The Unity-side dispatcher responds with {status:"success", result:{message:"pong"}}
728
+ if isinstance(probe, dict) and probe.get("status") == "success":
729
+ result = probe.get("result") if isinstance(
730
+ probe.get("result"), dict) else {}
731
+ if result.get("message") == "pong":
732
+ break
733
+ await asyncio.sleep(0.1)
734
+ else:
735
+ # Not ready within the bounded window: return retry hint without sending.
736
+ return MCPResponse(
737
+ success=False,
738
+ error=f"Unity session not ready for '{command_type}' (ping not answered); please retry",
739
+ hint="retry",
740
+ ).model_dump()
741
+
742
+ return await cls.send_command(session_id, command_type, params)
743
+
744
+ # ------------------------------------------------------------------
745
+ # Blocking helpers for synchronous tool code
746
+ # ------------------------------------------------------------------
747
+ @classmethod
748
+ def _run_coroutine_sync(cls, coro: "asyncio.Future[Any]") -> Any:
749
+ if cls._loop is None:
750
+ raise RuntimeError("PluginHub event loop not configured")
751
+ loop = cls._loop
752
+ if loop.is_running():
753
+ try:
754
+ running_loop = asyncio.get_running_loop()
755
+ except RuntimeError:
756
+ running_loop = None
757
+ else:
758
+ if running_loop is loop:
759
+ raise RuntimeError(
760
+ "Cannot wait synchronously for PluginHub coroutine from within the event loop"
761
+ )
762
+ future = asyncio.run_coroutine_threadsafe(coro, loop)
763
+ return future.result()
764
+
765
+ @classmethod
766
+ def send_command_blocking(
767
+ cls,
768
+ unity_instance: str | None,
769
+ command_type: str,
770
+ params: dict[str, Any],
771
+ ) -> dict[str, Any]:
772
+ return cls._run_coroutine_sync(
773
+ cls.send_command_for_instance(unity_instance, command_type, params)
774
+ )
775
+
776
+ @classmethod
777
+ def list_sessions_sync(cls) -> SessionList:
778
+ return cls._run_coroutine_sync(cls.get_sessions())
779
+
780
+
781
+ def send_command_to_plugin(
782
+ *,
783
+ unity_instance: str | None,
784
+ command_type: str,
785
+ params: dict[str, Any],
786
+ ) -> dict[str, Any]:
787
+ return PluginHub.send_command_blocking(unity_instance, command_type, params)