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.
- cli/__init__.py +3 -0
- cli/commands/__init__.py +3 -0
- cli/commands/animation.py +87 -0
- cli/commands/asset.py +310 -0
- cli/commands/audio.py +133 -0
- cli/commands/batch.py +184 -0
- cli/commands/code.py +189 -0
- cli/commands/component.py +212 -0
- cli/commands/editor.py +487 -0
- cli/commands/gameobject.py +510 -0
- cli/commands/instance.py +101 -0
- cli/commands/lighting.py +128 -0
- cli/commands/material.py +268 -0
- cli/commands/prefab.py +144 -0
- cli/commands/scene.py +255 -0
- cli/commands/script.py +240 -0
- cli/commands/shader.py +238 -0
- cli/commands/ui.py +263 -0
- cli/commands/vfx.py +439 -0
- cli/main.py +248 -0
- cli/utils/__init__.py +31 -0
- cli/utils/config.py +58 -0
- cli/utils/connection.py +191 -0
- cli/utils/output.py +195 -0
- main.py +174 -60
- {mcpforunityserver-9.0.8.dist-info → mcpforunityserver-9.2.0.dist-info}/METADATA +3 -2
- {mcpforunityserver-9.0.8.dist-info → mcpforunityserver-9.2.0.dist-info}/RECORD +37 -14
- {mcpforunityserver-9.0.8.dist-info → mcpforunityserver-9.2.0.dist-info}/WHEEL +1 -1
- {mcpforunityserver-9.0.8.dist-info → mcpforunityserver-9.2.0.dist-info}/entry_points.txt +1 -0
- {mcpforunityserver-9.0.8.dist-info → mcpforunityserver-9.2.0.dist-info}/top_level.txt +1 -1
- services/custom_tool_service.py +168 -13
- services/resources/__init__.py +6 -1
- services/tools/__init__.py +6 -1
- services/tools/refresh_unity.py +66 -16
- transport/legacy/unity_connection.py +26 -8
- transport/plugin_hub.py +17 -0
- __init__.py +0 -0
- {mcpforunityserver-9.0.8.dist-info → mcpforunityserver-9.2.0.dist-info}/licenses/LICENSE +0 -0
services/resources/__init__.py
CHANGED
|
@@ -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
|
|
services/tools/__init__.py
CHANGED
|
@@ -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)
|
services/tools/refresh_unity.py
CHANGED
|
@@ -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
|
-
#
|
|
54
|
-
# Unity
|
|
55
|
-
#
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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 "
|
|
74
|
+
or "aborted" in err # WinError 10053: connection aborted
|
|
75
|
+
or "timeout" in err
|
|
76
|
+
or reason == "reloading"
|
|
64
77
|
)
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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)
|
|
85
|
-
|
|
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(**
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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
|
|
File without changes
|