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 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.5.0
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.5.0#subdirectory=Server",
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=OH_Ux5Aj43q1lXPu4AS8zafRnFHZWFYCDZbswReQOac,18011
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.5.0.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,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=Kpqr82cmXH7wxXub3O0D8whksORSqn9nDRjskDe_A_w,7534
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=kYIouvt-iNUEsY0VIWp4FqagLjo7Up2TwKDhB4Nfxmo,14213
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=3BhIsbbtGiMNqBMQMqEsB4ajYmtx-VwWl-krOkFR_Bw,4648
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/read_console.py,sha256=gZWEf0Ru0hvN9oJUZqZ4w-mMBBLm5Z5KAUPv282XbYQ,4091
47
- services/tools/run_tests.py,sha256=LBVwGasLvmF4k1FiX3DdBQ8udh89WZJFiVHfJRWGvOs,3313
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=55R00ohrmUI0mk_smc_8BsYTvrQMPX4wwsvqXprj0Vk,15596
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=a-ULWU9b86w0CbYN3meyLxWGxTBXL5CQmBKZmmQ0xZQ,6197
56
- transport/unity_transport.py,sha256=dvwCjo2jRvnFXd8ruOL36C8W4P1VIQ91qreS2750lPM,3307
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.5.0.dist-info/METADATA,sha256=3YWJc7I-EKxcLzMgG0MweNSJm3QR_VYCNTTwwB45UNE,5712
63
- mcpforunityserver-8.5.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
64
- mcpforunityserver-8.5.0.dist-info/entry_points.txt,sha256=vCtkqw-J9t4pj7JwUZcB54_keVx7DpAR3fYzK6i-s6g,44
65
- mcpforunityserver-8.5.0.dist-info/top_level.txt,sha256=YKU5e5dREMfCnoVpmlsTm9bku7oqnrzSZ9FeTgjoxJw,58
66
- mcpforunityserver-8.5.0.dist-info/RECORD,,
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
+
@@ -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
+