mcpforunityserver 8.5.0__py3-none-any.whl → 8.7.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.
@@ -12,6 +12,7 @@ from services.tools import get_unity_instance_from_context
12
12
  from services.tools.utils import parse_json_payload, coerce_int
13
13
  from transport.unity_transport import send_with_unity_instance
14
14
  from transport.legacy.unity_connection import async_send_command_with_retry
15
+ from services.tools.preflight import preflight
15
16
 
16
17
 
17
18
  @mcp_for_unity_tool(
@@ -47,6 +48,12 @@ async def manage_asset(
47
48
  ) -> dict[str, Any]:
48
49
  unity_instance = get_unity_instance_from_context(ctx)
49
50
 
51
+ # Best-effort guard: if Unity is compiling/reloading or known external changes are pending,
52
+ # wait/refresh to avoid stale reads and flaky timeouts.
53
+ gate = await preflight(ctx, wait_for_no_compile=True, refresh_if_dirty=True)
54
+ if gate is not None:
55
+ return gate.model_dump()
56
+
50
57
  def _parse_properties_string(raw: str) -> tuple[dict[str, Any] | None, str | None]:
51
58
  try:
52
59
  parsed = json.loads(raw)
@@ -8,6 +8,7 @@ from services.tools import get_unity_instance_from_context
8
8
  from transport.unity_transport import send_with_unity_instance
9
9
  from transport.legacy.unity_connection import async_send_command_with_retry
10
10
  from services.tools.utils import coerce_bool, parse_json_payload, coerce_int
11
+ from services.tools.preflight import preflight
11
12
 
12
13
 
13
14
  @mcp_for_unity_tool(
@@ -92,6 +93,10 @@ async def manage_gameobject(
92
93
  # Removed session_state import
93
94
  unity_instance = get_unity_instance_from_context(ctx)
94
95
 
96
+ gate = await preflight(ctx, wait_for_no_compile=True, refresh_if_dirty=True)
97
+ if gate is not None:
98
+ return gate.model_dump()
99
+
95
100
  if action is None:
96
101
  return {
97
102
  "success": False,
@@ -6,6 +6,7 @@ from services.tools import get_unity_instance_from_context
6
6
  from services.tools.utils import coerce_int, coerce_bool
7
7
  from transport.unity_transport import send_with_unity_instance
8
8
  from transport.legacy.unity_connection import async_send_command_with_retry
9
+ from services.tools.preflight import preflight
9
10
 
10
11
 
11
12
  @mcp_for_unity_tool(
@@ -40,6 +41,9 @@ async def manage_scene(
40
41
  # Get active instance from session state
41
42
  # Removed session_state import
42
43
  unity_instance = get_unity_instance_from_context(ctx)
44
+ gate = await preflight(ctx, wait_for_no_compile=True, refresh_if_dirty=True)
45
+ if gate is not None:
46
+ return gate.model_dump()
43
47
  try:
44
48
  coerced_build_index = coerce_int(build_index, default=None)
45
49
  coerced_super_size = coerce_int(screenshot_super_size, default=None)
@@ -0,0 +1,107 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import os
5
+ import time
6
+ from typing import Any
7
+
8
+ from models import MCPResponse
9
+
10
+
11
+ def _in_pytest() -> bool:
12
+ # Integration tests in this repo stub transports and do not run against a live Unity editor.
13
+ # Preflight must be a no-op in that environment to avoid breaking the existing test suite.
14
+ return bool(os.environ.get("PYTEST_CURRENT_TEST"))
15
+
16
+
17
+ def _busy(reason: str, retry_after_ms: int) -> MCPResponse:
18
+ return MCPResponse(
19
+ success=False,
20
+ error="busy",
21
+ message=reason,
22
+ hint="retry",
23
+ data={"reason": reason, "retry_after_ms": int(retry_after_ms)},
24
+ )
25
+
26
+
27
+ async def preflight(
28
+ ctx,
29
+ *,
30
+ requires_no_tests: bool = False,
31
+ wait_for_no_compile: bool = False,
32
+ refresh_if_dirty: bool = False,
33
+ max_wait_s: float = 30.0,
34
+ ) -> MCPResponse | None:
35
+ """
36
+ Server-side preflight guard used by tools so they behave safely even if the client never reads resources.
37
+
38
+ Returns:
39
+ - MCPResponse busy/retry payload when the tool should not proceed right now
40
+ - None when the tool should proceed normally
41
+ """
42
+ if _in_pytest():
43
+ return None
44
+
45
+ # Load canonical v2 state (server enriches advice + staleness).
46
+ try:
47
+ from services.resources.editor_state_v2 import get_editor_state_v2
48
+ state_resp = await get_editor_state_v2(ctx)
49
+ state = state_resp.model_dump() if hasattr(state_resp, "model_dump") else state_resp
50
+ except Exception:
51
+ # If we cannot determine readiness, fall back to proceeding (tools already contain retry logic).
52
+ return None
53
+
54
+ if not isinstance(state, dict) or not state.get("success", False):
55
+ # Unknown state; proceed rather than blocking (avoids false positives when Unity is reachable but status isn't).
56
+ return None
57
+
58
+ data = state.get("data")
59
+ if not isinstance(data, dict):
60
+ return None
61
+
62
+ # Optional refresh-if-dirty
63
+ if refresh_if_dirty:
64
+ assets = data.get("assets")
65
+ if isinstance(assets, dict) and assets.get("external_changes_dirty") is True:
66
+ try:
67
+ from services.tools.refresh_unity import refresh_unity
68
+ await refresh_unity(ctx, mode="if_dirty", scope="all", compile="request", wait_for_ready=True)
69
+ except Exception:
70
+ # Best-effort only; fall through to normal tool dispatch.
71
+ pass
72
+
73
+ # Tests running: fail fast for tools that require exclusivity.
74
+ if requires_no_tests:
75
+ tests = data.get("tests")
76
+ if isinstance(tests, dict) and tests.get("is_running") is True:
77
+ return _busy("tests_running", 5000)
78
+
79
+ # Compilation: optionally wait for a bounded time.
80
+ if wait_for_no_compile:
81
+ deadline = time.monotonic() + float(max_wait_s)
82
+ while True:
83
+ compilation = data.get("compilation") if isinstance(data, dict) else None
84
+ is_compiling = isinstance(compilation, dict) and compilation.get("is_compiling") is True
85
+ is_domain_reload_pending = isinstance(compilation, dict) and compilation.get("is_domain_reload_pending") is True
86
+ if not is_compiling and not is_domain_reload_pending:
87
+ break
88
+ if time.monotonic() >= deadline:
89
+ return _busy("compiling", 500)
90
+ await asyncio.sleep(0.25)
91
+
92
+ # Refresh state for the next loop iteration.
93
+ try:
94
+ from services.resources.editor_state_v2 import get_editor_state_v2
95
+ state_resp = await get_editor_state_v2(ctx)
96
+ state = state_resp.model_dump() if hasattr(state_resp, "model_dump") else state_resp
97
+ data = state.get("data") if isinstance(state, dict) else None
98
+ if not isinstance(data, dict):
99
+ return None
100
+ except Exception:
101
+ return None
102
+
103
+ # Staleness: if the snapshot is stale, proceed (tools will still run), but callers that read resources can back off.
104
+ # In future we may make this strict for some tools.
105
+ return None
106
+
107
+
@@ -45,8 +45,18 @@ async def read_console(
45
45
  if isinstance(action, str):
46
46
  action = action.lower()
47
47
 
48
- # Coerce count defensively (string/float -> int)
49
- count = coerce_int(count)
48
+ # Coerce count defensively (string/float -> int).
49
+ # Important: leaving count unset previously meant "return all console entries", which can be extremely slow
50
+ # (and can exceed the plugin command timeout when Unity has a large console).
51
+ # To keep the tool responsive by default, we cap the default to a reasonable number of most-recent entries.
52
+ # If a client truly wants everything, it can pass count="all" (or count="*") explicitly.
53
+ if isinstance(count, str) and count.strip().lower() in ("all", "*"):
54
+ count = None
55
+ else:
56
+ count = coerce_int(count)
57
+
58
+ if action == "get" and count is None:
59
+ count = 200
50
60
 
51
61
  # Prepare parameters for the C# handler
52
62
  params_dict = {
@@ -0,0 +1,90 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import time
5
+ from typing import Annotated, Any, Literal
6
+
7
+ from fastmcp import Context
8
+
9
+ from models import MCPResponse
10
+ from services.registry import mcp_for_unity_tool
11
+ from services.tools import get_unity_instance_from_context
12
+ import transport.unity_transport as unity_transport
13
+ from transport.legacy.unity_connection import async_send_command_with_retry
14
+ from services.state.external_changes_scanner import external_changes_scanner
15
+
16
+
17
+ @mcp_for_unity_tool(
18
+ description="Request a Unity asset database refresh and optionally a script compilation. Can optionally wait for readiness."
19
+ )
20
+ async def refresh_unity(
21
+ ctx: Context,
22
+ mode: Annotated[Literal["if_dirty", "force"], "Refresh mode"] = "if_dirty",
23
+ scope: Annotated[Literal["assets", "scripts", "all"], "Refresh scope"] = "all",
24
+ compile: Annotated[Literal["none", "request"], "Whether to request compilation"] = "none",
25
+ wait_for_ready: Annotated[bool, "If true, wait until editor_state.advice.ready_for_tools is true"] = True,
26
+ ) -> MCPResponse | dict[str, Any]:
27
+ unity_instance = get_unity_instance_from_context(ctx)
28
+
29
+ params: dict[str, Any] = {
30
+ "mode": mode,
31
+ "scope": scope,
32
+ "compile": compile,
33
+ "wait_for_ready": bool(wait_for_ready),
34
+ }
35
+
36
+ recovered_from_disconnect = False
37
+ response = await unity_transport.send_with_unity_instance(
38
+ async_send_command_with_retry,
39
+ unity_instance,
40
+ "refresh_unity",
41
+ params,
42
+ )
43
+
44
+ # Option A: treat disconnects / retry hints as recoverable when wait_for_ready is true.
45
+ # Unity can legitimately disconnect during refresh/compile/domain reload, so callers should not
46
+ # interpret that as a hard failure (#503-style loops).
47
+ if isinstance(response, dict) and not response.get("success", True):
48
+ hint = response.get("hint")
49
+ err = (response.get("error") or response.get("message") or "")
50
+ is_retryable = (hint == "retry") or ("disconnected" in str(err).lower())
51
+ if (not wait_for_ready) or (not is_retryable):
52
+ return MCPResponse(**response)
53
+ recovered_from_disconnect = True
54
+
55
+ # Optional server-side wait loop (defensive): if Unity tool doesn't wait or returns quickly,
56
+ # poll the canonical editor_state v2 resource until ready or timeout.
57
+ if wait_for_ready:
58
+ timeout_s = 60.0
59
+ start = time.monotonic()
60
+ from services.resources.editor_state_v2 import get_editor_state_v2
61
+
62
+ while time.monotonic() - start < timeout_s:
63
+ state_resp = await get_editor_state_v2(ctx)
64
+ state = state_resp.model_dump() if hasattr(state_resp, "model_dump") else state_resp
65
+ data = (state or {}).get("data") if isinstance(state, dict) else None
66
+ advice = (data or {}).get("advice") if isinstance(data, dict) else None
67
+ if isinstance(advice, dict) and advice.get("ready_for_tools") is True:
68
+ break
69
+ await asyncio.sleep(0.25)
70
+
71
+ # After readiness is restored, clear any external-dirty flag for this instance so future tools can proceed cleanly.
72
+ try:
73
+ from services.resources.editor_state_v2 import _infer_single_instance_id
74
+
75
+ inst = unity_instance or await _infer_single_instance_id(ctx)
76
+ if inst:
77
+ external_changes_scanner.clear_dirty(inst)
78
+ except Exception:
79
+ pass
80
+
81
+ if recovered_from_disconnect:
82
+ return MCPResponse(
83
+ success=True,
84
+ message="Refresh recovered after Unity disconnect/retry; editor is ready.",
85
+ data={"recovered_from_disconnect": True},
86
+ )
87
+
88
+ return MCPResponse(**response) if isinstance(response, dict) else response
89
+
90
+
@@ -10,6 +10,7 @@ from services.tools import get_unity_instance_from_context
10
10
  from services.tools.utils import coerce_int
11
11
  from transport.unity_transport import send_with_unity_instance
12
12
  from transport.legacy.unity_connection import async_send_command_with_retry
13
+ from services.tools.preflight import preflight
13
14
 
14
15
 
15
16
  class RunTestsSummary(BaseModel):
@@ -34,7 +35,7 @@ class RunTestsTestResult(BaseModel):
34
35
  class RunTestsResult(BaseModel):
35
36
  mode: str
36
37
  summary: RunTestsSummary
37
- results: list[RunTestsTestResult]
38
+ results: list[RunTestsTestResult] | None = None
38
39
 
39
40
 
40
41
  class RunTestsResponse(MCPResponse):
@@ -42,7 +43,7 @@ class RunTestsResponse(MCPResponse):
42
43
 
43
44
 
44
45
  @mcp_for_unity_tool(
45
- description="Runs Unity tests for the specified mode"
46
+ description="Runs Unity tests synchronously (blocks until complete). Prefer run_tests_async for non-blocking execution with progress polling."
46
47
  )
47
48
  async def run_tests(
48
49
  ctx: Context,
@@ -52,9 +53,15 @@ async def run_tests(
52
53
  group_names: Annotated[list[str] | str, "Same as test_names, except it allows for Regex"] | None = None,
53
54
  category_names: Annotated[list[str] | str, "NUnit category names to filter by (tests marked with [Category] attribute)"] | None = None,
54
55
  assembly_names: Annotated[list[str] | str, "Assembly names to filter tests by"] | None = None,
55
- ) -> RunTestsResponse:
56
+ include_failed_tests: Annotated[bool, "Include details for failed/skipped tests only (default: false)"] = False,
57
+ include_details: Annotated[bool, "Include details for all tests (default: false)"] = False,
58
+ ) -> RunTestsResponse | MCPResponse:
56
59
  unity_instance = get_unity_instance_from_context(ctx)
57
60
 
61
+ gate = await preflight(ctx, requires_no_tests=True, wait_for_no_compile=True, refresh_if_dirty=True)
62
+ if isinstance(gate, MCPResponse):
63
+ return gate
64
+
58
65
  # Coerce string or list to list of strings
59
66
  def _coerce_string_list(value) -> list[str] | None:
60
67
  if value is None:
@@ -88,6 +95,26 @@ async def run_tests(
88
95
  if assembly_names_list:
89
96
  params["assemblyNames"] = assembly_names_list
90
97
 
98
+ # Add verbosity parameters
99
+ if include_failed_tests:
100
+ params["includeFailedTests"] = True
101
+ if include_details:
102
+ params["includeDetails"] = True
103
+
91
104
  response = await send_with_unity_instance(async_send_command_with_retry, unity_instance, "run_tests", params)
92
- await ctx.info(f'Response {response}')
105
+
106
+ # If Unity indicates a run is already active, return a structured "busy" response rather than
107
+ # letting clients interpret this as a generic failure (avoids #503 retry loops).
108
+ if isinstance(response, dict) and not response.get("success", True):
109
+ err = (response.get("error") or response.get("message") or "").strip()
110
+ if "test run is already in progress" in err.lower():
111
+ return MCPResponse(
112
+ success=False,
113
+ error="tests_running",
114
+ message=err,
115
+ hint="retry",
116
+ data={"reason": "tests_running", "retry_after_ms": 5000},
117
+ )
118
+ return MCPResponse(**response)
119
+
93
120
  return RunTestsResponse(**response) if isinstance(response, dict) else response
@@ -0,0 +1,94 @@
1
+ """Async Unity Test Runner jobs: start + poll."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Annotated, Any, Literal
5
+
6
+ from fastmcp import Context
7
+
8
+ from models import MCPResponse
9
+ from services.registry import mcp_for_unity_tool
10
+ from services.tools import get_unity_instance_from_context
11
+ from services.tools.preflight import preflight
12
+ import transport.unity_transport as unity_transport
13
+ from transport.legacy.unity_connection import async_send_command_with_retry
14
+
15
+
16
+ @mcp_for_unity_tool(description="Starts a Unity test run asynchronously and returns a job_id immediately. Preferred over run_tests for long-running suites. Poll with get_test_job for progress.")
17
+ async def run_tests_async(
18
+ ctx: Context,
19
+ mode: Annotated[Literal["EditMode", "PlayMode"], "Unity test mode to run"] = "EditMode",
20
+ test_names: Annotated[list[str] | str, "Full names of specific tests to run"] | None = None,
21
+ group_names: Annotated[list[str] | str, "Same as test_names, except it allows for Regex"] | None = None,
22
+ category_names: Annotated[list[str] | str, "NUnit category names to filter by"] | None = None,
23
+ assembly_names: Annotated[list[str] | str, "Assembly names to filter tests by"] | None = None,
24
+ include_failed_tests: Annotated[bool, "Include details for failed/skipped tests only (default: false)"] = False,
25
+ include_details: Annotated[bool, "Include details for all tests (default: false)"] = False,
26
+ ) -> dict[str, Any] | MCPResponse:
27
+ unity_instance = get_unity_instance_from_context(ctx)
28
+
29
+ gate = await preflight(ctx, requires_no_tests=True, wait_for_no_compile=True, refresh_if_dirty=True)
30
+ if isinstance(gate, MCPResponse):
31
+ return gate
32
+
33
+ def _coerce_string_list(value) -> list[str] | None:
34
+ if value is None:
35
+ return None
36
+ if isinstance(value, str):
37
+ return [value] if value.strip() else None
38
+ if isinstance(value, list):
39
+ result = [str(v).strip() for v in value if v and str(v).strip()]
40
+ return result if result else None
41
+ return None
42
+
43
+ params: dict[str, Any] = {"mode": mode}
44
+ if (t := _coerce_string_list(test_names)):
45
+ params["testNames"] = t
46
+ if (g := _coerce_string_list(group_names)):
47
+ params["groupNames"] = g
48
+ if (c := _coerce_string_list(category_names)):
49
+ params["categoryNames"] = c
50
+ if (a := _coerce_string_list(assembly_names)):
51
+ params["assemblyNames"] = a
52
+ if include_failed_tests:
53
+ params["includeFailedTests"] = True
54
+ if include_details:
55
+ params["includeDetails"] = True
56
+
57
+ response = await unity_transport.send_with_unity_instance(
58
+ async_send_command_with_retry,
59
+ unity_instance,
60
+ "run_tests_async",
61
+ params,
62
+ )
63
+
64
+ if isinstance(response, dict) and not response.get("success", True):
65
+ return MCPResponse(**response)
66
+ return response if isinstance(response, dict) else MCPResponse(success=False, error=str(response)).model_dump()
67
+
68
+
69
+ @mcp_for_unity_tool(description="Polls an async Unity test job by job_id.")
70
+ async def get_test_job(
71
+ ctx: Context,
72
+ job_id: Annotated[str, "Job id returned by run_tests_async"],
73
+ include_failed_tests: Annotated[bool, "Include details for failed/skipped tests only (default: false)"] = False,
74
+ include_details: Annotated[bool, "Include details for all tests (default: false)"] = False,
75
+ ) -> dict[str, Any] | MCPResponse:
76
+ unity_instance = get_unity_instance_from_context(ctx)
77
+
78
+ params: dict[str, Any] = {"job_id": job_id}
79
+ if include_failed_tests:
80
+ params["includeFailedTests"] = True
81
+ if include_details:
82
+ params["includeDetails"] = True
83
+
84
+ response = await unity_transport.send_with_unity_instance(
85
+ async_send_command_with_retry,
86
+ unity_instance,
87
+ "get_test_job",
88
+ params,
89
+ )
90
+ if isinstance(response, dict) and not response.get("success", True):
91
+ return MCPResponse(**response)
92
+ return response if isinstance(response, dict) else MCPResponse(success=False, error=str(response)).model_dump()
93
+
94
+
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,10 @@ 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
+
31
37
  class PluginHub(WebSocketEndpoint):
32
38
  """Manages persistent WebSocket connections to Unity plugins."""
33
39
 
@@ -35,10 +41,15 @@ class PluginHub(WebSocketEndpoint):
35
41
  KEEP_ALIVE_INTERVAL = 15
36
42
  SERVER_TIMEOUT = 30
37
43
  COMMAND_TIMEOUT = 30
44
+ # Fast-path commands should never block the client for long; return a retry hint instead.
45
+ # This helps avoid the Cursor-side ~30s tool-call timeout when Unity is compiling/reloading
46
+ # or is throttled while unfocused.
47
+ _FAST_FAIL_COMMANDS: set[str] = {"read_console", "get_editor_state", "ping"}
38
48
 
39
49
  _registry: PluginRegistry | None = None
40
50
  _connections: dict[str, WebSocket] = {}
41
- _pending: dict[str, asyncio.Future] = {}
51
+ # command_id -> {"future": Future, "session_id": str}
52
+ _pending: dict[str, dict[str, Any]] = {}
42
53
  _lock: asyncio.Lock | None = None
43
54
  _loop: asyncio.AbstractEventLoop | None = None
44
55
 
@@ -95,6 +106,21 @@ class PluginHub(WebSocketEndpoint):
95
106
  (sid for sid, ws in cls._connections.items() if ws is websocket), None)
96
107
  if session_id:
97
108
  cls._connections.pop(session_id, None)
109
+ # Fail-fast any in-flight commands for this session to avoid waiting for COMMAND_TIMEOUT.
110
+ pending_ids = [
111
+ command_id
112
+ for command_id, entry in cls._pending.items()
113
+ if entry.get("session_id") == session_id
114
+ ]
115
+ for command_id in pending_ids:
116
+ entry = cls._pending.get(command_id)
117
+ future = entry.get("future") if isinstance(entry, dict) else None
118
+ if future and not future.done():
119
+ future.set_exception(
120
+ PluginDisconnectedError(
121
+ f"Unity plugin session {session_id} disconnected while awaiting command_result"
122
+ )
123
+ )
98
124
  if cls._registry:
99
125
  await cls._registry.unregister(session_id)
100
126
  logger.info(
@@ -108,6 +134,39 @@ class PluginHub(WebSocketEndpoint):
108
134
  websocket = await cls._get_connection(session_id)
109
135
  command_id = str(uuid.uuid4())
110
136
  future: asyncio.Future = asyncio.get_running_loop().create_future()
137
+ # Compute a per-command timeout:
138
+ # - fast-path commands: short timeout (encourage retry)
139
+ # - long-running commands (e.g., run_tests): allow caller to request a longer timeout via params
140
+ unity_timeout_s = float(cls.COMMAND_TIMEOUT)
141
+ server_wait_s = float(cls.COMMAND_TIMEOUT)
142
+ if command_type in cls._FAST_FAIL_COMMANDS:
143
+ try:
144
+ fast_timeout = float(os.environ.get("UNITY_MCP_FAST_COMMAND_TIMEOUT", "3"))
145
+ except Exception:
146
+ fast_timeout = 3.0
147
+ unity_timeout_s = fast_timeout
148
+ server_wait_s = fast_timeout
149
+ else:
150
+ # Common tools pass a requested timeout in seconds (e.g., run_tests(timeout_seconds=900)).
151
+ requested = None
152
+ try:
153
+ if isinstance(params, dict):
154
+ requested = params.get("timeout_seconds", None)
155
+ if requested is None:
156
+ requested = params.get("timeoutSeconds", None)
157
+ except Exception:
158
+ requested = None
159
+
160
+ if requested is not None:
161
+ try:
162
+ requested_s = float(requested)
163
+ # Clamp to a sane upper bound to avoid accidental infinite hangs.
164
+ requested_s = max(1.0, min(requested_s, 60.0 * 60.0))
165
+ unity_timeout_s = max(unity_timeout_s, requested_s)
166
+ # Give the server a small cushion beyond the Unity-side timeout to account for transport overhead.
167
+ server_wait_s = max(server_wait_s, requested_s + 5.0)
168
+ except Exception:
169
+ pass
111
170
 
112
171
  lock = cls._lock
113
172
  if lock is None:
@@ -117,18 +176,35 @@ class PluginHub(WebSocketEndpoint):
117
176
  if command_id in cls._pending:
118
177
  raise RuntimeError(
119
178
  f"Duplicate command id generated: {command_id}")
120
- cls._pending[command_id] = future
179
+ cls._pending[command_id] = {"future": future, "session_id": session_id}
121
180
 
122
181
  try:
123
182
  msg = ExecuteCommandMessage(
124
183
  id=command_id,
125
184
  name=command_type,
126
185
  params=params,
127
- timeout=cls.COMMAND_TIMEOUT,
186
+ timeout=unity_timeout_s,
128
187
  )
129
- await websocket.send_json(msg.model_dump())
130
- result = await asyncio.wait_for(future, timeout=cls.COMMAND_TIMEOUT)
131
- return result
188
+ try:
189
+ await websocket.send_json(msg.model_dump())
190
+ except Exception as exc:
191
+ # If send fails (socket already closing), fail the future so callers don't hang.
192
+ if not future.done():
193
+ future.set_exception(exc)
194
+ raise
195
+ try:
196
+ result = await asyncio.wait_for(future, timeout=server_wait_s)
197
+ return result
198
+ except PluginDisconnectedError as exc:
199
+ return MCPResponse(success=False, error=str(exc), hint="retry").model_dump()
200
+ except asyncio.TimeoutError:
201
+ if command_type in cls._FAST_FAIL_COMMANDS:
202
+ return MCPResponse(
203
+ success=False,
204
+ error=f"Unity did not respond to '{command_type}' within {server_wait_s:.1f}s; please retry",
205
+ hint="retry",
206
+ ).model_dump()
207
+ raise
132
208
  finally:
133
209
  async with lock:
134
210
  cls._pending.pop(command_id, None)
@@ -245,7 +321,8 @@ class PluginHub(WebSocketEndpoint):
245
321
  return
246
322
 
247
323
  async with lock:
248
- future = cls._pending.get(command_id)
324
+ entry = cls._pending.get(command_id)
325
+ future = entry.get("future") if isinstance(entry, dict) else None
249
326
  if future and not future.done():
250
327
  future.set_result(result)
251
328
 
@@ -364,6 +441,40 @@ class PluginHub(WebSocketEndpoint):
364
441
  params: dict[str, Any],
365
442
  ) -> dict[str, Any]:
366
443
  session_id = await cls._resolve_session_id(unity_instance)
444
+
445
+ # During domain reload / immediate reconnect windows, the plugin may be connected but not yet
446
+ # ready to process execute commands on the Unity main thread (which can be further delayed when
447
+ # the Unity Editor is unfocused). For fast-path commands, we do a bounded readiness probe using
448
+ # a main-thread ping command (handled by TransportCommandDispatcher) rather than waiting on
449
+ # register_tools (which can be delayed by EditorApplication.delayCall).
450
+ if command_type in cls._FAST_FAIL_COMMANDS and command_type != "ping":
451
+ try:
452
+ max_wait_s = float(os.environ.get("UNITY_MCP_SESSION_READY_WAIT_SECONDS", "6"))
453
+ except Exception:
454
+ max_wait_s = 6.0
455
+ max_wait_s = max(0.0, min(max_wait_s, 30.0))
456
+ if max_wait_s > 0:
457
+ deadline = time.monotonic() + max_wait_s
458
+ while time.monotonic() < deadline:
459
+ try:
460
+ probe = await cls.send_command(session_id, "ping", {})
461
+ except Exception:
462
+ probe = None
463
+
464
+ # The Unity-side dispatcher responds with {status:"success", result:{message:"pong"}}
465
+ if isinstance(probe, dict) and probe.get("status") == "success":
466
+ result = probe.get("result") if isinstance(probe.get("result"), dict) else {}
467
+ if result.get("message") == "pong":
468
+ break
469
+ await asyncio.sleep(0.1)
470
+ else:
471
+ # Not ready within the bounded window: return retry hint without sending.
472
+ return MCPResponse(
473
+ success=False,
474
+ error=f"Unity session not ready for '{command_type}' (ping not answered); please retry",
475
+ hint="retry",
476
+ ).model_dump()
477
+
367
478
  return await cls.send_command(session_id, command_type, params)
368
479
 
369
480
  # ------------------------------------------------------------------