mcpforunityserver 8.7.0__py3-none-any.whl → 8.7.1__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mcpforunityserver
3
- Version: 8.7.0
3
+ Version: 8.7.1
4
4
  Summary: MCP for Unity Server: A Unity package for Unity Editor integration via the Model Context Protocol (MCP).
5
5
  Author-email: Marcus Sanatan <msanatan@gmail.com>, David Sarno <david.sarno@gmail.com>, Wu Shutong <martinwfire@gmail.com>
6
6
  License-Expression: MIT
@@ -108,7 +108,7 @@ Use this to run the latest released version from the repository. Change the vers
108
108
  "command": "uvx",
109
109
  "args": [
110
110
  "--from",
111
- "git+https://github.com/CoplayDev/unity-mcp@v8.7.0#subdirectory=Server",
111
+ "git+https://github.com/CoplayDev/unity-mcp@v8.7.1#subdirectory=Server",
112
112
  "mcp-for-unity",
113
113
  "--transport",
114
114
  "stdio"
@@ -5,7 +5,7 @@ core/config.py,sha256=czkTtNji1crQcQbUvmdx4OL7f-RBqkVhj_PtHh-w7rs,1623
5
5
  core/logging_decorator.py,sha256=D9CD7rFvQz-MBG-G4inizQj0Ivr6dfc9RBmTrw7q8mI,1383
6
6
  core/telemetry.py,sha256=eHjYgzd8f7eTwSwF2Kbi8D4TtJIcdaDjKLeo1c-0hVA,19829
7
7
  core/telemetry_decorator.py,sha256=ycSTrzVNCDQHSd-xmIWOpVfKFURPxpiZe_XkOQAGDAo,6705
8
- mcpforunityserver-8.7.0.dist-info/licenses/LICENSE,sha256=bv5lDJZQEqxBgjjc1rkRbkEwpSIHF-8N-1Od0VnEJFw,1066
8
+ mcpforunityserver-8.7.1.dist-info/licenses/LICENSE,sha256=bv5lDJZQEqxBgjjc1rkRbkEwpSIHF-8N-1Od0VnEJFw,1066
9
9
  models/__init__.py,sha256=JlscZkGWE9TRmSoBi99v_LSl8OAFNGmr8463PYkXin4,179
10
10
  models/models.py,sha256=heXuvdBtdats1SGwW8wKFFHM0qR4hA6A7qETn5s9BZ0,1827
11
11
  models/unity_response.py,sha256=oJ1PTsnNc5VBC-9OgM59C0C-R9N-GdmEdmz_yph4GSU,1454
@@ -46,8 +46,8 @@ services/tools/manage_script.py,sha256=lPA5HcS4Al0RiQVz-S6qahFTcPqsk3GSLLXJWHri8
46
46
  services/tools/manage_scriptable_object.py,sha256=Oi03CJLgepaWR59V-nJiAjnCC8at4YqFhRGpACruqgw,3150
47
47
  services/tools/manage_shader.py,sha256=HHnHKh7vLij3p8FAinNsPdZGEKivgwSUTxdgDydfmbs,2882
48
48
  services/tools/preflight.py,sha256=VJn61h-9pvoVaCyKL7DTKLfbpoZfNK4fnRmj91c2o8M,4093
49
- services/tools/read_console.py,sha256=MdQcrnVXra9PLu5AFkmARjriObT0miExtQKkFaANznU,4662
50
- services/tools/refresh_unity.py,sha256=anTEuEzxKTFse6ldZxTsk43zI6ahRBDv3Sg_pMHYRYA,3719
49
+ services/tools/read_console.py,sha256=ELryGXGhZi56pqe4cSdrNDaZGBlUydQIeJ5q86fq_Uo,5201
50
+ services/tools/refresh_unity.py,sha256=xlmqMeAxmYEa5l4OduYdDYWSKJPm2QoJg5CrxCJY8_A,3859
51
51
  services/tools/run_tests.py,sha256=8CqmgRN6Bata666ytF_S9no4gaFmHCmeZM82ZwNQJ68,4666
52
52
  services/tools/script_apply_edits.py,sha256=qPm_PsmsK3mYXnziX_btyk8CaB66LTqpDFA2Y4ebZ4U,47504
53
53
  services/tools/set_active_instance.py,sha256=B18Y8Jga0pKsx9mFywXr1tWfy0cJVopIMXYO-UJ1jOU,4136
@@ -55,17 +55,17 @@ services/tools/test_jobs.py,sha256=K6HjkzWPjJNldrp-Vq5gPH7oBkCq_sJZYXkK_Vg6I_I,4
55
55
  services/tools/utils.py,sha256=4ZgfIu178eXZqRyzs8X77B5lKLP1f73OZoGBSDNokJ4,2409
56
56
  transport/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
57
57
  transport/models.py,sha256=6wp7wsmSaeeJEvUGXPF1m6zuJnxJ1NJlCC4YZ9oQIq0,1226
58
- transport/plugin_hub.py,sha256=g_DOhCThgJ9Oco_z3m2qpwDeUcFvvt7Z47xMS0diihw,21497
58
+ transport/plugin_hub.py,sha256=77xYWunNw8hHRJuimx3Qrxu9wqlKURpuunkUqkqx9kE,22845
59
59
  transport/plugin_registry.py,sha256=nW-7O7PN0QUgSWivZTkpAVKKq9ZOe2b2yeIdpaNt_3I,4359
60
60
  transport/unity_instance_middleware.py,sha256=kf1QeA138r7PaC98dcMDYtUPGWZ4EUmZGESc2DdiWQs,10429
61
61
  transport/unity_transport.py,sha256=_cFVgD3pzFZRcDXANq4oPFYSoI6jntSGaN22dJC8LRU,3880
62
62
  transport/legacy/port_discovery.py,sha256=qM_mtndbYjAj4qPSZEWVeXFOt5_nKczG9pQqORXTBJ0,12768
63
63
  transport/legacy/stdio_port_registry.py,sha256=j4iARuP6wetppNDG8qKeuvo1bJKcSlgEhZvSyl_uf0A,2313
64
- transport/legacy/unity_connection.py,sha256=ujUX9WX7Gb-fxQveHts3uiepTPzFq8i7-XG7u5gSPuM,32668
64
+ transport/legacy/unity_connection.py,sha256=psMwMDiUXKUSrpP4UO9Lgu5PH-Mu6QPY-r5o81WIkVg,35802
65
65
  utils/module_discovery.py,sha256=My48ofB1BUqxiBoAZAGbEaLQYdsrDhMm8MayBP_bUSQ,2005
66
66
  utils/reload_sentinel.py,sha256=s1xMWhl-r2XwN0OUbiUv_VGUy8TvLtV5bkql-5n2DT0,373
67
- mcpforunityserver-8.7.0.dist-info/METADATA,sha256=m3U2_aFTIAFPWg8YE3v2KcMJmP-Ffz0R-EJsJwoD6pA,5712
68
- mcpforunityserver-8.7.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
69
- mcpforunityserver-8.7.0.dist-info/entry_points.txt,sha256=vCtkqw-J9t4pj7JwUZcB54_keVx7DpAR3fYzK6i-s6g,44
70
- mcpforunityserver-8.7.0.dist-info/top_level.txt,sha256=YKU5e5dREMfCnoVpmlsTm9bku7oqnrzSZ9FeTgjoxJw,58
71
- mcpforunityserver-8.7.0.dist-info/RECORD,,
67
+ mcpforunityserver-8.7.1.dist-info/METADATA,sha256=OzxsNyocEXg1K-ND-AesGUKXM8pQx_7CGUrW_XsKXMg,5712
68
+ mcpforunityserver-8.7.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
69
+ mcpforunityserver-8.7.1.dist-info/entry_points.txt,sha256=vCtkqw-J9t4pj7JwUZcB54_keVx7DpAR3fYzK6i-s6g,44
70
+ mcpforunityserver-8.7.1.dist-info/top_level.txt,sha256=YKU5e5dREMfCnoVpmlsTm9bku7oqnrzSZ9FeTgjoxJw,58
71
+ mcpforunityserver-8.7.1.dist-info/RECORD,,
@@ -11,8 +11,15 @@ from transport.unity_transport import send_with_unity_instance
11
11
  from transport.legacy.unity_connection import async_send_command_with_retry
12
12
 
13
13
 
14
+ def _strip_stacktrace_from_list(items: list) -> None:
15
+ """Remove stacktrace fields from a list of log entries."""
16
+ for item in items:
17
+ if isinstance(item, dict) and "stacktrace" in item:
18
+ item.pop("stacktrace", None)
19
+
20
+
14
21
  @mcp_for_unity_tool(
15
- description="Gets messages from or clears the Unity Editor console. Note: For maximum client compatibility, pass count as a quoted string (e.g., '5')."
22
+ description="Gets messages from or clears the Unity Editor console. Defaults to 10 most recent entries. Use page_size/cursor for paging. Note: For maximum client compatibility, pass count as a quoted string (e.g., '5')."
16
23
  )
17
24
  async def read_console(
18
25
  ctx: Context,
@@ -21,10 +28,12 @@ async def read_console(
21
28
  types: Annotated[list[Literal['error', 'warning',
22
29
  'log', 'all']], "Message types to get"] | None = None,
23
30
  count: Annotated[int | str,
24
- "Max messages to return (accepts int or string, e.g., 5 or '5')"] | None = None,
31
+ "Max messages to return in non-paging mode (accepts int or string, e.g., 5 or '5'). Ignored when paging with page_size/cursor."] | None = None,
25
32
  filter_text: Annotated[str, "Text filter for messages"] | None = None,
26
33
  since_timestamp: Annotated[str,
27
34
  "Get messages after this timestamp (ISO 8601)"] | None = None,
35
+ page_size: Annotated[int | str, "Page size for paginated console reads. Defaults to 50 when omitted."] | None = None,
36
+ cursor: Annotated[int | str, "Opaque cursor for paging (0-based offset). Defaults to 0."] | None = None,
28
37
  format: Annotated[Literal['plain', 'detailed',
29
38
  'json'], "Output format"] | None = None,
30
39
  include_stacktrace: Annotated[bool | str,
@@ -35,11 +44,13 @@ async def read_console(
35
44
  unity_instance = get_unity_instance_from_context(ctx)
36
45
  # Set defaults if values are None
37
46
  action = action if action is not None else 'get'
38
- types = types if types is not None else ['error', 'warning', 'log']
39
- format = format if format is not None else 'detailed'
47
+ types = types if types is not None else ['error', 'warning']
48
+ format = format if format is not None else 'plain'
40
49
  # Coerce booleans defensively (strings like 'true'/'false')
41
50
 
42
- include_stacktrace = coerce_bool(include_stacktrace, default=True)
51
+ include_stacktrace = coerce_bool(include_stacktrace, default=False)
52
+ coerced_page_size = coerce_int(page_size, default=None)
53
+ coerced_cursor = coerce_int(cursor, default=None)
43
54
 
44
55
  # Normalize action if it's a string
45
56
  if isinstance(action, str):
@@ -56,7 +67,7 @@ async def read_console(
56
67
  count = coerce_int(count)
57
68
 
58
69
  if action == "get" and count is None:
59
- count = 200
70
+ count = 10
60
71
 
61
72
  # Prepare parameters for the C# handler
62
73
  params_dict = {
@@ -65,6 +76,8 @@ async def read_console(
65
76
  "count": count,
66
77
  "filterText": filter_text,
67
78
  "sinceTimestamp": since_timestamp,
79
+ "pageSize": coerced_page_size,
80
+ "cursor": coerced_cursor,
68
81
  "format": format.lower() if isinstance(format, str) else format,
69
82
  "includeStacktrace": include_stacktrace
70
83
  }
@@ -83,16 +96,13 @@ async def read_console(
83
96
  # Strip stacktrace fields from returned lines if present
84
97
  try:
85
98
  data = resp.get("data")
86
- # Handle standard format: {"data": {"lines": [...]}}
87
- if isinstance(data, dict) and "lines" in data and isinstance(data["lines"], list):
88
- for line in data["lines"]:
89
- if isinstance(line, dict) and "stacktrace" in line:
90
- line.pop("stacktrace", None)
91
- # Handle legacy/direct list format if any
99
+ if isinstance(data, dict):
100
+ for key in ("lines", "items"):
101
+ if key in data and isinstance(data[key], list):
102
+ _strip_stacktrace_from_list(data[key])
103
+ break
92
104
  elif isinstance(data, list):
93
- for line in data:
94
- if isinstance(line, dict) and "stacktrace" in line:
95
- line.pop("stacktrace", None)
105
+ _strip_stacktrace_from_list(data)
96
106
  except Exception:
97
107
  pass
98
108
  return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)}
@@ -10,7 +10,7 @@ from models import MCPResponse
10
10
  from services.registry import mcp_for_unity_tool
11
11
  from services.tools import get_unity_instance_from_context
12
12
  import transport.unity_transport as unity_transport
13
- from transport.legacy.unity_connection import async_send_command_with_retry
13
+ from transport.legacy.unity_connection import async_send_command_with_retry, _extract_response_reason
14
14
  from services.state.external_changes_scanner import external_changes_scanner
15
15
 
16
16
 
@@ -47,10 +47,12 @@ async def refresh_unity(
47
47
  if isinstance(response, dict) and not response.get("success", True):
48
48
  hint = response.get("hint")
49
49
  err = (response.get("error") or response.get("message") or "")
50
+ reason = _extract_response_reason(response)
50
51
  is_retryable = (hint == "retry") or ("disconnected" in str(err).lower())
51
52
  if (not wait_for_ready) or (not is_retryable):
52
53
  return MCPResponse(**response)
53
- recovered_from_disconnect = True
54
+ if reason not in {"reloading", "no_unity_session"}:
55
+ recovered_from_disconnect = True
54
56
 
55
57
  # Optional server-side wait loop (defensive): if Unity tool doesn't wait or returns quickly,
56
58
  # poll the canonical editor_state v2 resource until ready or timeout.
@@ -86,5 +88,3 @@ async def refresh_unity(
86
88
  )
87
89
 
88
90
  return MCPResponse(**response) if isinstance(response, dict) else response
89
-
90
-
@@ -686,28 +686,46 @@ def get_unity_connection(instance_identifier: str | None = None) -> UnityConnect
686
686
  # Centralized retry helpers
687
687
  # -----------------------------
688
688
 
689
- def _is_reloading_response(resp: object) -> bool:
690
- """Return True if the Unity response indicates the editor is reloading.
689
+ def _extract_response_reason(resp: object) -> str | None:
690
+ """Extract a normalized (lowercase) reason string from a response.
691
691
 
692
- Supports both raw dict payloads from Unity and MCPResponse objects returned
693
- by preflight checks or transport helpers.
692
+ Returns lowercase reason values to enable case-insensitive comparisons
693
+ by callers (e.g. _is_reloading_response, refresh_unity).
694
694
  """
695
- # Structured MCPResponse from preflight/transport
696
695
  if isinstance(resp, MCPResponse):
697
- # Explicit "please retry" hint from preflight
698
- if getattr(resp, "hint", None) == "retry":
699
- return True
696
+ data = getattr(resp, "data", None)
697
+ if isinstance(data, dict):
698
+ reason = data.get("reason")
699
+ if isinstance(reason, str):
700
+ return reason.lower()
700
701
  message_text = f"{resp.message or ''} {resp.error or ''}".lower()
701
- return "reload" in message_text
702
+ if "reload" in message_text:
703
+ return "reloading"
704
+ return None
702
705
 
703
- # Raw Unity payloads
704
706
  if isinstance(resp, dict):
705
707
  if resp.get("state") == "reloading":
706
- return True
708
+ return "reloading"
709
+ data = resp.get("data")
710
+ if isinstance(data, dict):
711
+ reason = data.get("reason")
712
+ if isinstance(reason, str):
713
+ return reason.lower()
707
714
  message_text = (resp.get("message") or resp.get("error") or "").lower()
708
- return "reload" in message_text
715
+ if "reload" in message_text:
716
+ return "reloading"
717
+ return None
709
718
 
710
- return False
719
+ return None
720
+
721
+
722
+ def _is_reloading_response(resp: object) -> bool:
723
+ """Return True if the Unity response indicates the editor is reloading.
724
+
725
+ Supports both raw dict payloads from Unity and MCPResponse objects returned
726
+ by preflight checks or transport helpers.
727
+ """
728
+ return _extract_response_reason(resp) == "reloading"
711
729
 
712
730
 
713
731
  def send_command_with_retry(
@@ -738,15 +756,82 @@ def send_command_with_retry(
738
756
  max_retries = getattr(config, "reload_max_retries", 40)
739
757
  if retry_ms is None:
740
758
  retry_ms = getattr(config, "reload_retry_ms", 250)
759
+ try:
760
+ max_wait_s = float(os.environ.get(
761
+ "UNITY_MCP_RELOAD_MAX_WAIT_S", "2.0"))
762
+ except ValueError as e:
763
+ raw_val = os.environ.get("UNITY_MCP_RELOAD_MAX_WAIT_S", "2.0")
764
+ logger.warning(
765
+ "Invalid UNITY_MCP_RELOAD_MAX_WAIT_S=%r, using default 2.0: %s",
766
+ raw_val, e)
767
+ max_wait_s = 2.0
768
+ # Clamp to [0, 30] to prevent misconfiguration from causing excessive waits
769
+ max_wait_s = max(0.0, min(max_wait_s, 30.0))
741
770
 
742
771
  response = conn.send_command(command_type, params)
743
772
  retries = 0
773
+ wait_started = None
774
+ reason = _extract_response_reason(response)
744
775
  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))
776
+ if wait_started is None:
777
+ wait_started = time.monotonic()
778
+ logger.debug(
779
+ "Unity reload wait started: command=%s instance=%s reason=%s max_wait_s=%.2f",
780
+ command_type,
781
+ instance_id or "default",
782
+ reason or "reloading",
783
+ max_wait_s,
784
+ )
785
+ if max_wait_s <= 0:
786
+ break
787
+ elapsed = time.monotonic() - wait_started
788
+ if elapsed >= max_wait_s:
789
+ break
790
+ delay_ms = retry_ms
791
+ if isinstance(response, dict):
792
+ retry_after = response.get("retry_after_ms")
793
+ if retry_after is None and isinstance(response.get("data"), dict):
794
+ retry_after = response["data"].get("retry_after_ms")
795
+ if retry_after is not None:
796
+ delay_ms = int(retry_after)
797
+ sleep_ms = max(50, min(int(delay_ms), 250))
798
+ logger.debug(
799
+ "Unity reload wait retry: command=%s instance=%s reason=%s retry_after_ms=%s sleep_ms=%s",
800
+ command_type,
801
+ instance_id or "default",
802
+ reason or "reloading",
803
+ delay_ms,
804
+ sleep_ms,
805
+ )
806
+ time.sleep(max(0.0, sleep_ms / 1000.0))
748
807
  retries += 1
749
808
  response = conn.send_command(command_type, params)
809
+ reason = _extract_response_reason(response)
810
+
811
+ if wait_started is not None:
812
+ waited = time.monotonic() - wait_started
813
+ if _is_reloading_response(response):
814
+ logger.debug(
815
+ "Unity reload wait exceeded budget: command=%s instance=%s waited_s=%.3f",
816
+ command_type,
817
+ instance_id or "default",
818
+ waited,
819
+ )
820
+ return MCPResponse(
821
+ success=False,
822
+ error="Unity is reloading; please retry",
823
+ hint="retry",
824
+ data={
825
+ "reason": "reloading",
826
+ "retry_after_ms": min(250, max(50, retry_ms)),
827
+ },
828
+ )
829
+ logger.debug(
830
+ "Unity reload wait completed: command=%s instance=%s waited_s=%.3f",
831
+ command_type,
832
+ instance_id or "default",
833
+ waited,
834
+ )
750
835
  return response
751
836
 
752
837
 
transport/plugin_hub.py CHANGED
@@ -34,6 +34,10 @@ class PluginDisconnectedError(RuntimeError):
34
34
  """Raised when a plugin WebSocket disconnects while commands are in flight."""
35
35
 
36
36
 
37
+ class NoUnitySessionError(RuntimeError):
38
+ """Raised when no Unity plugins are available."""
39
+
40
+
37
41
  class PluginHub(WebSocketEndpoint):
38
42
  """Manages persistent WebSocket connections to Unity plugins."""
39
43
 
@@ -361,11 +365,20 @@ class PluginHub(WebSocketEndpoint):
361
365
  if cls._registry is None:
362
366
  raise RuntimeError("Plugin registry not configured")
363
367
 
364
- # Use the same defaults as the stdio transport reload handling so that
365
- # HTTP/WebSocket and TCP behave consistently without per-project env.
366
- max_retries = max(1, int(getattr(config, "reload_max_retries", 40)))
368
+ # Bound waiting for Unity sessions so calls fail fast when editors are not ready.
369
+ try:
370
+ max_wait_s = float(
371
+ os.environ.get("UNITY_MCP_SESSION_RESOLVE_MAX_WAIT_S", "2.0"))
372
+ except ValueError as e:
373
+ raw_val = os.environ.get("UNITY_MCP_SESSION_RESOLVE_MAX_WAIT_S", "2.0")
374
+ logger.warning(
375
+ "Invalid UNITY_MCP_SESSION_RESOLVE_MAX_WAIT_S=%r, using default 2.0: %s",
376
+ raw_val, e)
377
+ max_wait_s = 2.0
378
+ # Clamp to [0, 30] to prevent misconfiguration from causing excessive waits
379
+ max_wait_s = max(0.0, min(max_wait_s, 30.0))
367
380
  retry_ms = float(getattr(config, "reload_retry_ms", 250))
368
- sleep_seconds = max(0.05, retry_ms / 1000.0)
381
+ sleep_seconds = max(0.05, min(0.25, retry_ms / 1000.0))
369
382
 
370
383
  # Allow callers to provide either just the hash or Name@hash
371
384
  target_hash: str | None = None
@@ -394,7 +407,7 @@ class PluginHub(WebSocketEndpoint):
394
407
  return None, count
395
408
 
396
409
  session_id, session_count = await _try_once()
397
- deadline = time.monotonic() + (max_retries * sleep_seconds)
410
+ deadline = time.monotonic() + max_wait_s
398
411
  wait_started = None
399
412
 
400
413
  # If there is no active plugin yet (e.g., Unity starting up or reloading),
@@ -408,14 +421,18 @@ class PluginHub(WebSocketEndpoint):
408
421
  if wait_started is None:
409
422
  wait_started = time.monotonic()
410
423
  logger.debug(
411
- f"No plugin session available (instance={unity_instance or 'default'}); waiting up to {deadline - wait_started:.2f}s",
424
+ "No plugin session available (instance=%s); waiting up to %.2fs",
425
+ unity_instance or "default",
426
+ max_wait_s,
412
427
  )
413
428
  await asyncio.sleep(sleep_seconds)
414
429
  session_id, session_count = await _try_once()
415
430
 
416
431
  if session_id is not None and wait_started is not None:
417
432
  logger.debug(
418
- f"Plugin session restored after {time.monotonic() - wait_started:.3f}s (instance={unity_instance or 'default'})",
433
+ "Plugin session restored after %.3fs (instance=%s)",
434
+ time.monotonic() - wait_started,
435
+ unity_instance or "default",
419
436
  )
420
437
  if session_id is None and not target_hash and session_count > 1:
421
438
  raise RuntimeError(
@@ -425,11 +442,13 @@ class PluginHub(WebSocketEndpoint):
425
442
 
426
443
  if session_id is None:
427
444
  logger.warning(
428
- f"No Unity plugin reconnected within {max_retries * sleep_seconds:.2f}s (instance={unity_instance or 'default'})",
445
+ "No Unity plugin reconnected within %.2fs (instance=%s)",
446
+ max_wait_s,
447
+ unity_instance or "default",
429
448
  )
430
449
  # At this point we've given the plugin ample time to reconnect; surface
431
450
  # a clear error so the client can prompt the user to open Unity.
432
- raise RuntimeError("No Unity plugins are currently connected")
451
+ raise NoUnitySessionError("No Unity plugins are currently connected")
433
452
 
434
453
  return session_id
435
454
 
@@ -440,7 +459,20 @@ class PluginHub(WebSocketEndpoint):
440
459
  command_type: str,
441
460
  params: dict[str, Any],
442
461
  ) -> dict[str, Any]:
443
- session_id = await cls._resolve_session_id(unity_instance)
462
+ try:
463
+ session_id = await cls._resolve_session_id(unity_instance)
464
+ except NoUnitySessionError:
465
+ logger.debug(
466
+ "Unity session unavailable; returning retry: command=%s instance=%s",
467
+ command_type,
468
+ unity_instance or "default",
469
+ )
470
+ return MCPResponse(
471
+ success=False,
472
+ error="Unity session not available; please retry",
473
+ hint="retry",
474
+ data={"reason": "no_unity_session", "retry_after_ms": 250},
475
+ ).model_dump()
444
476
 
445
477
  # During domain reload / immediate reconnect windows, the plugin may be connected but not yet
446
478
  # ready to process execute commands on the Unity main thread (which can be further delayed when
@@ -450,7 +482,11 @@ class PluginHub(WebSocketEndpoint):
450
482
  if command_type in cls._FAST_FAIL_COMMANDS and command_type != "ping":
451
483
  try:
452
484
  max_wait_s = float(os.environ.get("UNITY_MCP_SESSION_READY_WAIT_SECONDS", "6"))
453
- except Exception:
485
+ except ValueError as e:
486
+ raw_val = os.environ.get("UNITY_MCP_SESSION_READY_WAIT_SECONDS", "6")
487
+ logger.warning(
488
+ "Invalid UNITY_MCP_SESSION_READY_WAIT_SECONDS=%r, using default 6.0: %s",
489
+ raw_val, e)
454
490
  max_wait_s = 6.0
455
491
  max_wait_s = max(0.0, min(max_wait_s, 30.0))
456
492
  if max_wait_s > 0: