mcpforunityserver 8.3.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
@@ -8,6 +8,32 @@ import time
8
8
  from typing import AsyncIterator, Any
9
9
  from urllib.parse import urlparse
10
10
 
11
+ # Workaround for environments where tool signature evaluation runs with a globals
12
+ # dict that does not include common `typing` names (e.g. when annotations are strings
13
+ # and evaluated via `eval()` during schema generation).
14
+ # Making these names available in builtins avoids `NameError: Annotated/Literal/... is not defined`.
15
+ try: # pragma: no cover - startup safety guard
16
+ import builtins
17
+ import typing as _typing
18
+
19
+ _typing_names = (
20
+ "Annotated",
21
+ "Literal",
22
+ "Any",
23
+ "Union",
24
+ "Optional",
25
+ "Dict",
26
+ "List",
27
+ "Tuple",
28
+ "Set",
29
+ "FrozenSet",
30
+ )
31
+ for _name in _typing_names:
32
+ if not hasattr(builtins, _name) and hasattr(_typing, _name):
33
+ setattr(builtins, _name, getattr(_typing, _name)) # type: ignore[attr-defined]
34
+ except Exception:
35
+ pass
36
+
11
37
  from fastmcp import FastMCP
12
38
  from logging.handlers import RotatingFileHandler
13
39
  from starlette.requests import Request
@@ -242,6 +268,18 @@ Console Monitoring:
242
268
  Menu Items:
243
269
  - Use `execute_menu_item` when you have read the menu items resource
244
270
  - This lets you interact with Unity's menu system and third-party tools
271
+
272
+ Payload sizing & paging (important):
273
+ - Many Unity queries can return very large JSON. Prefer **paged + summary-first** calls.
274
+ - `manage_scene(action="get_hierarchy")`:
275
+ - Use `page_size` + `cursor` and follow `next_cursor` until null.
276
+ - `page_size` is **items per page**; recommended starting point: **50**.
277
+ - `manage_gameobject(action="get_components")`:
278
+ - Start with `include_properties=false` (metadata-only) and small `page_size` (e.g. **10-25**).
279
+ - Only request `include_properties=true` when needed; keep `page_size` small (e.g. **3-10**) to bound payloads.
280
+ - `manage_asset(action="search")`:
281
+ - Use paging (`page_size`, `page_number`) and keep `page_size` modest (e.g. **25-50**) to avoid token-heavy responses.
282
+ - Keep `generate_preview=false` unless you explicitly need thumbnails (previews may include large base64 payloads).
245
283
  """
246
284
  )
247
285
 
@@ -353,6 +391,22 @@ Examples:
353
391
  help="HTTP server port (overrides URL port). "
354
392
  "Overrides UNITY_MCP_HTTP_PORT environment variable."
355
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
+ )
356
410
 
357
411
  args = parser.parse_args()
358
412
 
@@ -380,6 +434,20 @@ Examples:
380
434
  os.environ["UNITY_MCP_HTTP_HOST"] = http_host
381
435
  os.environ["UNITY_MCP_HTTP_PORT"] = str(http_port)
382
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
+
383
451
  if args.http_url != "http://localhost:8080":
384
452
  logger.info(f"HTTP URL set to: {http_url}")
385
453
  if args.http_host:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mcpforunityserver
3
- Version: 8.3.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
@@ -26,7 +26,7 @@ Requires-Python: >=3.10
26
26
  Description-Content-Type: text/markdown
27
27
  License-File: LICENSE
28
28
  Requires-Dist: httpx>=0.27.2
29
- Requires-Dist: fastmcp<2.13.2,>=2.13.0
29
+ Requires-Dist: fastmcp==2.14.1
30
30
  Requires-Dist: mcp>=1.16.0
31
31
  Requires-Dist: pydantic>=2.12.5
32
32
  Requires-Dist: tomli>=2.3.0
@@ -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.3.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=bjQfzp3c9rAe9TtDr1009ARZ6ULWBPxEBopCRmRyXc8,16316
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.3.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
@@ -30,36 +30,37 @@ services/resources/unity_instances.py,sha256=fR0cVopGQnmF41IFDycwlo2XniKstfJWLGo
30
30
  services/resources/windows.py,sha256=--QVsb0oyoBpSjK2D4kPcZFSe2zdR-t_KSHP-e2QNoY,1427
31
31
  services/tools/__init__.py,sha256=3Qav7fAowZ1_TbDRdZQQQES53gv2lTs-2D7PGECnlbM,2353
32
32
  services/tools/batch_execute.py,sha256=_ByjffeXQB9j64mcjaxJmrnbSJrMn0f9_6Zh9BBI_2c,2898
33
- services/tools/debug_request_context.py,sha256=pk02OGfa_yuc4SWxzTR6lEBo0R4ls2yFXp7sdIVshDM,2531
33
+ services/tools/debug_request_context.py,sha256=WQBtQdXSH5stw2MAwIM32H6jGwUVQOgU2r35VUWLlYo,2765
34
34
  services/tools/execute_custom_tool.py,sha256=K2qaO4-FTPz0_3j53hhDP9idjC002ugc8C03FtHGTbY,1376
35
35
  services/tools/execute_menu_item.py,sha256=FAC-1v_TwOcy6GSxkogDsVxeRtdap0DsPlIngf8uJdU,1184
36
36
  services/tools/find_in_file.py,sha256=xp80lqRN2cdZc3XGJWlCpeQEy6WnwyKOj2l5WiHNx0Q,6379
37
- services/tools/manage_asset.py,sha256=wvzdqgSKC3dhdLcFOgQj9thNYp16y8RJQNWu3PluDpg,6021
37
+ services/tools/manage_asset.py,sha256=Kpqr82cmXH7wxXub3O0D8whksORSqn9nDRjskDe_A_w,7534
38
38
  services/tools/manage_editor.py,sha256=_HZRT-_hBakH0g6p7BpxTv3gWpxsaV6KNGRol-qknwo,3243
39
- services/tools/manage_gameobject.py,sha256=wGJayE8giHwtOkeNBvjWaN8ecEnBDgGTraJKvVRHSgM,13257
39
+ services/tools/manage_gameobject.py,sha256=kYIouvt-iNUEsY0VIWp4FqagLjo7Up2TwKDhB4Nfxmo,14213
40
40
  services/tools/manage_material.py,sha256=wZB2H4orhL6wG9TTnmnk-Lj2Gj_zvg7koxW3t319BLU,3545
41
41
  services/tools/manage_prefabs.py,sha256=73XzznjFNOm1SazW_Y7l6uGIE7wosMpAIVQs8xpvK9A,3037
42
- services/tools/manage_scene.py,sha256=dw2ZkbEKCkLVIMA_KqAJmHXcAc9LJeo83GhhvYJZ0DQ,3192
42
+ services/tools/manage_scene.py,sha256=3BhIsbbtGiMNqBMQMqEsB4ajYmtx-VwWl-krOkFR_Bw,4648
43
43
  services/tools/manage_script.py,sha256=lPA5HcS4Al0RiQVz-S6qahFTcPqsk3GSLLXJWHri8P4,27557
44
+ services/tools/manage_scriptable_object.py,sha256=Oi03CJLgepaWR59V-nJiAjnCC8at4YqFhRGpACruqgw,3150
44
45
  services/tools/manage_shader.py,sha256=HHnHKh7vLij3p8FAinNsPdZGEKivgwSUTxdgDydfmbs,2882
45
- services/tools/read_console.py,sha256=JSqi2jSgEB5JUZbVripEjXoeEZC-r8rbG8zBprsnxNQ,4895
46
- services/tools/run_tests.py,sha256=Aga_yubZUuB_YnJNqBYJEq3nIV5z1u3YoyH4YvXCbuc,3780
46
+ services/tools/read_console.py,sha256=MdQcrnVXra9PLu5AFkmARjriObT0miExtQKkFaANznU,4662
47
+ services/tools/run_tests.py,sha256=eeHwFmBxbKHaL_RMxoDN6qKsmBp2qTrnG7FxnRQR5mQ,3709
47
48
  services/tools/script_apply_edits.py,sha256=qPm_PsmsK3mYXnziX_btyk8CaB66LTqpDFA2Y4ebZ4U,47504
48
49
  services/tools/set_active_instance.py,sha256=B18Y8Jga0pKsx9mFywXr1tWfy0cJVopIMXYO-UJ1jOU,4136
49
- services/tools/utils.py,sha256=ILZN7bYb5nZvS-60t6rdTsX6OwDu7mlIN3AXXQsJ8H4,1917
50
+ services/tools/utils.py,sha256=4ZgfIu178eXZqRyzs8X77B5lKLP1f73OZoGBSDNokJ4,2409
50
51
  transport/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
51
52
  transport/models.py,sha256=6wp7wsmSaeeJEvUGXPF1m6zuJnxJ1NJlCC4YZ9oQIq0,1226
52
- transport/plugin_hub.py,sha256=55R00ohrmUI0mk_smc_8BsYTvrQMPX4wwsvqXprj0Vk,15596
53
+ transport/plugin_hub.py,sha256=g_DOhCThgJ9Oco_z3m2qpwDeUcFvvt7Z47xMS0diihw,21497
53
54
  transport/plugin_registry.py,sha256=nW-7O7PN0QUgSWivZTkpAVKKq9ZOe2b2yeIdpaNt_3I,4359
54
- transport/unity_instance_middleware.py,sha256=a-ULWU9b86w0CbYN3meyLxWGxTBXL5CQmBKZmmQ0xZQ,6197
55
- transport/unity_transport.py,sha256=dvwCjo2jRvnFXd8ruOL36C8W4P1VIQ91qreS2750lPM,3307
55
+ transport/unity_instance_middleware.py,sha256=kf1QeA138r7PaC98dcMDYtUPGWZ4EUmZGESc2DdiWQs,10429
56
+ transport/unity_transport.py,sha256=_cFVgD3pzFZRcDXANq4oPFYSoI6jntSGaN22dJC8LRU,3880
56
57
  transport/legacy/port_discovery.py,sha256=qM_mtndbYjAj4qPSZEWVeXFOt5_nKczG9pQqORXTBJ0,12768
57
58
  transport/legacy/stdio_port_registry.py,sha256=j4iARuP6wetppNDG8qKeuvo1bJKcSlgEhZvSyl_uf0A,2313
58
59
  transport/legacy/unity_connection.py,sha256=ujUX9WX7Gb-fxQveHts3uiepTPzFq8i7-XG7u5gSPuM,32668
59
60
  utils/module_discovery.py,sha256=My48ofB1BUqxiBoAZAGbEaLQYdsrDhMm8MayBP_bUSQ,2005
60
61
  utils/reload_sentinel.py,sha256=s1xMWhl-r2XwN0OUbiUv_VGUy8TvLtV5bkql-5n2DT0,373
61
- mcpforunityserver-8.3.0.dist-info/METADATA,sha256=D3tTiaXi3mylMJ6TFcVdAc0w4VMQPc70B2ZroLkNiIY,5720
62
- mcpforunityserver-8.3.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
63
- mcpforunityserver-8.3.0.dist-info/entry_points.txt,sha256=vCtkqw-J9t4pj7JwUZcB54_keVx7DpAR3fYzK6i-s6g,44
64
- mcpforunityserver-8.3.0.dist-info/top_level.txt,sha256=YKU5e5dREMfCnoVpmlsTm9bku7oqnrzSZ9FeTgjoxJw,58
65
- mcpforunityserver-8.3.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
@@ -1,4 +1,8 @@
1
1
  from typing import Any
2
+ import os
3
+ import sys
4
+
5
+ from core.telemetry import get_package_version
2
6
 
3
7
  from fastmcp import Context
4
8
  from services.registry import mcp_for_unity_tool
@@ -50,6 +54,11 @@ def debug_request_context(ctx: Context) -> dict[str, Any]:
50
54
  return {
51
55
  "success": True,
52
56
  "data": {
57
+ "server": {
58
+ "version": get_package_version(),
59
+ "cwd": os.getcwd(),
60
+ "argv": list(sys.argv),
61
+ },
53
62
  "request_context": {
54
63
  "client_id": rc_client_id,
55
64
  "session_id": rc_session_id,
@@ -9,35 +9,41 @@ from typing import Annotated, Any, Literal
9
9
  from fastmcp import Context
10
10
  from services.registry import mcp_for_unity_tool
11
11
  from services.tools import get_unity_instance_from_context
12
- from services.tools.utils import parse_json_payload
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
15
 
16
16
 
17
17
  @mcp_for_unity_tool(
18
- description="Performs asset operations (import, create, modify, delete, etc.) in Unity."
18
+ description=(
19
+ "Performs asset operations (import, create, modify, delete, etc.) in Unity.\n\n"
20
+ "Tip (payload safety): for `action=\"search\"`, prefer paging (`page_size`, `page_number`) and keep "
21
+ "`generate_preview=false` (previews can add large base64 blobs)."
22
+ )
19
23
  )
20
24
  async def manage_asset(
21
25
  ctx: Context,
22
26
  action: Annotated[Literal["import", "create", "modify", "delete", "duplicate", "move", "rename", "search", "get_info", "create_folder", "get_components"], "Perform CRUD operations on assets."],
23
- path: Annotated[str, "Asset path (e.g., 'Materials/MyMaterial.mat') or search scope."],
27
+ path: Annotated[str, "Asset path (e.g., 'Materials/MyMaterial.mat') or search scope (e.g., 'Assets')."],
24
28
  asset_type: Annotated[str,
25
- "Asset type (e.g., 'Material', 'Folder') - required for 'create'."] | None = None,
29
+ "Asset type (e.g., 'Material', 'Folder') - required for 'create'. Note: For ScriptableObjects, use manage_scriptable_object."] | None = None,
26
30
  properties: Annotated[dict[str, Any] | str,
27
31
  "Dictionary (or JSON string) of properties for 'create'/'modify'."] | None = None,
28
32
  destination: Annotated[str,
29
33
  "Target path for 'duplicate'/'move'."] | None = None,
30
34
  generate_preview: Annotated[bool,
31
- "Generate a preview/thumbnail for the asset when supported."] = False,
35
+ "Generate a preview/thumbnail for the asset when supported. "
36
+ "Warning: previews may include large base64 payloads; keep false unless needed."] = False,
32
37
  search_pattern: Annotated[str,
33
- "Search pattern (e.g., '*.prefab')."] | None = None,
38
+ "Search pattern (e.g., '*.prefab' or AssetDatabase filters like 't:MonoScript'). "
39
+ "Recommended: put queries like 't:MonoScript' here and set path='Assets'."] | None = None,
34
40
  filter_type: Annotated[str, "Filter type for search"] | None = None,
35
41
  filter_date_after: Annotated[str,
36
42
  "Date after which to filter"] | None = None,
37
43
  page_size: Annotated[int | float | str,
38
- "Page size for pagination"] | None = None,
44
+ "Page size for pagination. Recommended: 25 (smaller for LLM-friendly responses)."] | None = None,
39
45
  page_number: Annotated[int | float | str,
40
- "Page number for pagination"] | None = None,
46
+ "Page number for pagination (1-based)."] | None = None,
41
47
  ) -> dict[str, Any]:
42
48
  unity_instance = get_unity_instance_from_context(ctx)
43
49
 
@@ -83,24 +89,32 @@ async def manage_asset(
83
89
  await ctx.error(parse_error)
84
90
  return {"success": False, "message": parse_error}
85
91
 
86
- # Coerce numeric inputs defensively
87
- def _coerce_int(value, default=None):
88
- if value is None:
89
- return default
92
+ page_size = coerce_int(page_size)
93
+ page_number = coerce_int(page_number)
94
+
95
+ # --- Payload-safe normalization for common LLM mistakes (search) ---
96
+ # Unity's C# handler treats `path` as a folder scope. If a model mistakenly puts a query like
97
+ # "t:MonoScript" into `path`, Unity will consider it an invalid folder and fall back to searching
98
+ # the entire project, which is token-heavy. Normalize such cases into search_pattern + Assets scope.
99
+ action_l = (action or "").lower()
100
+ if action_l == "search":
90
101
  try:
91
- if isinstance(value, bool):
92
- return default
93
- if isinstance(value, int):
94
- return int(value)
95
- s = str(value).strip()
96
- if s.lower() in ("", "none", "null"):
97
- return default
98
- return int(float(s))
99
- except Exception:
100
- return default
102
+ raw_path = (path or "").strip()
103
+ except (AttributeError, TypeError):
104
+ # Handle case where path is not a string despite type annotation
105
+ raw_path = ""
106
+
107
+ # If the caller put an AssetDatabase query into `path`, treat it as `search_pattern`.
108
+ if (not search_pattern) and raw_path.startswith("t:"):
109
+ search_pattern = raw_path
110
+ path = "Assets"
111
+ await ctx.info("manage_asset(search): normalized query from `path` into `search_pattern` and set path='Assets'")
101
112
 
102
- page_size = _coerce_int(page_size)
103
- page_number = _coerce_int(page_number)
113
+ # If the caller used `asset_type` to mean a search filter, map it to filter_type.
114
+ # (In Unity, filterType becomes `t:<filterType>`.)
115
+ if (not filter_type) and asset_type and isinstance(asset_type, str):
116
+ filter_type = asset_type
117
+ await ctx.info("manage_asset(search): mapped `asset_type` into `filter_type` for safer server-side filtering")
104
118
 
105
119
  # Prepare parameters for the C# handler
106
120
  params_dict = {
@@ -7,7 +7,7 @@ from services.registry import mcp_for_unity_tool
7
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
- from services.tools.utils import coerce_bool, parse_json_payload
10
+ from services.tools.utils import coerce_bool, parse_json_payload, coerce_int
11
11
 
12
12
 
13
13
  @mcp_for_unity_tool(
@@ -68,6 +68,11 @@ async def manage_gameobject(
68
68
  # Controls whether serialization of private [SerializeField] fields is included
69
69
  includeNonPublicSerialized: Annotated[bool | str,
70
70
  "Controls whether serialization of private [SerializeField] fields is included (accepts true/false or 'true'/'false')"] | None = None,
71
+ # --- Paging/safety for get_components ---
72
+ page_size: Annotated[int | str, "Page size for get_components paging."] | None = None,
73
+ cursor: Annotated[int | str, "Opaque cursor for get_components paging (offset)."] | None = None,
74
+ max_components: Annotated[int | str, "Hard cap on returned components per request (safety)."] | None = None,
75
+ include_properties: Annotated[bool | str, "If true, include serialized component properties (bounded)."] | None = None,
71
76
  # --- Parameters for 'duplicate' ---
72
77
  new_name: Annotated[str,
73
78
  "New name for the duplicated object (default: SourceName_Copy)"] | None = None,
@@ -134,7 +139,12 @@ async def manage_gameobject(
134
139
  search_in_children = coerce_bool(search_in_children)
135
140
  search_inactive = coerce_bool(search_inactive)
136
141
  includeNonPublicSerialized = coerce_bool(includeNonPublicSerialized)
142
+ include_properties = coerce_bool(include_properties)
137
143
  world_space = coerce_bool(world_space, default=True)
144
+ # If coercion fails, omit these fields (None) rather than preserving invalid input.
145
+ page_size = coerce_int(page_size, default=None)
146
+ cursor = coerce_int(cursor, default=None)
147
+ max_components = coerce_int(max_components, default=None)
138
148
 
139
149
  # Coerce 'component_properties' from JSON string to dict for client compatibility
140
150
  component_properties = parse_json_payload(component_properties)
@@ -194,6 +204,10 @@ async def manage_gameobject(
194
204
  "searchInactive": search_inactive,
195
205
  "componentName": component_name,
196
206
  "includeNonPublicSerialized": includeNonPublicSerialized,
207
+ "pageSize": page_size,
208
+ "cursor": cursor,
209
+ "maxComponents": max_components,
210
+ "includeProperties": include_properties,
197
211
  # Parameters for 'duplicate'
198
212
  "new_name": new_name,
199
213
  "offset": offset,
@@ -3,6 +3,7 @@ from typing import Annotated, Literal, Any
3
3
  from fastmcp import Context
4
4
  from services.registry import mcp_for_unity_tool
5
5
  from services.tools import get_unity_instance_from_context
6
+ from services.tools.utils import coerce_int, coerce_bool
6
7
  from transport.unity_transport import send_with_unity_instance
7
8
  from transport.legacy.unity_connection import async_send_command_with_retry
8
9
 
@@ -27,29 +28,27 @@ async def manage_scene(
27
28
  "Unity build index (quote as string, e.g., '0')."] | None = None,
28
29
  screenshot_file_name: Annotated[str, "Screenshot file name (optional). Defaults to timestamp when omitted."] | None = None,
29
30
  screenshot_super_size: Annotated[int | str, "Screenshot supersize multiplier (integer ≥1). Optional." ] | None = None,
31
+ # --- get_hierarchy paging/safety ---
32
+ parent: Annotated[str | int, "Optional parent GameObject reference (name/path/instanceID) to list direct children."] | None = None,
33
+ page_size: Annotated[int | str, "Page size for get_hierarchy paging."] | None = None,
34
+ cursor: Annotated[int | str, "Opaque cursor for paging (offset)."] | None = None,
35
+ max_nodes: Annotated[int | str, "Hard cap on returned nodes per request (safety)."] | None = None,
36
+ max_depth: Annotated[int | str, "Accepted for forward-compatibility; current paging returns a single level."] | None = None,
37
+ max_children_per_node: Annotated[int | str, "Child paging hint (safety)."] | None = None,
38
+ include_transform: Annotated[bool | str, "If true, include local transform in node summaries."] | None = None,
30
39
  ) -> dict[str, Any]:
31
40
  # Get active instance from session state
32
41
  # Removed session_state import
33
42
  unity_instance = get_unity_instance_from_context(ctx)
34
43
  try:
35
- # Coerce numeric inputs defensively
36
- def _coerce_int(value, default=None):
37
- if value is None:
38
- return default
39
- try:
40
- if isinstance(value, bool):
41
- return default
42
- if isinstance(value, int):
43
- return int(value)
44
- s = str(value).strip()
45
- if s.lower() in ("", "none", "null"):
46
- return default
47
- return int(float(s))
48
- except Exception:
49
- return default
50
-
51
- coerced_build_index = _coerce_int(build_index, default=None)
52
- coerced_super_size = _coerce_int(screenshot_super_size, default=None)
44
+ coerced_build_index = coerce_int(build_index, default=None)
45
+ coerced_super_size = coerce_int(screenshot_super_size, default=None)
46
+ coerced_page_size = coerce_int(page_size, default=None)
47
+ coerced_cursor = coerce_int(cursor, default=None)
48
+ coerced_max_nodes = coerce_int(max_nodes, default=None)
49
+ coerced_max_depth = coerce_int(max_depth, default=None)
50
+ coerced_max_children_per_node = coerce_int(max_children_per_node, default=None)
51
+ coerced_include_transform = coerce_bool(include_transform, default=None)
53
52
 
54
53
  params: dict[str, Any] = {"action": action}
55
54
  if name:
@@ -62,6 +61,22 @@ async def manage_scene(
62
61
  params["fileName"] = screenshot_file_name
63
62
  if coerced_super_size is not None:
64
63
  params["superSize"] = coerced_super_size
64
+
65
+ # get_hierarchy paging/safety params (optional)
66
+ if parent is not None:
67
+ params["parent"] = parent
68
+ if coerced_page_size is not None:
69
+ params["pageSize"] = coerced_page_size
70
+ if coerced_cursor is not None:
71
+ params["cursor"] = coerced_cursor
72
+ if coerced_max_nodes is not None:
73
+ params["maxNodes"] = coerced_max_nodes
74
+ if coerced_max_depth is not None:
75
+ params["maxDepth"] = coerced_max_depth
76
+ if coerced_max_children_per_node is not None:
77
+ params["maxChildrenPerNode"] = coerced_max_children_per_node
78
+ if coerced_include_transform is not None:
79
+ params["includeTransform"] = coerced_include_transform
65
80
 
66
81
  # Use centralized retry helper with instance routing
67
82
  response = await send_with_unity_instance(async_send_command_with_retry, unity_instance, "manage_scene", params)
@@ -0,0 +1,75 @@
1
+ """
2
+ Tool wrapper for managing ScriptableObject assets via Unity MCP.
3
+
4
+ Unity-side handler: MCPForUnity.Editor.Tools.ManageScriptableObject
5
+ Command name: "manage_scriptable_object"
6
+ Actions:
7
+ - create: create an SO asset (optionally with patches)
8
+ - modify: apply serialized property patches to an existing SO asset
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Annotated, Any, Literal
14
+
15
+ from fastmcp import Context
16
+
17
+ from services.registry import mcp_for_unity_tool
18
+ from services.tools import get_unity_instance_from_context
19
+ from services.tools.utils import coerce_bool, parse_json_payload
20
+ from transport.unity_transport import send_with_unity_instance
21
+ from transport.legacy.unity_connection import async_send_command_with_retry
22
+
23
+
24
+ @mcp_for_unity_tool(
25
+ description="Creates and modifies ScriptableObject assets using Unity SerializedObject property paths."
26
+ )
27
+ async def manage_scriptable_object(
28
+ ctx: Context,
29
+ action: Annotated[Literal["create", "modify"], "Action to perform: create or modify."],
30
+ # --- create params ---
31
+ type_name: Annotated[str | None, "Namespace-qualified ScriptableObject type name (for create)."] = None,
32
+ folder_path: Annotated[str | None, "Target folder under Assets/... (for create)."] = None,
33
+ asset_name: Annotated[str | None, "Asset file name without extension (for create)."] = None,
34
+ overwrite: Annotated[bool | str | None, "If true, overwrite existing asset at same path (for create)."] = None,
35
+ # --- modify params ---
36
+ target: Annotated[dict[str, Any] | str | None, "Target asset reference {guid|path} (for modify)."] = None,
37
+ # --- shared ---
38
+ patches: Annotated[list[dict[str, Any]] | str | None, "Patch list (or JSON string) to apply."] = None,
39
+ ) -> dict[str, Any]:
40
+ unity_instance = get_unity_instance_from_context(ctx)
41
+
42
+ # Tolerate JSON-string payloads (LLMs sometimes stringify complex objects)
43
+ parsed_target = parse_json_payload(target)
44
+ parsed_patches = parse_json_payload(patches)
45
+
46
+ if parsed_target is not None and not isinstance(parsed_target, dict):
47
+ return {"success": False, "message": "manage_scriptable_object: 'target' must be an object {guid|path} (or JSON string of such)."}
48
+
49
+ if parsed_patches is not None and not isinstance(parsed_patches, list):
50
+ return {"success": False, "message": "manage_scriptable_object: 'patches' must be a list (or JSON string of a list)."}
51
+
52
+ params: dict[str, Any] = {
53
+ "action": action,
54
+ "typeName": type_name,
55
+ "folderPath": folder_path,
56
+ "assetName": asset_name,
57
+ "overwrite": coerce_bool(overwrite, default=None),
58
+ "target": parsed_target,
59
+ "patches": parsed_patches,
60
+ }
61
+
62
+ # Remove None values to keep Unity handler simpler
63
+ params = {k: v for k, v in params.items() if v is not None}
64
+
65
+ response = await send_with_unity_instance(
66
+ async_send_command_with_retry,
67
+ unity_instance,
68
+ "manage_scriptable_object",
69
+ params,
70
+ )
71
+ await ctx.info(f"Response {response}")
72
+ return response if isinstance(response, dict) else {"success": False, "message": "Unexpected response from Unity."}
73
+
74
+
75
+
@@ -6,6 +6,7 @@ from typing import Annotated, Any, Literal
6
6
  from fastmcp import Context
7
7
  from services.registry import mcp_for_unity_tool
8
8
  from services.tools import get_unity_instance_from_context
9
+ from services.tools.utils import coerce_int, coerce_bool
9
10
  from transport.unity_transport import send_with_unity_instance
10
11
  from transport.legacy.unity_connection import async_send_command_with_retry
11
12
 
@@ -38,42 +39,24 @@ async def read_console(
38
39
  format = format if format is not None else 'detailed'
39
40
  # Coerce booleans defensively (strings like 'true'/'false')
40
41
 
41
- def _coerce_bool(value, default=None):
42
- if value is None:
43
- return default
44
- if isinstance(value, bool):
45
- return value
46
- if isinstance(value, str):
47
- v = value.strip().lower()
48
- if v in ("true", "1", "yes", "on"):
49
- return True
50
- if v in ("false", "0", "no", "off"):
51
- return False
52
- return bool(value)
53
-
54
- include_stacktrace = _coerce_bool(include_stacktrace, True)
42
+ include_stacktrace = coerce_bool(include_stacktrace, default=True)
55
43
 
56
44
  # Normalize action if it's a string
57
45
  if isinstance(action, str):
58
46
  action = action.lower()
59
47
 
60
- # Coerce count defensively (string/float -> int)
61
- def _coerce_int(value, default=None):
62
- if value is None:
63
- return default
64
- try:
65
- if isinstance(value, bool):
66
- return default
67
- if isinstance(value, int):
68
- return int(value)
69
- s = str(value).strip()
70
- if s.lower() in ("", "none", "null"):
71
- return default
72
- return int(float(s))
73
- except Exception:
74
- return default
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)
75
57
 
76
- count = _coerce_int(count)
58
+ if action == "get" and count is None:
59
+ count = 200
77
60
 
78
61
  # Prepare parameters for the C# handler
79
62
  params_dict = {
@@ -7,6 +7,7 @@ from pydantic import BaseModel, Field
7
7
  from models import MCPResponse
8
8
  from services.registry import mcp_for_unity_tool
9
9
  from services.tools import get_unity_instance_from_context
10
+ from services.tools.utils import coerce_int
10
11
  from transport.unity_transport import send_with_unity_instance
11
12
  from transport.legacy.unity_connection import async_send_command_with_retry
12
13
 
@@ -33,7 +34,7 @@ class RunTestsTestResult(BaseModel):
33
34
  class RunTestsResult(BaseModel):
34
35
  mode: str
35
36
  summary: RunTestsSummary
36
- results: list[RunTestsTestResult]
37
+ results: list[RunTestsTestResult] | None = None
37
38
 
38
39
 
39
40
  class RunTestsResponse(MCPResponse):
@@ -51,25 +52,11 @@ async def run_tests(
51
52
  group_names: Annotated[list[str] | str, "Same as test_names, except it allows for Regex"] | None = None,
52
53
  category_names: Annotated[list[str] | str, "NUnit category names to filter by (tests marked with [Category] attribute)"] | None = None,
53
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,
54
57
  ) -> RunTestsResponse:
55
58
  unity_instance = get_unity_instance_from_context(ctx)
56
59
 
57
- # Coerce timeout defensively (string/float -> int)
58
- def _coerce_int(value, default=None):
59
- if value is None:
60
- return default
61
- try:
62
- if isinstance(value, bool):
63
- return default
64
- if isinstance(value, int):
65
- return int(value)
66
- s = str(value).strip()
67
- if s.lower() in ("", "none", "null"):
68
- return default
69
- return int(float(s))
70
- except Exception:
71
- return default
72
-
73
60
  # Coerce string or list to list of strings
74
61
  def _coerce_string_list(value) -> list[str] | None:
75
62
  if value is None:
@@ -82,7 +69,7 @@ async def run_tests(
82
69
  return None
83
70
 
84
71
  params: dict[str, Any] = {"mode": mode}
85
- ts = _coerce_int(timeout_seconds)
72
+ ts = coerce_int(timeout_seconds)
86
73
  if ts is not None:
87
74
  params["timeoutSeconds"] = ts
88
75
 
@@ -103,6 +90,12 @@ async def run_tests(
103
90
  if assembly_names_list:
104
91
  params["assemblyNames"] = assembly_names_list
105
92
 
93
+ # Add verbosity parameters
94
+ if include_failed_tests:
95
+ params["includeFailedTests"] = True
96
+ if include_details:
97
+ params["includeDetails"] = True
98
+
106
99
  response = await send_with_unity_instance(async_send_command_with_retry, unity_instance, "run_tests", params)
107
100
  await ctx.info(f'Response {response}')
108
101
  return RunTestsResponse(**response) if isinstance(response, dict) else response
services/tools/utils.py CHANGED
@@ -58,3 +58,20 @@ def parse_json_payload(value: Any) -> Any:
58
58
  except (json.JSONDecodeError, ValueError):
59
59
  # If parsing fails, assume it was meant to be a literal string
60
60
  return value
61
+
62
+
63
+ def coerce_int(value: Any, default: int | None = None) -> int | None:
64
+ """Attempt to coerce a loosely-typed value to an integer."""
65
+ if value is None:
66
+ return default
67
+ try:
68
+ if isinstance(value, bool):
69
+ return default
70
+ if isinstance(value, int):
71
+ return value
72
+ s = str(value).strip()
73
+ if s.lower() in ("", "none", "null"):
74
+ return default
75
+ return int(float(s))
76
+ except Exception:
77
+ return default
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)