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.
- 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 +207 -62
- {mcpforunityserver-8.5.0.dist-info → mcpforunityserver-9.1.0.dist-info}/METADATA +4 -2
- mcpforunityserver-9.1.0.dist-info/RECORD +96 -0
- {mcpforunityserver-8.5.0.dist-info → mcpforunityserver-9.1.0.dist-info}/WHEEL +1 -1
- {mcpforunityserver-8.5.0.dist-info → mcpforunityserver-9.1.0.dist-info}/entry_points.txt +1 -0
- {mcpforunityserver-8.5.0.dist-info → mcpforunityserver-9.1.0.dist-info}/top_level.txt +1 -2
- services/custom_tool_service.py +179 -19
- services/resources/__init__.py +6 -1
- services/resources/active_tool.py +1 -1
- services/resources/custom_tools.py +2 -2
- services/resources/editor_state.py +283 -21
- services/resources/gameobject.py +243 -0
- services/resources/layers.py +1 -1
- services/resources/prefab_stage.py +1 -1
- services/resources/project_info.py +1 -1
- services/resources/selection.py +1 -1
- services/resources/tags.py +1 -1
- services/resources/unity_instances.py +1 -1
- services/resources/windows.py +1 -1
- services/state/external_changes_scanner.py +245 -0
- services/tools/__init__.py +6 -1
- services/tools/batch_execute.py +24 -9
- services/tools/debug_request_context.py +8 -2
- services/tools/execute_custom_tool.py +6 -1
- services/tools/execute_menu_item.py +6 -3
- services/tools/find_gameobjects.py +89 -0
- services/tools/find_in_file.py +26 -19
- services/tools/manage_asset.py +19 -43
- services/tools/manage_components.py +131 -0
- services/tools/manage_editor.py +9 -8
- services/tools/manage_gameobject.py +120 -79
- services/tools/manage_material.py +80 -31
- services/tools/manage_prefabs.py +7 -1
- services/tools/manage_scene.py +34 -13
- services/tools/manage_script.py +62 -19
- services/tools/manage_scriptable_object.py +22 -10
- services/tools/manage_shader.py +8 -1
- services/tools/manage_vfx.py +738 -0
- services/tools/preflight.py +110 -0
- services/tools/read_console.py +81 -18
- services/tools/refresh_unity.py +153 -0
- services/tools/run_tests.py +202 -41
- services/tools/script_apply_edits.py +15 -7
- services/tools/set_active_instance.py +12 -7
- services/tools/utils.py +60 -6
- transport/legacy/port_discovery.py +2 -2
- transport/legacy/unity_connection.py +129 -26
- transport/plugin_hub.py +191 -19
- transport/unity_instance_middleware.py +93 -2
- transport/unity_transport.py +17 -6
- utils/focus_nudge.py +321 -0
- __init__.py +0 -0
- mcpforunityserver-8.5.0.dist-info/RECORD +0 -66
- routes/__init__.py +0 -0
- {mcpforunityserver-8.5.0.dist-info → mcpforunityserver-9.1.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -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:
|
|
@@ -584,7 +591,7 @@ class UnityConnectionPool:
|
|
|
584
591
|
raise ConnectionError(
|
|
585
592
|
f"Unity instance '{identifier}' not found. "
|
|
586
593
|
f"Available instances: {available_ids}. "
|
|
587
|
-
f"Check
|
|
594
|
+
f"Check mcpforunity://instances resource for all instances."
|
|
588
595
|
)
|
|
589
596
|
|
|
590
597
|
def get_connection(self, instance_identifier: str | None = None) -> UnityConnection:
|
|
@@ -686,28 +693,46 @@ def get_unity_connection(instance_identifier: str | None = None) -> UnityConnect
|
|
|
686
693
|
# Centralized retry helpers
|
|
687
694
|
# -----------------------------
|
|
688
695
|
|
|
689
|
-
def
|
|
690
|
-
"""
|
|
696
|
+
def _extract_response_reason(resp: object) -> str | None:
|
|
697
|
+
"""Extract a normalized (lowercase) reason string from a response.
|
|
691
698
|
|
|
692
|
-
|
|
693
|
-
by
|
|
699
|
+
Returns lowercase reason values to enable case-insensitive comparisons
|
|
700
|
+
by callers (e.g. _is_reloading_response, refresh_unity).
|
|
694
701
|
"""
|
|
695
|
-
# Structured MCPResponse from preflight/transport
|
|
696
702
|
if isinstance(resp, MCPResponse):
|
|
697
|
-
|
|
698
|
-
if
|
|
699
|
-
|
|
703
|
+
data = getattr(resp, "data", None)
|
|
704
|
+
if isinstance(data, dict):
|
|
705
|
+
reason = data.get("reason")
|
|
706
|
+
if isinstance(reason, str):
|
|
707
|
+
return reason.lower()
|
|
700
708
|
message_text = f"{resp.message or ''} {resp.error or ''}".lower()
|
|
701
|
-
|
|
709
|
+
if "reload" in message_text:
|
|
710
|
+
return "reloading"
|
|
711
|
+
return None
|
|
702
712
|
|
|
703
|
-
# Raw Unity payloads
|
|
704
713
|
if isinstance(resp, dict):
|
|
705
714
|
if resp.get("state") == "reloading":
|
|
706
|
-
return
|
|
715
|
+
return "reloading"
|
|
716
|
+
data = resp.get("data")
|
|
717
|
+
if isinstance(data, dict):
|
|
718
|
+
reason = data.get("reason")
|
|
719
|
+
if isinstance(reason, str):
|
|
720
|
+
return reason.lower()
|
|
707
721
|
message_text = (resp.get("message") or resp.get("error") or "").lower()
|
|
708
|
-
|
|
722
|
+
if "reload" in message_text:
|
|
723
|
+
return "reloading"
|
|
724
|
+
return None
|
|
709
725
|
|
|
710
|
-
return
|
|
726
|
+
return None
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
def _is_reloading_response(resp: object) -> bool:
|
|
730
|
+
"""Return True if the Unity response indicates the editor is reloading.
|
|
731
|
+
|
|
732
|
+
Supports both raw dict payloads from Unity and MCPResponse objects returned
|
|
733
|
+
by preflight checks or transport helpers.
|
|
734
|
+
"""
|
|
735
|
+
return _extract_response_reason(resp) == "reloading"
|
|
711
736
|
|
|
712
737
|
|
|
713
738
|
def send_command_with_retry(
|
|
@@ -716,7 +741,8 @@ def send_command_with_retry(
|
|
|
716
741
|
*,
|
|
717
742
|
instance_id: str | None = None,
|
|
718
743
|
max_retries: int | None = None,
|
|
719
|
-
retry_ms: int | None = None
|
|
744
|
+
retry_ms: int | None = None,
|
|
745
|
+
retry_on_reload: bool = True
|
|
720
746
|
) -> dict[str, Any] | MCPResponse:
|
|
721
747
|
"""Send a command to a Unity instance, waiting politely through Unity reloads.
|
|
722
748
|
|
|
@@ -726,6 +752,8 @@ def send_command_with_retry(
|
|
|
726
752
|
instance_id: Optional Unity instance identifier (name, hash, name@hash, etc.)
|
|
727
753
|
max_retries: Maximum number of retries for reload states
|
|
728
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)
|
|
729
757
|
|
|
730
758
|
Returns:
|
|
731
759
|
Response dictionary or MCPResponse from Unity
|
|
@@ -738,15 +766,87 @@ def send_command_with_retry(
|
|
|
738
766
|
max_retries = getattr(config, "reload_max_retries", 40)
|
|
739
767
|
if retry_ms is None:
|
|
740
768
|
retry_ms = getattr(config, "reload_retry_ms", 250)
|
|
741
|
-
|
|
742
|
-
|
|
769
|
+
try:
|
|
770
|
+
max_wait_s = float(os.environ.get(
|
|
771
|
+
"UNITY_MCP_RELOAD_MAX_WAIT_S", "2.0"))
|
|
772
|
+
except ValueError as e:
|
|
773
|
+
raw_val = os.environ.get("UNITY_MCP_RELOAD_MAX_WAIT_S", "2.0")
|
|
774
|
+
logger.warning(
|
|
775
|
+
"Invalid UNITY_MCP_RELOAD_MAX_WAIT_S=%r, using default 2.0: %s",
|
|
776
|
+
raw_val, e)
|
|
777
|
+
max_wait_s = 2.0
|
|
778
|
+
# Clamp to [0, 30] to prevent misconfiguration from causing excessive waits
|
|
779
|
+
max_wait_s = max(0.0, min(max_wait_s, 30.0))
|
|
780
|
+
|
|
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)
|
|
743
787
|
retries = 0
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
788
|
+
wait_started = None
|
|
789
|
+
reason = _extract_response_reason(response)
|
|
790
|
+
while retry_on_reload and _is_reloading_response(response) and retries < max_retries:
|
|
791
|
+
if wait_started is None:
|
|
792
|
+
wait_started = time.monotonic()
|
|
793
|
+
logger.debug(
|
|
794
|
+
"Unity reload wait started: command=%s instance=%s reason=%s max_wait_s=%.2f",
|
|
795
|
+
command_type,
|
|
796
|
+
instance_id or "default",
|
|
797
|
+
reason or "reloading",
|
|
798
|
+
max_wait_s,
|
|
799
|
+
)
|
|
800
|
+
if max_wait_s <= 0:
|
|
801
|
+
break
|
|
802
|
+
elapsed = time.monotonic() - wait_started
|
|
803
|
+
if elapsed >= max_wait_s:
|
|
804
|
+
break
|
|
805
|
+
delay_ms = retry_ms
|
|
806
|
+
if isinstance(response, dict):
|
|
807
|
+
retry_after = response.get("retry_after_ms")
|
|
808
|
+
if retry_after is None and isinstance(response.get("data"), dict):
|
|
809
|
+
retry_after = response["data"].get("retry_after_ms")
|
|
810
|
+
if retry_after is not None:
|
|
811
|
+
delay_ms = int(retry_after)
|
|
812
|
+
sleep_ms = max(50, min(int(delay_ms), 250))
|
|
813
|
+
logger.debug(
|
|
814
|
+
"Unity reload wait retry: command=%s instance=%s reason=%s retry_after_ms=%s sleep_ms=%s",
|
|
815
|
+
command_type,
|
|
816
|
+
instance_id or "default",
|
|
817
|
+
reason or "reloading",
|
|
818
|
+
delay_ms,
|
|
819
|
+
sleep_ms,
|
|
820
|
+
)
|
|
821
|
+
time.sleep(max(0.0, sleep_ms / 1000.0))
|
|
748
822
|
retries += 1
|
|
749
823
|
response = conn.send_command(command_type, params)
|
|
824
|
+
reason = _extract_response_reason(response)
|
|
825
|
+
|
|
826
|
+
if wait_started is not None:
|
|
827
|
+
waited = time.monotonic() - wait_started
|
|
828
|
+
if _is_reloading_response(response):
|
|
829
|
+
logger.debug(
|
|
830
|
+
"Unity reload wait exceeded budget: command=%s instance=%s waited_s=%.3f",
|
|
831
|
+
command_type,
|
|
832
|
+
instance_id or "default",
|
|
833
|
+
waited,
|
|
834
|
+
)
|
|
835
|
+
return MCPResponse(
|
|
836
|
+
success=False,
|
|
837
|
+
error="Unity is reloading; please retry",
|
|
838
|
+
hint="retry",
|
|
839
|
+
data={
|
|
840
|
+
"reason": "reloading",
|
|
841
|
+
"retry_after_ms": min(250, max(50, retry_ms)),
|
|
842
|
+
},
|
|
843
|
+
)
|
|
844
|
+
logger.debug(
|
|
845
|
+
"Unity reload wait completed: command=%s instance=%s waited_s=%.3f",
|
|
846
|
+
command_type,
|
|
847
|
+
instance_id or "default",
|
|
848
|
+
waited,
|
|
849
|
+
)
|
|
750
850
|
return response
|
|
751
851
|
|
|
752
852
|
|
|
@@ -757,7 +857,8 @@ async def async_send_command_with_retry(
|
|
|
757
857
|
instance_id: str | None = None,
|
|
758
858
|
loop=None,
|
|
759
859
|
max_retries: int | None = None,
|
|
760
|
-
retry_ms: int | None = None
|
|
860
|
+
retry_ms: int | None = None,
|
|
861
|
+
retry_on_reload: bool = True
|
|
761
862
|
) -> dict[str, Any] | MCPResponse:
|
|
762
863
|
"""Async wrapper that runs the blocking retry helper in a thread pool.
|
|
763
864
|
|
|
@@ -768,6 +869,7 @@ async def async_send_command_with_retry(
|
|
|
768
869
|
loop: Optional asyncio event loop
|
|
769
870
|
max_retries: Maximum number of retries for reload states
|
|
770
871
|
retry_ms: Delay between retries in milliseconds
|
|
872
|
+
retry_on_reload: If False, don't retry when Unity is reloading
|
|
771
873
|
|
|
772
874
|
Returns:
|
|
773
875
|
Response dictionary or MCPResponse on error
|
|
@@ -779,7 +881,8 @@ async def async_send_command_with_retry(
|
|
|
779
881
|
return await loop.run_in_executor(
|
|
780
882
|
None,
|
|
781
883
|
lambda: send_command_with_retry(
|
|
782
|
-
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),
|
|
783
886
|
)
|
|
784
887
|
except Exception as e:
|
|
785
888
|
return MCPResponse(success=False, error=str(e))
|
transport/plugin_hub.py
CHANGED
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import asyncio
|
|
6
6
|
import logging
|
|
7
|
+
import os
|
|
7
8
|
import time
|
|
8
9
|
import uuid
|
|
9
10
|
from typing import Any
|
|
@@ -12,6 +13,7 @@ from starlette.endpoints import WebSocketEndpoint
|
|
|
12
13
|
from starlette.websockets import WebSocket
|
|
13
14
|
|
|
14
15
|
from core.config import config
|
|
16
|
+
from models.models import MCPResponse
|
|
15
17
|
from transport.plugin_registry import PluginRegistry
|
|
16
18
|
from transport.models import (
|
|
17
19
|
WelcomeMessage,
|
|
@@ -28,6 +30,14 @@ from transport.models import (
|
|
|
28
30
|
logger = logging.getLogger("mcp-for-unity-server")
|
|
29
31
|
|
|
30
32
|
|
|
33
|
+
class PluginDisconnectedError(RuntimeError):
|
|
34
|
+
"""Raised when a plugin WebSocket disconnects while commands are in flight."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class NoUnitySessionError(RuntimeError):
|
|
38
|
+
"""Raised when no Unity plugins are available."""
|
|
39
|
+
|
|
40
|
+
|
|
31
41
|
class PluginHub(WebSocketEndpoint):
|
|
32
42
|
"""Manages persistent WebSocket connections to Unity plugins."""
|
|
33
43
|
|
|
@@ -35,10 +45,19 @@ class PluginHub(WebSocketEndpoint):
|
|
|
35
45
|
KEEP_ALIVE_INTERVAL = 15
|
|
36
46
|
SERVER_TIMEOUT = 30
|
|
37
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
|
|
51
|
+
# Fast-path commands should never block the client for long; return a retry hint instead.
|
|
52
|
+
# This helps avoid the Cursor-side ~30s tool-call timeout when Unity is compiling/reloading
|
|
53
|
+
# or is throttled while unfocused.
|
|
54
|
+
_FAST_FAIL_COMMANDS: set[str] = {
|
|
55
|
+
"read_console", "get_editor_state", "ping"}
|
|
38
56
|
|
|
39
57
|
_registry: PluginRegistry | None = None
|
|
40
58
|
_connections: dict[str, WebSocket] = {}
|
|
41
|
-
|
|
59
|
+
# command_id -> {"future": Future, "session_id": str}
|
|
60
|
+
_pending: dict[str, dict[str, Any]] = {}
|
|
42
61
|
_lock: asyncio.Lock | None = None
|
|
43
62
|
_loop: asyncio.AbstractEventLoop | None = None
|
|
44
63
|
|
|
@@ -95,6 +114,22 @@ class PluginHub(WebSocketEndpoint):
|
|
|
95
114
|
(sid for sid, ws in cls._connections.items() if ws is websocket), None)
|
|
96
115
|
if session_id:
|
|
97
116
|
cls._connections.pop(session_id, None)
|
|
117
|
+
# Fail-fast any in-flight commands for this session to avoid waiting for COMMAND_TIMEOUT.
|
|
118
|
+
pending_ids = [
|
|
119
|
+
command_id
|
|
120
|
+
for command_id, entry in cls._pending.items()
|
|
121
|
+
if entry.get("session_id") == session_id
|
|
122
|
+
]
|
|
123
|
+
for command_id in pending_ids:
|
|
124
|
+
entry = cls._pending.get(command_id)
|
|
125
|
+
future = entry.get("future") if isinstance(
|
|
126
|
+
entry, dict) else None
|
|
127
|
+
if future and not future.done():
|
|
128
|
+
future.set_exception(
|
|
129
|
+
PluginDisconnectedError(
|
|
130
|
+
f"Unity plugin session {session_id} disconnected while awaiting command_result"
|
|
131
|
+
)
|
|
132
|
+
)
|
|
98
133
|
if cls._registry:
|
|
99
134
|
await cls._registry.unregister(session_id)
|
|
100
135
|
logger.info(
|
|
@@ -108,6 +143,36 @@ class PluginHub(WebSocketEndpoint):
|
|
|
108
143
|
websocket = await cls._get_connection(session_id)
|
|
109
144
|
command_id = str(uuid.uuid4())
|
|
110
145
|
future: asyncio.Future = asyncio.get_running_loop().create_future()
|
|
146
|
+
# Compute a per-command timeout:
|
|
147
|
+
# - fast-path commands: short timeout (encourage retry)
|
|
148
|
+
# - long-running commands: allow caller to request a longer timeout via params
|
|
149
|
+
unity_timeout_s = float(cls.COMMAND_TIMEOUT)
|
|
150
|
+
server_wait_s = float(cls.COMMAND_TIMEOUT)
|
|
151
|
+
if command_type in cls._FAST_FAIL_COMMANDS:
|
|
152
|
+
fast_timeout = float(cls.FAST_FAIL_TIMEOUT)
|
|
153
|
+
unity_timeout_s = fast_timeout
|
|
154
|
+
server_wait_s = fast_timeout
|
|
155
|
+
else:
|
|
156
|
+
# Common tools pass a requested timeout in seconds (e.g., timeout_seconds=900).
|
|
157
|
+
requested = None
|
|
158
|
+
try:
|
|
159
|
+
if isinstance(params, dict):
|
|
160
|
+
requested = params.get("timeout_seconds", None)
|
|
161
|
+
if requested is None:
|
|
162
|
+
requested = params.get("timeoutSeconds", None)
|
|
163
|
+
except Exception:
|
|
164
|
+
requested = None
|
|
165
|
+
|
|
166
|
+
if requested is not None:
|
|
167
|
+
try:
|
|
168
|
+
requested_s = float(requested)
|
|
169
|
+
# Clamp to a sane upper bound to avoid accidental infinite hangs.
|
|
170
|
+
requested_s = max(1.0, min(requested_s, 60.0 * 60.0))
|
|
171
|
+
unity_timeout_s = max(unity_timeout_s, requested_s)
|
|
172
|
+
# Give the server a small cushion beyond the Unity-side timeout to account for transport overhead.
|
|
173
|
+
server_wait_s = max(server_wait_s, requested_s + 5.0)
|
|
174
|
+
except Exception:
|
|
175
|
+
pass
|
|
111
176
|
|
|
112
177
|
lock = cls._lock
|
|
113
178
|
if lock is None:
|
|
@@ -117,18 +182,36 @@ class PluginHub(WebSocketEndpoint):
|
|
|
117
182
|
if command_id in cls._pending:
|
|
118
183
|
raise RuntimeError(
|
|
119
184
|
f"Duplicate command id generated: {command_id}")
|
|
120
|
-
cls._pending[command_id] =
|
|
185
|
+
cls._pending[command_id] = {
|
|
186
|
+
"future": future, "session_id": session_id}
|
|
121
187
|
|
|
122
188
|
try:
|
|
123
189
|
msg = ExecuteCommandMessage(
|
|
124
190
|
id=command_id,
|
|
125
191
|
name=command_type,
|
|
126
192
|
params=params,
|
|
127
|
-
timeout=
|
|
193
|
+
timeout=unity_timeout_s,
|
|
128
194
|
)
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
195
|
+
try:
|
|
196
|
+
await websocket.send_json(msg.model_dump())
|
|
197
|
+
except Exception as exc:
|
|
198
|
+
# If send fails (socket already closing), fail the future so callers don't hang.
|
|
199
|
+
if not future.done():
|
|
200
|
+
future.set_exception(exc)
|
|
201
|
+
raise
|
|
202
|
+
try:
|
|
203
|
+
result = await asyncio.wait_for(future, timeout=server_wait_s)
|
|
204
|
+
return result
|
|
205
|
+
except PluginDisconnectedError as exc:
|
|
206
|
+
return MCPResponse(success=False, error=str(exc), hint="retry").model_dump()
|
|
207
|
+
except asyncio.TimeoutError:
|
|
208
|
+
if command_type in cls._FAST_FAIL_COMMANDS:
|
|
209
|
+
return MCPResponse(
|
|
210
|
+
success=False,
|
|
211
|
+
error=f"Unity did not respond to '{command_type}' within {server_wait_s:.1f}s; please retry",
|
|
212
|
+
hint="retry",
|
|
213
|
+
).model_dump()
|
|
214
|
+
raise
|
|
132
215
|
finally:
|
|
133
216
|
async with lock:
|
|
134
217
|
cls._pending.pop(command_id, None)
|
|
@@ -232,6 +315,23 @@ class PluginHub(WebSocketEndpoint):
|
|
|
232
315
|
logger.info(
|
|
233
316
|
f"Registered {len(payload.tools)} tools for session {session_id}")
|
|
234
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
|
+
|
|
235
335
|
async def _handle_command_result(self, payload: CommandResultMessage) -> None:
|
|
236
336
|
cls = type(self)
|
|
237
337
|
lock = cls._lock
|
|
@@ -245,7 +345,8 @@ class PluginHub(WebSocketEndpoint):
|
|
|
245
345
|
return
|
|
246
346
|
|
|
247
347
|
async with lock:
|
|
248
|
-
|
|
348
|
+
entry = cls._pending.get(command_id)
|
|
349
|
+
future = entry.get("future") if isinstance(entry, dict) else None
|
|
249
350
|
if future and not future.done():
|
|
250
351
|
future.set_result(result)
|
|
251
352
|
|
|
@@ -284,11 +385,21 @@ class PluginHub(WebSocketEndpoint):
|
|
|
284
385
|
if cls._registry is None:
|
|
285
386
|
raise RuntimeError("Plugin registry not configured")
|
|
286
387
|
|
|
287
|
-
#
|
|
288
|
-
|
|
289
|
-
|
|
388
|
+
# Bound waiting for Unity sessions so calls fail fast when editors are not ready.
|
|
389
|
+
try:
|
|
390
|
+
max_wait_s = float(
|
|
391
|
+
os.environ.get("UNITY_MCP_SESSION_RESOLVE_MAX_WAIT_S", "2.0"))
|
|
392
|
+
except ValueError as e:
|
|
393
|
+
raw_val = os.environ.get(
|
|
394
|
+
"UNITY_MCP_SESSION_RESOLVE_MAX_WAIT_S", "2.0")
|
|
395
|
+
logger.warning(
|
|
396
|
+
"Invalid UNITY_MCP_SESSION_RESOLVE_MAX_WAIT_S=%r, using default 2.0: %s",
|
|
397
|
+
raw_val, e)
|
|
398
|
+
max_wait_s = 2.0
|
|
399
|
+
# Clamp to [0, 30] to prevent misconfiguration from causing excessive waits
|
|
400
|
+
max_wait_s = max(0.0, min(max_wait_s, 30.0))
|
|
290
401
|
retry_ms = float(getattr(config, "reload_retry_ms", 250))
|
|
291
|
-
sleep_seconds = max(0.05, retry_ms / 1000.0)
|
|
402
|
+
sleep_seconds = max(0.05, min(0.25, retry_ms / 1000.0))
|
|
292
403
|
|
|
293
404
|
# Allow callers to provide either just the hash or Name@hash
|
|
294
405
|
target_hash: str | None = None
|
|
@@ -317,7 +428,7 @@ class PluginHub(WebSocketEndpoint):
|
|
|
317
428
|
return None, count
|
|
318
429
|
|
|
319
430
|
session_id, session_count = await _try_once()
|
|
320
|
-
deadline = time.monotonic() +
|
|
431
|
+
deadline = time.monotonic() + max_wait_s
|
|
321
432
|
wait_started = None
|
|
322
433
|
|
|
323
434
|
# If there is no active plugin yet (e.g., Unity starting up or reloading),
|
|
@@ -326,33 +437,40 @@ class PluginHub(WebSocketEndpoint):
|
|
|
326
437
|
if not target_hash and session_count > 1:
|
|
327
438
|
raise RuntimeError(
|
|
328
439
|
"Multiple Unity instances are connected. "
|
|
329
|
-
"Call set_active_instance with Name@hash from
|
|
440
|
+
"Call set_active_instance with Name@hash from mcpforunity://instances."
|
|
330
441
|
)
|
|
331
442
|
if wait_started is None:
|
|
332
443
|
wait_started = time.monotonic()
|
|
333
444
|
logger.debug(
|
|
334
|
-
|
|
445
|
+
"No plugin session available (instance=%s); waiting up to %.2fs",
|
|
446
|
+
unity_instance or "default",
|
|
447
|
+
max_wait_s,
|
|
335
448
|
)
|
|
336
449
|
await asyncio.sleep(sleep_seconds)
|
|
337
450
|
session_id, session_count = await _try_once()
|
|
338
451
|
|
|
339
452
|
if session_id is not None and wait_started is not None:
|
|
340
453
|
logger.debug(
|
|
341
|
-
|
|
454
|
+
"Plugin session restored after %.3fs (instance=%s)",
|
|
455
|
+
time.monotonic() - wait_started,
|
|
456
|
+
unity_instance or "default",
|
|
342
457
|
)
|
|
343
458
|
if session_id is None and not target_hash and session_count > 1:
|
|
344
459
|
raise RuntimeError(
|
|
345
460
|
"Multiple Unity instances are connected. "
|
|
346
|
-
"Call set_active_instance with Name@hash from
|
|
461
|
+
"Call set_active_instance with Name@hash from mcpforunity://instances."
|
|
347
462
|
)
|
|
348
463
|
|
|
349
464
|
if session_id is None:
|
|
350
465
|
logger.warning(
|
|
351
|
-
|
|
466
|
+
"No Unity plugin reconnected within %.2fs (instance=%s)",
|
|
467
|
+
max_wait_s,
|
|
468
|
+
unity_instance or "default",
|
|
352
469
|
)
|
|
353
470
|
# At this point we've given the plugin ample time to reconnect; surface
|
|
354
471
|
# a clear error so the client can prompt the user to open Unity.
|
|
355
|
-
raise
|
|
472
|
+
raise NoUnitySessionError(
|
|
473
|
+
"No Unity plugins are currently connected")
|
|
356
474
|
|
|
357
475
|
return session_id
|
|
358
476
|
|
|
@@ -363,7 +481,61 @@ class PluginHub(WebSocketEndpoint):
|
|
|
363
481
|
command_type: str,
|
|
364
482
|
params: dict[str, Any],
|
|
365
483
|
) -> dict[str, Any]:
|
|
366
|
-
|
|
484
|
+
try:
|
|
485
|
+
session_id = await cls._resolve_session_id(unity_instance)
|
|
486
|
+
except NoUnitySessionError:
|
|
487
|
+
logger.debug(
|
|
488
|
+
"Unity session unavailable; returning retry: command=%s instance=%s",
|
|
489
|
+
command_type,
|
|
490
|
+
unity_instance or "default",
|
|
491
|
+
)
|
|
492
|
+
return MCPResponse(
|
|
493
|
+
success=False,
|
|
494
|
+
error="Unity session not available; please retry",
|
|
495
|
+
hint="retry",
|
|
496
|
+
data={"reason": "no_unity_session", "retry_after_ms": 250},
|
|
497
|
+
).model_dump()
|
|
498
|
+
|
|
499
|
+
# During domain reload / immediate reconnect windows, the plugin may be connected but not yet
|
|
500
|
+
# ready to process execute commands on the Unity main thread (which can be further delayed when
|
|
501
|
+
# the Unity Editor is unfocused). For fast-path commands, we do a bounded readiness probe using
|
|
502
|
+
# a main-thread ping command (handled by TransportCommandDispatcher) rather than waiting on
|
|
503
|
+
# register_tools (which can be delayed by EditorApplication.delayCall).
|
|
504
|
+
if command_type in cls._FAST_FAIL_COMMANDS and command_type != "ping":
|
|
505
|
+
try:
|
|
506
|
+
max_wait_s = float(os.environ.get(
|
|
507
|
+
"UNITY_MCP_SESSION_READY_WAIT_SECONDS", "6"))
|
|
508
|
+
except ValueError as e:
|
|
509
|
+
raw_val = os.environ.get(
|
|
510
|
+
"UNITY_MCP_SESSION_READY_WAIT_SECONDS", "6")
|
|
511
|
+
logger.warning(
|
|
512
|
+
"Invalid UNITY_MCP_SESSION_READY_WAIT_SECONDS=%r, using default 6.0: %s",
|
|
513
|
+
raw_val, e)
|
|
514
|
+
max_wait_s = 6.0
|
|
515
|
+
max_wait_s = max(0.0, min(max_wait_s, 30.0))
|
|
516
|
+
if max_wait_s > 0:
|
|
517
|
+
deadline = time.monotonic() + max_wait_s
|
|
518
|
+
while time.monotonic() < deadline:
|
|
519
|
+
try:
|
|
520
|
+
probe = await cls.send_command(session_id, "ping", {})
|
|
521
|
+
except Exception:
|
|
522
|
+
probe = None
|
|
523
|
+
|
|
524
|
+
# The Unity-side dispatcher responds with {status:"success", result:{message:"pong"}}
|
|
525
|
+
if isinstance(probe, dict) and probe.get("status") == "success":
|
|
526
|
+
result = probe.get("result") if isinstance(
|
|
527
|
+
probe.get("result"), dict) else {}
|
|
528
|
+
if result.get("message") == "pong":
|
|
529
|
+
break
|
|
530
|
+
await asyncio.sleep(0.1)
|
|
531
|
+
else:
|
|
532
|
+
# Not ready within the bounded window: return retry hint without sending.
|
|
533
|
+
return MCPResponse(
|
|
534
|
+
success=False,
|
|
535
|
+
error=f"Unity session not ready for '{command_type}' (ping not answered); please retry",
|
|
536
|
+
hint="retry",
|
|
537
|
+
).model_dump()
|
|
538
|
+
|
|
367
539
|
return await cls.send_command(session_id, command_type, params)
|
|
368
540
|
|
|
369
541
|
# ------------------------------------------------------------------
|