mcpforunityserver 9.0.8__py3-none-any.whl → 9.2.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 (38) 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 +174 -60
  26. {mcpforunityserver-9.0.8.dist-info → mcpforunityserver-9.2.0.dist-info}/METADATA +3 -2
  27. {mcpforunityserver-9.0.8.dist-info → mcpforunityserver-9.2.0.dist-info}/RECORD +37 -14
  28. {mcpforunityserver-9.0.8.dist-info → mcpforunityserver-9.2.0.dist-info}/WHEEL +1 -1
  29. {mcpforunityserver-9.0.8.dist-info → mcpforunityserver-9.2.0.dist-info}/entry_points.txt +1 -0
  30. {mcpforunityserver-9.0.8.dist-info → mcpforunityserver-9.2.0.dist-info}/top_level.txt +1 -1
  31. services/custom_tool_service.py +168 -13
  32. services/resources/__init__.py +6 -1
  33. services/tools/__init__.py +6 -1
  34. services/tools/refresh_unity.py +66 -16
  35. transport/legacy/unity_connection.py +26 -8
  36. transport/plugin_hub.py +17 -0
  37. __init__.py +0 -0
  38. {mcpforunityserver-9.0.8.dist-info → mcpforunityserver-9.2.0.dist-info}/licenses/LICENSE +0 -0
@@ -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
 
@@ -20,7 +20,7 @@ __all__ = [
20
20
  ]
21
21
 
22
22
 
23
- def register_all_tools(mcp: FastMCP):
23
+ def register_all_tools(mcp: FastMCP, *, project_scoped_tools: bool = True):
24
24
  """
25
25
  Auto-discover and register all tools in the tools/ directory.
26
26
 
@@ -46,6 +46,11 @@ def register_all_tools(mcp: FastMCP):
46
46
  description = tool_info['description']
47
47
  kwargs = tool_info['kwargs']
48
48
 
49
+ if not project_scoped_tools and tool_name == "execute_custom_tool":
50
+ logger.info(
51
+ "Skipping execute_custom_tool registration (project-scoped tools disabled)")
52
+ continue
53
+
49
54
  # Apply the @mcp.tool decorator, telemetry, and logging
50
55
  wrapped = log_execution(tool_name, "Tool")(func)
51
56
  wrapped = telemetry_tool(tool_name)(wrapped)
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import asyncio
4
+ import logging
4
5
  import time
5
6
  from typing import Annotated, Any, Literal
6
7
 
@@ -15,6 +16,8 @@ from transport.legacy.unity_connection import async_send_command_with_retry, _ex
15
16
  from services.state.external_changes_scanner import external_changes_scanner
16
17
  import services.resources.editor_state as editor_state
17
18
 
19
+ logger = logging.getLogger(__name__)
20
+
18
21
 
19
22
  @mcp_for_unity_tool(
20
23
  description="Request a Unity asset database refresh and optionally a script compilation. Can optionally wait for readiness.",
@@ -43,36 +46,65 @@ async def refresh_unity(
43
46
  }
44
47
 
45
48
  recovered_from_disconnect = False
49
+ # Don't retry on reload - refresh_unity triggers compilation/reload,
50
+ # so retrying would cause multiple reloads (issue #577)
46
51
  response = await unity_transport.send_with_unity_instance(
47
52
  async_send_command_with_retry,
48
53
  unity_instance,
49
54
  "refresh_unity",
50
55
  params,
56
+ retry_on_reload=False,
51
57
  )
52
58
 
53
- # Option A: treat disconnects / retry hints as recoverable when wait_for_ready is true.
54
- # Unity can legitimately disconnect during refresh/compile/domain reload, so callers should not
55
- # interpret that as a hard failure (#503-style loops).
56
- if isinstance(response, dict) and not response.get("success", True):
57
- hint = response.get("hint")
58
- err = (response.get("error") or response.get("message") or "").lower()
59
- reason = _extract_response_reason(response)
60
- is_retryable = (
61
- hint == "retry"
59
+ # Handle connection errors during refresh/compile gracefully.
60
+ # Unity disconnects during domain reload, which is expected behavior - not a failure.
61
+ # If we sent the command and connection closed, the refresh was likely triggered successfully.
62
+ # Convert MCPResponse to dict if needed
63
+ response_dict = response if isinstance(response, dict) else (response.model_dump() if hasattr(response, "model_dump") else response.__dict__)
64
+ if not response_dict.get("success", True):
65
+ hint = response_dict.get("hint")
66
+ err = (response_dict.get("error") or response_dict.get("message") or "").lower()
67
+ reason = _extract_response_reason(response_dict)
68
+
69
+ # Connection closed/timeout during compile = refresh was triggered, Unity is reloading
70
+ # This is SUCCESS, not failure - don't return error to prevent Claude Code from retrying
71
+ is_connection_lost = (
72
+ "connection closed" in err
62
73
  or "disconnected" in err
63
- or "could not connect" in err # Connection failed during domain reload
74
+ or "aborted" in err # WinError 10053: connection aborted
75
+ or "timeout" in err
76
+ or reason == "reloading"
64
77
  )
65
- if (not wait_for_ready) or (not is_retryable):
66
- return MCPResponse(**response)
67
- if reason not in {"reloading", "no_unity_session"}:
78
+
79
+ if is_connection_lost and compile == "request":
80
+ # EXPECTED BEHAVIOR: When compile="request", Unity triggers domain reload which
81
+ # causes connection to close mid-command. This is NOT a failure - the refresh
82
+ # was successfully triggered. Treating this as success prevents Claude Code from
83
+ # retrying unnecessarily (which would cause multiple domain reloads - issue #577).
84
+ # The subsequent wait_for_ready loop (below) will verify Unity becomes ready.
85
+ logger.info("refresh_unity: Connection lost during compile (expected - domain reload triggered)")
68
86
  recovered_from_disconnect = True
87
+ elif hint == "retry" or "could not connect" in err:
88
+ # Retryable error - proceed to wait loop if wait_for_ready
89
+ if not wait_for_ready:
90
+ return MCPResponse(**response_dict)
91
+ recovered_from_disconnect = True
92
+ else:
93
+ # Non-recoverable error - connection issue unrelated to domain reload
94
+ logger.warning(f"refresh_unity: Non-recoverable error (compile={compile}): {err[:100]}")
95
+ return MCPResponse(**response_dict)
69
96
 
70
97
  # Optional server-side wait loop (defensive): if Unity tool doesn't wait or returns quickly,
71
98
  # poll the canonical editor_state resource until ready or timeout.
99
+ ready_confirmed = False
72
100
  if wait_for_ready:
73
101
  timeout_s = 60.0
74
102
  start = time.monotonic()
75
103
 
104
+ # Blocking reasons that indicate Unity is actually busy (not just stale status)
105
+ # Must match activityPhase values from EditorStateCache.cs
106
+ real_blocking_reasons = {"compiling", "domain_reload", "running_tests", "asset_import"}
107
+
76
108
  while time.monotonic() - start < timeout_s:
77
109
  state_resp = await editor_state.get_editor_state(ctx)
78
110
  state = state_resp.model_dump() if hasattr(
@@ -81,10 +113,28 @@ async def refresh_unity(
81
113
  state, dict) else None
82
114
  advice = (data or {}).get(
83
115
  "advice") if isinstance(data, dict) else None
84
- if isinstance(advice, dict) and advice.get("ready_for_tools") is True:
85
- break
116
+ if isinstance(advice, dict):
117
+ # Exit if ready_for_tools is True
118
+ if advice.get("ready_for_tools") is True:
119
+ ready_confirmed = True
120
+ break
121
+ # Also exit if the only blocking reason is "stale_status" (Unity in background)
122
+ # Staleness means we can't confirm status, not that Unity is actually busy
123
+ blocking = set(advice.get("blocking_reasons") or [])
124
+ if not (blocking & real_blocking_reasons):
125
+ ready_confirmed = True # No real blocking reasons, consider ready
126
+ break
86
127
  await asyncio.sleep(0.25)
87
128
 
129
+ # If we timed out without confirming readiness, log and return failure
130
+ if not ready_confirmed:
131
+ logger.warning(f"refresh_unity: Timed out after {timeout_s}s waiting for editor to become ready")
132
+ return MCPResponse(
133
+ success=False,
134
+ message=f"Refresh triggered but timed out after {timeout_s}s waiting for editor readiness.",
135
+ data={"timeout": True, "wait_seconds": timeout_s},
136
+ )
137
+
88
138
  # After readiness is restored, clear any external-dirty flag for this instance so future tools can proceed cleanly.
89
139
  try:
90
140
  inst = unity_instance or await editor_state.infer_single_instance_id(ctx)
@@ -100,4 +150,4 @@ async def refresh_unity(
100
150
  data={"recovered_from_disconnect": True},
101
151
  )
102
152
 
103
- return MCPResponse(**response) if isinstance(response, dict) else response
153
+ return MCPResponse(**response_dict) if isinstance(response, dict) else response
@@ -233,14 +233,21 @@ class UnityConnection:
233
233
  logger.error(f"Error during receive: {str(e)}")
234
234
  raise
235
235
 
236
- def send_command(self, command_type: str, params: dict[str, Any] = None) -> dict[str, Any]:
237
- """Send a command with retry/backoff and port rediscovery. Pings only when requested."""
236
+ def send_command(self, command_type: str, params: dict[str, Any] = None, max_attempts: int | None = None) -> dict[str, Any]:
237
+ """Send a command with retry/backoff and port rediscovery. Pings only when requested.
238
+
239
+ Args:
240
+ command_type: The Unity command to send
241
+ params: Command parameters
242
+ max_attempts: Maximum retry attempts (None = use config default, 0 = no retries)
243
+ """
238
244
  # Defensive guard: catch empty/placeholder invocations early
239
245
  if not command_type:
240
246
  raise ValueError("MCP call missing command_type")
241
247
  if params is None:
242
248
  return MCPResponse(success=False, error="MCP call received with no parameters (client placeholder?)")
243
- attempts = max(config.max_retries, 5)
249
+ attempts = max(config.max_retries,
250
+ 5) if max_attempts is None else max_attempts
244
251
  base_backoff = max(0.5, config.retry_delay)
245
252
 
246
253
  def read_status_file(target_hash: str | None = None) -> dict | None:
@@ -734,7 +741,8 @@ def send_command_with_retry(
734
741
  *,
735
742
  instance_id: str | None = None,
736
743
  max_retries: int | None = None,
737
- retry_ms: int | None = None
744
+ retry_ms: int | None = None,
745
+ retry_on_reload: bool = True
738
746
  ) -> dict[str, Any] | MCPResponse:
739
747
  """Send a command to a Unity instance, waiting politely through Unity reloads.
740
748
 
@@ -744,6 +752,8 @@ def send_command_with_retry(
744
752
  instance_id: Optional Unity instance identifier (name, hash, name@hash, etc.)
745
753
  max_retries: Maximum number of retries for reload states
746
754
  retry_ms: Delay between retries in milliseconds
755
+ retry_on_reload: If False, don't retry when Unity is reloading (for commands
756
+ that trigger compilation/reload and shouldn't be re-sent)
747
757
 
748
758
  Returns:
749
759
  Response dictionary or MCPResponse from Unity
@@ -768,11 +778,16 @@ def send_command_with_retry(
768
778
  # Clamp to [0, 30] to prevent misconfiguration from causing excessive waits
769
779
  max_wait_s = max(0.0, min(max_wait_s, 30.0))
770
780
 
771
- response = conn.send_command(command_type, params)
781
+ # If retry_on_reload=False, disable connection-level retries too (issue #577)
782
+ # Commands that trigger compilation/reload shouldn't retry on disconnect
783
+ send_max_attempts = None if retry_on_reload else 0
784
+
785
+ response = conn.send_command(
786
+ command_type, params, max_attempts=send_max_attempts)
772
787
  retries = 0
773
788
  wait_started = None
774
789
  reason = _extract_response_reason(response)
775
- while _is_reloading_response(response) and retries < max_retries:
790
+ while retry_on_reload and _is_reloading_response(response) and retries < max_retries:
776
791
  if wait_started is None:
777
792
  wait_started = time.monotonic()
778
793
  logger.debug(
@@ -842,7 +857,8 @@ async def async_send_command_with_retry(
842
857
  instance_id: str | None = None,
843
858
  loop=None,
844
859
  max_retries: int | None = None,
845
- retry_ms: int | None = None
860
+ retry_ms: int | None = None,
861
+ retry_on_reload: bool = True
846
862
  ) -> dict[str, Any] | MCPResponse:
847
863
  """Async wrapper that runs the blocking retry helper in a thread pool.
848
864
 
@@ -853,6 +869,7 @@ async def async_send_command_with_retry(
853
869
  loop: Optional asyncio event loop
854
870
  max_retries: Maximum number of retries for reload states
855
871
  retry_ms: Delay between retries in milliseconds
872
+ retry_on_reload: If False, don't retry when Unity is reloading
856
873
 
857
874
  Returns:
858
875
  Response dictionary or MCPResponse on error
@@ -864,7 +881,8 @@ async def async_send_command_with_retry(
864
881
  return await loop.run_in_executor(
865
882
  None,
866
883
  lambda: send_command_with_retry(
867
- command_type, params, instance_id=instance_id, max_retries=max_retries, retry_ms=retry_ms),
884
+ command_type, params, instance_id=instance_id, max_retries=max_retries,
885
+ retry_ms=retry_ms, retry_on_reload=retry_on_reload),
868
886
  )
869
887
  except Exception as e:
870
888
  return MCPResponse(success=False, error=str(e))
transport/plugin_hub.py CHANGED
@@ -315,6 +315,23 @@ class PluginHub(WebSocketEndpoint):
315
315
  logger.info(
316
316
  f"Registered {len(payload.tools)} tools for session {session_id}")
317
317
 
318
+ try:
319
+ from services.custom_tool_service import CustomToolService
320
+
321
+ service = CustomToolService.get_instance()
322
+ service.register_global_tools(payload.tools)
323
+ except RuntimeError as exc:
324
+ logger.debug(
325
+ "Skipping global custom tool registration: CustomToolService not initialized yet (%s)",
326
+ exc,
327
+ )
328
+ except Exception as exc:
329
+ logger.warning(
330
+ "Unexpected error during global custom tool registration; "
331
+ "custom tools may not be available globally",
332
+ exc_info=exc,
333
+ )
334
+
318
335
  async def _handle_command_result(self, payload: CommandResultMessage) -> None:
319
336
  cls = type(self)
320
337
  lock = cls._lock
__init__.py DELETED
File without changes