mcpforunityserver 8.7.1__py3-none-any.whl → 9.0.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 (53) hide show
  1. main.py +4 -3
  2. {mcpforunityserver-8.7.1.dist-info → mcpforunityserver-9.0.0.dist-info}/METADATA +2 -2
  3. mcpforunityserver-9.0.0.dist-info/RECORD +72 -0
  4. {mcpforunityserver-8.7.1.dist-info → mcpforunityserver-9.0.0.dist-info}/top_level.txt +0 -1
  5. services/custom_tool_service.py +13 -8
  6. services/resources/active_tool.py +1 -1
  7. services/resources/custom_tools.py +2 -2
  8. services/resources/editor_state.py +283 -30
  9. services/resources/gameobject.py +243 -0
  10. services/resources/layers.py +1 -1
  11. services/resources/prefab_stage.py +1 -1
  12. services/resources/project_info.py +1 -1
  13. services/resources/selection.py +1 -1
  14. services/resources/tags.py +1 -1
  15. services/resources/unity_instances.py +1 -1
  16. services/resources/windows.py +1 -1
  17. services/state/external_changes_scanner.py +3 -4
  18. services/tools/batch_execute.py +24 -9
  19. services/tools/debug_request_context.py +8 -2
  20. services/tools/execute_custom_tool.py +6 -1
  21. services/tools/execute_menu_item.py +6 -3
  22. services/tools/find_gameobjects.py +89 -0
  23. services/tools/find_in_file.py +26 -19
  24. services/tools/manage_asset.py +13 -44
  25. services/tools/manage_components.py +131 -0
  26. services/tools/manage_editor.py +9 -8
  27. services/tools/manage_gameobject.py +115 -79
  28. services/tools/manage_material.py +80 -31
  29. services/tools/manage_prefabs.py +7 -1
  30. services/tools/manage_scene.py +30 -13
  31. services/tools/manage_script.py +62 -19
  32. services/tools/manage_scriptable_object.py +22 -10
  33. services/tools/manage_shader.py +8 -1
  34. services/tools/manage_vfx.py +738 -0
  35. services/tools/preflight.py +15 -12
  36. services/tools/read_console.py +11 -4
  37. services/tools/refresh_unity.py +24 -14
  38. services/tools/run_tests.py +162 -53
  39. services/tools/script_apply_edits.py +15 -7
  40. services/tools/set_active_instance.py +12 -7
  41. services/tools/utils.py +60 -6
  42. transport/legacy/port_discovery.py +2 -2
  43. transport/legacy/unity_connection.py +1 -1
  44. transport/plugin_hub.py +24 -16
  45. transport/unity_instance_middleware.py +4 -3
  46. transport/unity_transport.py +2 -1
  47. mcpforunityserver-8.7.1.dist-info/RECORD +0 -71
  48. routes/__init__.py +0 -0
  49. services/resources/editor_state_v2.py +0 -270
  50. services/tools/test_jobs.py +0 -94
  51. {mcpforunityserver-8.7.1.dist-info → mcpforunityserver-9.0.0.dist-info}/WHEEL +0 -0
  52. {mcpforunityserver-8.7.1.dist-info → mcpforunityserver-9.0.0.dist-info}/entry_points.txt +0 -0
  53. {mcpforunityserver-8.7.1.dist-info → mcpforunityserver-9.0.0.dist-info}/licenses/LICENSE +0 -0
main.py CHANGED
@@ -30,7 +30,8 @@ try: # pragma: no cover - startup safety guard
30
30
  )
31
31
  for _name in _typing_names:
32
32
  if not hasattr(builtins, _name) and hasattr(_typing, _name):
33
- setattr(builtins, _name, getattr(_typing, _name)) # type: ignore[attr-defined]
33
+ # type: ignore[attr-defined]
34
+ setattr(builtins, _name, getattr(_typing, _name))
34
35
  except Exception:
35
36
  pass
36
37
 
@@ -234,10 +235,10 @@ mcp = FastMCP(
234
235
  instructions="""
235
236
  This server provides tools to interact with the Unity Game Engine Editor.
236
237
 
237
- I have a dynamic tool system. Always check the unity://custom-tools resource first to see what special capabilities are available for the current project.
238
+ I have a dynamic tool system. Always check the mcpforunity://custom-tools resource first to see what special capabilities are available for the current project.
238
239
 
239
240
  Targeting Unity instances:
240
- - Use the resource unity://instances to list active Unity sessions (Name@hash).
241
+ - Use the resource mcpforunity://instances to list active Unity sessions (Name@hash).
241
242
  - When multiple instances are connected, call set_active_instance with the exact Name@hash before using tools/resources. The server will error if multiple are connected and no active instance is set.
242
243
 
243
244
  Important Workflows:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mcpforunityserver
3
- Version: 8.7.1
3
+ Version: 9.0.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.7.1#subdirectory=Server",
111
+ "git+https://github.com/CoplayDev/unity-mcp@v9.0.0#subdirectory=Server",
112
112
  "mcp-for-unity",
113
113
  "--transport",
114
114
  "stdio"
@@ -0,0 +1,72 @@
1
+ __init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ main.py,sha256=2JiIIoWXnhEhEOg-YN-KWNG-tB_uGscR1U7dK_hQO88,19207
3
+ core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ core/config.py,sha256=czkTtNji1crQcQbUvmdx4OL7f-RBqkVhj_PtHh-w7rs,1623
5
+ core/logging_decorator.py,sha256=D9CD7rFvQz-MBG-G4inizQj0Ivr6dfc9RBmTrw7q8mI,1383
6
+ core/telemetry.py,sha256=eHjYgzd8f7eTwSwF2Kbi8D4TtJIcdaDjKLeo1c-0hVA,19829
7
+ core/telemetry_decorator.py,sha256=ycSTrzVNCDQHSd-xmIWOpVfKFURPxpiZe_XkOQAGDAo,6705
8
+ mcpforunityserver-9.0.0.dist-info/licenses/LICENSE,sha256=bv5lDJZQEqxBgjjc1rkRbkEwpSIHF-8N-1Od0VnEJFw,1066
9
+ models/__init__.py,sha256=JlscZkGWE9TRmSoBi99v_LSl8OAFNGmr8463PYkXin4,179
10
+ models/models.py,sha256=heXuvdBtdats1SGwW8wKFFHM0qR4hA6A7qETn5s9BZ0,1827
11
+ models/unity_response.py,sha256=oJ1PTsnNc5VBC-9OgM59C0C-R9N-GdmEdmz_yph4GSU,1454
12
+ services/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
13
+ services/custom_tool_service.py,sha256=VYqKcP0BZLTo9SGyNMtoLhFbzRlF8oCeMjeNdTScJiU,12320
14
+ services/registry/__init__.py,sha256=QCwcYThvGF0kBt3WR6DBskdyxkegJC7NymEChgJA-YM,470
15
+ services/registry/resource_registry.py,sha256=T_Kznqgvt5kKgV7mU85nb0LlFuB4rg-Tm4Cjhxt-IcI,1467
16
+ services/registry/tool_registry.py,sha256=9tMwOP07JE92QFYUS4KvoysO0qC9pkBD5B79kjRsSPw,1304
17
+ services/resources/__init__.py,sha256=O5heeMcgCswnQX1qG2nNtMeAZIaLut734qD7t5UsA0k,2801
18
+ services/resources/active_tool.py,sha256=zDuWRK1uz853TrMNv0w8vhZVOxemDPoI4QAkXSIezN8,1480
19
+ services/resources/custom_tools.py,sha256=3t0mKAL9PkJbv8S4DpRFU8D-NlRWkCd2geO6QnlQo7I,1716
20
+ services/resources/editor_state.py,sha256=pQdcsWGcKV7-6icpcVXtFD35CHUXodANc0jXkljVdLs,10823
21
+ services/resources/gameobject.py,sha256=RM28kfsV208zdTy-549U2_nwSPiAHYo6SqXy22k4tC8,9116
22
+ services/resources/layers.py,sha256=wE-mSgZsknGrXKu-0Cppv6NeijszD7beFf88dizT0ZI,1086
23
+ services/resources/menu_items.py,sha256=9SNycjwTXoeS1ZHra0Y1fTyCjSEdPCo34JyxtuqauG8,1021
24
+ services/resources/prefab_stage.py,sha256=RyVskG-P9lb4szbsTDhPpyDMb0ptLskr0BnoYJylhw0,1388
25
+ services/resources/project_info.py,sha256=ggiUj9rJUvIddxorKu9yqJiHTWOnxyywkjjsKXhIyqA,1329
26
+ services/resources/selection.py,sha256=MALwKkM9xsKing2bALNVTVLWzDTE_b26EVbnVUGZivU,1845
27
+ services/resources/tags.py,sha256=IKZWiZhBO_HkJqFXqBvWeIcMxhGN_QXkonzuAEFsEfg,1055
28
+ services/resources/tests.py,sha256=xDvvgesPSU93nLD_ERQopOpkpq69pbMEqmFsJd0jekI,2063
29
+ services/resources/unity_instances.py,sha256=XRR5YCDe8v_FXG45VlSdEPaqu7Qlbnm4NYIRzK5brjc,4354
30
+ services/resources/windows.py,sha256=FyzPEtEmfKiXYh1lviemZ7-bFyjkAR61_seSTXQA9rk,1433
31
+ services/state/external_changes_scanner.py,sha256=ZiXu8ZcK5B-hv7CaJLmnEIa9JxzgOBpdmrsRDY2eK5I,9052
32
+ services/tools/__init__.py,sha256=3Qav7fAowZ1_TbDRdZQQQES53gv2lTs-2D7PGECnlbM,2353
33
+ services/tools/batch_execute.py,sha256=hjh67kgWvQDHyGd2N-Tfezv9WAj5x_pWTt_Vybmmq7s,3501
34
+ services/tools/debug_request_context.py,sha256=Duq5xiuSmRO5GdvWAlZhCfOfmrwvK7gGkRC4wYnXmXk,2907
35
+ services/tools/execute_custom_tool.py,sha256=hiZbm2A9t84f92jitzvkE2G4CMOIUiDVm7u5B8K-RbU,1527
36
+ services/tools/execute_menu_item.py,sha256=k4J89LlXmEGyo9z3NK8Q0vREIzr11ucF_9tN_JeQq9M,1248
37
+ services/tools/find_gameobjects.py,sha256=Qpfd_oQG0fluz8S1CfriGh1FmLnZ080-ZEZOrAsij8U,3602
38
+ services/tools/find_in_file.py,sha256=SxhMeo8lRrt0OiGApGZSFUnq671bxVfK8qgAsHxLua8,6493
39
+ services/tools/manage_asset.py,sha256=St_iWQWg9icztnRthU78t6JNhJN0AlC6ELiZhn-SNZU,5990
40
+ services/tools/manage_components.py,sha256=Z74QWrOdJyvQBRqIk3ZNYhUxyyH-R0Er1cEkt-70Hcg,5025
41
+ services/tools/manage_editor.py,sha256=ShvlSBQRfoNQ0DvqBWak_Hi3MB7tv2WkMKEhrKQipk0,3279
42
+ services/tools/manage_gameobject.py,sha256=fOuaYoQAZ1OgCnfSMZboqISRUr6bIuaBcG6XV-iVN_M,15092
43
+ services/tools/manage_material.py,sha256=0i5gsTXkahzq_5qEByXvsMHxMpWuMBJH3HzbEr8GQns,5520
44
+ services/tools/manage_prefabs.py,sha256=5waHRvxbdVA9N60tvf9m1f9NTRPspyxZOh4GZfuCfII,3179
45
+ services/tools/manage_scene.py,sha256=-ARtRuj7ZNk_14lmMSORnQs0qTAYKBTPtUfk0sNDo6A,5370
46
+ services/tools/manage_script.py,sha256=MzPw0xXjtbdjEyjvUfLem9fa3GVE-WGvCr4WEVfW9Cs,28461
47
+ services/tools/manage_scriptable_object.py,sha256=tezG_mbGzPLNpL3F7l5JJLyyjJN3rJi1thGMU8cpOC4,3659
48
+ services/tools/manage_shader.py,sha256=bucRKzQww7opy6DK5nf6isVaEECWWqJ-DVkFulp8CV8,3185
49
+ services/tools/manage_vfx.py,sha256=eeqf4xUYw_yT2rALIGHrHLJCpemx9H__S3zCjj_GZsI,34054
50
+ services/tools/preflight.py,sha256=0nvo0BmZMdIGop1Ha_vypkjn2VLiRvskF0uxh_SlZgE,4162
51
+ services/tools/read_console.py,sha256=k1brS1yR-wyMgdhL6TPL-j4KJCD0CKSWrHYiTh-gj9Y,5452
52
+ services/tools/refresh_unity.py,sha256=IcShwasfveDGxXQ3YdbaQP3ICCt8e8O_q_NsnDa8glw,4054
53
+ services/tools/run_tests.py,sha256=9M9noRsZWjqcCfUWo5XVVtGNggxg5HpPvmkobs2lu-A,8082
54
+ services/tools/script_apply_edits.py,sha256=0f-SaP5NUYGuivl4CWHjR8F-CXUpt3-5qkHpf_edn1U,47677
55
+ services/tools/set_active_instance.py,sha256=pdmC1SxFijyzzjeEyC2N1bXk-GNMu_iXsbCieIpa-R4,4242
56
+ services/tools/utils.py,sha256=uk--6w_-O0eVAxczackXbgKde2ONmsgci43G3wY7dfA,4258
57
+ transport/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
58
+ transport/models.py,sha256=6wp7wsmSaeeJEvUGXPF1m6zuJnxJ1NJlCC4YZ9oQIq0,1226
59
+ transport/plugin_hub.py,sha256=ku8CdK6kdxUrRMd9zjUn7fFARx8rMZ2FWD7zWGuN2Ys,23083
60
+ transport/plugin_registry.py,sha256=nW-7O7PN0QUgSWivZTkpAVKKq9ZOe2b2yeIdpaNt_3I,4359
61
+ transport/unity_instance_middleware.py,sha256=DD8gs-peMRmRJz9CYwaHEh4m75LTYPDjVuKuw9sArBw,10438
62
+ transport/unity_transport.py,sha256=G6aMC1qR31YZOBZs4fxQbSQBHuXBP1d5Qn0MJaB3yGs,3908
63
+ transport/legacy/port_discovery.py,sha256=JDSCqXLodfTT7fOsE0DFC1jJ3QsU6hVaYQb7x7FgdxY,12728
64
+ transport/legacy/stdio_port_registry.py,sha256=j4iARuP6wetppNDG8qKeuvo1bJKcSlgEhZvSyl_uf0A,2313
65
+ transport/legacy/unity_connection.py,sha256=UZ6tztBO9VlXNiV0jN66k5QMrtTIGAOdGxwtcLnkXLU,35808
66
+ utils/module_discovery.py,sha256=My48ofB1BUqxiBoAZAGbEaLQYdsrDhMm8MayBP_bUSQ,2005
67
+ utils/reload_sentinel.py,sha256=s1xMWhl-r2XwN0OUbiUv_VGUy8TvLtV5bkql-5n2DT0,373
68
+ mcpforunityserver-9.0.0.dist-info/METADATA,sha256=Nj5uKhcyIwsnrAdL6wX_EVE6lUbF6iyv1y4cHADM24k,5712
69
+ mcpforunityserver-9.0.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
70
+ mcpforunityserver-9.0.0.dist-info/entry_points.txt,sha256=vCtkqw-J9t4pj7JwUZcB54_keVx7DpAR3fYzK6i-s6g,44
71
+ mcpforunityserver-9.0.0.dist-info/top_level.txt,sha256=UYGWDnyTlnS7PnuZNw8-gM_jWcdmcHwffK_2yBRl6Cc,51
72
+ mcpforunityserver-9.0.0.dist-info/RECORD,,
@@ -2,7 +2,6 @@ __init__
2
2
  core
3
3
  main
4
4
  models
5
- routes
6
5
  services
7
6
  transport
8
7
  utils
@@ -266,15 +266,14 @@ class CustomToolService:
266
266
  return None
267
267
  return {"message": str(response)}
268
268
 
269
- def _safe_response(self, response):
270
- if isinstance(response, dict):
271
- return response
272
- if response is None:
273
- return None
274
- return {"message": str(response)}
275
-
276
269
 
277
270
  def compute_project_id(project_name: str, project_path: str) -> str:
271
+ """
272
+ DEPRECATED: Computes a SHA256-based project ID.
273
+ This function is no longer used as of the multi-session fix.
274
+ Unity instances now use their native project_hash (SHA1-based) for consistency
275
+ across stdio and WebSocket transports.
276
+ """
278
277
  combined = f"{project_name}:{project_path}"
279
278
  return sha256(combined.encode("utf-8")).hexdigest().upper()[:16]
280
279
 
@@ -307,7 +306,13 @@ def resolve_project_id_for_unity_instance(unity_instance: str | None) -> str | N
307
306
  )
308
307
 
309
308
  if target:
310
- return compute_project_id(target.name, target.path)
309
+ # Return the project_hash from Unity (not a computed SHA256 hash).
310
+ # This matches the hash Unity uses when registering tools via WebSocket.
311
+ if target.hash:
312
+ return target.hash
313
+ logger.warning(
314
+ f"Unity instance {target.id} has empty hash; cannot resolve project ID")
315
+ return None
311
316
  except Exception:
312
317
  logger.debug(
313
318
  f"Failed to resolve project id via connection pool for {unity_instance}")
@@ -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,51 +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
- # 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
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)