mcpforunityserver 8.6.0__tar.gz → 8.7.1__tar.gz

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.
Files changed (76) hide show
  1. {mcpforunityserver-8.6.0 → mcpforunityserver-8.7.1}/PKG-INFO +2 -2
  2. {mcpforunityserver-8.6.0 → mcpforunityserver-8.7.1}/README.md +1 -1
  3. {mcpforunityserver-8.6.0 → mcpforunityserver-8.7.1}/pyproject.toml +1 -1
  4. {mcpforunityserver-8.6.0 → mcpforunityserver-8.7.1}/src/mcpforunityserver.egg-info/PKG-INFO +2 -2
  5. {mcpforunityserver-8.6.0 → mcpforunityserver-8.7.1}/src/mcpforunityserver.egg-info/SOURCES.txt +5 -0
  6. mcpforunityserver-8.7.1/src/services/resources/editor_state_v2.py +270 -0
  7. mcpforunityserver-8.7.1/src/services/state/external_changes_scanner.py +246 -0
  8. {mcpforunityserver-8.6.0 → mcpforunityserver-8.7.1}/src/services/tools/manage_asset.py +7 -0
  9. {mcpforunityserver-8.6.0 → mcpforunityserver-8.7.1}/src/services/tools/manage_gameobject.py +5 -0
  10. {mcpforunityserver-8.6.0 → mcpforunityserver-8.7.1}/src/services/tools/manage_scene.py +4 -0
  11. mcpforunityserver-8.7.1/src/services/tools/preflight.py +107 -0
  12. {mcpforunityserver-8.6.0 → mcpforunityserver-8.7.1}/src/services/tools/read_console.py +25 -15
  13. mcpforunityserver-8.7.1/src/services/tools/refresh_unity.py +90 -0
  14. {mcpforunityserver-8.6.0 → mcpforunityserver-8.7.1}/src/services/tools/run_tests.py +22 -3
  15. mcpforunityserver-8.7.1/src/services/tools/test_jobs.py +94 -0
  16. {mcpforunityserver-8.6.0 → mcpforunityserver-8.7.1}/src/transport/legacy/unity_connection.py +101 -16
  17. {mcpforunityserver-8.6.0 → mcpforunityserver-8.7.1}/src/transport/plugin_hub.py +47 -11
  18. {mcpforunityserver-8.6.0 → mcpforunityserver-8.7.1}/LICENSE +0 -0
  19. {mcpforunityserver-8.6.0 → mcpforunityserver-8.7.1}/setup.cfg +0 -0
  20. {mcpforunityserver-8.6.0 → mcpforunityserver-8.7.1}/src/__init__.py +0 -0
  21. {mcpforunityserver-8.6.0 → mcpforunityserver-8.7.1}/src/core/__init__.py +0 -0
  22. {mcpforunityserver-8.6.0 → mcpforunityserver-8.7.1}/src/core/config.py +0 -0
  23. {mcpforunityserver-8.6.0 → mcpforunityserver-8.7.1}/src/core/logging_decorator.py +0 -0
  24. {mcpforunityserver-8.6.0 → mcpforunityserver-8.7.1}/src/core/telemetry.py +0 -0
  25. {mcpforunityserver-8.6.0 → mcpforunityserver-8.7.1}/src/core/telemetry_decorator.py +0 -0
  26. {mcpforunityserver-8.6.0 → mcpforunityserver-8.7.1}/src/main.py +0 -0
  27. {mcpforunityserver-8.6.0 → mcpforunityserver-8.7.1}/src/mcpforunityserver.egg-info/dependency_links.txt +0 -0
  28. {mcpforunityserver-8.6.0 → mcpforunityserver-8.7.1}/src/mcpforunityserver.egg-info/entry_points.txt +0 -0
  29. {mcpforunityserver-8.6.0 → mcpforunityserver-8.7.1}/src/mcpforunityserver.egg-info/requires.txt +0 -0
  30. {mcpforunityserver-8.6.0 → mcpforunityserver-8.7.1}/src/mcpforunityserver.egg-info/top_level.txt +0 -0
  31. {mcpforunityserver-8.6.0 → mcpforunityserver-8.7.1}/src/models/__init__.py +0 -0
  32. {mcpforunityserver-8.6.0 → mcpforunityserver-8.7.1}/src/models/models.py +0 -0
  33. {mcpforunityserver-8.6.0 → mcpforunityserver-8.7.1}/src/models/unity_response.py +0 -0
  34. {mcpforunityserver-8.6.0 → mcpforunityserver-8.7.1}/src/routes/__init__.py +0 -0
  35. {mcpforunityserver-8.6.0 → mcpforunityserver-8.7.1}/src/services/__init__.py +0 -0
  36. {mcpforunityserver-8.6.0 → mcpforunityserver-8.7.1}/src/services/custom_tool_service.py +0 -0
  37. {mcpforunityserver-8.6.0 → mcpforunityserver-8.7.1}/src/services/registry/__init__.py +0 -0
  38. {mcpforunityserver-8.6.0 → mcpforunityserver-8.7.1}/src/services/registry/resource_registry.py +0 -0
  39. {mcpforunityserver-8.6.0 → mcpforunityserver-8.7.1}/src/services/registry/tool_registry.py +0 -0
  40. {mcpforunityserver-8.6.0 → mcpforunityserver-8.7.1}/src/services/resources/__init__.py +0 -0
  41. {mcpforunityserver-8.6.0 → mcpforunityserver-8.7.1}/src/services/resources/active_tool.py +0 -0
  42. {mcpforunityserver-8.6.0 → mcpforunityserver-8.7.1}/src/services/resources/custom_tools.py +0 -0
  43. {mcpforunityserver-8.6.0 → mcpforunityserver-8.7.1}/src/services/resources/editor_state.py +0 -0
  44. {mcpforunityserver-8.6.0 → mcpforunityserver-8.7.1}/src/services/resources/layers.py +0 -0
  45. {mcpforunityserver-8.6.0 → mcpforunityserver-8.7.1}/src/services/resources/menu_items.py +0 -0
  46. {mcpforunityserver-8.6.0 → mcpforunityserver-8.7.1}/src/services/resources/prefab_stage.py +0 -0
  47. {mcpforunityserver-8.6.0 → mcpforunityserver-8.7.1}/src/services/resources/project_info.py +0 -0
  48. {mcpforunityserver-8.6.0 → mcpforunityserver-8.7.1}/src/services/resources/selection.py +0 -0
  49. {mcpforunityserver-8.6.0 → mcpforunityserver-8.7.1}/src/services/resources/tags.py +0 -0
  50. {mcpforunityserver-8.6.0 → mcpforunityserver-8.7.1}/src/services/resources/tests.py +0 -0
  51. {mcpforunityserver-8.6.0 → mcpforunityserver-8.7.1}/src/services/resources/unity_instances.py +0 -0
  52. {mcpforunityserver-8.6.0 → mcpforunityserver-8.7.1}/src/services/resources/windows.py +0 -0
  53. {mcpforunityserver-8.6.0 → mcpforunityserver-8.7.1}/src/services/tools/__init__.py +0 -0
  54. {mcpforunityserver-8.6.0 → mcpforunityserver-8.7.1}/src/services/tools/batch_execute.py +0 -0
  55. {mcpforunityserver-8.6.0 → mcpforunityserver-8.7.1}/src/services/tools/debug_request_context.py +0 -0
  56. {mcpforunityserver-8.6.0 → mcpforunityserver-8.7.1}/src/services/tools/execute_custom_tool.py +0 -0
  57. {mcpforunityserver-8.6.0 → mcpforunityserver-8.7.1}/src/services/tools/execute_menu_item.py +0 -0
  58. {mcpforunityserver-8.6.0 → mcpforunityserver-8.7.1}/src/services/tools/find_in_file.py +0 -0
  59. {mcpforunityserver-8.6.0 → mcpforunityserver-8.7.1}/src/services/tools/manage_editor.py +0 -0
  60. {mcpforunityserver-8.6.0 → mcpforunityserver-8.7.1}/src/services/tools/manage_material.py +0 -0
  61. {mcpforunityserver-8.6.0 → mcpforunityserver-8.7.1}/src/services/tools/manage_prefabs.py +0 -0
  62. {mcpforunityserver-8.6.0 → mcpforunityserver-8.7.1}/src/services/tools/manage_script.py +0 -0
  63. {mcpforunityserver-8.6.0 → mcpforunityserver-8.7.1}/src/services/tools/manage_scriptable_object.py +0 -0
  64. {mcpforunityserver-8.6.0 → mcpforunityserver-8.7.1}/src/services/tools/manage_shader.py +0 -0
  65. {mcpforunityserver-8.6.0 → mcpforunityserver-8.7.1}/src/services/tools/script_apply_edits.py +0 -0
  66. {mcpforunityserver-8.6.0 → mcpforunityserver-8.7.1}/src/services/tools/set_active_instance.py +0 -0
  67. {mcpforunityserver-8.6.0 → mcpforunityserver-8.7.1}/src/services/tools/utils.py +0 -0
  68. {mcpforunityserver-8.6.0 → mcpforunityserver-8.7.1}/src/transport/__init__.py +0 -0
  69. {mcpforunityserver-8.6.0 → mcpforunityserver-8.7.1}/src/transport/legacy/port_discovery.py +0 -0
  70. {mcpforunityserver-8.6.0 → mcpforunityserver-8.7.1}/src/transport/legacy/stdio_port_registry.py +0 -0
  71. {mcpforunityserver-8.6.0 → mcpforunityserver-8.7.1}/src/transport/models.py +0 -0
  72. {mcpforunityserver-8.6.0 → mcpforunityserver-8.7.1}/src/transport/plugin_registry.py +0 -0
  73. {mcpforunityserver-8.6.0 → mcpforunityserver-8.7.1}/src/transport/unity_instance_middleware.py +0 -0
  74. {mcpforunityserver-8.6.0 → mcpforunityserver-8.7.1}/src/transport/unity_transport.py +0 -0
  75. {mcpforunityserver-8.6.0 → mcpforunityserver-8.7.1}/src/utils/module_discovery.py +0 -0
  76. {mcpforunityserver-8.6.0 → mcpforunityserver-8.7.1}/src/utils/reload_sentinel.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mcpforunityserver
3
- Version: 8.6.0
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.6.0#subdirectory=Server",
111
+ "git+https://github.com/CoplayDev/unity-mcp@v8.7.1#subdirectory=Server",
112
112
  "mcp-for-unity",
113
113
  "--transport",
114
114
  "stdio"
@@ -69,7 +69,7 @@ Use this to run the latest released version from the repository. Change the vers
69
69
  "command": "uvx",
70
70
  "args": [
71
71
  "--from",
72
- "git+https://github.com/CoplayDev/unity-mcp@v8.6.0#subdirectory=Server",
72
+ "git+https://github.com/CoplayDev/unity-mcp@v8.7.1#subdirectory=Server",
73
73
  "mcp-for-unity",
74
74
  "--transport",
75
75
  "stdio"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "mcpforunityserver"
3
- version = "8.6.0"
3
+ version = "8.7.1"
4
4
  description = "MCP for Unity Server: A Unity package for Unity Editor integration via the Model Context Protocol (MCP)."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mcpforunityserver
3
- Version: 8.6.0
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.6.0#subdirectory=Server",
111
+ "git+https://github.com/CoplayDev/unity-mcp@v8.7.1#subdirectory=Server",
112
112
  "mcp-for-unity",
113
113
  "--transport",
114
114
  "stdio"
@@ -27,6 +27,7 @@ src/services/resources/__init__.py
27
27
  src/services/resources/active_tool.py
28
28
  src/services/resources/custom_tools.py
29
29
  src/services/resources/editor_state.py
30
+ src/services/resources/editor_state_v2.py
30
31
  src/services/resources/layers.py
31
32
  src/services/resources/menu_items.py
32
33
  src/services/resources/prefab_stage.py
@@ -36,6 +37,7 @@ src/services/resources/tags.py
36
37
  src/services/resources/tests.py
37
38
  src/services/resources/unity_instances.py
38
39
  src/services/resources/windows.py
40
+ src/services/state/external_changes_scanner.py
39
41
  src/services/tools/__init__.py
40
42
  src/services/tools/batch_execute.py
41
43
  src/services/tools/debug_request_context.py
@@ -51,10 +53,13 @@ src/services/tools/manage_scene.py
51
53
  src/services/tools/manage_script.py
52
54
  src/services/tools/manage_scriptable_object.py
53
55
  src/services/tools/manage_shader.py
56
+ src/services/tools/preflight.py
54
57
  src/services/tools/read_console.py
58
+ src/services/tools/refresh_unity.py
55
59
  src/services/tools/run_tests.py
56
60
  src/services/tools/script_apply_edits.py
57
61
  src/services/tools/set_active_instance.py
62
+ src/services/tools/test_jobs.py
58
63
  src/services/tools/utils.py
59
64
  src/transport/__init__.py
60
65
  src/transport/models.py
@@ -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
+
@@ -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,
@@ -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)