mcpforunityserver 8.2.3__py3-none-any.whl → 8.7.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- main.py +68 -0
- {mcpforunityserver-8.2.3.dist-info → mcpforunityserver-8.7.0.dist-info}/METADATA +38 -66
- {mcpforunityserver-8.2.3.dist-info → mcpforunityserver-8.7.0.dist-info}/RECORD +24 -18
- services/resources/editor_state.py +10 -1
- services/resources/editor_state_v2.py +270 -0
- services/state/external_changes_scanner.py +246 -0
- services/tools/debug_request_context.py +9 -0
- services/tools/manage_asset.py +46 -25
- services/tools/manage_gameobject.py +20 -1
- services/tools/manage_scene.py +37 -18
- services/tools/manage_scriptable_object.py +75 -0
- services/tools/preflight.py +107 -0
- services/tools/read_console.py +13 -30
- services/tools/refresh_unity.py +90 -0
- services/tools/run_tests.py +32 -20
- services/tools/test_jobs.py +94 -0
- 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.2.3.dist-info → mcpforunityserver-8.7.0.dist-info}/WHEEL +0 -0
- {mcpforunityserver-8.2.3.dist-info → mcpforunityserver-8.7.0.dist-info}/entry_points.txt +0 -0
- {mcpforunityserver-8.2.3.dist-info → mcpforunityserver-8.7.0.dist-info}/licenses/LICENSE +0 -0
- {mcpforunityserver-8.2.3.dist-info → mcpforunityserver-8.7.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.7.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
|
|
29
|
+
Requires-Dist: fastmcp==2.14.1
|
|
30
30
|
Requires-Dist: mcp>=1.16.0
|
|
31
|
-
Requires-Dist: pydantic>=2.12.
|
|
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:
|
|
59
|
+
### Option 1: PyPI
|
|
60
60
|
|
|
61
|
-
|
|
61
|
+
Install and run directly from PyPI using `uvx`.
|
|
62
62
|
|
|
63
|
-
|
|
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
|
-
|
|
69
|
-
uvx --from
|
|
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
|
-
"
|
|
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:
|
|
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
|
-
|
|
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": "
|
|
108
|
+
"command": "uvx",
|
|
138
109
|
"args": [
|
|
139
|
-
"
|
|
140
|
-
"
|
|
141
|
-
"
|
|
142
|
-
"server.py",
|
|
110
|
+
"--from",
|
|
111
|
+
"git+https://github.com/CoplayDev/unity-mcp@v8.7.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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
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"`.
|
|
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=
|
|
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.7.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,8 @@ 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
|
+
services/resources/editor_state_v2.py,sha256=zgss1EEhJo7oZeHnjOXsdJPuFQsHLwpsZmzcDy3ybq0,10874
|
|
22
23
|
services/resources/layers.py,sha256=q4UQ5PUVUVhmM5l3oXID1wa_wOWAS8l5BGXadBgFuwY,1080
|
|
23
24
|
services/resources/menu_items.py,sha256=9SNycjwTXoeS1ZHra0Y1fTyCjSEdPCo34JyxtuqauG8,1021
|
|
24
25
|
services/resources/prefab_stage.py,sha256=C3mn3UapKYVOA8QUNmLsYreG5YiXdlvGm9ypHQeKBeQ,1382
|
|
@@ -28,38 +29,43 @@ services/resources/tags.py,sha256=7EhmQjMotz85DSSr7cVKYIy7LPT5mmPfrEySr1mTE6w,10
|
|
|
28
29
|
services/resources/tests.py,sha256=xDvvgesPSU93nLD_ERQopOpkpq69pbMEqmFsJd0jekI,2063
|
|
29
30
|
services/resources/unity_instances.py,sha256=fR0cVopGQnmF41IFDycwlo2XniKstfJWLGobgJeiabE,4348
|
|
30
31
|
services/resources/windows.py,sha256=--QVsb0oyoBpSjK2D4kPcZFSe2zdR-t_KSHP-e2QNoY,1427
|
|
32
|
+
services/state/external_changes_scanner.py,sha256=qwdiriHR1D11aPiLUbpS7COXtfVOjNj9DpzcSDK067o,9042
|
|
31
33
|
services/tools/__init__.py,sha256=3Qav7fAowZ1_TbDRdZQQQES53gv2lTs-2D7PGECnlbM,2353
|
|
32
34
|
services/tools/batch_execute.py,sha256=_ByjffeXQB9j64mcjaxJmrnbSJrMn0f9_6Zh9BBI_2c,2898
|
|
33
|
-
services/tools/debug_request_context.py,sha256=
|
|
35
|
+
services/tools/debug_request_context.py,sha256=WQBtQdXSH5stw2MAwIM32H6jGwUVQOgU2r35VUWLlYo,2765
|
|
34
36
|
services/tools/execute_custom_tool.py,sha256=K2qaO4-FTPz0_3j53hhDP9idjC002ugc8C03FtHGTbY,1376
|
|
35
37
|
services/tools/execute_menu_item.py,sha256=FAC-1v_TwOcy6GSxkogDsVxeRtdap0DsPlIngf8uJdU,1184
|
|
36
38
|
services/tools/find_in_file.py,sha256=xp80lqRN2cdZc3XGJWlCpeQEy6WnwyKOj2l5WiHNx0Q,6379
|
|
37
|
-
services/tools/manage_asset.py,sha256=
|
|
39
|
+
services/tools/manage_asset.py,sha256=6YjWOl2b58vRwjp-9XbQE9e1l3ajhGookVY8ncQN0wo,7877
|
|
38
40
|
services/tools/manage_editor.py,sha256=_HZRT-_hBakH0g6p7BpxTv3gWpxsaV6KNGRol-qknwo,3243
|
|
39
|
-
services/tools/manage_gameobject.py,sha256=
|
|
41
|
+
services/tools/manage_gameobject.py,sha256=OgFIsoPGiWHOj6-d3Lmtp3xlAW9Tr0c38tV4atAaFAU,14400
|
|
40
42
|
services/tools/manage_material.py,sha256=wZB2H4orhL6wG9TTnmnk-Lj2Gj_zvg7koxW3t319BLU,3545
|
|
41
43
|
services/tools/manage_prefabs.py,sha256=73XzznjFNOm1SazW_Y7l6uGIE7wosMpAIVQs8xpvK9A,3037
|
|
42
|
-
services/tools/manage_scene.py,sha256=
|
|
44
|
+
services/tools/manage_scene.py,sha256=oJ1qDX0T06mINZ1hX2AcDp3ItHo-oUz7Uck0yujI9eA,4834
|
|
43
45
|
services/tools/manage_script.py,sha256=lPA5HcS4Al0RiQVz-S6qahFTcPqsk3GSLLXJWHri8P4,27557
|
|
46
|
+
services/tools/manage_scriptable_object.py,sha256=Oi03CJLgepaWR59V-nJiAjnCC8at4YqFhRGpACruqgw,3150
|
|
44
47
|
services/tools/manage_shader.py,sha256=HHnHKh7vLij3p8FAinNsPdZGEKivgwSUTxdgDydfmbs,2882
|
|
45
|
-
services/tools/
|
|
46
|
-
services/tools/
|
|
48
|
+
services/tools/preflight.py,sha256=VJn61h-9pvoVaCyKL7DTKLfbpoZfNK4fnRmj91c2o8M,4093
|
|
49
|
+
services/tools/read_console.py,sha256=MdQcrnVXra9PLu5AFkmARjriObT0miExtQKkFaANznU,4662
|
|
50
|
+
services/tools/refresh_unity.py,sha256=anTEuEzxKTFse6ldZxTsk43zI6ahRBDv3Sg_pMHYRYA,3719
|
|
51
|
+
services/tools/run_tests.py,sha256=8CqmgRN6Bata666ytF_S9no4gaFmHCmeZM82ZwNQJ68,4666
|
|
47
52
|
services/tools/script_apply_edits.py,sha256=qPm_PsmsK3mYXnziX_btyk8CaB66LTqpDFA2Y4ebZ4U,47504
|
|
48
53
|
services/tools/set_active_instance.py,sha256=B18Y8Jga0pKsx9mFywXr1tWfy0cJVopIMXYO-UJ1jOU,4136
|
|
49
|
-
services/tools/
|
|
54
|
+
services/tools/test_jobs.py,sha256=K6HjkzWPjJNldrp-Vq5gPH7oBkCq_sJZYXkK_Vg6I_I,4059
|
|
55
|
+
services/tools/utils.py,sha256=4ZgfIu178eXZqRyzs8X77B5lKLP1f73OZoGBSDNokJ4,2409
|
|
50
56
|
transport/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
51
57
|
transport/models.py,sha256=6wp7wsmSaeeJEvUGXPF1m6zuJnxJ1NJlCC4YZ9oQIq0,1226
|
|
52
|
-
transport/plugin_hub.py,sha256=
|
|
58
|
+
transport/plugin_hub.py,sha256=g_DOhCThgJ9Oco_z3m2qpwDeUcFvvt7Z47xMS0diihw,21497
|
|
53
59
|
transport/plugin_registry.py,sha256=nW-7O7PN0QUgSWivZTkpAVKKq9ZOe2b2yeIdpaNt_3I,4359
|
|
54
|
-
transport/unity_instance_middleware.py,sha256=
|
|
55
|
-
transport/unity_transport.py,sha256=
|
|
60
|
+
transport/unity_instance_middleware.py,sha256=kf1QeA138r7PaC98dcMDYtUPGWZ4EUmZGESc2DdiWQs,10429
|
|
61
|
+
transport/unity_transport.py,sha256=_cFVgD3pzFZRcDXANq4oPFYSoI6jntSGaN22dJC8LRU,3880
|
|
56
62
|
transport/legacy/port_discovery.py,sha256=qM_mtndbYjAj4qPSZEWVeXFOt5_nKczG9pQqORXTBJ0,12768
|
|
57
63
|
transport/legacy/stdio_port_registry.py,sha256=j4iARuP6wetppNDG8qKeuvo1bJKcSlgEhZvSyl_uf0A,2313
|
|
58
64
|
transport/legacy/unity_connection.py,sha256=ujUX9WX7Gb-fxQveHts3uiepTPzFq8i7-XG7u5gSPuM,32668
|
|
59
65
|
utils/module_discovery.py,sha256=My48ofB1BUqxiBoAZAGbEaLQYdsrDhMm8MayBP_bUSQ,2005
|
|
60
66
|
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.
|
|
67
|
+
mcpforunityserver-8.7.0.dist-info/METADATA,sha256=m3U2_aFTIAFPWg8YE3v2KcMJmP-Ffz0R-EJsJwoD6pA,5712
|
|
68
|
+
mcpforunityserver-8.7.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
69
|
+
mcpforunityserver-8.7.0.dist-info/entry_points.txt,sha256=vCtkqw-J9t4pj7JwUZcB54_keVx7DpAR3fYzK6i-s6g,44
|
|
70
|
+
mcpforunityserver-8.7.0.dist-info/top_level.txt,sha256=YKU5e5dREMfCnoVpmlsTm9bku7oqnrzSZ9FeTgjoxJw,58
|
|
71
|
+
mcpforunityserver-8.7.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
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import time
|
|
2
|
+
import os
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from fastmcp import Context
|
|
6
|
+
|
|
7
|
+
from models import MCPResponse
|
|
8
|
+
from services.registry import mcp_for_unity_resource
|
|
9
|
+
from services.tools import get_unity_instance_from_context
|
|
10
|
+
import transport.unity_transport as unity_transport
|
|
11
|
+
from transport.legacy.unity_connection import async_send_command_with_retry
|
|
12
|
+
from services.state.external_changes_scanner import external_changes_scanner
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _now_unix_ms() -> int:
|
|
16
|
+
return int(time.time() * 1000)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _in_pytest() -> bool:
|
|
20
|
+
# Avoid instance-discovery side effects during the Python integration test suite.
|
|
21
|
+
return bool(os.environ.get("PYTEST_CURRENT_TEST"))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
async def _infer_single_instance_id(ctx: Context) -> str | None:
|
|
25
|
+
"""
|
|
26
|
+
Best-effort: if exactly one Unity instance is connected, return its Name@hash id.
|
|
27
|
+
This makes editor_state outputs self-describing even when no explicit active instance is set.
|
|
28
|
+
"""
|
|
29
|
+
if _in_pytest():
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
transport = unity_transport._current_transport()
|
|
34
|
+
except Exception:
|
|
35
|
+
transport = None
|
|
36
|
+
|
|
37
|
+
if transport == "http":
|
|
38
|
+
# HTTP/WebSocket transport: derive from PluginHub sessions.
|
|
39
|
+
try:
|
|
40
|
+
from transport.plugin_hub import PluginHub
|
|
41
|
+
|
|
42
|
+
sessions_data = await PluginHub.get_sessions()
|
|
43
|
+
sessions = sessions_data.sessions if hasattr(sessions_data, "sessions") else {}
|
|
44
|
+
if isinstance(sessions, dict) and len(sessions) == 1:
|
|
45
|
+
session = next(iter(sessions.values()))
|
|
46
|
+
project = getattr(session, "project", None)
|
|
47
|
+
project_hash = getattr(session, "hash", None)
|
|
48
|
+
if project and project_hash:
|
|
49
|
+
return f"{project}@{project_hash}"
|
|
50
|
+
except Exception:
|
|
51
|
+
return None
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
# Stdio/TCP transport: derive from connection pool discovery.
|
|
55
|
+
try:
|
|
56
|
+
from transport.legacy.unity_connection import get_unity_connection_pool
|
|
57
|
+
|
|
58
|
+
pool = get_unity_connection_pool()
|
|
59
|
+
instances = pool.discover_all_instances(force_refresh=False)
|
|
60
|
+
if isinstance(instances, list) and len(instances) == 1:
|
|
61
|
+
inst = instances[0]
|
|
62
|
+
inst_id = getattr(inst, "id", None)
|
|
63
|
+
return str(inst_id) if inst_id else None
|
|
64
|
+
except Exception:
|
|
65
|
+
return None
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _build_v2_from_legacy(legacy: dict[str, Any]) -> dict[str, Any]:
|
|
70
|
+
"""
|
|
71
|
+
Best-effort mapping from legacy get_editor_state payload into the v2 contract.
|
|
72
|
+
Legacy shape (Unity): {isPlaying,isPaused,isCompiling,isUpdating,timeSinceStartup,...}
|
|
73
|
+
"""
|
|
74
|
+
now_ms = _now_unix_ms()
|
|
75
|
+
# legacy may arrive already wrapped as MCPResponse-like {success,data:{...}}
|
|
76
|
+
state = legacy.get("data") if isinstance(legacy.get("data"), dict) else {}
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
"schema_version": "unity-mcp/editor_state@2",
|
|
80
|
+
"observed_at_unix_ms": now_ms,
|
|
81
|
+
"sequence": 0,
|
|
82
|
+
"unity": {
|
|
83
|
+
"instance_id": None,
|
|
84
|
+
"unity_version": None,
|
|
85
|
+
"project_id": None,
|
|
86
|
+
"platform": None,
|
|
87
|
+
"is_batch_mode": None,
|
|
88
|
+
},
|
|
89
|
+
"editor": {
|
|
90
|
+
"is_focused": None,
|
|
91
|
+
"play_mode": {
|
|
92
|
+
"is_playing": bool(state.get("isPlaying", False)),
|
|
93
|
+
"is_paused": bool(state.get("isPaused", False)),
|
|
94
|
+
"is_changing": None,
|
|
95
|
+
},
|
|
96
|
+
"active_scene": {
|
|
97
|
+
"path": None,
|
|
98
|
+
"guid": None,
|
|
99
|
+
"name": state.get("activeSceneName", "") or "",
|
|
100
|
+
},
|
|
101
|
+
"selection": {
|
|
102
|
+
"count": int(state.get("selectionCount", 0) or 0),
|
|
103
|
+
"active_object_name": state.get("activeObjectName", None),
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
"activity": {
|
|
107
|
+
"phase": "unknown",
|
|
108
|
+
"since_unix_ms": now_ms,
|
|
109
|
+
"reasons": ["legacy_fallback"],
|
|
110
|
+
},
|
|
111
|
+
"compilation": {
|
|
112
|
+
"is_compiling": bool(state.get("isCompiling", False)),
|
|
113
|
+
"is_domain_reload_pending": None,
|
|
114
|
+
"last_compile_started_unix_ms": None,
|
|
115
|
+
"last_compile_finished_unix_ms": None,
|
|
116
|
+
},
|
|
117
|
+
"assets": {
|
|
118
|
+
"is_updating": bool(state.get("isUpdating", False)),
|
|
119
|
+
"external_changes_dirty": False,
|
|
120
|
+
"external_changes_last_seen_unix_ms": None,
|
|
121
|
+
"refresh": {
|
|
122
|
+
"is_refresh_in_progress": False,
|
|
123
|
+
"last_refresh_requested_unix_ms": None,
|
|
124
|
+
"last_refresh_finished_unix_ms": None,
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
"tests": {
|
|
128
|
+
"is_running": False,
|
|
129
|
+
"mode": None,
|
|
130
|
+
"started_unix_ms": None,
|
|
131
|
+
"started_by": "unknown",
|
|
132
|
+
"last_run": None,
|
|
133
|
+
},
|
|
134
|
+
"transport": {
|
|
135
|
+
"unity_bridge_connected": None,
|
|
136
|
+
"last_message_unix_ms": None,
|
|
137
|
+
},
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _enrich_advice_and_staleness(state_v2: dict[str, Any]) -> dict[str, Any]:
|
|
142
|
+
now_ms = _now_unix_ms()
|
|
143
|
+
observed = state_v2.get("observed_at_unix_ms")
|
|
144
|
+
try:
|
|
145
|
+
observed_ms = int(observed)
|
|
146
|
+
except Exception:
|
|
147
|
+
observed_ms = now_ms
|
|
148
|
+
|
|
149
|
+
age_ms = max(0, now_ms - observed_ms)
|
|
150
|
+
# Conservative default: treat >2s as stale (covers common unfocused-editor throttling).
|
|
151
|
+
is_stale = age_ms > 2000
|
|
152
|
+
|
|
153
|
+
compilation = state_v2.get("compilation") or {}
|
|
154
|
+
tests = state_v2.get("tests") or {}
|
|
155
|
+
assets = state_v2.get("assets") or {}
|
|
156
|
+
refresh = (assets.get("refresh") or {}) if isinstance(assets, dict) else {}
|
|
157
|
+
|
|
158
|
+
blocking: list[str] = []
|
|
159
|
+
if compilation.get("is_compiling") is True:
|
|
160
|
+
blocking.append("compiling")
|
|
161
|
+
if compilation.get("is_domain_reload_pending") is True:
|
|
162
|
+
blocking.append("domain_reload")
|
|
163
|
+
if tests.get("is_running") is True:
|
|
164
|
+
blocking.append("running_tests")
|
|
165
|
+
if refresh.get("is_refresh_in_progress") is True:
|
|
166
|
+
blocking.append("asset_refresh")
|
|
167
|
+
if is_stale:
|
|
168
|
+
blocking.append("stale_status")
|
|
169
|
+
|
|
170
|
+
ready_for_tools = len(blocking) == 0
|
|
171
|
+
|
|
172
|
+
state_v2["advice"] = {
|
|
173
|
+
"ready_for_tools": ready_for_tools,
|
|
174
|
+
"blocking_reasons": blocking,
|
|
175
|
+
"recommended_retry_after_ms": 0 if ready_for_tools else 500,
|
|
176
|
+
"recommended_next_action": "none" if ready_for_tools else "retry_later",
|
|
177
|
+
}
|
|
178
|
+
state_v2["staleness"] = {"age_ms": age_ms, "is_stale": is_stale}
|
|
179
|
+
return state_v2
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@mcp_for_unity_resource(
|
|
183
|
+
uri="unity://editor_state",
|
|
184
|
+
name="editor_state_v2",
|
|
185
|
+
description="Canonical editor readiness snapshot (v2). Includes advice and server-computed staleness.",
|
|
186
|
+
)
|
|
187
|
+
async def get_editor_state_v2(ctx: Context) -> MCPResponse:
|
|
188
|
+
unity_instance = get_unity_instance_from_context(ctx)
|
|
189
|
+
|
|
190
|
+
# Try v2 snapshot first (Unity-side cache will make this fast once implemented).
|
|
191
|
+
response = await unity_transport.send_with_unity_instance(
|
|
192
|
+
async_send_command_with_retry,
|
|
193
|
+
unity_instance,
|
|
194
|
+
"get_editor_state_v2",
|
|
195
|
+
{},
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
# If Unity returns a structured retry hint or error, surface it directly.
|
|
199
|
+
if isinstance(response, dict) and not response.get("success", True):
|
|
200
|
+
return MCPResponse(**response)
|
|
201
|
+
|
|
202
|
+
# If v2 is unavailable (older plugin), fall back to legacy get_editor_state and map.
|
|
203
|
+
if not (isinstance(response, dict) and isinstance(response.get("data"), dict) and response["data"].get("schema_version")):
|
|
204
|
+
legacy = await unity_transport.send_with_unity_instance(
|
|
205
|
+
async_send_command_with_retry,
|
|
206
|
+
unity_instance,
|
|
207
|
+
"get_editor_state",
|
|
208
|
+
{},
|
|
209
|
+
)
|
|
210
|
+
if isinstance(legacy, dict) and not legacy.get("success", True):
|
|
211
|
+
return MCPResponse(**legacy)
|
|
212
|
+
state_v2 = _build_v2_from_legacy(legacy if isinstance(legacy, dict) else {})
|
|
213
|
+
else:
|
|
214
|
+
state_v2 = response.get("data") if isinstance(response.get("data"), dict) else {}
|
|
215
|
+
# Ensure required v2 marker exists even if Unity returns partial.
|
|
216
|
+
state_v2.setdefault("schema_version", "unity-mcp/editor_state@2")
|
|
217
|
+
state_v2.setdefault("observed_at_unix_ms", _now_unix_ms())
|
|
218
|
+
state_v2.setdefault("sequence", 0)
|
|
219
|
+
|
|
220
|
+
# Ensure the returned snapshot is clearly associated with the targeted instance.
|
|
221
|
+
# (This matters when multiple Unity instances are connected and the client is polling readiness.)
|
|
222
|
+
unity_section = state_v2.get("unity")
|
|
223
|
+
if not isinstance(unity_section, dict):
|
|
224
|
+
unity_section = {}
|
|
225
|
+
state_v2["unity"] = unity_section
|
|
226
|
+
current_instance_id = unity_section.get("instance_id")
|
|
227
|
+
if current_instance_id in (None, ""):
|
|
228
|
+
if unity_instance:
|
|
229
|
+
unity_section["instance_id"] = unity_instance
|
|
230
|
+
else:
|
|
231
|
+
inferred = await _infer_single_instance_id(ctx)
|
|
232
|
+
if inferred:
|
|
233
|
+
unity_section["instance_id"] = inferred
|
|
234
|
+
|
|
235
|
+
# External change detection (server-side): compute per instance based on project root path.
|
|
236
|
+
# This helps detect stale assets when external tools edit the filesystem.
|
|
237
|
+
try:
|
|
238
|
+
instance_id = unity_section.get("instance_id")
|
|
239
|
+
if isinstance(instance_id, str) and instance_id.strip():
|
|
240
|
+
from services.resources.project_info import get_project_info
|
|
241
|
+
|
|
242
|
+
# Cache the project root for this instance (best-effort).
|
|
243
|
+
proj_resp = await get_project_info(ctx)
|
|
244
|
+
proj = proj_resp.model_dump() if hasattr(proj_resp, "model_dump") else proj_resp
|
|
245
|
+
proj_data = proj.get("data") if isinstance(proj, dict) else None
|
|
246
|
+
project_root = proj_data.get("projectRoot") if isinstance(proj_data, dict) else None
|
|
247
|
+
if isinstance(project_root, str) and project_root.strip():
|
|
248
|
+
external_changes_scanner.set_project_root(instance_id, project_root)
|
|
249
|
+
|
|
250
|
+
ext = external_changes_scanner.update_and_get(instance_id)
|
|
251
|
+
|
|
252
|
+
assets = state_v2.get("assets")
|
|
253
|
+
if not isinstance(assets, dict):
|
|
254
|
+
assets = {}
|
|
255
|
+
state_v2["assets"] = assets
|
|
256
|
+
# IMPORTANT: Unity's cached snapshot may include placeholder defaults; the server scanner is authoritative
|
|
257
|
+
# for external changes (filesystem edits outside Unity). Always overwrite these fields from the scanner.
|
|
258
|
+
assets["external_changes_dirty"] = bool(ext.get("external_changes_dirty", False))
|
|
259
|
+
assets["external_changes_last_seen_unix_ms"] = ext.get("external_changes_last_seen_unix_ms")
|
|
260
|
+
# Extra bookkeeping fields (server-only) are safe to add under assets.
|
|
261
|
+
assets["external_changes_dirty_since_unix_ms"] = ext.get("dirty_since_unix_ms")
|
|
262
|
+
assets["external_changes_last_cleared_unix_ms"] = ext.get("last_cleared_unix_ms")
|
|
263
|
+
except Exception:
|
|
264
|
+
# Best-effort; do not fail readiness resource if filesystem scan can't run.
|
|
265
|
+
pass
|
|
266
|
+
|
|
267
|
+
state_v2 = _enrich_advice_and_staleness(state_v2)
|
|
268
|
+
return MCPResponse(success=True, message="Retrieved editor state (v2).", data=state_v2)
|
|
269
|
+
|
|
270
|
+
|