mcpforunityserver 8.5.0__py3-none-any.whl → 8.6.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.
main.py CHANGED
@@ -391,6 +391,22 @@ Examples:
391
391
  help="HTTP server port (overrides URL port). "
392
392
  "Overrides UNITY_MCP_HTTP_PORT environment variable."
393
393
  )
394
+ parser.add_argument(
395
+ "--unity-instance-token",
396
+ type=str,
397
+ default=None,
398
+ metavar="TOKEN",
399
+ help="Optional per-launch token set by Unity for deterministic lifecycle management. "
400
+ "Used by Unity to validate it is stopping the correct process."
401
+ )
402
+ parser.add_argument(
403
+ "--pidfile",
404
+ type=str,
405
+ default=None,
406
+ metavar="PATH",
407
+ help="Optional path where the server will write its PID on startup. "
408
+ "Used by Unity to stop the exact process it launched when running in a terminal."
409
+ )
394
410
 
395
411
  args = parser.parse_args()
396
412
 
@@ -418,6 +434,20 @@ Examples:
418
434
  os.environ["UNITY_MCP_HTTP_HOST"] = http_host
419
435
  os.environ["UNITY_MCP_HTTP_PORT"] = str(http_port)
420
436
 
437
+ # Optional lifecycle handshake for Unity-managed terminal launches
438
+ if args.unity_instance_token:
439
+ os.environ["UNITY_MCP_INSTANCE_TOKEN"] = args.unity_instance_token
440
+ if args.pidfile:
441
+ try:
442
+ pid_dir = os.path.dirname(args.pidfile)
443
+ if pid_dir:
444
+ os.makedirs(pid_dir, exist_ok=True)
445
+ with open(args.pidfile, "w", encoding="ascii") as f:
446
+ f.write(str(os.getpid()))
447
+ except Exception as exc:
448
+ logger.warning(
449
+ "Failed to write pidfile '%s': %s", args.pidfile, exc)
450
+
421
451
  if args.http_url != "http://localhost:8080":
422
452
  logger.info(f"HTTP URL set to: {http_url}")
423
453
  if args.http_host:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mcpforunityserver
3
- Version: 8.5.0
3
+ Version: 8.6.0
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.5.0#subdirectory=Server",
111
+ "git+https://github.com/CoplayDev/unity-mcp@v8.6.0#subdirectory=Server",
112
112
  "mcp-for-unity",
113
113
  "--transport",
114
114
  "stdio"
@@ -1,11 +1,11 @@
1
1
  __init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
- main.py,sha256=OH_Ux5Aj43q1lXPu4AS8zafRnFHZWFYCDZbswReQOac,18011
2
+ main.py,sha256=ITxelXUAmr9BoasG0knZifXKp3lWJyml_RLaIwvEyVs,19184
3
3
  core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
4
  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.5.0.dist-info/licenses/LICENSE,sha256=bv5lDJZQEqxBgjjc1rkRbkEwpSIHF-8N-1Od0VnEJFw,1066
8
+ mcpforunityserver-8.6.0.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
@@ -18,7 +18,7 @@ services/registry/tool_registry.py,sha256=9tMwOP07JE92QFYUS4KvoysO0qC9pkBD5B79kj
18
18
  services/resources/__init__.py,sha256=O5heeMcgCswnQX1qG2nNtMeAZIaLut734qD7t5UsA0k,2801
19
19
  services/resources/active_tool.py,sha256=YTbsiy_hmnKH2q7IoM7oYD7pJkoveZTszRiL1PlhO9M,1474
20
20
  services/resources/custom_tools.py,sha256=8lyryGhN3vD2LwMt6ZyKIp5ONtxdI1nfcCAlYjlfQnQ,1704
21
- services/resources/editor_state.py,sha256=acrSyMfdulRgYQIn7wKHqKqyw4uED_oUf9GU-4o4GAg,1497
21
+ services/resources/editor_state.py,sha256=8hrNnskSFdsvdKagAYEeZGJ0Oz9QRlkWJjpM4q0XeNo,2013
22
22
  services/resources/layers.py,sha256=q4UQ5PUVUVhmM5l3oXID1wa_wOWAS8l5BGXadBgFuwY,1080
23
23
  services/resources/menu_items.py,sha256=9SNycjwTXoeS1ZHra0Y1fTyCjSEdPCo34JyxtuqauG8,1021
24
24
  services/resources/prefab_stage.py,sha256=C3mn3UapKYVOA8QUNmLsYreG5YiXdlvGm9ypHQeKBeQ,1382
@@ -43,24 +43,24 @@ services/tools/manage_scene.py,sha256=3BhIsbbtGiMNqBMQMqEsB4ajYmtx-VwWl-krOkFR_B
43
43
  services/tools/manage_script.py,sha256=lPA5HcS4Al0RiQVz-S6qahFTcPqsk3GSLLXJWHri8P4,27557
44
44
  services/tools/manage_scriptable_object.py,sha256=Oi03CJLgepaWR59V-nJiAjnCC8at4YqFhRGpACruqgw,3150
45
45
  services/tools/manage_shader.py,sha256=HHnHKh7vLij3p8FAinNsPdZGEKivgwSUTxdgDydfmbs,2882
46
- services/tools/read_console.py,sha256=gZWEf0Ru0hvN9oJUZqZ4w-mMBBLm5Z5KAUPv282XbYQ,4091
47
- services/tools/run_tests.py,sha256=LBVwGasLvmF4k1FiX3DdBQ8udh89WZJFiVHfJRWGvOs,3313
46
+ services/tools/read_console.py,sha256=MdQcrnVXra9PLu5AFkmARjriObT0miExtQKkFaANznU,4662
47
+ services/tools/run_tests.py,sha256=eeHwFmBxbKHaL_RMxoDN6qKsmBp2qTrnG7FxnRQR5mQ,3709
48
48
  services/tools/script_apply_edits.py,sha256=qPm_PsmsK3mYXnziX_btyk8CaB66LTqpDFA2Y4ebZ4U,47504
49
49
  services/tools/set_active_instance.py,sha256=B18Y8Jga0pKsx9mFywXr1tWfy0cJVopIMXYO-UJ1jOU,4136
50
50
  services/tools/utils.py,sha256=4ZgfIu178eXZqRyzs8X77B5lKLP1f73OZoGBSDNokJ4,2409
51
51
  transport/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
52
52
  transport/models.py,sha256=6wp7wsmSaeeJEvUGXPF1m6zuJnxJ1NJlCC4YZ9oQIq0,1226
53
- transport/plugin_hub.py,sha256=55R00ohrmUI0mk_smc_8BsYTvrQMPX4wwsvqXprj0Vk,15596
53
+ transport/plugin_hub.py,sha256=g_DOhCThgJ9Oco_z3m2qpwDeUcFvvt7Z47xMS0diihw,21497
54
54
  transport/plugin_registry.py,sha256=nW-7O7PN0QUgSWivZTkpAVKKq9ZOe2b2yeIdpaNt_3I,4359
55
- transport/unity_instance_middleware.py,sha256=a-ULWU9b86w0CbYN3meyLxWGxTBXL5CQmBKZmmQ0xZQ,6197
56
- transport/unity_transport.py,sha256=dvwCjo2jRvnFXd8ruOL36C8W4P1VIQ91qreS2750lPM,3307
55
+ transport/unity_instance_middleware.py,sha256=kf1QeA138r7PaC98dcMDYtUPGWZ4EUmZGESc2DdiWQs,10429
56
+ transport/unity_transport.py,sha256=_cFVgD3pzFZRcDXANq4oPFYSoI6jntSGaN22dJC8LRU,3880
57
57
  transport/legacy/port_discovery.py,sha256=qM_mtndbYjAj4qPSZEWVeXFOt5_nKczG9pQqORXTBJ0,12768
58
58
  transport/legacy/stdio_port_registry.py,sha256=j4iARuP6wetppNDG8qKeuvo1bJKcSlgEhZvSyl_uf0A,2313
59
59
  transport/legacy/unity_connection.py,sha256=ujUX9WX7Gb-fxQveHts3uiepTPzFq8i7-XG7u5gSPuM,32668
60
60
  utils/module_discovery.py,sha256=My48ofB1BUqxiBoAZAGbEaLQYdsrDhMm8MayBP_bUSQ,2005
61
61
  utils/reload_sentinel.py,sha256=s1xMWhl-r2XwN0OUbiUv_VGUy8TvLtV5bkql-5n2DT0,373
62
- mcpforunityserver-8.5.0.dist-info/METADATA,sha256=3YWJc7I-EKxcLzMgG0MweNSJm3QR_VYCNTTwwB45UNE,5712
63
- mcpforunityserver-8.5.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
64
- mcpforunityserver-8.5.0.dist-info/entry_points.txt,sha256=vCtkqw-J9t4pj7JwUZcB54_keVx7DpAR3fYzK6i-s6g,44
65
- mcpforunityserver-8.5.0.dist-info/top_level.txt,sha256=YKU5e5dREMfCnoVpmlsTm9bku7oqnrzSZ9FeTgjoxJw,58
66
- mcpforunityserver-8.5.0.dist-info/RECORD,,
62
+ mcpforunityserver-8.6.0.dist-info/METADATA,sha256=mhrVzwZHOC4hsY1aNMsgBK3wLZuFNyE05hJ0xN94k18,5712
63
+ mcpforunityserver-8.6.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
64
+ mcpforunityserver-8.6.0.dist-info/entry_points.txt,sha256=vCtkqw-J9t4pj7JwUZcB54_keVx7DpAR3fYzK6i-s6g,44
65
+ mcpforunityserver-8.6.0.dist-info/top_level.txt,sha256=YKU5e5dREMfCnoVpmlsTm9bku7oqnrzSZ9FeTgjoxJw,58
66
+ mcpforunityserver-8.6.0.dist-info/RECORD,,
@@ -39,4 +39,13 @@ async def get_editor_state(ctx: Context) -> EditorStateResponse | MCPResponse:
39
39
  "get_editor_state",
40
40
  {}
41
41
  )
42
- return EditorStateResponse(**response) if isinstance(response, dict) else response
42
+ # When Unity is reloading/unresponsive (often when unfocused), transports may return
43
+ # a retryable MCPResponse payload with success=false and no data. Do not attempt to
44
+ # coerce that into EditorStateResponse (it would fail validation); return it as-is.
45
+ if isinstance(response, dict):
46
+ if not response.get("success", True):
47
+ return MCPResponse(**response)
48
+ if response.get("data") is None:
49
+ return MCPResponse(success=False, error="Editor state missing 'data' payload", data=response)
50
+ return EditorStateResponse(**response)
51
+ return response
@@ -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 = {
@@ -34,7 +34,7 @@ class RunTestsTestResult(BaseModel):
34
34
  class RunTestsResult(BaseModel):
35
35
  mode: str
36
36
  summary: RunTestsSummary
37
- results: list[RunTestsTestResult]
37
+ results: list[RunTestsTestResult] | None = None
38
38
 
39
39
 
40
40
  class RunTestsResponse(MCPResponse):
@@ -52,6 +52,8 @@ async def run_tests(
52
52
  group_names: Annotated[list[str] | str, "Same as test_names, except it allows for Regex"] | None = None,
53
53
  category_names: Annotated[list[str] | str, "NUnit category names to filter by (tests marked with [Category] attribute)"] | None = None,
54
54
  assembly_names: Annotated[list[str] | str, "Assembly names to filter tests by"] | None = None,
55
+ include_failed_tests: Annotated[bool, "Include details for failed/skipped tests only (default: false)"] = False,
56
+ include_details: Annotated[bool, "Include details for all tests (default: false)"] = False,
55
57
  ) -> RunTestsResponse:
56
58
  unity_instance = get_unity_instance_from_context(ctx)
57
59
 
@@ -88,6 +90,12 @@ async def run_tests(
88
90
  if assembly_names_list:
89
91
  params["assemblyNames"] = assembly_names_list
90
92
 
93
+ # Add verbosity parameters
94
+ if include_failed_tests:
95
+ params["includeFailedTests"] = True
96
+ if include_details:
97
+ params["includeDetails"] = True
98
+
91
99
  response = await send_with_unity_instance(async_send_command_with_retry, unity_instance, "run_tests", params)
92
100
  await ctx.info(f'Response {response}')
93
101
  return RunTestsResponse(**response) if isinstance(response, dict) else response
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
  # ------------------------------------------------------------------
@@ -83,11 +83,101 @@ class UnityInstanceMiddleware(Middleware):
83
83
  with self._lock:
84
84
  self._active_by_key.pop(key, None)
85
85
 
86
+ async def _maybe_autoselect_instance(self, ctx) -> str | None:
87
+ """
88
+ Auto-select the sole Unity instance when no active instance is set.
89
+
90
+ Note: This method both *discovers* and *persists* the selection via
91
+ `set_active_instance` as a side-effect, since callers expect the selection
92
+ to stick for subsequent tool/resource calls in the same session.
93
+ """
94
+ try:
95
+ # Import here to avoid circular dependencies / optional transport modules.
96
+ from transport.unity_transport import _current_transport
97
+
98
+ transport = _current_transport()
99
+ if PluginHub.is_configured():
100
+ try:
101
+ sessions_data = await PluginHub.get_sessions()
102
+ sessions = sessions_data.sessions or {}
103
+ ids: list[str] = []
104
+ for session_info in sessions.values():
105
+ project = getattr(session_info, "project", None) or "Unknown"
106
+ hash_value = getattr(session_info, "hash", None)
107
+ if hash_value:
108
+ ids.append(f"{project}@{hash_value}")
109
+ if len(ids) == 1:
110
+ chosen = ids[0]
111
+ self.set_active_instance(ctx, chosen)
112
+ logger.info(
113
+ "Auto-selected sole Unity instance via PluginHub: %s",
114
+ chosen,
115
+ )
116
+ return chosen
117
+ except (ConnectionError, ValueError, KeyError, TimeoutError, AttributeError) as exc:
118
+ logger.debug(
119
+ "PluginHub auto-select probe failed (%s); falling back to stdio",
120
+ type(exc).__name__,
121
+ exc_info=True,
122
+ )
123
+ except Exception as exc:
124
+ if isinstance(exc, (SystemExit, KeyboardInterrupt)):
125
+ raise
126
+ logger.debug(
127
+ "PluginHub auto-select probe failed with unexpected error (%s); falling back to stdio",
128
+ type(exc).__name__,
129
+ exc_info=True,
130
+ )
131
+
132
+ if transport != "http":
133
+ try:
134
+ # Import here to avoid circular imports in legacy transport paths.
135
+ from transport.legacy.unity_connection import get_unity_connection_pool
136
+
137
+ pool = get_unity_connection_pool()
138
+ instances = pool.discover_all_instances(force_refresh=True)
139
+ ids = [getattr(inst, "id", None) for inst in instances]
140
+ ids = [inst_id for inst_id in ids if inst_id]
141
+ if len(ids) == 1:
142
+ chosen = ids[0]
143
+ self.set_active_instance(ctx, chosen)
144
+ logger.info(
145
+ "Auto-selected sole Unity instance via stdio discovery: %s",
146
+ chosen,
147
+ )
148
+ return chosen
149
+ except (ConnectionError, ValueError, KeyError, TimeoutError, AttributeError) as exc:
150
+ logger.debug(
151
+ "Stdio auto-select probe failed (%s)",
152
+ type(exc).__name__,
153
+ exc_info=True,
154
+ )
155
+ except Exception as exc:
156
+ if isinstance(exc, (SystemExit, KeyboardInterrupt)):
157
+ raise
158
+ logger.debug(
159
+ "Stdio auto-select probe failed with unexpected error (%s)",
160
+ type(exc).__name__,
161
+ exc_info=True,
162
+ )
163
+ except Exception as exc:
164
+ if isinstance(exc, (SystemExit, KeyboardInterrupt)):
165
+ raise
166
+ logger.debug(
167
+ "Auto-select path encountered an unexpected error (%s)",
168
+ type(exc).__name__,
169
+ exc_info=True,
170
+ )
171
+
172
+ return None
173
+
86
174
  async def _inject_unity_instance(self, context: MiddlewareContext) -> None:
87
175
  """Inject active Unity instance into context if available."""
88
176
  ctx = context.fastmcp_context
89
177
 
90
178
  active_instance = self.get_active_instance(ctx)
179
+ if not active_instance:
180
+ active_instance = await self._maybe_autoselect_instance(ctx)
91
181
  if active_instance:
92
182
  # If using HTTP transport (PluginHub configured), validate session
93
183
  # But for stdio transport (no PluginHub needed or maybe partially configured),
@@ -9,6 +9,7 @@ from typing import Awaitable, Callable, TypeVar
9
9
  from fastmcp import Context
10
10
 
11
11
  from transport.plugin_hub import PluginHub
12
+ from models.models import MCPResponse
12
13
  from models.unity_response import normalize_unity_response
13
14
  from services.tools import get_unity_instance_from_context
14
15
 
@@ -91,12 +92,21 @@ async def send_with_unity_instance(
91
92
  if not isinstance(params, dict):
92
93
  raise TypeError(
93
94
  "Command parameters must be a dict for HTTP transport")
94
- raw = await PluginHub.send_command_for_instance(
95
- unity_instance,
96
- command_type,
97
- params,
98
- )
99
- return normalize_unity_response(raw)
95
+ try:
96
+ raw = await PluginHub.send_command_for_instance(
97
+ unity_instance,
98
+ command_type,
99
+ params,
100
+ )
101
+ return normalize_unity_response(raw)
102
+ except Exception as exc:
103
+ # NOTE: asyncio.TimeoutError has an empty str() by default, which is confusing for clients.
104
+ err = str(exc) or f"{type(exc).__name__}"
105
+ # Fail fast with a retry hint instead of hanging for COMMAND_TIMEOUT.
106
+ # The client can decide whether retrying is appropriate for the command.
107
+ return normalize_unity_response(
108
+ MCPResponse(success=False, error=err, hint="retry").model_dump()
109
+ )
100
110
 
101
111
  if unity_instance:
102
112
  kwargs.setdefault("instance_id", unity_instance)