mcpforunityserver 8.6.0__py3-none-any.whl → 8.7.1__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.
- {mcpforunityserver-8.6.0.dist-info → mcpforunityserver-8.7.1.dist-info}/METADATA +2 -2
- {mcpforunityserver-8.6.0.dist-info → mcpforunityserver-8.7.1.dist-info}/RECORD +18 -13
- 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 +25 -15
- services/tools/refresh_unity.py +90 -0
- services/tools/run_tests.py +22 -3
- services/tools/test_jobs.py +94 -0
- transport/legacy/unity_connection.py +101 -16
- transport/plugin_hub.py +47 -11
- {mcpforunityserver-8.6.0.dist-info → mcpforunityserver-8.7.1.dist-info}/WHEEL +0 -0
- {mcpforunityserver-8.6.0.dist-info → mcpforunityserver-8.7.1.dist-info}/entry_points.txt +0 -0
- {mcpforunityserver-8.6.0.dist-info → mcpforunityserver-8.7.1.dist-info}/licenses/LICENSE +0 -0
- {mcpforunityserver-8.6.0.dist-info → mcpforunityserver-8.7.1.dist-info}/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mcpforunityserver
|
|
3
|
-
Version: 8.
|
|
3
|
+
Version: 8.7.1
|
|
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.1#subdirectory=Server",
|
|
112
112
|
"mcp-for-unity",
|
|
113
113
|
"--transport",
|
|
114
114
|
"stdio"
|
|
@@ -5,7 +5,7 @@ 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.1.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
|
|
@@ -19,6 +19,7 @@ services/resources/__init__.py,sha256=O5heeMcgCswnQX1qG2nNtMeAZIaLut734qD7t5UsA0
|
|
|
19
19
|
services/resources/active_tool.py,sha256=YTbsiy_hmnKH2q7IoM7oYD7pJkoveZTszRiL1PlhO9M,1474
|
|
20
20
|
services/resources/custom_tools.py,sha256=8lyryGhN3vD2LwMt6ZyKIp5ONtxdI1nfcCAlYjlfQnQ,1704
|
|
21
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=ELryGXGhZi56pqe4cSdrNDaZGBlUydQIeJ5q86fq_Uo,5201
|
|
50
|
+
services/tools/refresh_unity.py,sha256=xlmqMeAxmYEa5l4OduYdDYWSKJPm2QoJg5CrxCJY8_A,3859
|
|
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=77xYWunNw8hHRJuimx3Qrxu9wqlKURpuunkUqkqx9kE,22845
|
|
54
59
|
transport/plugin_registry.py,sha256=nW-7O7PN0QUgSWivZTkpAVKKq9ZOe2b2yeIdpaNt_3I,4359
|
|
55
60
|
transport/unity_instance_middleware.py,sha256=kf1QeA138r7PaC98dcMDYtUPGWZ4EUmZGESc2DdiWQs,10429
|
|
56
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
|
-
transport/legacy/unity_connection.py,sha256=
|
|
64
|
+
transport/legacy/unity_connection.py,sha256=psMwMDiUXKUSrpP4UO9Lgu5PH-Mu6QPY-r5o81WIkVg,35802
|
|
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.1.dist-info/METADATA,sha256=OzxsNyocEXg1K-ND-AesGUKXM8pQx_7CGUrW_XsKXMg,5712
|
|
68
|
+
mcpforunityserver-8.7.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
69
|
+
mcpforunityserver-8.7.1.dist-info/entry_points.txt,sha256=vCtkqw-J9t4pj7JwUZcB54_keVx7DpAR3fYzK6i-s6g,44
|
|
70
|
+
mcpforunityserver-8.7.1.dist-info/top_level.txt,sha256=YKU5e5dREMfCnoVpmlsTm9bku7oqnrzSZ9FeTgjoxJw,58
|
|
71
|
+
mcpforunityserver-8.7.1.dist-info/RECORD,,
|
|
@@ -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
|
+
|
services/tools/manage_asset.py
CHANGED
|
@@ -12,6 +12,7 @@ from services.tools import get_unity_instance_from_context
|
|
|
12
12
|
from services.tools.utils import parse_json_payload, coerce_int
|
|
13
13
|
from transport.unity_transport import send_with_unity_instance
|
|
14
14
|
from transport.legacy.unity_connection import async_send_command_with_retry
|
|
15
|
+
from services.tools.preflight import preflight
|
|
15
16
|
|
|
16
17
|
|
|
17
18
|
@mcp_for_unity_tool(
|
|
@@ -47,6 +48,12 @@ async def manage_asset(
|
|
|
47
48
|
) -> dict[str, Any]:
|
|
48
49
|
unity_instance = get_unity_instance_from_context(ctx)
|
|
49
50
|
|
|
51
|
+
# Best-effort guard: if Unity is compiling/reloading or known external changes are pending,
|
|
52
|
+
# wait/refresh to avoid stale reads and flaky timeouts.
|
|
53
|
+
gate = await preflight(ctx, wait_for_no_compile=True, refresh_if_dirty=True)
|
|
54
|
+
if gate is not None:
|
|
55
|
+
return gate.model_dump()
|
|
56
|
+
|
|
50
57
|
def _parse_properties_string(raw: str) -> tuple[dict[str, Any] | None, str | None]:
|
|
51
58
|
try:
|
|
52
59
|
parsed = json.loads(raw)
|
|
@@ -8,6 +8,7 @@ from services.tools import get_unity_instance_from_context
|
|
|
8
8
|
from transport.unity_transport import send_with_unity_instance
|
|
9
9
|
from transport.legacy.unity_connection import async_send_command_with_retry
|
|
10
10
|
from services.tools.utils import coerce_bool, parse_json_payload, coerce_int
|
|
11
|
+
from services.tools.preflight import preflight
|
|
11
12
|
|
|
12
13
|
|
|
13
14
|
@mcp_for_unity_tool(
|
|
@@ -92,6 +93,10 @@ async def manage_gameobject(
|
|
|
92
93
|
# Removed session_state import
|
|
93
94
|
unity_instance = get_unity_instance_from_context(ctx)
|
|
94
95
|
|
|
96
|
+
gate = await preflight(ctx, wait_for_no_compile=True, refresh_if_dirty=True)
|
|
97
|
+
if gate is not None:
|
|
98
|
+
return gate.model_dump()
|
|
99
|
+
|
|
95
100
|
if action is None:
|
|
96
101
|
return {
|
|
97
102
|
"success": False,
|
services/tools/manage_scene.py
CHANGED
|
@@ -6,6 +6,7 @@ from services.tools import get_unity_instance_from_context
|
|
|
6
6
|
from services.tools.utils import coerce_int, coerce_bool
|
|
7
7
|
from transport.unity_transport import send_with_unity_instance
|
|
8
8
|
from transport.legacy.unity_connection import async_send_command_with_retry
|
|
9
|
+
from services.tools.preflight import preflight
|
|
9
10
|
|
|
10
11
|
|
|
11
12
|
@mcp_for_unity_tool(
|
|
@@ -40,6 +41,9 @@ async def manage_scene(
|
|
|
40
41
|
# Get active instance from session state
|
|
41
42
|
# Removed session_state import
|
|
42
43
|
unity_instance = get_unity_instance_from_context(ctx)
|
|
44
|
+
gate = await preflight(ctx, wait_for_no_compile=True, refresh_if_dirty=True)
|
|
45
|
+
if gate is not None:
|
|
46
|
+
return gate.model_dump()
|
|
43
47
|
try:
|
|
44
48
|
coerced_build_index = coerce_int(build_index, default=None)
|
|
45
49
|
coerced_super_size = coerce_int(screenshot_super_size, default=None)
|