mcpforunityserver 8.5.0__py3-none-any.whl → 9.1.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.
Files changed (79) hide show
  1. cli/__init__.py +3 -0
  2. cli/commands/__init__.py +3 -0
  3. cli/commands/animation.py +87 -0
  4. cli/commands/asset.py +310 -0
  5. cli/commands/audio.py +133 -0
  6. cli/commands/batch.py +184 -0
  7. cli/commands/code.py +189 -0
  8. cli/commands/component.py +212 -0
  9. cli/commands/editor.py +487 -0
  10. cli/commands/gameobject.py +510 -0
  11. cli/commands/instance.py +101 -0
  12. cli/commands/lighting.py +128 -0
  13. cli/commands/material.py +268 -0
  14. cli/commands/prefab.py +144 -0
  15. cli/commands/scene.py +255 -0
  16. cli/commands/script.py +240 -0
  17. cli/commands/shader.py +238 -0
  18. cli/commands/ui.py +263 -0
  19. cli/commands/vfx.py +439 -0
  20. cli/main.py +248 -0
  21. cli/utils/__init__.py +31 -0
  22. cli/utils/config.py +58 -0
  23. cli/utils/connection.py +191 -0
  24. cli/utils/output.py +195 -0
  25. main.py +207 -62
  26. {mcpforunityserver-8.5.0.dist-info → mcpforunityserver-9.1.0.dist-info}/METADATA +4 -2
  27. mcpforunityserver-9.1.0.dist-info/RECORD +96 -0
  28. {mcpforunityserver-8.5.0.dist-info → mcpforunityserver-9.1.0.dist-info}/WHEEL +1 -1
  29. {mcpforunityserver-8.5.0.dist-info → mcpforunityserver-9.1.0.dist-info}/entry_points.txt +1 -0
  30. {mcpforunityserver-8.5.0.dist-info → mcpforunityserver-9.1.0.dist-info}/top_level.txt +1 -2
  31. services/custom_tool_service.py +179 -19
  32. services/resources/__init__.py +6 -1
  33. services/resources/active_tool.py +1 -1
  34. services/resources/custom_tools.py +2 -2
  35. services/resources/editor_state.py +283 -21
  36. services/resources/gameobject.py +243 -0
  37. services/resources/layers.py +1 -1
  38. services/resources/prefab_stage.py +1 -1
  39. services/resources/project_info.py +1 -1
  40. services/resources/selection.py +1 -1
  41. services/resources/tags.py +1 -1
  42. services/resources/unity_instances.py +1 -1
  43. services/resources/windows.py +1 -1
  44. services/state/external_changes_scanner.py +245 -0
  45. services/tools/__init__.py +6 -1
  46. services/tools/batch_execute.py +24 -9
  47. services/tools/debug_request_context.py +8 -2
  48. services/tools/execute_custom_tool.py +6 -1
  49. services/tools/execute_menu_item.py +6 -3
  50. services/tools/find_gameobjects.py +89 -0
  51. services/tools/find_in_file.py +26 -19
  52. services/tools/manage_asset.py +19 -43
  53. services/tools/manage_components.py +131 -0
  54. services/tools/manage_editor.py +9 -8
  55. services/tools/manage_gameobject.py +120 -79
  56. services/tools/manage_material.py +80 -31
  57. services/tools/manage_prefabs.py +7 -1
  58. services/tools/manage_scene.py +34 -13
  59. services/tools/manage_script.py +62 -19
  60. services/tools/manage_scriptable_object.py +22 -10
  61. services/tools/manage_shader.py +8 -1
  62. services/tools/manage_vfx.py +738 -0
  63. services/tools/preflight.py +110 -0
  64. services/tools/read_console.py +81 -18
  65. services/tools/refresh_unity.py +153 -0
  66. services/tools/run_tests.py +202 -41
  67. services/tools/script_apply_edits.py +15 -7
  68. services/tools/set_active_instance.py +12 -7
  69. services/tools/utils.py +60 -6
  70. transport/legacy/port_discovery.py +2 -2
  71. transport/legacy/unity_connection.py +129 -26
  72. transport/plugin_hub.py +191 -19
  73. transport/unity_instance_middleware.py +93 -2
  74. transport/unity_transport.py +17 -6
  75. utils/focus_nudge.py +321 -0
  76. __init__.py +0 -0
  77. mcpforunityserver-8.5.0.dist-info/RECORD +0 -66
  78. routes/__init__.py +0 -0
  79. {mcpforunityserver-8.5.0.dist-info → mcpforunityserver-9.1.0.dist-info}/licenses/LICENSE +0 -0
@@ -1,21 +1,26 @@
1
1
  import asyncio
2
+ import inspect
2
3
  import logging
3
4
  import time
4
5
  from hashlib import sha256
5
6
  from typing import Optional
6
7
 
7
- from fastmcp import FastMCP
8
+ from fastmcp import Context, FastMCP
8
9
  from pydantic import BaseModel, Field, ValidationError
9
10
  from starlette.requests import Request
10
11
  from starlette.responses import JSONResponse
11
12
 
12
13
  from models.models import MCPResponse, ToolDefinitionModel, ToolParameterModel
14
+ from core.logging_decorator import log_execution
15
+ from core.telemetry_decorator import telemetry_tool
13
16
  from transport.unity_transport import send_with_unity_instance
14
17
  from transport.legacy.unity_connection import (
15
18
  async_send_command_with_retry,
16
19
  get_unity_connection_pool,
17
20
  )
18
21
  from transport.plugin_hub import PluginHub
22
+ from services.tools import get_unity_instance_from_context
23
+ from services.registry import get_registered_tools
19
24
 
20
25
  logger = logging.getLogger("mcp-for-unity-server")
21
26
 
@@ -39,11 +44,13 @@ class ToolRegistrationResponse(BaseModel):
39
44
  class CustomToolService:
40
45
  _instance: "CustomToolService | None" = None
41
46
 
42
- def __init__(self, mcp: FastMCP):
47
+ def __init__(self, mcp: FastMCP, project_scoped_tools: bool = True):
43
48
  CustomToolService._instance = self
44
49
  self._mcp = mcp
50
+ self._project_scoped_tools = project_scoped_tools
45
51
  self._project_tools: dict[str, dict[str, ToolDefinitionModel]] = {}
46
52
  self._hash_to_project: dict[str, str] = {}
53
+ self._global_tools: dict[str, ToolDefinitionModel] = {}
47
54
  self._register_http_routes()
48
55
 
49
56
  @classmethod
@@ -61,17 +68,8 @@ class CustomToolService:
61
68
  except ValidationError as exc:
62
69
  return JSONResponse({"success": False, "error": exc.errors()}, status_code=400)
63
70
 
64
- registered: list[str] = []
65
- replaced: list[str] = []
66
- for tool in payload.tools:
67
- if self._is_registered(payload.project_id, tool.name):
68
- replaced.append(tool.name)
69
- self._register_tool(payload.project_id, tool)
70
- registered.append(tool.name)
71
-
72
- if payload.project_hash:
73
- self._hash_to_project[payload.project_hash.lower(
74
- )] = payload.project_id
71
+ registered, replaced = self._register_project_tools(
72
+ payload.project_id, payload.tools, project_hash=payload.project_hash)
75
73
 
76
74
  message = f"Registered {len(registered)} tool(s)"
77
75
  if replaced:
@@ -266,15 +264,171 @@ class CustomToolService:
266
264
  return None
267
265
  return {"message": str(response)}
268
266
 
269
- def _safe_response(self, response):
270
- if isinstance(response, dict):
271
- return response
272
- if response is None:
267
+ def _register_project_tools(
268
+ self,
269
+ project_id: str,
270
+ tools: list[ToolDefinitionModel],
271
+ project_hash: str | None = None,
272
+ ) -> tuple[list[str], list[str]]:
273
+ registered: list[str] = []
274
+ replaced: list[str] = []
275
+ for tool in tools:
276
+ if self._is_registered(project_id, tool.name):
277
+ replaced.append(tool.name)
278
+ self._register_tool(project_id, tool)
279
+ registered.append(tool.name)
280
+ if not self._project_scoped_tools:
281
+ self._register_global_tool(tool)
282
+
283
+ if project_hash:
284
+ self._hash_to_project[project_hash.lower()] = project_id
285
+
286
+ return registered, replaced
287
+
288
+ def register_global_tools(self, tools: list[ToolDefinitionModel]) -> None:
289
+ if self._project_scoped_tools:
290
+ return
291
+ builtin_names = self._get_builtin_tool_names()
292
+ for tool in tools:
293
+ if tool.name in builtin_names:
294
+ logger.info(
295
+ "Skipping global custom tool registration for built-in tool '%s'",
296
+ tool.name,
297
+ )
298
+ continue
299
+ self._register_global_tool(tool)
300
+
301
+ def _get_builtin_tool_names(self) -> set[str]:
302
+ return {tool["name"] for tool in get_registered_tools()}
303
+
304
+ def _register_global_tool(self, definition: ToolDefinitionModel) -> None:
305
+ existing = self._global_tools.get(definition.name)
306
+ if existing:
307
+ if existing.model_dump() != definition.model_dump():
308
+ logger.warning(
309
+ "Custom tool '%s' already registered with a different schema; keeping existing definition.",
310
+ definition.name,
311
+ )
312
+ return
313
+
314
+ handler = self._build_global_tool_handler(definition)
315
+ wrapped = log_execution(definition.name, "Tool")(handler)
316
+ wrapped = telemetry_tool(definition.name)(wrapped)
317
+
318
+ try:
319
+ wrapped = self._mcp.tool(
320
+ name=definition.name,
321
+ description=definition.description,
322
+ )(wrapped)
323
+ except Exception as exc: # pragma: no cover - defensive against tool conflicts
324
+ logger.warning(
325
+ "Failed to register custom tool '%s' globally: %s",
326
+ definition.name,
327
+ exc,
328
+ )
329
+ return
330
+
331
+ self._global_tools[definition.name] = definition
332
+
333
+ def _build_global_tool_handler(self, definition: ToolDefinitionModel):
334
+ async def _handler(ctx: Context, **kwargs) -> MCPResponse:
335
+ unity_instance = get_unity_instance_from_context(ctx)
336
+ if not unity_instance:
337
+ return MCPResponse(
338
+ success=False,
339
+ message="No active Unity instance. Call set_active_instance with Name@hash from mcpforunity://instances.",
340
+ )
341
+
342
+ project_id = resolve_project_id_for_unity_instance(unity_instance)
343
+ if project_id is None:
344
+ return MCPResponse(
345
+ success=False,
346
+ message=f"Could not resolve project id for {unity_instance}. Ensure Unity is running and reachable.",
347
+ )
348
+
349
+ params = {k: v for k, v in kwargs.items() if v is not None}
350
+ service = CustomToolService.get_instance()
351
+ return await service.execute_tool(project_id, definition.name, unity_instance, params)
352
+
353
+ _handler.__name__ = f"custom_tool_{definition.name}"
354
+ _handler.__doc__ = definition.description or ""
355
+ _handler.__signature__ = self._build_signature(definition)
356
+ _handler.__annotations__ = self._build_annotations(definition)
357
+ return _handler
358
+
359
+ def _build_signature(self, definition: ToolDefinitionModel) -> inspect.Signature:
360
+ params: list[inspect.Parameter] = [
361
+ inspect.Parameter(
362
+ "ctx",
363
+ inspect.Parameter.POSITIONAL_OR_KEYWORD,
364
+ annotation=Context,
365
+ )
366
+ ]
367
+ for param in definition.parameters:
368
+ if not param.name.isidentifier():
369
+ logger.warning(
370
+ "Custom tool '%s' has non-identifier parameter '%s'; exposing via kwargs only.",
371
+ definition.name,
372
+ param.name,
373
+ )
374
+ continue
375
+ default = inspect._empty if param.required else self._coerce_default(
376
+ param.default_value, param.type)
377
+ params.append(
378
+ inspect.Parameter(
379
+ param.name,
380
+ inspect.Parameter.POSITIONAL_OR_KEYWORD,
381
+ default=default,
382
+ annotation=self._map_param_type(param),
383
+ )
384
+ )
385
+ return inspect.Signature(parameters=params)
386
+
387
+ def _build_annotations(self, definition: ToolDefinitionModel) -> dict[str, object]:
388
+ annotations: dict[str, object] = {"ctx": Context}
389
+ for param in definition.parameters:
390
+ if not param.name.isidentifier():
391
+ continue
392
+ annotations[param.name] = self._map_param_type(param)
393
+ return annotations
394
+
395
+ def _map_param_type(self, param: ToolParameterModel):
396
+ ptype = (param.type or "string").lower()
397
+ if ptype in ("integer", "int"):
398
+ return int
399
+ if ptype in ("number", "float", "double"):
400
+ return float
401
+ if ptype in ("bool", "boolean"):
402
+ return bool
403
+ if ptype in ("array", "list"):
404
+ return list
405
+ if ptype in ("object", "dict"):
406
+ return dict
407
+ return str
408
+
409
+ def _coerce_default(self, value: str | None, param_type: str | None):
410
+ if value is None:
273
411
  return None
274
- return {"message": str(response)}
412
+ try:
413
+ ptype = (param_type or "string").lower()
414
+ if ptype in ("integer", "int"):
415
+ return int(value)
416
+ if ptype in ("number", "float", "double"):
417
+ return float(value)
418
+ if ptype in ("bool", "boolean"):
419
+ return str(value).lower() in ("1", "true", "yes", "on")
420
+ return value
421
+ except Exception:
422
+ return value
275
423
 
276
424
 
277
425
  def compute_project_id(project_name: str, project_path: str) -> str:
426
+ """
427
+ DEPRECATED: Computes a SHA256-based project ID.
428
+ This function is no longer used as of the multi-session fix.
429
+ Unity instances now use their native project_hash (SHA1-based) for consistency
430
+ across stdio and WebSocket transports.
431
+ """
278
432
  combined = f"{project_name}:{project_path}"
279
433
  return sha256(combined.encode("utf-8")).hexdigest().upper()[:16]
280
434
 
@@ -307,7 +461,13 @@ def resolve_project_id_for_unity_instance(unity_instance: str | None) -> str | N
307
461
  )
308
462
 
309
463
  if target:
310
- return compute_project_id(target.name, target.path)
464
+ # Return the project_hash from Unity (not a computed SHA256 hash).
465
+ # This matches the hash Unity uses when registering tools via WebSocket.
466
+ if target.hash:
467
+ return target.hash
468
+ logger.warning(
469
+ f"Unity instance {target.id} has empty hash; cannot resolve project ID")
470
+ return None
311
471
  except Exception:
312
472
  logger.debug(
313
473
  f"Failed to resolve project id via connection pool for {unity_instance}")
@@ -18,7 +18,7 @@ logger = logging.getLogger("mcp-for-unity-server")
18
18
  __all__ = ['register_all_resources']
19
19
 
20
20
 
21
- def register_all_resources(mcp: FastMCP):
21
+ def register_all_resources(mcp: FastMCP, *, project_scoped_tools: bool = True):
22
22
  """
23
23
  Auto-discover and register all resources in the resources/ directory.
24
24
 
@@ -46,6 +46,11 @@ def register_all_resources(mcp: FastMCP):
46
46
  description = resource_info['description']
47
47
  kwargs = resource_info['kwargs']
48
48
 
49
+ if not project_scoped_tools and resource_name == "custom_tools":
50
+ logger.info(
51
+ "Skipping custom_tools resource registration (project-scoped tools disabled)")
52
+ continue
53
+
49
54
  # Check if URI contains query parameters (e.g., {?unity_instance})
50
55
  has_query_params = '{?' in uri
51
56
 
@@ -31,7 +31,7 @@ class ActiveToolResponse(MCPResponse):
31
31
 
32
32
 
33
33
  @mcp_for_unity_resource(
34
- uri="unity://editor/active-tool",
34
+ uri="mcpforunity://editor/active-tool",
35
35
  name="editor_active_tool",
36
36
  description="Currently active editor tool (Move, Rotate, Scale, etc.) and transform handle settings."
37
37
  )
@@ -22,7 +22,7 @@ class CustomToolsResourceResponse(MCPResponse):
22
22
 
23
23
 
24
24
  @mcp_for_unity_resource(
25
- uri="unity://custom-tools",
25
+ uri="mcpforunity://custom-tools",
26
26
  name="custom_tools",
27
27
  description="Lists custom tools available for the active Unity project.",
28
28
  )
@@ -31,7 +31,7 @@ async def get_custom_tools(ctx: Context) -> CustomToolsResourceResponse | MCPRes
31
31
  if not unity_instance:
32
32
  return MCPResponse(
33
33
  success=False,
34
- message="No active Unity instance. Call set_active_instance with Name@hash from unity://instances.",
34
+ message="No active Unity instance. Call set_active_instance with Name@hash from mcpforunity://instances.",
35
35
  )
36
36
 
37
37
  project_id = resolve_project_id_for_unity_instance(unity_instance)
@@ -1,42 +1,304 @@
1
- from pydantic import BaseModel
1
+ import os
2
+ import time
3
+ from typing import Any
4
+
2
5
  from fastmcp import Context
6
+ from pydantic import BaseModel
3
7
 
4
8
  from models import MCPResponse
5
9
  from services.registry import mcp_for_unity_resource
6
10
  from services.tools import get_unity_instance_from_context
7
- from transport.unity_transport import send_with_unity_instance
11
+ from services.state.external_changes_scanner import external_changes_scanner
12
+ import transport.unity_transport as unity_transport
8
13
  from transport.legacy.unity_connection import async_send_command_with_retry
9
14
 
10
15
 
16
+ class EditorStateUnity(BaseModel):
17
+ instance_id: str | None = None
18
+ unity_version: str | None = None
19
+ project_id: str | None = None
20
+ platform: str | None = None
21
+ is_batch_mode: bool | None = None
22
+
23
+
24
+ class EditorStatePlayMode(BaseModel):
25
+ is_playing: bool | None = None
26
+ is_paused: bool | None = None
27
+ is_changing: bool | None = None
28
+
29
+
30
+ class EditorStateActiveScene(BaseModel):
31
+ path: str | None = None
32
+ guid: str | None = None
33
+ name: str | None = None
34
+
35
+
36
+ class EditorStateEditor(BaseModel):
37
+ is_focused: bool | None = None
38
+ play_mode: EditorStatePlayMode | None = None
39
+ active_scene: EditorStateActiveScene | None = None
40
+
41
+
42
+ class EditorStateActivity(BaseModel):
43
+ phase: str | None = None
44
+ since_unix_ms: int | None = None
45
+ reasons: list[str] | None = None
46
+
47
+
48
+ class EditorStateCompilation(BaseModel):
49
+ is_compiling: bool | None = None
50
+ is_domain_reload_pending: bool | None = None
51
+ last_compile_started_unix_ms: int | None = None
52
+ last_compile_finished_unix_ms: int | None = None
53
+ last_domain_reload_before_unix_ms: int | None = None
54
+ last_domain_reload_after_unix_ms: int | None = None
55
+
56
+
57
+ class EditorStateRefresh(BaseModel):
58
+ is_refresh_in_progress: bool | None = None
59
+ last_refresh_requested_unix_ms: int | None = None
60
+ last_refresh_finished_unix_ms: int | None = None
61
+
62
+
63
+ class EditorStateAssets(BaseModel):
64
+ is_updating: bool | None = None
65
+ external_changes_dirty: bool | None = None
66
+ external_changes_last_seen_unix_ms: int | None = None
67
+ external_changes_dirty_since_unix_ms: int | None = None
68
+ external_changes_last_cleared_unix_ms: int | None = None
69
+ refresh: EditorStateRefresh | None = None
70
+
71
+
72
+ class EditorStateLastRun(BaseModel):
73
+ finished_unix_ms: int | None = None
74
+ result: str | None = None
75
+ counts: Any | None = None
76
+
77
+
78
+ class EditorStateTests(BaseModel):
79
+ is_running: bool | None = None
80
+ mode: str | None = None
81
+ current_job_id: str | None = None
82
+ started_unix_ms: int | None = None
83
+ started_by: str | None = None
84
+ last_run: EditorStateLastRun | None = None
85
+
86
+
87
+ class EditorStateTransport(BaseModel):
88
+ unity_bridge_connected: bool | None = None
89
+ last_message_unix_ms: int | None = None
90
+
91
+
92
+ class EditorStateAdvice(BaseModel):
93
+ ready_for_tools: bool | None = None
94
+ blocking_reasons: list[str] | None = None
95
+ recommended_retry_after_ms: int | None = None
96
+ recommended_next_action: str | None = None
97
+
98
+
99
+ class EditorStateStaleness(BaseModel):
100
+ age_ms: int | None = None
101
+ is_stale: bool | None = None
102
+
103
+
11
104
  class EditorStateData(BaseModel):
12
- """Editor state data fields."""
13
- isPlaying: bool = False
14
- isPaused: bool = False
15
- isCompiling: bool = False
16
- isUpdating: bool = False
17
- timeSinceStartup: float = 0.0
18
- activeSceneName: str = ""
19
- selectionCount: int = 0
20
- activeObjectName: str | None = None
105
+ schema_version: str
106
+ observed_at_unix_ms: int
107
+ sequence: int
108
+ unity: EditorStateUnity | None = None
109
+ editor: EditorStateEditor | None = None
110
+ activity: EditorStateActivity | None = None
111
+ compilation: EditorStateCompilation | None = None
112
+ assets: EditorStateAssets | None = None
113
+ tests: EditorStateTests | None = None
114
+ transport: EditorStateTransport | None = None
115
+ advice: EditorStateAdvice | None = None
116
+ staleness: EditorStateStaleness | None = None
117
+
118
+
119
+ def _now_unix_ms() -> int:
120
+ return int(time.time() * 1000)
121
+
122
+
123
+ def _in_pytest() -> bool:
124
+ # Avoid instance-discovery side effects during the Python integration test suite.
125
+ return bool(os.environ.get("PYTEST_CURRENT_TEST"))
126
+
127
+
128
+ async def infer_single_instance_id(ctx: Context) -> str | None:
129
+ """
130
+ Best-effort: if exactly one Unity instance is connected, return its Name@hash id.
131
+ This makes editor_state outputs self-describing even when no explicit active instance is set.
132
+ """
133
+ await ctx.info("If exactly one Unity instance is connected, return its Name@hash id.")
134
+
135
+ try:
136
+ transport = unity_transport._current_transport()
137
+ except Exception:
138
+ transport = None
139
+
140
+ if transport == "http":
141
+ # HTTP/WebSocket transport: derive from PluginHub sessions.
142
+ try:
143
+ from transport.plugin_hub import PluginHub
144
+
145
+ sessions_data = await PluginHub.get_sessions()
146
+ sessions = sessions_data.sessions if hasattr(
147
+ sessions_data, "sessions") else {}
148
+ if isinstance(sessions, dict) and len(sessions) == 1:
149
+ session = next(iter(sessions.values()))
150
+ project = getattr(session, "project", None)
151
+ project_hash = getattr(session, "hash", None)
152
+ if project and project_hash:
153
+ return f"{project}@{project_hash}"
154
+ except Exception:
155
+ return None
156
+ return None
157
+
158
+ # Stdio/TCP transport: derive from connection pool discovery.
159
+ try:
160
+ from transport.legacy.unity_connection import get_unity_connection_pool
161
+
162
+ pool = get_unity_connection_pool()
163
+ instances = pool.discover_all_instances(force_refresh=False)
164
+ if isinstance(instances, list) and len(instances) == 1:
165
+ inst = instances[0]
166
+ inst_id = getattr(inst, "id", None)
167
+ return str(inst_id) if inst_id else None
168
+ except Exception:
169
+ return None
170
+ return None
171
+
172
+
173
+ def _enrich_advice_and_staleness(state_v2: dict[str, Any]) -> dict[str, Any]:
174
+ now_ms = _now_unix_ms()
175
+ observed = state_v2.get("observed_at_unix_ms")
176
+ try:
177
+ observed_ms = int(observed)
178
+ except Exception:
179
+ observed_ms = now_ms
21
180
 
181
+ age_ms = max(0, now_ms - observed_ms)
182
+ # Conservative default: treat >2s as stale (covers common unfocused-editor throttling).
183
+ is_stale = age_ms > 2000
22
184
 
23
- class EditorStateResponse(MCPResponse):
24
- """Dynamic editor state information that changes frequently."""
25
- data: EditorStateData = EditorStateData()
185
+ compilation = state_v2.get("compilation") or {}
186
+ tests = state_v2.get("tests") or {}
187
+ assets = state_v2.get("assets") or {}
188
+ refresh = (assets.get("refresh") or {}) if isinstance(assets, dict) else {}
189
+
190
+ blocking: list[str] = []
191
+ if compilation.get("is_compiling") is True:
192
+ blocking.append("compiling")
193
+ if compilation.get("is_domain_reload_pending") is True:
194
+ blocking.append("domain_reload")
195
+ if tests.get("is_running") is True:
196
+ blocking.append("running_tests")
197
+ if refresh.get("is_refresh_in_progress") is True:
198
+ blocking.append("asset_refresh")
199
+ if is_stale:
200
+ blocking.append("stale_status")
201
+
202
+ ready_for_tools = len(blocking) == 0
203
+
204
+ state_v2["advice"] = {
205
+ "ready_for_tools": ready_for_tools,
206
+ "blocking_reasons": blocking,
207
+ "recommended_retry_after_ms": 0 if ready_for_tools else 500,
208
+ "recommended_next_action": "none" if ready_for_tools else "retry_later",
209
+ }
210
+ state_v2["staleness"] = {"age_ms": age_ms, "is_stale": is_stale}
211
+ return state_v2
26
212
 
27
213
 
28
214
  @mcp_for_unity_resource(
29
- uri="unity://editor/state",
215
+ uri="mcpforunity://editor/state",
30
216
  name="editor_state",
31
- description="Current editor runtime state including play mode, compilation status, active scene, and selection summary. Refresh frequently for up-to-date information."
217
+ description="Canonical editor readiness snapshot. Includes advice and server-computed staleness.",
32
218
  )
33
- async def get_editor_state(ctx: Context) -> EditorStateResponse | MCPResponse:
34
- """Get current editor runtime state."""
219
+ async def get_editor_state(ctx: Context) -> MCPResponse:
35
220
  unity_instance = get_unity_instance_from_context(ctx)
36
- response = await send_with_unity_instance(
221
+
222
+ response = await unity_transport.send_with_unity_instance(
37
223
  async_send_command_with_retry,
38
224
  unity_instance,
39
225
  "get_editor_state",
40
- {}
226
+ {},
41
227
  )
42
- return EditorStateResponse(**response) if isinstance(response, dict) else response
228
+
229
+ # If Unity returns a structured retry hint or error, surface it directly.
230
+ if isinstance(response, dict) and not response.get("success", True):
231
+ return MCPResponse(**response)
232
+
233
+ state_v2 = response.get("data") if isinstance(
234
+ response, dict) and isinstance(response.get("data"), dict) else {}
235
+ state_v2.setdefault("schema_version", "unity-mcp/editor_state@2")
236
+ state_v2.setdefault("observed_at_unix_ms", _now_unix_ms())
237
+ state_v2.setdefault("sequence", 0)
238
+
239
+ # Ensure the returned snapshot is clearly associated with the targeted instance.
240
+ unity_section = state_v2.get("unity")
241
+ if not isinstance(unity_section, dict):
242
+ unity_section = {}
243
+ state_v2["unity"] = unity_section
244
+ current_instance_id = unity_section.get("instance_id")
245
+ if current_instance_id in (None, ""):
246
+ if unity_instance:
247
+ unity_section["instance_id"] = unity_instance
248
+ else:
249
+ inferred = await infer_single_instance_id(ctx)
250
+ if inferred:
251
+ unity_section["instance_id"] = inferred
252
+
253
+ # External change detection (server-side): compute per instance based on project root path.
254
+ try:
255
+ instance_id = unity_section.get("instance_id")
256
+ if isinstance(instance_id, str) and instance_id.strip():
257
+ from services.resources.project_info import get_project_info
258
+
259
+ proj_resp = await get_project_info(ctx)
260
+ proj = proj_resp.model_dump() if hasattr(
261
+ proj_resp, "model_dump") else proj_resp
262
+ proj_data = proj.get("data") if isinstance(proj, dict) else None
263
+ project_root = proj_data.get("projectRoot") if isinstance(
264
+ proj_data, dict) else None
265
+ if isinstance(project_root, str) and project_root.strip():
266
+ external_changes_scanner.set_project_root(
267
+ instance_id, project_root)
268
+
269
+ ext = external_changes_scanner.update_and_get(instance_id)
270
+
271
+ assets = state_v2.get("assets")
272
+ if not isinstance(assets, dict):
273
+ assets = {}
274
+ state_v2["assets"] = assets
275
+ assets["external_changes_dirty"] = bool(
276
+ ext.get("external_changes_dirty", False))
277
+ assets["external_changes_last_seen_unix_ms"] = ext.get(
278
+ "external_changes_last_seen_unix_ms")
279
+ assets["external_changes_dirty_since_unix_ms"] = ext.get(
280
+ "dirty_since_unix_ms")
281
+ assets["external_changes_last_cleared_unix_ms"] = ext.get(
282
+ "last_cleared_unix_ms")
283
+ except Exception:
284
+ pass
285
+
286
+ state_v2 = _enrich_advice_and_staleness(state_v2)
287
+
288
+ try:
289
+ if hasattr(EditorStateData, "model_validate"):
290
+ validated = EditorStateData.model_validate(state_v2)
291
+ else:
292
+ validated = EditorStateData.parse_obj(
293
+ state_v2) # type: ignore[attr-defined]
294
+ data = validated.model_dump() if hasattr(
295
+ validated, "model_dump") else validated.dict()
296
+ except Exception as e:
297
+ return MCPResponse(
298
+ success=False,
299
+ error="invalid_editor_state",
300
+ message=f"Editor state payload failed validation: {e}",
301
+ data={"raw": state_v2},
302
+ )
303
+
304
+ return MCPResponse(success=True, message="Retrieved editor state.", data=data)