mcpforunityserver 8.7.1__py3-none-any.whl → 9.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. main.py +4 -3
  2. {mcpforunityserver-8.7.1.dist-info → mcpforunityserver-9.0.1.dist-info}/METADATA +2 -2
  3. mcpforunityserver-9.0.1.dist-info/RECORD +72 -0
  4. {mcpforunityserver-8.7.1.dist-info → mcpforunityserver-9.0.1.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.1.dist-info}/WHEEL +0 -0
  52. {mcpforunityserver-8.7.1.dist-info → mcpforunityserver-9.0.1.dist-info}/entry_points.txt +0 -0
  53. {mcpforunityserver-8.7.1.dist-info → mcpforunityserver-9.0.1.dist-info}/licenses/LICENSE +0 -0
services/tools/utils.py CHANGED
@@ -8,6 +8,7 @@ from typing import Any
8
8
  _TRUTHY = {"true", "1", "yes", "on"}
9
9
  _FALSY = {"false", "0", "no", "off"}
10
10
 
11
+
11
12
  def coerce_bool(value: Any, default: bool | None = None) -> bool | None:
12
13
  """Attempt to coerce a loosely-typed value to a boolean."""
13
14
  if value is None:
@@ -27,26 +28,26 @@ def coerce_bool(value: Any, default: bool | None = None) -> bool | None:
27
28
  def parse_json_payload(value: Any) -> Any:
28
29
  """
29
30
  Attempt to parse a value that might be a JSON string into its native object.
30
-
31
+
31
32
  This is a tolerant parser used to handle cases where MCP clients or LLMs
32
33
  serialize complex objects (lists, dicts) into strings. It also handles
33
34
  scalar values like numbers, booleans, and null.
34
-
35
+
35
36
  Args:
36
37
  value: The input value (can be str, list, dict, etc.)
37
-
38
+
38
39
  Returns:
39
40
  The parsed JSON object/list if the input was a valid JSON string,
40
41
  or the original value if parsing failed or wasn't necessary.
41
42
  """
42
43
  if not isinstance(value, str):
43
44
  return value
44
-
45
+
45
46
  val_trimmed = value.strip()
46
-
47
+
47
48
  # Fast path: if it doesn't look like JSON structure, return as is
48
49
  if not (
49
- (val_trimmed.startswith("{") and val_trimmed.endswith("}")) or
50
+ (val_trimmed.startswith("{") and val_trimmed.endswith("}")) or
50
51
  (val_trimmed.startswith("[") and val_trimmed.endswith("]")) or
51
52
  val_trimmed in ("true", "false", "null") or
52
53
  (val_trimmed.replace(".", "", 1).replace("-", "", 1).isdigit())
@@ -75,3 +76,56 @@ def coerce_int(value: Any, default: int | None = None) -> int | None:
75
76
  return int(float(s))
76
77
  except Exception:
77
78
  return default
79
+
80
+
81
+ def coerce_float(value: Any, default: float | None = None) -> float | None:
82
+ """Attempt to coerce a loosely-typed value to a float-like number."""
83
+ if value is None:
84
+ return default
85
+ try:
86
+ # Treat booleans as invalid numeric input instead of coercing to 0/1.
87
+ if isinstance(value, bool):
88
+ return default
89
+ if isinstance(value, (int, float)):
90
+ return float(value)
91
+ s = str(value).strip()
92
+ if s.lower() in ("", "none", "null"):
93
+ return default
94
+ return float(s)
95
+ except (TypeError, ValueError):
96
+ return default
97
+
98
+
99
+ def normalize_properties(value: Any) -> tuple[dict[str, Any] | None, str | None]:
100
+ """
101
+ Robustly normalize a properties parameter to a dict.
102
+
103
+ Handles various input formats from MCP clients/LLMs:
104
+ - None -> (None, None)
105
+ - dict -> (dict, None)
106
+ - JSON string -> (parsed_dict, None) or (None, error_message)
107
+ - Invalid values -> (None, error_message)
108
+
109
+ Returns:
110
+ Tuple of (parsed_dict, error_message). If error_message is set, parsed_dict is None.
111
+ """
112
+ if value is None:
113
+ return None, None
114
+
115
+ # Already a dict - return as-is
116
+ if isinstance(value, dict):
117
+ return value, None
118
+
119
+ # Try parsing as string
120
+ if isinstance(value, str):
121
+ # Check for obviously invalid values from serialization bugs
122
+ if value in ("[object Object]", "undefined", "null", ""):
123
+ return None, f"properties received invalid value: '{value}'. Expected a JSON object like {{\"key\": value}}"
124
+
125
+ parsed = parse_json_payload(value)
126
+ if isinstance(parsed, dict):
127
+ return parsed, None
128
+
129
+ return None, f"properties must be a JSON object (dict), got string that parsed to {type(parsed).__name__}"
130
+
131
+ return None, f"properties must be a dict or JSON string, got {type(value).__name__}"
@@ -279,9 +279,9 @@ class PortDiscovery:
279
279
  if freshness.tzinfo:
280
280
  from datetime import timezone
281
281
  now = datetime.now(timezone.utc)
282
-
282
+
283
283
  age_s = (now - freshness).total_seconds()
284
-
284
+
285
285
  if is_reloading and age_s < 60:
286
286
  pass # keep it, status="reloading"
287
287
  else:
@@ -584,7 +584,7 @@ class UnityConnectionPool:
584
584
  raise ConnectionError(
585
585
  f"Unity instance '{identifier}' not found. "
586
586
  f"Available instances: {available_ids}. "
587
- f"Check unity://instances resource for all instances."
587
+ f"Check mcpforunity://instances resource for all instances."
588
588
  )
589
589
 
590
590
  def get_connection(self, instance_identifier: str | None = None) -> UnityConnection:
transport/plugin_hub.py CHANGED
@@ -45,10 +45,14 @@ class PluginHub(WebSocketEndpoint):
45
45
  KEEP_ALIVE_INTERVAL = 15
46
46
  SERVER_TIMEOUT = 30
47
47
  COMMAND_TIMEOUT = 30
48
+ # Timeout (seconds) for fast-fail commands like ping/read_console/get_editor_state.
49
+ # Keep short so MCP clients aren't blocked during Unity compilation/reload/unfocused throttling.
50
+ FAST_FAIL_TIMEOUT = 2.0
48
51
  # Fast-path commands should never block the client for long; return a retry hint instead.
49
52
  # This helps avoid the Cursor-side ~30s tool-call timeout when Unity is compiling/reloading
50
53
  # or is throttled while unfocused.
51
- _FAST_FAIL_COMMANDS: set[str] = {"read_console", "get_editor_state", "ping"}
54
+ _FAST_FAIL_COMMANDS: set[str] = {
55
+ "read_console", "get_editor_state", "ping"}
52
56
 
53
57
  _registry: PluginRegistry | None = None
54
58
  _connections: dict[str, WebSocket] = {}
@@ -118,7 +122,8 @@ class PluginHub(WebSocketEndpoint):
118
122
  ]
119
123
  for command_id in pending_ids:
120
124
  entry = cls._pending.get(command_id)
121
- future = entry.get("future") if isinstance(entry, dict) else None
125
+ future = entry.get("future") if isinstance(
126
+ entry, dict) else None
122
127
  if future and not future.done():
123
128
  future.set_exception(
124
129
  PluginDisconnectedError(
@@ -140,18 +145,15 @@ class PluginHub(WebSocketEndpoint):
140
145
  future: asyncio.Future = asyncio.get_running_loop().create_future()
141
146
  # Compute a per-command timeout:
142
147
  # - fast-path commands: short timeout (encourage retry)
143
- # - long-running commands (e.g., run_tests): allow caller to request a longer timeout via params
148
+ # - long-running commands: allow caller to request a longer timeout via params
144
149
  unity_timeout_s = float(cls.COMMAND_TIMEOUT)
145
150
  server_wait_s = float(cls.COMMAND_TIMEOUT)
146
151
  if command_type in cls._FAST_FAIL_COMMANDS:
147
- try:
148
- fast_timeout = float(os.environ.get("UNITY_MCP_FAST_COMMAND_TIMEOUT", "3"))
149
- except Exception:
150
- fast_timeout = 3.0
152
+ fast_timeout = float(cls.FAST_FAIL_TIMEOUT)
151
153
  unity_timeout_s = fast_timeout
152
154
  server_wait_s = fast_timeout
153
155
  else:
154
- # Common tools pass a requested timeout in seconds (e.g., run_tests(timeout_seconds=900)).
156
+ # Common tools pass a requested timeout in seconds (e.g., timeout_seconds=900).
155
157
  requested = None
156
158
  try:
157
159
  if isinstance(params, dict):
@@ -180,7 +182,8 @@ class PluginHub(WebSocketEndpoint):
180
182
  if command_id in cls._pending:
181
183
  raise RuntimeError(
182
184
  f"Duplicate command id generated: {command_id}")
183
- cls._pending[command_id] = {"future": future, "session_id": session_id}
185
+ cls._pending[command_id] = {
186
+ "future": future, "session_id": session_id}
184
187
 
185
188
  try:
186
189
  msg = ExecuteCommandMessage(
@@ -370,7 +373,8 @@ class PluginHub(WebSocketEndpoint):
370
373
  max_wait_s = float(
371
374
  os.environ.get("UNITY_MCP_SESSION_RESOLVE_MAX_WAIT_S", "2.0"))
372
375
  except ValueError as e:
373
- raw_val = os.environ.get("UNITY_MCP_SESSION_RESOLVE_MAX_WAIT_S", "2.0")
376
+ raw_val = os.environ.get(
377
+ "UNITY_MCP_SESSION_RESOLVE_MAX_WAIT_S", "2.0")
374
378
  logger.warning(
375
379
  "Invalid UNITY_MCP_SESSION_RESOLVE_MAX_WAIT_S=%r, using default 2.0: %s",
376
380
  raw_val, e)
@@ -416,7 +420,7 @@ class PluginHub(WebSocketEndpoint):
416
420
  if not target_hash and session_count > 1:
417
421
  raise RuntimeError(
418
422
  "Multiple Unity instances are connected. "
419
- "Call set_active_instance with Name@hash from unity://instances."
423
+ "Call set_active_instance with Name@hash from mcpforunity://instances."
420
424
  )
421
425
  if wait_started is None:
422
426
  wait_started = time.monotonic()
@@ -437,7 +441,7 @@ class PluginHub(WebSocketEndpoint):
437
441
  if session_id is None and not target_hash and session_count > 1:
438
442
  raise RuntimeError(
439
443
  "Multiple Unity instances are connected. "
440
- "Call set_active_instance with Name@hash from unity://instances."
444
+ "Call set_active_instance with Name@hash from mcpforunity://instances."
441
445
  )
442
446
 
443
447
  if session_id is None:
@@ -448,7 +452,8 @@ class PluginHub(WebSocketEndpoint):
448
452
  )
449
453
  # At this point we've given the plugin ample time to reconnect; surface
450
454
  # a clear error so the client can prompt the user to open Unity.
451
- raise NoUnitySessionError("No Unity plugins are currently connected")
455
+ raise NoUnitySessionError(
456
+ "No Unity plugins are currently connected")
452
457
 
453
458
  return session_id
454
459
 
@@ -481,9 +486,11 @@ class PluginHub(WebSocketEndpoint):
481
486
  # register_tools (which can be delayed by EditorApplication.delayCall).
482
487
  if command_type in cls._FAST_FAIL_COMMANDS and command_type != "ping":
483
488
  try:
484
- max_wait_s = float(os.environ.get("UNITY_MCP_SESSION_READY_WAIT_SECONDS", "6"))
489
+ max_wait_s = float(os.environ.get(
490
+ "UNITY_MCP_SESSION_READY_WAIT_SECONDS", "6"))
485
491
  except ValueError as e:
486
- raw_val = os.environ.get("UNITY_MCP_SESSION_READY_WAIT_SECONDS", "6")
492
+ raw_val = os.environ.get(
493
+ "UNITY_MCP_SESSION_READY_WAIT_SECONDS", "6")
487
494
  logger.warning(
488
495
  "Invalid UNITY_MCP_SESSION_READY_WAIT_SECONDS=%r, using default 6.0: %s",
489
496
  raw_val, e)
@@ -499,7 +506,8 @@ class PluginHub(WebSocketEndpoint):
499
506
 
500
507
  # The Unity-side dispatcher responds with {status:"success", result:{message:"pong"}}
501
508
  if isinstance(probe, dict) and probe.get("status") == "success":
502
- result = probe.get("result") if isinstance(probe.get("result"), dict) else {}
509
+ result = probe.get("result") if isinstance(
510
+ probe.get("result"), dict) else {}
503
511
  if result.get("message") == "pong":
504
512
  break
505
513
  await asyncio.sleep(0.1)
@@ -27,7 +27,7 @@ def get_unity_instance_middleware() -> 'UnityInstanceMiddleware':
27
27
  if _unity_instance_middleware is None:
28
28
  # Auto-initialize if not set (lazy singleton) to handle import order or test cases
29
29
  _unity_instance_middleware = UnityInstanceMiddleware()
30
-
30
+
31
31
  return _unity_instance_middleware
32
32
 
33
33
 
@@ -102,7 +102,8 @@ class UnityInstanceMiddleware(Middleware):
102
102
  sessions = sessions_data.sessions or {}
103
103
  ids: list[str] = []
104
104
  for session_info in sessions.values():
105
- project = getattr(session_info, "project", None) or "Unknown"
105
+ project = getattr(
106
+ session_info, "project", None) or "Unknown"
106
107
  hash_value = getattr(session_info, "hash", None)
107
108
  if hash_value:
108
109
  ids.append(f"{project}@{hash_value}")
@@ -183,7 +184,7 @@ class UnityInstanceMiddleware(Middleware):
183
184
  # But for stdio transport (no PluginHub needed or maybe partially configured),
184
185
  # we should be careful not to clear instance just because PluginHub can't resolve it.
185
186
  # The 'active_instance' (Name@hash) might be valid for stdio even if PluginHub fails.
186
-
187
+
187
188
  session_id: str | None = None
188
189
  # Only validate via PluginHub if we are actually using HTTP transport
189
190
  # OR if we want to support hybrid mode. For now, let's be permissive.
@@ -105,7 +105,8 @@ async def send_with_unity_instance(
105
105
  # Fail fast with a retry hint instead of hanging for COMMAND_TIMEOUT.
106
106
  # The client can decide whether retrying is appropriate for the command.
107
107
  return normalize_unity_response(
108
- MCPResponse(success=False, error=err, hint="retry").model_dump()
108
+ MCPResponse(success=False, error=err,
109
+ hint="retry").model_dump()
109
110
  )
110
111
 
111
112
  if unity_instance:
@@ -1,71 +0,0 @@
1
- __init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
- main.py,sha256=ITxelXUAmr9BoasG0knZifXKp3lWJyml_RLaIwvEyVs,19184
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-8.7.1.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
- routes/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
13
- services/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
14
- services/custom_tool_service.py,sha256=ZOb6LsOQc3C9n3iLn5b7QaG3WJ25M-Id3WG_iE1YhMY,11960
15
- services/registry/__init__.py,sha256=QCwcYThvGF0kBt3WR6DBskdyxkegJC7NymEChgJA-YM,470
16
- services/registry/resource_registry.py,sha256=T_Kznqgvt5kKgV7mU85nb0LlFuB4rg-Tm4Cjhxt-IcI,1467
17
- services/registry/tool_registry.py,sha256=9tMwOP07JE92QFYUS4KvoysO0qC9pkBD5B79kjRsSPw,1304
18
- services/resources/__init__.py,sha256=O5heeMcgCswnQX1qG2nNtMeAZIaLut734qD7t5UsA0k,2801
19
- services/resources/active_tool.py,sha256=YTbsiy_hmnKH2q7IoM7oYD7pJkoveZTszRiL1PlhO9M,1474
20
- services/resources/custom_tools.py,sha256=8lyryGhN3vD2LwMt6ZyKIp5ONtxdI1nfcCAlYjlfQnQ,1704
21
- services/resources/editor_state.py,sha256=8hrNnskSFdsvdKagAYEeZGJ0Oz9QRlkWJjpM4q0XeNo,2013
22
- services/resources/editor_state_v2.py,sha256=zgss1EEhJo7oZeHnjOXsdJPuFQsHLwpsZmzcDy3ybq0,10874
23
- services/resources/layers.py,sha256=q4UQ5PUVUVhmM5l3oXID1wa_wOWAS8l5BGXadBgFuwY,1080
24
- services/resources/menu_items.py,sha256=9SNycjwTXoeS1ZHra0Y1fTyCjSEdPCo34JyxtuqauG8,1021
25
- services/resources/prefab_stage.py,sha256=C3mn3UapKYVOA8QUNmLsYreG5YiXdlvGm9ypHQeKBeQ,1382
26
- services/resources/project_info.py,sha256=gSVSFfwP0u2FmxSowOkdbNoSSQHxfQtLfndvoCXTVSw,1323
27
- services/resources/selection.py,sha256=4rI5Bdkes4uxtMc_5jQhUaqUl-iprhIiTWqnOJl8tmg,1839
28
- services/resources/tags.py,sha256=7EhmQjMotz85DSSr7cVKYIy7LPT5mmPfrEySr1mTE6w,1049
29
- services/resources/tests.py,sha256=xDvvgesPSU93nLD_ERQopOpkpq69pbMEqmFsJd0jekI,2063
30
- services/resources/unity_instances.py,sha256=fR0cVopGQnmF41IFDycwlo2XniKstfJWLGobgJeiabE,4348
31
- services/resources/windows.py,sha256=--QVsb0oyoBpSjK2D4kPcZFSe2zdR-t_KSHP-e2QNoY,1427
32
- services/state/external_changes_scanner.py,sha256=qwdiriHR1D11aPiLUbpS7COXtfVOjNj9DpzcSDK067o,9042
33
- services/tools/__init__.py,sha256=3Qav7fAowZ1_TbDRdZQQQES53gv2lTs-2D7PGECnlbM,2353
34
- services/tools/batch_execute.py,sha256=_ByjffeXQB9j64mcjaxJmrnbSJrMn0f9_6Zh9BBI_2c,2898
35
- services/tools/debug_request_context.py,sha256=WQBtQdXSH5stw2MAwIM32H6jGwUVQOgU2r35VUWLlYo,2765
36
- services/tools/execute_custom_tool.py,sha256=K2qaO4-FTPz0_3j53hhDP9idjC002ugc8C03FtHGTbY,1376
37
- services/tools/execute_menu_item.py,sha256=FAC-1v_TwOcy6GSxkogDsVxeRtdap0DsPlIngf8uJdU,1184
38
- services/tools/find_in_file.py,sha256=xp80lqRN2cdZc3XGJWlCpeQEy6WnwyKOj2l5WiHNx0Q,6379
39
- services/tools/manage_asset.py,sha256=6YjWOl2b58vRwjp-9XbQE9e1l3ajhGookVY8ncQN0wo,7877
40
- services/tools/manage_editor.py,sha256=_HZRT-_hBakH0g6p7BpxTv3gWpxsaV6KNGRol-qknwo,3243
41
- services/tools/manage_gameobject.py,sha256=OgFIsoPGiWHOj6-d3Lmtp3xlAW9Tr0c38tV4atAaFAU,14400
42
- services/tools/manage_material.py,sha256=wZB2H4orhL6wG9TTnmnk-Lj2Gj_zvg7koxW3t319BLU,3545
43
- services/tools/manage_prefabs.py,sha256=73XzznjFNOm1SazW_Y7l6uGIE7wosMpAIVQs8xpvK9A,3037
44
- services/tools/manage_scene.py,sha256=oJ1qDX0T06mINZ1hX2AcDp3ItHo-oUz7Uck0yujI9eA,4834
45
- services/tools/manage_script.py,sha256=lPA5HcS4Al0RiQVz-S6qahFTcPqsk3GSLLXJWHri8P4,27557
46
- services/tools/manage_scriptable_object.py,sha256=Oi03CJLgepaWR59V-nJiAjnCC8at4YqFhRGpACruqgw,3150
47
- services/tools/manage_shader.py,sha256=HHnHKh7vLij3p8FAinNsPdZGEKivgwSUTxdgDydfmbs,2882
48
- services/tools/preflight.py,sha256=VJn61h-9pvoVaCyKL7DTKLfbpoZfNK4fnRmj91c2o8M,4093
49
- services/tools/read_console.py,sha256=ELryGXGhZi56pqe4cSdrNDaZGBlUydQIeJ5q86fq_Uo,5201
50
- services/tools/refresh_unity.py,sha256=xlmqMeAxmYEa5l4OduYdDYWSKJPm2QoJg5CrxCJY8_A,3859
51
- services/tools/run_tests.py,sha256=8CqmgRN6Bata666ytF_S9no4gaFmHCmeZM82ZwNQJ68,4666
52
- services/tools/script_apply_edits.py,sha256=qPm_PsmsK3mYXnziX_btyk8CaB66LTqpDFA2Y4ebZ4U,47504
53
- services/tools/set_active_instance.py,sha256=B18Y8Jga0pKsx9mFywXr1tWfy0cJVopIMXYO-UJ1jOU,4136
54
- services/tools/test_jobs.py,sha256=K6HjkzWPjJNldrp-Vq5gPH7oBkCq_sJZYXkK_Vg6I_I,4059
55
- services/tools/utils.py,sha256=4ZgfIu178eXZqRyzs8X77B5lKLP1f73OZoGBSDNokJ4,2409
56
- transport/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
57
- transport/models.py,sha256=6wp7wsmSaeeJEvUGXPF1m6zuJnxJ1NJlCC4YZ9oQIq0,1226
58
- transport/plugin_hub.py,sha256=77xYWunNw8hHRJuimx3Qrxu9wqlKURpuunkUqkqx9kE,22845
59
- transport/plugin_registry.py,sha256=nW-7O7PN0QUgSWivZTkpAVKKq9ZOe2b2yeIdpaNt_3I,4359
60
- transport/unity_instance_middleware.py,sha256=kf1QeA138r7PaC98dcMDYtUPGWZ4EUmZGESc2DdiWQs,10429
61
- transport/unity_transport.py,sha256=_cFVgD3pzFZRcDXANq4oPFYSoI6jntSGaN22dJC8LRU,3880
62
- transport/legacy/port_discovery.py,sha256=qM_mtndbYjAj4qPSZEWVeXFOt5_nKczG9pQqORXTBJ0,12768
63
- transport/legacy/stdio_port_registry.py,sha256=j4iARuP6wetppNDG8qKeuvo1bJKcSlgEhZvSyl_uf0A,2313
64
- transport/legacy/unity_connection.py,sha256=psMwMDiUXKUSrpP4UO9Lgu5PH-Mu6QPY-r5o81WIkVg,35802
65
- utils/module_discovery.py,sha256=My48ofB1BUqxiBoAZAGbEaLQYdsrDhMm8MayBP_bUSQ,2005
66
- utils/reload_sentinel.py,sha256=s1xMWhl-r2XwN0OUbiUv_VGUy8TvLtV5bkql-5n2DT0,373
67
- mcpforunityserver-8.7.1.dist-info/METADATA,sha256=OzxsNyocEXg1K-ND-AesGUKXM8pQx_7CGUrW_XsKXMg,5712
68
- mcpforunityserver-8.7.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
69
- mcpforunityserver-8.7.1.dist-info/entry_points.txt,sha256=vCtkqw-J9t4pj7JwUZcB54_keVx7DpAR3fYzK6i-s6g,44
70
- mcpforunityserver-8.7.1.dist-info/top_level.txt,sha256=YKU5e5dREMfCnoVpmlsTm9bku7oqnrzSZ9FeTgjoxJw,58
71
- mcpforunityserver-8.7.1.dist-info/RECORD,,
routes/__init__.py DELETED
File without changes
@@ -1,270 +0,0 @@
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
-