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 +68 -0
- {mcpforunityserver-8.3.0.dist-info → mcpforunityserver-8.6.0.dist-info}/METADATA +3 -3
- {mcpforunityserver-8.3.0.dist-info → mcpforunityserver-8.6.0.dist-info}/RECORD +19 -18
- services/resources/editor_state.py +10 -1
- services/tools/debug_request_context.py +9 -0
- services/tools/manage_asset.py +38 -24
- services/tools/manage_gameobject.py +15 -1
- services/tools/manage_scene.py +33 -18
- services/tools/manage_scriptable_object.py +75 -0
- services/tools/read_console.py +13 -30
- services/tools/run_tests.py +11 -18
- services/tools/utils.py +17 -0
- transport/plugin_hub.py +118 -7
- transport/unity_instance_middleware.py +90 -0
- transport/unity_transport.py +16 -6
- {mcpforunityserver-8.3.0.dist-info → mcpforunityserver-8.6.0.dist-info}/WHEEL +0 -0
- {mcpforunityserver-8.3.0.dist-info → mcpforunityserver-8.6.0.dist-info}/entry_points.txt +0 -0
- {mcpforunityserver-8.3.0.dist-info → mcpforunityserver-8.6.0.dist-info}/licenses/LICENSE +0 -0
- {mcpforunityserver-8.3.0.dist-info → mcpforunityserver-8.6.0.dist-info}/top_level.txt +0 -0
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
|
+
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
|
|
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.
|
|
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=
|
|
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.
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
46
|
-
services/tools/run_tests.py,sha256=
|
|
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=
|
|
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=
|
|
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=
|
|
55
|
-
transport/unity_transport.py,sha256=
|
|
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.
|
|
62
|
-
mcpforunityserver-8.
|
|
63
|
-
mcpforunityserver-8.
|
|
64
|
-
mcpforunityserver-8.
|
|
65
|
-
mcpforunityserver-8.
|
|
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
|
-
|
|
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,
|
services/tools/manage_asset.py
CHANGED
|
@@ -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=
|
|
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."
|
|
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'
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
103
|
-
|
|
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,
|
services/tools/manage_scene.py
CHANGED
|
@@ -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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
+
|
services/tools/read_console.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
|
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 = {
|
services/tools/run_tests.py
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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=
|
|
186
|
+
timeout=unity_timeout_s,
|
|
128
187
|
)
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
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),
|
transport/unity_transport.py
CHANGED
|
@@ -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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|