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
transport/models.py ADDED
@@ -0,0 +1,63 @@
1
+ from typing import Any
2
+ from pydantic import BaseModel, Field
3
+ from models.models import ToolDefinitionModel
4
+
5
+ # Outgoing (Server -> Plugin)
6
+
7
+
8
+ class WelcomeMessage(BaseModel):
9
+ type: str = "welcome"
10
+ serverTimeout: int
11
+ keepAliveInterval: int
12
+
13
+
14
+ class RegisteredMessage(BaseModel):
15
+ type: str = "registered"
16
+ session_id: str
17
+
18
+
19
+ class ExecuteCommandMessage(BaseModel):
20
+ type: str = "execute"
21
+ id: str
22
+ name: str
23
+ params: dict[str, Any]
24
+ timeout: float
25
+
26
+ # Incoming (Plugin -> Server)
27
+
28
+
29
+ class RegisterMessage(BaseModel):
30
+ type: str = "register"
31
+ project_name: str = "Unknown Project"
32
+ project_hash: str
33
+ unity_version: str = "Unknown"
34
+ project_path: str | None = None # Full path to project root (for focus nudging)
35
+
36
+
37
+ class RegisterToolsMessage(BaseModel):
38
+ type: str = "register_tools"
39
+ tools: list[ToolDefinitionModel]
40
+
41
+
42
+ class PongMessage(BaseModel):
43
+ type: str = "pong"
44
+ session_id: str | None = None
45
+
46
+
47
+ class CommandResultMessage(BaseModel):
48
+ type: str = "command_result"
49
+ id: str
50
+ result: dict[str, Any] = Field(default_factory=dict)
51
+
52
+ # Session Info (API response)
53
+
54
+
55
+ class SessionDetails(BaseModel):
56
+ project: str
57
+ hash: str
58
+ unity_version: str
59
+ connected_at: str
60
+
61
+
62
+ class SessionList(BaseModel):
63
+ sessions: dict[str, SessionDetails]
@@ -0,0 +1,585 @@
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
11
+
12
+ from starlette.endpoints import WebSocketEndpoint
13
+ from starlette.websockets import WebSocket
14
+
15
+ from core.config import config
16
+ from models.models import MCPResponse
17
+ from transport.plugin_registry import PluginRegistry
18
+ from transport.models import (
19
+ WelcomeMessage,
20
+ RegisteredMessage,
21
+ ExecuteCommandMessage,
22
+ RegisterMessage,
23
+ RegisterToolsMessage,
24
+ PongMessage,
25
+ CommandResultMessage,
26
+ SessionList,
27
+ SessionDetails,
28
+ )
29
+
30
+ logger = logging.getLogger("mcp-for-unity-server")
31
+
32
+
33
+ class PluginDisconnectedError(RuntimeError):
34
+ """Raised when a plugin WebSocket disconnects while commands are in flight."""
35
+
36
+
37
+ class NoUnitySessionError(RuntimeError):
38
+ """Raised when no Unity plugins are available."""
39
+
40
+
41
+ class PluginHub(WebSocketEndpoint):
42
+ """Manages persistent WebSocket connections to Unity plugins."""
43
+
44
+ encoding = "json"
45
+ KEEP_ALIVE_INTERVAL = 15
46
+ SERVER_TIMEOUT = 30
47
+ COMMAND_TIMEOUT = 30
48
+ # Timeout (seconds) for fast-fail commands like ping/read_console/get_editor_state.
49
+ # Keep short so MCP clients aren't blocked during Unity compilation/reload/unfocused throttling.
50
+ FAST_FAIL_TIMEOUT = 2.0
51
+ # Fast-path commands should never block the client for long; return a retry hint instead.
52
+ # This helps avoid the Cursor-side ~30s tool-call timeout when Unity is compiling/reloading
53
+ # or is throttled while unfocused.
54
+ _FAST_FAIL_COMMANDS: set[str] = {
55
+ "read_console", "get_editor_state", "ping"}
56
+
57
+ _registry: PluginRegistry | None = None
58
+ _connections: dict[str, WebSocket] = {}
59
+ # command_id -> {"future": Future, "session_id": str}
60
+ _pending: dict[str, dict[str, Any]] = {}
61
+ _lock: asyncio.Lock | None = None
62
+ _loop: asyncio.AbstractEventLoop | None = None
63
+
64
+ @classmethod
65
+ def configure(
66
+ cls,
67
+ registry: PluginRegistry,
68
+ loop: asyncio.AbstractEventLoop | None = None,
69
+ ) -> None:
70
+ cls._registry = registry
71
+ cls._loop = loop or asyncio.get_running_loop()
72
+ # Ensure coordination primitives are bound to the configured loop
73
+ cls._lock = asyncio.Lock()
74
+
75
+ @classmethod
76
+ def is_configured(cls) -> bool:
77
+ return cls._registry is not None and cls._lock is not None
78
+
79
+ async def on_connect(self, websocket: WebSocket) -> None:
80
+ await websocket.accept()
81
+ msg = WelcomeMessage(
82
+ serverTimeout=self.SERVER_TIMEOUT,
83
+ keepAliveInterval=self.KEEP_ALIVE_INTERVAL,
84
+ )
85
+ await websocket.send_json(msg.model_dump())
86
+
87
+ async def on_receive(self, websocket: WebSocket, data: Any) -> None:
88
+ if not isinstance(data, dict):
89
+ logger.warning(f"Received non-object payload from plugin: {data}")
90
+ return
91
+
92
+ message_type = data.get("type")
93
+ try:
94
+ if message_type == "register":
95
+ await self._handle_register(websocket, RegisterMessage(**data))
96
+ elif message_type == "register_tools":
97
+ await self._handle_register_tools(websocket, RegisterToolsMessage(**data))
98
+ elif message_type == "pong":
99
+ await self._handle_pong(PongMessage(**data))
100
+ elif message_type == "command_result":
101
+ await self._handle_command_result(CommandResultMessage(**data))
102
+ else:
103
+ logger.debug(f"Ignoring plugin message: {data}")
104
+ except Exception as e:
105
+ logger.error(f"Error handling message type {message_type}: {e}")
106
+
107
+ async def on_disconnect(self, websocket: WebSocket, close_code: int) -> None:
108
+ cls = type(self)
109
+ lock = cls._lock
110
+ if lock is None:
111
+ return
112
+ async with lock:
113
+ session_id = next(
114
+ (sid for sid, ws in cls._connections.items() if ws is websocket), None)
115
+ if session_id:
116
+ cls._connections.pop(session_id, None)
117
+ # Fail-fast any in-flight commands for this session to avoid waiting for COMMAND_TIMEOUT.
118
+ pending_ids = [
119
+ command_id
120
+ for command_id, entry in cls._pending.items()
121
+ if entry.get("session_id") == session_id
122
+ ]
123
+ for command_id in pending_ids:
124
+ entry = cls._pending.get(command_id)
125
+ future = entry.get("future") if isinstance(
126
+ entry, dict) else None
127
+ if future and not future.done():
128
+ future.set_exception(
129
+ PluginDisconnectedError(
130
+ f"Unity plugin session {session_id} disconnected while awaiting command_result"
131
+ )
132
+ )
133
+ if cls._registry:
134
+ await cls._registry.unregister(session_id)
135
+ logger.info(
136
+ f"Plugin session {session_id} disconnected ({close_code})")
137
+
138
+ # ------------------------------------------------------------------
139
+ # Public API
140
+ # ------------------------------------------------------------------
141
+ @classmethod
142
+ async def send_command(cls, session_id: str, command_type: str, params: dict[str, Any]) -> dict[str, Any]:
143
+ websocket = await cls._get_connection(session_id)
144
+ command_id = str(uuid.uuid4())
145
+ future: asyncio.Future = asyncio.get_running_loop().create_future()
146
+ # Compute a per-command timeout:
147
+ # - fast-path commands: short timeout (encourage retry)
148
+ # - long-running commands: allow caller to request a longer timeout via params
149
+ unity_timeout_s = float(cls.COMMAND_TIMEOUT)
150
+ server_wait_s = float(cls.COMMAND_TIMEOUT)
151
+ if command_type in cls._FAST_FAIL_COMMANDS:
152
+ fast_timeout = float(cls.FAST_FAIL_TIMEOUT)
153
+ unity_timeout_s = fast_timeout
154
+ server_wait_s = fast_timeout
155
+ else:
156
+ # Common tools pass a requested timeout in seconds (e.g., timeout_seconds=900).
157
+ requested = None
158
+ try:
159
+ if isinstance(params, dict):
160
+ requested = params.get("timeout_seconds", None)
161
+ if requested is None:
162
+ requested = params.get("timeoutSeconds", None)
163
+ except Exception:
164
+ requested = None
165
+
166
+ if requested is not None:
167
+ try:
168
+ requested_s = float(requested)
169
+ # Clamp to a sane upper bound to avoid accidental infinite hangs.
170
+ requested_s = max(1.0, min(requested_s, 60.0 * 60.0))
171
+ unity_timeout_s = max(unity_timeout_s, requested_s)
172
+ # Give the server a small cushion beyond the Unity-side timeout to account for transport overhead.
173
+ server_wait_s = max(server_wait_s, requested_s + 5.0)
174
+ except Exception:
175
+ pass
176
+
177
+ lock = cls._lock
178
+ if lock is None:
179
+ raise RuntimeError("PluginHub not configured")
180
+
181
+ async with lock:
182
+ if command_id in cls._pending:
183
+ raise RuntimeError(
184
+ f"Duplicate command id generated: {command_id}")
185
+ cls._pending[command_id] = {
186
+ "future": future, "session_id": session_id}
187
+
188
+ try:
189
+ msg = ExecuteCommandMessage(
190
+ id=command_id,
191
+ name=command_type,
192
+ params=params,
193
+ timeout=unity_timeout_s,
194
+ )
195
+ try:
196
+ await websocket.send_json(msg.model_dump())
197
+ except Exception as exc:
198
+ # If send fails (socket already closing), fail the future so callers don't hang.
199
+ if not future.done():
200
+ future.set_exception(exc)
201
+ raise
202
+ try:
203
+ result = await asyncio.wait_for(future, timeout=server_wait_s)
204
+ return result
205
+ except PluginDisconnectedError as exc:
206
+ return MCPResponse(success=False, error=str(exc), hint="retry").model_dump()
207
+ except asyncio.TimeoutError:
208
+ if command_type in cls._FAST_FAIL_COMMANDS:
209
+ return MCPResponse(
210
+ success=False,
211
+ error=f"Unity did not respond to '{command_type}' within {server_wait_s:.1f}s; please retry",
212
+ hint="retry",
213
+ ).model_dump()
214
+ raise
215
+ finally:
216
+ async with lock:
217
+ cls._pending.pop(command_id, None)
218
+
219
+ @classmethod
220
+ async def get_sessions(cls) -> SessionList:
221
+ if cls._registry is None:
222
+ return SessionList(sessions={})
223
+ sessions = await cls._registry.list_sessions()
224
+ return SessionList(
225
+ sessions={
226
+ session_id: SessionDetails(
227
+ project=session.project_name,
228
+ hash=session.project_hash,
229
+ unity_version=session.unity_version,
230
+ connected_at=session.connected_at.isoformat(),
231
+ )
232
+ for session_id, session in sessions.items()
233
+ }
234
+ )
235
+
236
+ @classmethod
237
+ async def get_tools_for_project(cls, project_hash: str) -> list[Any]:
238
+ """Retrieve tools registered for a active project hash."""
239
+ if cls._registry is None:
240
+ return []
241
+
242
+ session_id = await cls._registry.get_session_id_by_hash(project_hash)
243
+ if not session_id:
244
+ return []
245
+
246
+ session = await cls._registry.get_session(session_id)
247
+ if not session:
248
+ return []
249
+
250
+ return list(session.tools.values())
251
+
252
+ @classmethod
253
+ async def get_tool_definition(cls, project_hash: str, tool_name: str) -> Any | None:
254
+ """Retrieve a specific tool definition for an active project hash."""
255
+ if cls._registry is None:
256
+ return None
257
+
258
+ session_id = await cls._registry.get_session_id_by_hash(project_hash)
259
+ if not session_id:
260
+ return None
261
+
262
+ session = await cls._registry.get_session(session_id)
263
+ if not session:
264
+ return None
265
+
266
+ return session.tools.get(tool_name)
267
+
268
+ # ------------------------------------------------------------------
269
+ # Internal helpers
270
+ # ------------------------------------------------------------------
271
+ async def _handle_register(self, websocket: WebSocket, payload: RegisterMessage) -> None:
272
+ cls = type(self)
273
+ registry = cls._registry
274
+ lock = cls._lock
275
+ if registry is None or lock is None:
276
+ await websocket.close(code=1011)
277
+ raise RuntimeError("PluginHub not configured")
278
+
279
+ project_name = payload.project_name
280
+ project_hash = payload.project_hash
281
+ unity_version = payload.unity_version
282
+ project_path = payload.project_path
283
+
284
+ if not project_hash:
285
+ await websocket.close(code=4400)
286
+ raise ValueError(
287
+ "Plugin registration missing project_hash")
288
+
289
+ session_id = str(uuid.uuid4())
290
+ # Inform the plugin of its assigned session ID
291
+ response = RegisteredMessage(session_id=session_id)
292
+ await websocket.send_json(response.model_dump())
293
+
294
+ session = await registry.register(session_id, project_name, project_hash, unity_version, project_path)
295
+ async with lock:
296
+ cls._connections[session.session_id] = websocket
297
+ logger.info(f"Plugin registered: {project_name} ({project_hash})")
298
+
299
+ async def _handle_register_tools(self, websocket: WebSocket, payload: RegisterToolsMessage) -> None:
300
+ cls = type(self)
301
+ registry = cls._registry
302
+ lock = cls._lock
303
+ if registry is None or lock is None:
304
+ return
305
+
306
+ # Find session_id for this websocket
307
+ async with lock:
308
+ session_id = next(
309
+ (sid for sid, ws in cls._connections.items() if ws is websocket), None)
310
+
311
+ if not session_id:
312
+ logger.warning("Received register_tools from unknown connection")
313
+ return
314
+
315
+ await registry.register_tools_for_session(session_id, payload.tools)
316
+ logger.info(
317
+ f"Registered {len(payload.tools)} tools for session {session_id}")
318
+
319
+ try:
320
+ from services.custom_tool_service import CustomToolService
321
+
322
+ service = CustomToolService.get_instance()
323
+ service.register_global_tools(payload.tools)
324
+ except RuntimeError as exc:
325
+ logger.debug(
326
+ "Skipping global custom tool registration: CustomToolService not initialized yet (%s)",
327
+ exc,
328
+ )
329
+ except Exception as exc:
330
+ logger.warning(
331
+ "Unexpected error during global custom tool registration; "
332
+ "custom tools may not be available globally",
333
+ exc_info=exc,
334
+ )
335
+
336
+ async def _handle_command_result(self, payload: CommandResultMessage) -> None:
337
+ cls = type(self)
338
+ lock = cls._lock
339
+ if lock is None:
340
+ return
341
+ command_id = payload.id
342
+ result = payload.result
343
+
344
+ if not command_id:
345
+ logger.warning(f"Command result missing id: {payload}")
346
+ return
347
+
348
+ async with lock:
349
+ entry = cls._pending.get(command_id)
350
+ future = entry.get("future") if isinstance(entry, dict) else None
351
+ if future and not future.done():
352
+ future.set_result(result)
353
+
354
+ async def _handle_pong(self, payload: PongMessage) -> None:
355
+ cls = type(self)
356
+ registry = cls._registry
357
+ if registry is None:
358
+ return
359
+ session_id = payload.session_id
360
+ if session_id:
361
+ await registry.touch(session_id)
362
+
363
+ @classmethod
364
+ async def _get_connection(cls, session_id: str) -> WebSocket:
365
+ lock = cls._lock
366
+ if lock is None:
367
+ raise RuntimeError("PluginHub not configured")
368
+ async with lock:
369
+ websocket = cls._connections.get(session_id)
370
+ if websocket is None:
371
+ raise RuntimeError(f"Plugin session {session_id} not connected")
372
+ return websocket
373
+
374
+ # ------------------------------------------------------------------
375
+ # Session resolution helpers
376
+ # ------------------------------------------------------------------
377
+ @classmethod
378
+ async def _resolve_session_id(cls, unity_instance: str | None) -> str:
379
+ """Resolve a project hash (Unity instance id) to an active plugin session.
380
+
381
+ During Unity domain reloads the plugin's WebSocket session is torn down
382
+ and reconnected shortly afterwards. Instead of failing immediately when
383
+ no sessions are available, we wait for a bounded period for a plugin
384
+ to reconnect so in-flight MCP calls can succeed transparently.
385
+ """
386
+ if cls._registry is None:
387
+ raise RuntimeError("Plugin registry not configured")
388
+
389
+ # Bound waiting for Unity sessions so calls fail fast when editors are not ready.
390
+ try:
391
+ max_wait_s = float(
392
+ os.environ.get("UNITY_MCP_SESSION_RESOLVE_MAX_WAIT_S", "2.0"))
393
+ except ValueError as e:
394
+ raw_val = os.environ.get(
395
+ "UNITY_MCP_SESSION_RESOLVE_MAX_WAIT_S", "2.0")
396
+ logger.warning(
397
+ "Invalid UNITY_MCP_SESSION_RESOLVE_MAX_WAIT_S=%r, using default 2.0: %s",
398
+ raw_val, e)
399
+ max_wait_s = 2.0
400
+ # Clamp to [0, 30] to prevent misconfiguration from causing excessive waits
401
+ max_wait_s = max(0.0, min(max_wait_s, 30.0))
402
+ retry_ms = float(getattr(config, "reload_retry_ms", 250))
403
+ sleep_seconds = max(0.05, min(0.25, retry_ms / 1000.0))
404
+
405
+ # Allow callers to provide either just the hash or Name@hash
406
+ target_hash: str | None = None
407
+ if unity_instance:
408
+ if "@" in unity_instance:
409
+ _, _, suffix = unity_instance.rpartition("@")
410
+ target_hash = suffix or None
411
+ else:
412
+ target_hash = unity_instance
413
+
414
+ async def _try_once() -> tuple[str | None, int]:
415
+ # Prefer a specific Unity instance if one was requested
416
+ if target_hash:
417
+ session_id = await cls._registry.get_session_id_by_hash(target_hash)
418
+ sessions = await cls._registry.list_sessions()
419
+ return session_id, len(sessions)
420
+
421
+ # No target provided: determine if we can auto-select
422
+ sessions = await cls._registry.list_sessions()
423
+ count = len(sessions)
424
+ if count == 0:
425
+ return None, count
426
+ if count == 1:
427
+ return next(iter(sessions.keys())), count
428
+ # Multiple sessions but no explicit target is ambiguous
429
+ return None, count
430
+
431
+ session_id, session_count = await _try_once()
432
+ deadline = time.monotonic() + max_wait_s
433
+ wait_started = None
434
+
435
+ # If there is no active plugin yet (e.g., Unity starting up or reloading),
436
+ # wait politely for a session to appear before surfacing an error.
437
+ while session_id is None and time.monotonic() < deadline:
438
+ if not target_hash and session_count > 1:
439
+ raise RuntimeError(
440
+ "Multiple Unity instances are connected. "
441
+ "Call set_active_instance with Name@hash from mcpforunity://instances."
442
+ )
443
+ if wait_started is None:
444
+ wait_started = time.monotonic()
445
+ logger.debug(
446
+ "No plugin session available (instance=%s); waiting up to %.2fs",
447
+ unity_instance or "default",
448
+ max_wait_s,
449
+ )
450
+ await asyncio.sleep(sleep_seconds)
451
+ session_id, session_count = await _try_once()
452
+
453
+ if session_id is not None and wait_started is not None:
454
+ logger.debug(
455
+ "Plugin session restored after %.3fs (instance=%s)",
456
+ time.monotonic() - wait_started,
457
+ unity_instance or "default",
458
+ )
459
+ if session_id is None and not target_hash and session_count > 1:
460
+ raise RuntimeError(
461
+ "Multiple Unity instances are connected. "
462
+ "Call set_active_instance with Name@hash from mcpforunity://instances."
463
+ )
464
+
465
+ if session_id is None:
466
+ logger.warning(
467
+ "No Unity plugin reconnected within %.2fs (instance=%s)",
468
+ max_wait_s,
469
+ unity_instance or "default",
470
+ )
471
+ # At this point we've given the plugin ample time to reconnect; surface
472
+ # a clear error so the client can prompt the user to open Unity.
473
+ raise NoUnitySessionError(
474
+ "No Unity plugins are currently connected")
475
+
476
+ return session_id
477
+
478
+ @classmethod
479
+ async def send_command_for_instance(
480
+ cls,
481
+ unity_instance: str | None,
482
+ command_type: str,
483
+ params: dict[str, Any],
484
+ ) -> dict[str, Any]:
485
+ try:
486
+ session_id = await cls._resolve_session_id(unity_instance)
487
+ except NoUnitySessionError:
488
+ logger.debug(
489
+ "Unity session unavailable; returning retry: command=%s instance=%s",
490
+ command_type,
491
+ unity_instance or "default",
492
+ )
493
+ return MCPResponse(
494
+ success=False,
495
+ error="Unity session not available; please retry",
496
+ hint="retry",
497
+ data={"reason": "no_unity_session", "retry_after_ms": 250},
498
+ ).model_dump()
499
+
500
+ # During domain reload / immediate reconnect windows, the plugin may be connected but not yet
501
+ # ready to process execute commands on the Unity main thread (which can be further delayed when
502
+ # the Unity Editor is unfocused). For fast-path commands, we do a bounded readiness probe using
503
+ # a main-thread ping command (handled by TransportCommandDispatcher) rather than waiting on
504
+ # register_tools (which can be delayed by EditorApplication.delayCall).
505
+ if command_type in cls._FAST_FAIL_COMMANDS and command_type != "ping":
506
+ try:
507
+ max_wait_s = float(os.environ.get(
508
+ "UNITY_MCP_SESSION_READY_WAIT_SECONDS", "6"))
509
+ except ValueError as e:
510
+ raw_val = os.environ.get(
511
+ "UNITY_MCP_SESSION_READY_WAIT_SECONDS", "6")
512
+ logger.warning(
513
+ "Invalid UNITY_MCP_SESSION_READY_WAIT_SECONDS=%r, using default 6.0: %s",
514
+ raw_val, e)
515
+ max_wait_s = 6.0
516
+ max_wait_s = max(0.0, min(max_wait_s, 30.0))
517
+ if max_wait_s > 0:
518
+ deadline = time.monotonic() + max_wait_s
519
+ while time.monotonic() < deadline:
520
+ try:
521
+ probe = await cls.send_command(session_id, "ping", {})
522
+ except Exception:
523
+ probe = None
524
+
525
+ # The Unity-side dispatcher responds with {status:"success", result:{message:"pong"}}
526
+ if isinstance(probe, dict) and probe.get("status") == "success":
527
+ result = probe.get("result") if isinstance(
528
+ probe.get("result"), dict) else {}
529
+ if result.get("message") == "pong":
530
+ break
531
+ await asyncio.sleep(0.1)
532
+ else:
533
+ # Not ready within the bounded window: return retry hint without sending.
534
+ return MCPResponse(
535
+ success=False,
536
+ error=f"Unity session not ready for '{command_type}' (ping not answered); please retry",
537
+ hint="retry",
538
+ ).model_dump()
539
+
540
+ return await cls.send_command(session_id, command_type, params)
541
+
542
+ # ------------------------------------------------------------------
543
+ # Blocking helpers for synchronous tool code
544
+ # ------------------------------------------------------------------
545
+ @classmethod
546
+ def _run_coroutine_sync(cls, coro: "asyncio.Future[Any]") -> Any:
547
+ if cls._loop is None:
548
+ raise RuntimeError("PluginHub event loop not configured")
549
+ loop = cls._loop
550
+ if loop.is_running():
551
+ try:
552
+ running_loop = asyncio.get_running_loop()
553
+ except RuntimeError:
554
+ running_loop = None
555
+ else:
556
+ if running_loop is loop:
557
+ raise RuntimeError(
558
+ "Cannot wait synchronously for PluginHub coroutine from within the event loop"
559
+ )
560
+ future = asyncio.run_coroutine_threadsafe(coro, loop)
561
+ return future.result()
562
+
563
+ @classmethod
564
+ def send_command_blocking(
565
+ cls,
566
+ unity_instance: str | None,
567
+ command_type: str,
568
+ params: dict[str, Any],
569
+ ) -> dict[str, Any]:
570
+ return cls._run_coroutine_sync(
571
+ cls.send_command_for_instance(unity_instance, command_type, params)
572
+ )
573
+
574
+ @classmethod
575
+ def list_sessions_sync(cls) -> SessionList:
576
+ return cls._run_coroutine_sync(cls.get_sessions())
577
+
578
+
579
+ def send_command_to_plugin(
580
+ *,
581
+ unity_instance: str | None,
582
+ command_type: str,
583
+ params: dict[str, Any],
584
+ ) -> dict[str, Any]:
585
+ return PluginHub.send_command_blocking(unity_instance, command_type, params)