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 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.2.3
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<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.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
- **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=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.2.3.dist-info/licenses/LICENSE,sha256=bv5lDJZQEqxBgjjc1rkRbkEwpSIHF-8N-1Od0VnEJFw,1066
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=acrSyMfdulRgYQIn7wKHqKqyw4uED_oUf9GU-4o4GAg,1497
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=pk02OGfa_yuc4SWxzTR6lEBo0R4ls2yFXp7sdIVshDM,2531
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=wvzdqgSKC3dhdLcFOgQj9thNYp16y8RJQNWu3PluDpg,6021
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=wGJayE8giHwtOkeNBvjWaN8ecEnBDgGTraJKvVRHSgM,13257
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=dw2ZkbEKCkLVIMA_KqAJmHXcAc9LJeo83GhhvYJZ0DQ,3192
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/read_console.py,sha256=JSqi2jSgEB5JUZbVripEjXoeEZC-r8rbG8zBprsnxNQ,4895
46
- services/tools/run_tests.py,sha256=Aga_yubZUuB_YnJNqBYJEq3nIV5z1u3YoyH4YvXCbuc,3780
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/utils.py,sha256=ILZN7bYb5nZvS-60t6rdTsX6OwDu7mlIN3AXXQsJ8H4,1917
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=55R00ohrmUI0mk_smc_8BsYTvrQMPX4wwsvqXprj0Vk,15596
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=a-ULWU9b86w0CbYN3meyLxWGxTBXL5CQmBKZmmQ0xZQ,6197
55
- transport/unity_transport.py,sha256=dvwCjo2jRvnFXd8ruOL36C8W4P1VIQ91qreS2750lPM,3307
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.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,,
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
- return EditorStateResponse(**response) if isinstance(response, dict) else response
42
+ # When Unity is reloading/unresponsive (often when unfocused), transports may return
43
+ # a retryable MCPResponse payload with success=false and no data. Do not attempt to
44
+ # coerce that into EditorStateResponse (it would fail validation); return it as-is.
45
+ if isinstance(response, dict):
46
+ if not response.get("success", True):
47
+ return MCPResponse(**response)
48
+ if response.get("data") is None:
49
+ return MCPResponse(success=False, error="Editor state missing 'data' payload", data=response)
50
+ return EditorStateResponse(**response)
51
+ return response
@@ -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
+