mcpforunityserver 8.5.0__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 +30 -0
- {mcpforunityserver-8.5.0.dist-info → mcpforunityserver-8.7.0.dist-info}/METADATA +2 -2
- {mcpforunityserver-8.5.0.dist-info → mcpforunityserver-8.7.0.dist-info}/RECORD +21 -16
- 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/manage_asset.py +7 -0
- services/tools/manage_gameobject.py +5 -0
- services/tools/manage_scene.py +4 -0
- services/tools/preflight.py +107 -0
- services/tools/read_console.py +12 -2
- services/tools/refresh_unity.py +90 -0
- services/tools/run_tests.py +31 -4
- services/tools/test_jobs.py +94 -0
- transport/plugin_hub.py +118 -7
- transport/unity_instance_middleware.py +90 -0
- transport/unity_transport.py +16 -6
- {mcpforunityserver-8.5.0.dist-info → mcpforunityserver-8.7.0.dist-info}/WHEEL +0 -0
- {mcpforunityserver-8.5.0.dist-info → mcpforunityserver-8.7.0.dist-info}/entry_points.txt +0 -0
- {mcpforunityserver-8.5.0.dist-info → mcpforunityserver-8.7.0.dist-info}/licenses/LICENSE +0 -0
- {mcpforunityserver-8.5.0.dist-info → mcpforunityserver-8.7.0.dist-info}/top_level.txt +0 -0
main.py
CHANGED
|
@@ -391,6 +391,22 @@ Examples:
|
|
|
391
391
|
help="HTTP server port (overrides URL port). "
|
|
392
392
|
"Overrides UNITY_MCP_HTTP_PORT environment variable."
|
|
393
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
|
+
)
|
|
394
410
|
|
|
395
411
|
args = parser.parse_args()
|
|
396
412
|
|
|
@@ -418,6 +434,20 @@ Examples:
|
|
|
418
434
|
os.environ["UNITY_MCP_HTTP_HOST"] = http_host
|
|
419
435
|
os.environ["UNITY_MCP_HTTP_PORT"] = str(http_port)
|
|
420
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
|
+
|
|
421
451
|
if args.http_url != "http://localhost:8080":
|
|
422
452
|
logger.info(f"HTTP URL set to: {http_url}")
|
|
423
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
|
|
@@ -108,7 +108,7 @@ Use this to run the latest released version from the repository. Change the vers
|
|
|
108
108
|
"command": "uvx",
|
|
109
109
|
"args": [
|
|
110
110
|
"--from",
|
|
111
|
-
"git+https://github.com/CoplayDev/unity-mcp@v8.
|
|
111
|
+
"git+https://github.com/CoplayDev/unity-mcp@v8.7.0#subdirectory=Server",
|
|
112
112
|
"mcp-for-unity",
|
|
113
113
|
"--transport",
|
|
114
114
|
"stdio"
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
-
main.py,sha256=
|
|
2
|
+
main.py,sha256=ITxelXUAmr9BoasG0knZifXKp3lWJyml_RLaIwvEyVs,19184
|
|
3
3
|
core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
4
|
core/config.py,sha256=czkTtNji1crQcQbUvmdx4OL7f-RBqkVhj_PtHh-w7rs,1623
|
|
5
5
|
core/logging_decorator.py,sha256=D9CD7rFvQz-MBG-G4inizQj0Ivr6dfc9RBmTrw7q8mI,1383
|
|
6
6
|
core/telemetry.py,sha256=eHjYgzd8f7eTwSwF2Kbi8D4TtJIcdaDjKLeo1c-0hVA,19829
|
|
7
7
|
core/telemetry_decorator.py,sha256=ycSTrzVNCDQHSd-xmIWOpVfKFURPxpiZe_XkOQAGDAo,6705
|
|
8
|
-
mcpforunityserver-8.
|
|
8
|
+
mcpforunityserver-8.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,39 +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
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
|
|
44
46
|
services/tools/manage_scriptable_object.py,sha256=Oi03CJLgepaWR59V-nJiAjnCC8at4YqFhRGpACruqgw,3150
|
|
45
47
|
services/tools/manage_shader.py,sha256=HHnHKh7vLij3p8FAinNsPdZGEKivgwSUTxdgDydfmbs,2882
|
|
46
|
-
services/tools/
|
|
47
|
-
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
|
|
48
52
|
services/tools/script_apply_edits.py,sha256=qPm_PsmsK3mYXnziX_btyk8CaB66LTqpDFA2Y4ebZ4U,47504
|
|
49
53
|
services/tools/set_active_instance.py,sha256=B18Y8Jga0pKsx9mFywXr1tWfy0cJVopIMXYO-UJ1jOU,4136
|
|
54
|
+
services/tools/test_jobs.py,sha256=K6HjkzWPjJNldrp-Vq5gPH7oBkCq_sJZYXkK_Vg6I_I,4059
|
|
50
55
|
services/tools/utils.py,sha256=4ZgfIu178eXZqRyzs8X77B5lKLP1f73OZoGBSDNokJ4,2409
|
|
51
56
|
transport/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
52
57
|
transport/models.py,sha256=6wp7wsmSaeeJEvUGXPF1m6zuJnxJ1NJlCC4YZ9oQIq0,1226
|
|
53
|
-
transport/plugin_hub.py,sha256=
|
|
58
|
+
transport/plugin_hub.py,sha256=g_DOhCThgJ9Oco_z3m2qpwDeUcFvvt7Z47xMS0diihw,21497
|
|
54
59
|
transport/plugin_registry.py,sha256=nW-7O7PN0QUgSWivZTkpAVKKq9ZOe2b2yeIdpaNt_3I,4359
|
|
55
|
-
transport/unity_instance_middleware.py,sha256=
|
|
56
|
-
transport/unity_transport.py,sha256=
|
|
60
|
+
transport/unity_instance_middleware.py,sha256=kf1QeA138r7PaC98dcMDYtUPGWZ4EUmZGESc2DdiWQs,10429
|
|
61
|
+
transport/unity_transport.py,sha256=_cFVgD3pzFZRcDXANq4oPFYSoI6jntSGaN22dJC8LRU,3880
|
|
57
62
|
transport/legacy/port_discovery.py,sha256=qM_mtndbYjAj4qPSZEWVeXFOt5_nKczG9pQqORXTBJ0,12768
|
|
58
63
|
transport/legacy/stdio_port_registry.py,sha256=j4iARuP6wetppNDG8qKeuvo1bJKcSlgEhZvSyl_uf0A,2313
|
|
59
64
|
transport/legacy/unity_connection.py,sha256=ujUX9WX7Gb-fxQveHts3uiepTPzFq8i7-XG7u5gSPuM,32668
|
|
60
65
|
utils/module_discovery.py,sha256=My48ofB1BUqxiBoAZAGbEaLQYdsrDhMm8MayBP_bUSQ,2005
|
|
61
66
|
utils/reload_sentinel.py,sha256=s1xMWhl-r2XwN0OUbiUv_VGUy8TvLtV5bkql-5n2DT0,373
|
|
62
|
-
mcpforunityserver-8.
|
|
63
|
-
mcpforunityserver-8.
|
|
64
|
-
mcpforunityserver-8.
|
|
65
|
-
mcpforunityserver-8.
|
|
66
|
-
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
|
+
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import json
|
|
5
|
+
import time
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Iterable
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _now_unix_ms() -> int:
|
|
12
|
+
return int(time.time() * 1000)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _in_pytest() -> bool:
|
|
16
|
+
# Keep scanner inert during the Python integration suite unless explicitly invoked.
|
|
17
|
+
return bool(os.environ.get("PYTEST_CURRENT_TEST"))
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class ExternalChangesState:
|
|
22
|
+
project_root: str | None = None
|
|
23
|
+
last_scan_unix_ms: int | None = None
|
|
24
|
+
last_seen_mtime_ns: int | None = None
|
|
25
|
+
dirty: bool = False
|
|
26
|
+
dirty_since_unix_ms: int | None = None
|
|
27
|
+
external_changes_last_seen_unix_ms: int | None = None
|
|
28
|
+
last_cleared_unix_ms: int | None = None
|
|
29
|
+
# Cached package roots referenced by Packages/manifest.json "file:" dependencies
|
|
30
|
+
extra_roots: list[str] | None = None
|
|
31
|
+
manifest_last_mtime_ns: int | None = None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ExternalChangesScanner:
|
|
35
|
+
"""
|
|
36
|
+
Lightweight external-changes detector using recursive max-mtime scan.
|
|
37
|
+
|
|
38
|
+
This is intentionally conservative:
|
|
39
|
+
- It only marks dirty when it sees a strictly newer mtime than the baseline.
|
|
40
|
+
- It scans at most once per scan_interval_ms per instance to keep overhead bounded.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(self, *, scan_interval_ms: int = 1500, max_entries: int = 20000):
|
|
44
|
+
self._states: dict[str, ExternalChangesState] = {}
|
|
45
|
+
self._scan_interval_ms = int(scan_interval_ms)
|
|
46
|
+
self._max_entries = int(max_entries)
|
|
47
|
+
|
|
48
|
+
def _get_state(self, instance_id: str) -> ExternalChangesState:
|
|
49
|
+
return self._states.setdefault(instance_id, ExternalChangesState())
|
|
50
|
+
|
|
51
|
+
def set_project_root(self, instance_id: str, project_root: str | None) -> None:
|
|
52
|
+
st = self._get_state(instance_id)
|
|
53
|
+
if project_root:
|
|
54
|
+
st.project_root = project_root
|
|
55
|
+
|
|
56
|
+
def clear_dirty(self, instance_id: str) -> None:
|
|
57
|
+
st = self._get_state(instance_id)
|
|
58
|
+
st.dirty = False
|
|
59
|
+
st.dirty_since_unix_ms = None
|
|
60
|
+
st.last_cleared_unix_ms = _now_unix_ms()
|
|
61
|
+
# Reset baseline to “now” on next scan.
|
|
62
|
+
st.last_seen_mtime_ns = None
|
|
63
|
+
|
|
64
|
+
def _scan_paths_max_mtime_ns(self, roots: Iterable[Path]) -> int | None:
|
|
65
|
+
newest: int | None = None
|
|
66
|
+
entries = 0
|
|
67
|
+
|
|
68
|
+
for root in roots:
|
|
69
|
+
if not root.exists():
|
|
70
|
+
continue
|
|
71
|
+
|
|
72
|
+
# Walk the tree; skip common massive/irrelevant dirs (Library/Temp/Logs).
|
|
73
|
+
for dirpath, dirnames, filenames in os.walk(str(root)):
|
|
74
|
+
entries += 1
|
|
75
|
+
if entries > self._max_entries:
|
|
76
|
+
return newest
|
|
77
|
+
|
|
78
|
+
dp = Path(dirpath)
|
|
79
|
+
name = dp.name.lower()
|
|
80
|
+
if name in {"library", "temp", "logs", "obj", ".git", "node_modules"}:
|
|
81
|
+
dirnames[:] = []
|
|
82
|
+
continue
|
|
83
|
+
|
|
84
|
+
# Allow skipping hidden directories quickly
|
|
85
|
+
dirnames[:] = [d for d in dirnames if not d.startswith(".")]
|
|
86
|
+
|
|
87
|
+
for fn in filenames:
|
|
88
|
+
if fn.startswith("."):
|
|
89
|
+
continue
|
|
90
|
+
entries += 1
|
|
91
|
+
if entries > self._max_entries:
|
|
92
|
+
return newest
|
|
93
|
+
p = dp / fn
|
|
94
|
+
try:
|
|
95
|
+
stat = p.stat()
|
|
96
|
+
except OSError:
|
|
97
|
+
continue
|
|
98
|
+
m = getattr(stat, "st_mtime_ns", None)
|
|
99
|
+
if m is None:
|
|
100
|
+
# Fallback when st_mtime_ns is unavailable
|
|
101
|
+
m = int(stat.st_mtime * 1_000_000_000)
|
|
102
|
+
newest = m if newest is None else max(newest, int(m))
|
|
103
|
+
|
|
104
|
+
return newest
|
|
105
|
+
|
|
106
|
+
def _resolve_manifest_extra_roots(self, project_root: Path, st: ExternalChangesState) -> list[Path]:
|
|
107
|
+
"""
|
|
108
|
+
Parse Packages/manifest.json for local file: dependencies and resolve them to absolute paths.
|
|
109
|
+
Returns a list of Paths that exist and are directories.
|
|
110
|
+
"""
|
|
111
|
+
manifest_path = project_root / "Packages" / "manifest.json"
|
|
112
|
+
try:
|
|
113
|
+
stat = manifest_path.stat()
|
|
114
|
+
except OSError:
|
|
115
|
+
st.extra_roots = []
|
|
116
|
+
st.manifest_last_mtime_ns = None
|
|
117
|
+
return []
|
|
118
|
+
|
|
119
|
+
mtime_ns = getattr(stat, "st_mtime_ns", int(stat.st_mtime * 1_000_000_000))
|
|
120
|
+
if st.extra_roots is not None and st.manifest_last_mtime_ns == mtime_ns:
|
|
121
|
+
return [Path(p) for p in st.extra_roots if p]
|
|
122
|
+
|
|
123
|
+
try:
|
|
124
|
+
raw = manifest_path.read_text(encoding="utf-8")
|
|
125
|
+
doc = json.loads(raw)
|
|
126
|
+
except Exception:
|
|
127
|
+
st.extra_roots = []
|
|
128
|
+
st.manifest_last_mtime_ns = mtime_ns
|
|
129
|
+
return []
|
|
130
|
+
|
|
131
|
+
deps = doc.get("dependencies") if isinstance(doc, dict) else None
|
|
132
|
+
if not isinstance(deps, dict):
|
|
133
|
+
st.extra_roots = []
|
|
134
|
+
st.manifest_last_mtime_ns = mtime_ns
|
|
135
|
+
return []
|
|
136
|
+
|
|
137
|
+
roots: list[str] = []
|
|
138
|
+
base_dir = manifest_path.parent
|
|
139
|
+
|
|
140
|
+
for _, ver in deps.items():
|
|
141
|
+
if not isinstance(ver, str):
|
|
142
|
+
continue
|
|
143
|
+
v = ver.strip()
|
|
144
|
+
if not v.startswith("file:"):
|
|
145
|
+
continue
|
|
146
|
+
suffix = v[len("file:") :].strip()
|
|
147
|
+
# Handle file:///abs/path or file:/abs/path
|
|
148
|
+
if suffix.startswith("///"):
|
|
149
|
+
candidate = Path("/" + suffix.lstrip("/"))
|
|
150
|
+
elif suffix.startswith("/"):
|
|
151
|
+
candidate = Path(suffix)
|
|
152
|
+
else:
|
|
153
|
+
candidate = (base_dir / suffix).resolve()
|
|
154
|
+
try:
|
|
155
|
+
if candidate.exists() and candidate.is_dir():
|
|
156
|
+
roots.append(str(candidate))
|
|
157
|
+
except OSError:
|
|
158
|
+
continue
|
|
159
|
+
|
|
160
|
+
# De-dupe, preserve order
|
|
161
|
+
deduped: list[str] = []
|
|
162
|
+
seen = set()
|
|
163
|
+
for r in roots:
|
|
164
|
+
if r not in seen:
|
|
165
|
+
seen.add(r)
|
|
166
|
+
deduped.append(r)
|
|
167
|
+
|
|
168
|
+
st.extra_roots = deduped
|
|
169
|
+
st.manifest_last_mtime_ns = mtime_ns
|
|
170
|
+
return [Path(p) for p in deduped if p]
|
|
171
|
+
|
|
172
|
+
def update_and_get(self, instance_id: str) -> dict[str, int | bool | None]:
|
|
173
|
+
"""
|
|
174
|
+
Returns a small dict suitable for embedding in editor_state_v2.assets:
|
|
175
|
+
- external_changes_dirty
|
|
176
|
+
- external_changes_last_seen_unix_ms
|
|
177
|
+
- dirty_since_unix_ms
|
|
178
|
+
- last_cleared_unix_ms
|
|
179
|
+
"""
|
|
180
|
+
st = self._get_state(instance_id)
|
|
181
|
+
|
|
182
|
+
if _in_pytest():
|
|
183
|
+
return {
|
|
184
|
+
"external_changes_dirty": st.dirty,
|
|
185
|
+
"external_changes_last_seen_unix_ms": st.external_changes_last_seen_unix_ms,
|
|
186
|
+
"dirty_since_unix_ms": st.dirty_since_unix_ms,
|
|
187
|
+
"last_cleared_unix_ms": st.last_cleared_unix_ms,
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
now = _now_unix_ms()
|
|
191
|
+
if st.last_scan_unix_ms is not None and (now - st.last_scan_unix_ms) < self._scan_interval_ms:
|
|
192
|
+
return {
|
|
193
|
+
"external_changes_dirty": st.dirty,
|
|
194
|
+
"external_changes_last_seen_unix_ms": st.external_changes_last_seen_unix_ms,
|
|
195
|
+
"dirty_since_unix_ms": st.dirty_since_unix_ms,
|
|
196
|
+
"last_cleared_unix_ms": st.last_cleared_unix_ms,
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
st.last_scan_unix_ms = now
|
|
200
|
+
|
|
201
|
+
project_root = st.project_root
|
|
202
|
+
if not project_root:
|
|
203
|
+
return {
|
|
204
|
+
"external_changes_dirty": st.dirty,
|
|
205
|
+
"external_changes_last_seen_unix_ms": st.external_changes_last_seen_unix_ms,
|
|
206
|
+
"dirty_since_unix_ms": st.dirty_since_unix_ms,
|
|
207
|
+
"last_cleared_unix_ms": st.last_cleared_unix_ms,
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
root = Path(project_root)
|
|
211
|
+
paths = [root / "Assets", root / "ProjectSettings", root / "Packages"]
|
|
212
|
+
# Include any local package roots referenced by file: deps in Packages/manifest.json
|
|
213
|
+
try:
|
|
214
|
+
paths.extend(self._resolve_manifest_extra_roots(root, st))
|
|
215
|
+
except Exception:
|
|
216
|
+
pass
|
|
217
|
+
newest = self._scan_paths_max_mtime_ns(paths)
|
|
218
|
+
if newest is None:
|
|
219
|
+
return {
|
|
220
|
+
"external_changes_dirty": st.dirty,
|
|
221
|
+
"external_changes_last_seen_unix_ms": st.external_changes_last_seen_unix_ms,
|
|
222
|
+
"dirty_since_unix_ms": st.dirty_since_unix_ms,
|
|
223
|
+
"last_cleared_unix_ms": st.last_cleared_unix_ms,
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if st.last_seen_mtime_ns is None:
|
|
227
|
+
st.last_seen_mtime_ns = newest
|
|
228
|
+
elif newest > st.last_seen_mtime_ns:
|
|
229
|
+
st.last_seen_mtime_ns = newest
|
|
230
|
+
st.external_changes_last_seen_unix_ms = now
|
|
231
|
+
if not st.dirty:
|
|
232
|
+
st.dirty = True
|
|
233
|
+
st.dirty_since_unix_ms = now
|
|
234
|
+
|
|
235
|
+
return {
|
|
236
|
+
"external_changes_dirty": st.dirty,
|
|
237
|
+
"external_changes_last_seen_unix_ms": st.external_changes_last_seen_unix_ms,
|
|
238
|
+
"dirty_since_unix_ms": st.dirty_since_unix_ms,
|
|
239
|
+
"last_cleared_unix_ms": st.last_cleared_unix_ms,
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
# Global singleton (simple, process-local)
|
|
244
|
+
external_changes_scanner = ExternalChangesScanner()
|
|
245
|
+
|
|
246
|
+
|