mcpforunityserver 8.5.0__py3-none-any.whl → 9.1.0__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 (79) hide show
  1. cli/__init__.py +3 -0
  2. cli/commands/__init__.py +3 -0
  3. cli/commands/animation.py +87 -0
  4. cli/commands/asset.py +310 -0
  5. cli/commands/audio.py +133 -0
  6. cli/commands/batch.py +184 -0
  7. cli/commands/code.py +189 -0
  8. cli/commands/component.py +212 -0
  9. cli/commands/editor.py +487 -0
  10. cli/commands/gameobject.py +510 -0
  11. cli/commands/instance.py +101 -0
  12. cli/commands/lighting.py +128 -0
  13. cli/commands/material.py +268 -0
  14. cli/commands/prefab.py +144 -0
  15. cli/commands/scene.py +255 -0
  16. cli/commands/script.py +240 -0
  17. cli/commands/shader.py +238 -0
  18. cli/commands/ui.py +263 -0
  19. cli/commands/vfx.py +439 -0
  20. cli/main.py +248 -0
  21. cli/utils/__init__.py +31 -0
  22. cli/utils/config.py +58 -0
  23. cli/utils/connection.py +191 -0
  24. cli/utils/output.py +195 -0
  25. main.py +207 -62
  26. {mcpforunityserver-8.5.0.dist-info → mcpforunityserver-9.1.0.dist-info}/METADATA +4 -2
  27. mcpforunityserver-9.1.0.dist-info/RECORD +96 -0
  28. {mcpforunityserver-8.5.0.dist-info → mcpforunityserver-9.1.0.dist-info}/WHEEL +1 -1
  29. {mcpforunityserver-8.5.0.dist-info → mcpforunityserver-9.1.0.dist-info}/entry_points.txt +1 -0
  30. {mcpforunityserver-8.5.0.dist-info → mcpforunityserver-9.1.0.dist-info}/top_level.txt +1 -2
  31. services/custom_tool_service.py +179 -19
  32. services/resources/__init__.py +6 -1
  33. services/resources/active_tool.py +1 -1
  34. services/resources/custom_tools.py +2 -2
  35. services/resources/editor_state.py +283 -21
  36. services/resources/gameobject.py +243 -0
  37. services/resources/layers.py +1 -1
  38. services/resources/prefab_stage.py +1 -1
  39. services/resources/project_info.py +1 -1
  40. services/resources/selection.py +1 -1
  41. services/resources/tags.py +1 -1
  42. services/resources/unity_instances.py +1 -1
  43. services/resources/windows.py +1 -1
  44. services/state/external_changes_scanner.py +245 -0
  45. services/tools/__init__.py +6 -1
  46. services/tools/batch_execute.py +24 -9
  47. services/tools/debug_request_context.py +8 -2
  48. services/tools/execute_custom_tool.py +6 -1
  49. services/tools/execute_menu_item.py +6 -3
  50. services/tools/find_gameobjects.py +89 -0
  51. services/tools/find_in_file.py +26 -19
  52. services/tools/manage_asset.py +19 -43
  53. services/tools/manage_components.py +131 -0
  54. services/tools/manage_editor.py +9 -8
  55. services/tools/manage_gameobject.py +120 -79
  56. services/tools/manage_material.py +80 -31
  57. services/tools/manage_prefabs.py +7 -1
  58. services/tools/manage_scene.py +34 -13
  59. services/tools/manage_script.py +62 -19
  60. services/tools/manage_scriptable_object.py +22 -10
  61. services/tools/manage_shader.py +8 -1
  62. services/tools/manage_vfx.py +738 -0
  63. services/tools/preflight.py +110 -0
  64. services/tools/read_console.py +81 -18
  65. services/tools/refresh_unity.py +153 -0
  66. services/tools/run_tests.py +202 -41
  67. services/tools/script_apply_edits.py +15 -7
  68. services/tools/set_active_instance.py +12 -7
  69. services/tools/utils.py +60 -6
  70. transport/legacy/port_discovery.py +2 -2
  71. transport/legacy/unity_connection.py +129 -26
  72. transport/plugin_hub.py +191 -19
  73. transport/unity_instance_middleware.py +93 -2
  74. transport/unity_transport.py +17 -6
  75. utils/focus_nudge.py +321 -0
  76. __init__.py +0 -0
  77. mcpforunityserver-8.5.0.dist-info/RECORD +0 -66
  78. routes/__init__.py +0 -0
  79. {mcpforunityserver-8.5.0.dist-info → mcpforunityserver-9.1.0.dist-info}/licenses/LICENSE +0 -0
@@ -233,14 +233,21 @@ class UnityConnection:
233
233
  logger.error(f"Error during receive: {str(e)}")
234
234
  raise
235
235
 
236
- def send_command(self, command_type: str, params: dict[str, Any] = None) -> dict[str, Any]:
237
- """Send a command with retry/backoff and port rediscovery. Pings only when requested."""
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
+ """
238
244
  # Defensive guard: catch empty/placeholder invocations early
239
245
  if not command_type:
240
246
  raise ValueError("MCP call missing command_type")
241
247
  if params is None:
242
248
  return MCPResponse(success=False, error="MCP call received with no parameters (client placeholder?)")
243
- attempts = max(config.max_retries, 5)
249
+ attempts = max(config.max_retries,
250
+ 5) if max_attempts is None else max_attempts
244
251
  base_backoff = max(0.5, config.retry_delay)
245
252
 
246
253
  def read_status_file(target_hash: str | None = None) -> dict | None:
@@ -584,7 +591,7 @@ class UnityConnectionPool:
584
591
  raise ConnectionError(
585
592
  f"Unity instance '{identifier}' not found. "
586
593
  f"Available instances: {available_ids}. "
587
- f"Check unity://instances resource for all instances."
594
+ f"Check mcpforunity://instances resource for all instances."
588
595
  )
589
596
 
590
597
  def get_connection(self, instance_identifier: str | None = None) -> UnityConnection:
@@ -686,28 +693,46 @@ def get_unity_connection(instance_identifier: str | None = None) -> UnityConnect
686
693
  # Centralized retry helpers
687
694
  # -----------------------------
688
695
 
689
- def _is_reloading_response(resp: object) -> bool:
690
- """Return True if the Unity response indicates the editor is reloading.
696
+ def _extract_response_reason(resp: object) -> str | None:
697
+ """Extract a normalized (lowercase) reason string from a response.
691
698
 
692
- Supports both raw dict payloads from Unity and MCPResponse objects returned
693
- by preflight checks or transport helpers.
699
+ Returns lowercase reason values to enable case-insensitive comparisons
700
+ by callers (e.g. _is_reloading_response, refresh_unity).
694
701
  """
695
- # Structured MCPResponse from preflight/transport
696
702
  if isinstance(resp, MCPResponse):
697
- # Explicit "please retry" hint from preflight
698
- if getattr(resp, "hint", None) == "retry":
699
- return True
703
+ data = getattr(resp, "data", None)
704
+ if isinstance(data, dict):
705
+ reason = data.get("reason")
706
+ if isinstance(reason, str):
707
+ return reason.lower()
700
708
  message_text = f"{resp.message or ''} {resp.error or ''}".lower()
701
- return "reload" in message_text
709
+ if "reload" in message_text:
710
+ return "reloading"
711
+ return None
702
712
 
703
- # Raw Unity payloads
704
713
  if isinstance(resp, dict):
705
714
  if resp.get("state") == "reloading":
706
- return True
715
+ return "reloading"
716
+ data = resp.get("data")
717
+ if isinstance(data, dict):
718
+ reason = data.get("reason")
719
+ if isinstance(reason, str):
720
+ return reason.lower()
707
721
  message_text = (resp.get("message") or resp.get("error") or "").lower()
708
- return "reload" in message_text
722
+ if "reload" in message_text:
723
+ return "reloading"
724
+ return None
709
725
 
710
- return False
726
+ return None
727
+
728
+
729
+ def _is_reloading_response(resp: object) -> bool:
730
+ """Return True if the Unity response indicates the editor is reloading.
731
+
732
+ Supports both raw dict payloads from Unity and MCPResponse objects returned
733
+ by preflight checks or transport helpers.
734
+ """
735
+ return _extract_response_reason(resp) == "reloading"
711
736
 
712
737
 
713
738
  def send_command_with_retry(
@@ -716,7 +741,8 @@ def send_command_with_retry(
716
741
  *,
717
742
  instance_id: str | None = None,
718
743
  max_retries: int | None = None,
719
- retry_ms: int | None = None
744
+ retry_ms: int | None = None,
745
+ retry_on_reload: bool = True
720
746
  ) -> dict[str, Any] | MCPResponse:
721
747
  """Send a command to a Unity instance, waiting politely through Unity reloads.
722
748
 
@@ -726,6 +752,8 @@ def send_command_with_retry(
726
752
  instance_id: Optional Unity instance identifier (name, hash, name@hash, etc.)
727
753
  max_retries: Maximum number of retries for reload states
728
754
  retry_ms: Delay between retries in milliseconds
755
+ retry_on_reload: If False, don't retry when Unity is reloading (for commands
756
+ that trigger compilation/reload and shouldn't be re-sent)
729
757
 
730
758
  Returns:
731
759
  Response dictionary or MCPResponse from Unity
@@ -738,15 +766,87 @@ def send_command_with_retry(
738
766
  max_retries = getattr(config, "reload_max_retries", 40)
739
767
  if retry_ms is None:
740
768
  retry_ms = getattr(config, "reload_retry_ms", 250)
741
-
742
- response = conn.send_command(command_type, params)
769
+ try:
770
+ max_wait_s = float(os.environ.get(
771
+ "UNITY_MCP_RELOAD_MAX_WAIT_S", "2.0"))
772
+ except ValueError as e:
773
+ raw_val = os.environ.get("UNITY_MCP_RELOAD_MAX_WAIT_S", "2.0")
774
+ logger.warning(
775
+ "Invalid UNITY_MCP_RELOAD_MAX_WAIT_S=%r, using default 2.0: %s",
776
+ raw_val, e)
777
+ max_wait_s = 2.0
778
+ # Clamp to [0, 30] to prevent misconfiguration from causing excessive waits
779
+ max_wait_s = max(0.0, min(max_wait_s, 30.0))
780
+
781
+ # If retry_on_reload=False, disable connection-level retries too (issue #577)
782
+ # Commands that trigger compilation/reload shouldn't retry on disconnect
783
+ send_max_attempts = None if retry_on_reload else 0
784
+
785
+ response = conn.send_command(
786
+ command_type, params, max_attempts=send_max_attempts)
743
787
  retries = 0
744
- while _is_reloading_response(response) and retries < max_retries:
745
- delay_ms = int(response.get("retry_after_ms", retry_ms)
746
- ) if isinstance(response, dict) else retry_ms
747
- time.sleep(max(0.0, delay_ms / 1000.0))
788
+ wait_started = None
789
+ reason = _extract_response_reason(response)
790
+ while retry_on_reload and _is_reloading_response(response) and retries < max_retries:
791
+ if wait_started is None:
792
+ wait_started = time.monotonic()
793
+ logger.debug(
794
+ "Unity reload wait started: command=%s instance=%s reason=%s max_wait_s=%.2f",
795
+ command_type,
796
+ instance_id or "default",
797
+ reason or "reloading",
798
+ max_wait_s,
799
+ )
800
+ if max_wait_s <= 0:
801
+ break
802
+ elapsed = time.monotonic() - wait_started
803
+ if elapsed >= max_wait_s:
804
+ break
805
+ delay_ms = retry_ms
806
+ if isinstance(response, dict):
807
+ retry_after = response.get("retry_after_ms")
808
+ if retry_after is None and isinstance(response.get("data"), dict):
809
+ retry_after = response["data"].get("retry_after_ms")
810
+ if retry_after is not None:
811
+ delay_ms = int(retry_after)
812
+ sleep_ms = max(50, min(int(delay_ms), 250))
813
+ logger.debug(
814
+ "Unity reload wait retry: command=%s instance=%s reason=%s retry_after_ms=%s sleep_ms=%s",
815
+ command_type,
816
+ instance_id or "default",
817
+ reason or "reloading",
818
+ delay_ms,
819
+ sleep_ms,
820
+ )
821
+ time.sleep(max(0.0, sleep_ms / 1000.0))
748
822
  retries += 1
749
823
  response = conn.send_command(command_type, params)
824
+ reason = _extract_response_reason(response)
825
+
826
+ if wait_started is not None:
827
+ waited = time.monotonic() - wait_started
828
+ if _is_reloading_response(response):
829
+ logger.debug(
830
+ "Unity reload wait exceeded budget: command=%s instance=%s waited_s=%.3f",
831
+ command_type,
832
+ instance_id or "default",
833
+ waited,
834
+ )
835
+ return MCPResponse(
836
+ success=False,
837
+ error="Unity is reloading; please retry",
838
+ hint="retry",
839
+ data={
840
+ "reason": "reloading",
841
+ "retry_after_ms": min(250, max(50, retry_ms)),
842
+ },
843
+ )
844
+ logger.debug(
845
+ "Unity reload wait completed: command=%s instance=%s waited_s=%.3f",
846
+ command_type,
847
+ instance_id or "default",
848
+ waited,
849
+ )
750
850
  return response
751
851
 
752
852
 
@@ -757,7 +857,8 @@ async def async_send_command_with_retry(
757
857
  instance_id: str | None = None,
758
858
  loop=None,
759
859
  max_retries: int | None = None,
760
- retry_ms: int | None = None
860
+ retry_ms: int | None = None,
861
+ retry_on_reload: bool = True
761
862
  ) -> dict[str, Any] | MCPResponse:
762
863
  """Async wrapper that runs the blocking retry helper in a thread pool.
763
864
 
@@ -768,6 +869,7 @@ async def async_send_command_with_retry(
768
869
  loop: Optional asyncio event loop
769
870
  max_retries: Maximum number of retries for reload states
770
871
  retry_ms: Delay between retries in milliseconds
872
+ retry_on_reload: If False, don't retry when Unity is reloading
771
873
 
772
874
  Returns:
773
875
  Response dictionary or MCPResponse on error
@@ -779,7 +881,8 @@ async def async_send_command_with_retry(
779
881
  return await loop.run_in_executor(
780
882
  None,
781
883
  lambda: send_command_with_retry(
782
- command_type, params, instance_id=instance_id, max_retries=max_retries, retry_ms=retry_ms),
884
+ command_type, params, instance_id=instance_id, max_retries=max_retries,
885
+ retry_ms=retry_ms, retry_on_reload=retry_on_reload),
783
886
  )
784
887
  except Exception as e:
785
888
  return MCPResponse(success=False, error=str(e))
transport/plugin_hub.py CHANGED
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import asyncio
6
6
  import logging
7
+ import os
7
8
  import time
8
9
  import uuid
9
10
  from typing import Any
@@ -12,6 +13,7 @@ from starlette.endpoints import WebSocketEndpoint
12
13
  from starlette.websockets import WebSocket
13
14
 
14
15
  from core.config import config
16
+ from models.models import MCPResponse
15
17
  from transport.plugin_registry import PluginRegistry
16
18
  from transport.models import (
17
19
  WelcomeMessage,
@@ -28,6 +30,14 @@ from transport.models import (
28
30
  logger = logging.getLogger("mcp-for-unity-server")
29
31
 
30
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
+
31
41
  class PluginHub(WebSocketEndpoint):
32
42
  """Manages persistent WebSocket connections to Unity plugins."""
33
43
 
@@ -35,10 +45,19 @@ class PluginHub(WebSocketEndpoint):
35
45
  KEEP_ALIVE_INTERVAL = 15
36
46
  SERVER_TIMEOUT = 30
37
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"}
38
56
 
39
57
  _registry: PluginRegistry | None = None
40
58
  _connections: dict[str, WebSocket] = {}
41
- _pending: dict[str, asyncio.Future] = {}
59
+ # command_id -> {"future": Future, "session_id": str}
60
+ _pending: dict[str, dict[str, Any]] = {}
42
61
  _lock: asyncio.Lock | None = None
43
62
  _loop: asyncio.AbstractEventLoop | None = None
44
63
 
@@ -95,6 +114,22 @@ class PluginHub(WebSocketEndpoint):
95
114
  (sid for sid, ws in cls._connections.items() if ws is websocket), None)
96
115
  if session_id:
97
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
+ )
98
133
  if cls._registry:
99
134
  await cls._registry.unregister(session_id)
100
135
  logger.info(
@@ -108,6 +143,36 @@ class PluginHub(WebSocketEndpoint):
108
143
  websocket = await cls._get_connection(session_id)
109
144
  command_id = str(uuid.uuid4())
110
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
111
176
 
112
177
  lock = cls._lock
113
178
  if lock is None:
@@ -117,18 +182,36 @@ class PluginHub(WebSocketEndpoint):
117
182
  if command_id in cls._pending:
118
183
  raise RuntimeError(
119
184
  f"Duplicate command id generated: {command_id}")
120
- cls._pending[command_id] = future
185
+ cls._pending[command_id] = {
186
+ "future": future, "session_id": session_id}
121
187
 
122
188
  try:
123
189
  msg = ExecuteCommandMessage(
124
190
  id=command_id,
125
191
  name=command_type,
126
192
  params=params,
127
- timeout=cls.COMMAND_TIMEOUT,
193
+ timeout=unity_timeout_s,
128
194
  )
129
- await websocket.send_json(msg.model_dump())
130
- result = await asyncio.wait_for(future, timeout=cls.COMMAND_TIMEOUT)
131
- return result
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
132
215
  finally:
133
216
  async with lock:
134
217
  cls._pending.pop(command_id, None)
@@ -232,6 +315,23 @@ class PluginHub(WebSocketEndpoint):
232
315
  logger.info(
233
316
  f"Registered {len(payload.tools)} tools for session {session_id}")
234
317
 
318
+ try:
319
+ from services.custom_tool_service import CustomToolService
320
+
321
+ service = CustomToolService.get_instance()
322
+ service.register_global_tools(payload.tools)
323
+ except RuntimeError as exc:
324
+ logger.debug(
325
+ "Skipping global custom tool registration: CustomToolService not initialized yet (%s)",
326
+ exc,
327
+ )
328
+ except Exception as exc:
329
+ logger.warning(
330
+ "Unexpected error during global custom tool registration; "
331
+ "custom tools may not be available globally",
332
+ exc_info=exc,
333
+ )
334
+
235
335
  async def _handle_command_result(self, payload: CommandResultMessage) -> None:
236
336
  cls = type(self)
237
337
  lock = cls._lock
@@ -245,7 +345,8 @@ class PluginHub(WebSocketEndpoint):
245
345
  return
246
346
 
247
347
  async with lock:
248
- future = cls._pending.get(command_id)
348
+ entry = cls._pending.get(command_id)
349
+ future = entry.get("future") if isinstance(entry, dict) else None
249
350
  if future and not future.done():
250
351
  future.set_result(result)
251
352
 
@@ -284,11 +385,21 @@ class PluginHub(WebSocketEndpoint):
284
385
  if cls._registry is None:
285
386
  raise RuntimeError("Plugin registry not configured")
286
387
 
287
- # Use the same defaults as the stdio transport reload handling so that
288
- # HTTP/WebSocket and TCP behave consistently without per-project env.
289
- max_retries = max(1, int(getattr(config, "reload_max_retries", 40)))
388
+ # Bound waiting for Unity sessions so calls fail fast when editors are not ready.
389
+ try:
390
+ max_wait_s = float(
391
+ os.environ.get("UNITY_MCP_SESSION_RESOLVE_MAX_WAIT_S", "2.0"))
392
+ except ValueError as e:
393
+ raw_val = os.environ.get(
394
+ "UNITY_MCP_SESSION_RESOLVE_MAX_WAIT_S", "2.0")
395
+ logger.warning(
396
+ "Invalid UNITY_MCP_SESSION_RESOLVE_MAX_WAIT_S=%r, using default 2.0: %s",
397
+ raw_val, e)
398
+ max_wait_s = 2.0
399
+ # Clamp to [0, 30] to prevent misconfiguration from causing excessive waits
400
+ max_wait_s = max(0.0, min(max_wait_s, 30.0))
290
401
  retry_ms = float(getattr(config, "reload_retry_ms", 250))
291
- sleep_seconds = max(0.05, retry_ms / 1000.0)
402
+ sleep_seconds = max(0.05, min(0.25, retry_ms / 1000.0))
292
403
 
293
404
  # Allow callers to provide either just the hash or Name@hash
294
405
  target_hash: str | None = None
@@ -317,7 +428,7 @@ class PluginHub(WebSocketEndpoint):
317
428
  return None, count
318
429
 
319
430
  session_id, session_count = await _try_once()
320
- deadline = time.monotonic() + (max_retries * sleep_seconds)
431
+ deadline = time.monotonic() + max_wait_s
321
432
  wait_started = None
322
433
 
323
434
  # If there is no active plugin yet (e.g., Unity starting up or reloading),
@@ -326,33 +437,40 @@ class PluginHub(WebSocketEndpoint):
326
437
  if not target_hash and session_count > 1:
327
438
  raise RuntimeError(
328
439
  "Multiple Unity instances are connected. "
329
- "Call set_active_instance with Name@hash from unity://instances."
440
+ "Call set_active_instance with Name@hash from mcpforunity://instances."
330
441
  )
331
442
  if wait_started is None:
332
443
  wait_started = time.monotonic()
333
444
  logger.debug(
334
- f"No plugin session available (instance={unity_instance or 'default'}); waiting up to {deadline - wait_started:.2f}s",
445
+ "No plugin session available (instance=%s); waiting up to %.2fs",
446
+ unity_instance or "default",
447
+ max_wait_s,
335
448
  )
336
449
  await asyncio.sleep(sleep_seconds)
337
450
  session_id, session_count = await _try_once()
338
451
 
339
452
  if session_id is not None and wait_started is not None:
340
453
  logger.debug(
341
- f"Plugin session restored after {time.monotonic() - wait_started:.3f}s (instance={unity_instance or 'default'})",
454
+ "Plugin session restored after %.3fs (instance=%s)",
455
+ time.monotonic() - wait_started,
456
+ unity_instance or "default",
342
457
  )
343
458
  if session_id is None and not target_hash and session_count > 1:
344
459
  raise RuntimeError(
345
460
  "Multiple Unity instances are connected. "
346
- "Call set_active_instance with Name@hash from unity://instances."
461
+ "Call set_active_instance with Name@hash from mcpforunity://instances."
347
462
  )
348
463
 
349
464
  if session_id is None:
350
465
  logger.warning(
351
- f"No Unity plugin reconnected within {max_retries * sleep_seconds:.2f}s (instance={unity_instance or 'default'})",
466
+ "No Unity plugin reconnected within %.2fs (instance=%s)",
467
+ max_wait_s,
468
+ unity_instance or "default",
352
469
  )
353
470
  # At this point we've given the plugin ample time to reconnect; surface
354
471
  # a clear error so the client can prompt the user to open Unity.
355
- raise RuntimeError("No Unity plugins are currently connected")
472
+ raise NoUnitySessionError(
473
+ "No Unity plugins are currently connected")
356
474
 
357
475
  return session_id
358
476
 
@@ -363,7 +481,61 @@ class PluginHub(WebSocketEndpoint):
363
481
  command_type: str,
364
482
  params: dict[str, Any],
365
483
  ) -> dict[str, Any]:
366
- session_id = await cls._resolve_session_id(unity_instance)
484
+ try:
485
+ session_id = await cls._resolve_session_id(unity_instance)
486
+ except NoUnitySessionError:
487
+ logger.debug(
488
+ "Unity session unavailable; returning retry: command=%s instance=%s",
489
+ command_type,
490
+ unity_instance or "default",
491
+ )
492
+ return MCPResponse(
493
+ success=False,
494
+ error="Unity session not available; please retry",
495
+ hint="retry",
496
+ data={"reason": "no_unity_session", "retry_after_ms": 250},
497
+ ).model_dump()
498
+
499
+ # During domain reload / immediate reconnect windows, the plugin may be connected but not yet
500
+ # ready to process execute commands on the Unity main thread (which can be further delayed when
501
+ # the Unity Editor is unfocused). For fast-path commands, we do a bounded readiness probe using
502
+ # a main-thread ping command (handled by TransportCommandDispatcher) rather than waiting on
503
+ # register_tools (which can be delayed by EditorApplication.delayCall).
504
+ if command_type in cls._FAST_FAIL_COMMANDS and command_type != "ping":
505
+ try:
506
+ max_wait_s = float(os.environ.get(
507
+ "UNITY_MCP_SESSION_READY_WAIT_SECONDS", "6"))
508
+ except ValueError as e:
509
+ raw_val = os.environ.get(
510
+ "UNITY_MCP_SESSION_READY_WAIT_SECONDS", "6")
511
+ logger.warning(
512
+ "Invalid UNITY_MCP_SESSION_READY_WAIT_SECONDS=%r, using default 6.0: %s",
513
+ raw_val, e)
514
+ max_wait_s = 6.0
515
+ max_wait_s = max(0.0, min(max_wait_s, 30.0))
516
+ if max_wait_s > 0:
517
+ deadline = time.monotonic() + max_wait_s
518
+ while time.monotonic() < deadline:
519
+ try:
520
+ probe = await cls.send_command(session_id, "ping", {})
521
+ except Exception:
522
+ probe = None
523
+
524
+ # The Unity-side dispatcher responds with {status:"success", result:{message:"pong"}}
525
+ if isinstance(probe, dict) and probe.get("status") == "success":
526
+ result = probe.get("result") if isinstance(
527
+ probe.get("result"), dict) else {}
528
+ if result.get("message") == "pong":
529
+ break
530
+ await asyncio.sleep(0.1)
531
+ else:
532
+ # Not ready within the bounded window: return retry hint without sending.
533
+ return MCPResponse(
534
+ success=False,
535
+ error=f"Unity session not ready for '{command_type}' (ping not answered); please retry",
536
+ hint="retry",
537
+ ).model_dump()
538
+
367
539
  return await cls.send_command(session_id, command_type, params)
368
540
 
369
541
  # ------------------------------------------------------------------