mcpforunityserver 8.2.3__py3-none-any.whl → 8.5.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
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mcpforunityserver
3
- Version: 8.2.3
3
+ Version: 8.5.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,9 +26,9 @@ 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
- Requires-Dist: pydantic>=2.12.0
31
+ Requires-Dist: pydantic>=2.12.5
32
32
  Requires-Dist: tomli>=2.3.0
33
33
  Requires-Dist: fastapi>=0.104.0
34
34
  Requires-Dist: uvicorn>=0.35.0
@@ -50,24 +50,20 @@ Model Context Protocol server for Unity Editor integration. Control Unity throug
50
50
 
51
51
  💬 **Join our community:** [Discord Server](https://discord.gg/y4p8KfzrN4)
52
52
 
53
- **Required:** Install the [Unity MCP Plugin](https://github.com/CoplayDev/unity-mcp?tab=readme-ov-file#-step-1-install-the-unity-package) to connect Unity Editor with this MCP server.
53
+ **Required:** Install the [Unity MCP Plugin](https://github.com/CoplayDev/unity-mcp?tab=readme-ov-file#-step-1-install-the-unity-package) to connect Unity Editor with this MCP server. You also need `uvx` (requires [uv](https://docs.astral.sh/uv/)) to run the server.
54
54
 
55
55
  ---
56
56
 
57
57
  ## Installation
58
58
 
59
- ### Option 1: Using uvx (Recommended)
59
+ ### Option 1: PyPI
60
60
 
61
- Run directly from GitHub without installation:
61
+ Install and run directly from PyPI using `uvx`.
62
62
 
63
- ```bash
64
- # HTTP (default)
65
- uvx --from git+https://github.com/CoplayDev/unity-mcp@v8.2.3#subdirectory=Server \
66
- mcp-for-unity --transport http --http-url http://localhost:8080
63
+ **Run Server (HTTP):**
67
64
 
68
- # Stdio
69
- uvx --from git+https://github.com/CoplayDev/unity-mcp@v8.2.3#subdirectory=Server \
70
- mcp-for-unity --transport stdio
65
+ ```bash
66
+ uvx --from mcpforunityserver mcp-for-unity --transport http --http-url http://localhost:8080
71
67
  ```
72
68
 
73
69
  **MCP Client Configuration (HTTP):**
@@ -91,55 +87,29 @@ uvx --from git+https://github.com/CoplayDev/unity-mcp@v8.2.3#subdirectory=Server
91
87
  "command": "uvx",
92
88
  "args": [
93
89
  "--from",
94
- "git+https://github.com/CoplayDev/unity-mcp@v8.2.3#subdirectory=Server",
90
+ "mcpforunityserver",
95
91
  "mcp-for-unity",
96
92
  "--transport",
97
93
  "stdio"
98
- ],
99
- "type": "stdio"
94
+ ]
100
95
  }
101
96
  }
102
97
  }
103
98
  ```
104
99
 
105
- ### Option 2: Using uv (Local Installation)
106
-
107
- For local development or custom installations:
108
-
109
- ```bash
110
- # Clone the repository
111
- git clone https://github.com/CoplayDev/unity-mcp.git
112
- cd unity-mcp/Server
113
-
114
- # Run with uv (HTTP)
115
- uv run server.py --transport http --http-url http://localhost:8080
116
-
117
- # Run with uv (stdio)
118
- uv run server.py --transport stdio
119
- ```
100
+ ### Option 2: From GitHub Source
120
101
 
121
- **MCP Client Configuration (HTTP):**
122
- ```json
123
- {
124
- "mcpServers": {
125
- "UnityMCP": {
126
- "url": "http://localhost:8080/mcp"
127
- }
128
- }
129
- }
130
- ```
102
+ Use this to run the latest released version from the repository. Change the version to `main` to run the latest unreleased changes from the repository.
131
103
 
132
- **MCP Client Configuration (stdio – Windows):**
133
104
  ```json
134
105
  {
135
106
  "mcpServers": {
136
107
  "UnityMCP": {
137
- "command": "uv",
108
+ "command": "uvx",
138
109
  "args": [
139
- "run",
140
- "--directory",
141
- "C:\\path\\to\\unity-mcp\\Server",
142
- "server.py",
110
+ "--from",
111
+ "git+https://github.com/CoplayDev/unity-mcp@v8.5.0#subdirectory=Server",
112
+ "mcp-for-unity",
143
113
  "--transport",
144
114
  "stdio"
145
115
  ]
@@ -148,33 +118,35 @@ uv run server.py --transport stdio
148
118
  }
149
119
  ```
150
120
 
151
- **MCP Client Configuration (stdio – macOS/Linux):**
152
- ```json
153
- {
154
- "mcpServers": {
155
- "UnityMCP": {
156
- "command": "uv",
157
- "args": [
158
- "run",
159
- "--directory",
160
- "/path/to/unity-mcp/Server",
161
- "server.py",
162
- "--transport",
163
- "stdio"
164
- ]
165
- }
166
- }
167
- }
121
+ ### Option 3: Docker
122
+
123
+ **Use Pre-built Image:**
124
+
125
+ ```bash
126
+ docker run -p 8080:8080 msanatan/mcp-for-unity-server:latest --transport http --http-url http://0.0.0.0:8080
168
127
  ```
169
128
 
170
- ### Option 3: Using Docker
129
+ **Build Locally:**
171
130
 
172
131
  ```bash
173
132
  docker build -t unity-mcp-server .
174
133
  docker run -p 8080:8080 unity-mcp-server --transport http --http-url http://0.0.0.0:8080
175
134
  ```
176
135
 
177
- Configure your MCP client with `"url": "http://localhost:8080/mcp"`. For stdio-in-docker (rare), run the container with `--transport stdio` and use the same `command`/`args` pattern as the uv examples, wrapping it in `docker run -i ...` if needed.
136
+ Configure your MCP client with `"url": "http://localhost:8080/mcp"`.
137
+
138
+ ### Option 4: Local Development
139
+
140
+ For contributing or modifying the server code:
141
+
142
+ ```bash
143
+ # Clone the repository
144
+ git clone https://github.com/CoplayDev/unity-mcp.git
145
+ cd unity-mcp/Server
146
+
147
+ # Run with uv
148
+ uv run src/main.py --transport stdio
149
+ ```
178
150
 
179
151
  ---
180
152
 
@@ -1,11 +1,11 @@
1
1
  __init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
- main.py,sha256=bjQfzp3c9rAe9TtDr1009ARZ6ULWBPxEBopCRmRyXc8,16316
2
+ main.py,sha256=OH_Ux5Aj43q1lXPu4AS8zafRnFHZWFYCDZbswReQOac,18011
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.2.3.dist-info/licenses/LICENSE,sha256=bv5lDJZQEqxBgjjc1rkRbkEwpSIHF-8N-1Od0VnEJFw,1066
8
+ mcpforunityserver-8.5.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
@@ -30,23 +30,24 @@ 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=gZWEf0Ru0hvN9oJUZqZ4w-mMBBLm5Z5KAUPv282XbYQ,4091
47
+ services/tools/run_tests.py,sha256=LBVwGasLvmF4k1FiX3DdBQ8udh89WZJFiVHfJRWGvOs,3313
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
53
  transport/plugin_hub.py,sha256=55R00ohrmUI0mk_smc_8BsYTvrQMPX4wwsvqXprj0Vk,15596
@@ -58,8 +59,8 @@ transport/legacy/stdio_port_registry.py,sha256=j4iARuP6wetppNDG8qKeuvo1bJKcSlgEh
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.2.3.dist-info/METADATA,sha256=OgSQ7AOFOMi03YLWBKxF0jB-96_4RCTcegiXj23njII,6313
62
- mcpforunityserver-8.2.3.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
63
- mcpforunityserver-8.2.3.dist-info/entry_points.txt,sha256=vCtkqw-J9t4pj7JwUZcB54_keVx7DpAR3fYzK6i-s6g,44
64
- mcpforunityserver-8.2.3.dist-info/top_level.txt,sha256=YKU5e5dREMfCnoVpmlsTm9bku7oqnrzSZ9FeTgjoxJw,58
65
- mcpforunityserver-8.2.3.dist-info/RECORD,,
62
+ mcpforunityserver-8.5.0.dist-info/METADATA,sha256=3YWJc7I-EKxcLzMgG0MweNSJm3QR_VYCNTTwwB45UNE,5712
63
+ mcpforunityserver-8.5.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
64
+ mcpforunityserver-8.5.0.dist-info/entry_points.txt,sha256=vCtkqw-J9t4pj7JwUZcB54_keVx7DpAR3fYzK6i-s6g,44
65
+ mcpforunityserver-8.5.0.dist-info/top_level.txt,sha256=YKU5e5dREMfCnoVpmlsTm9bku7oqnrzSZ9FeTgjoxJw,58
66
+ mcpforunityserver-8.5.0.dist-info/RECORD,,
@@ -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,14 @@ 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
48
  # 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
75
-
76
- count = _coerce_int(count)
49
+ count = coerce_int(count)
77
50
 
78
51
  # Prepare parameters for the C# handler
79
52
  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
 
@@ -54,22 +55,6 @@ async def run_tests(
54
55
  ) -> RunTestsResponse:
55
56
  unity_instance = get_unity_instance_from_context(ctx)
56
57
 
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
58
  # Coerce string or list to list of strings
74
59
  def _coerce_string_list(value) -> list[str] | None:
75
60
  if value is None:
@@ -82,7 +67,7 @@ async def run_tests(
82
67
  return None
83
68
 
84
69
  params: dict[str, Any] = {"mode": mode}
85
- ts = _coerce_int(timeout_seconds)
70
+ ts = coerce_int(timeout_seconds)
86
71
  if ts is not None:
87
72
  params["timeoutSeconds"] = ts
88
73
 
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