mcpforunityserver 8.7.0__py3-none-any.whl → 9.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- main.py +4 -3
- {mcpforunityserver-8.7.0.dist-info → mcpforunityserver-9.0.0.dist-info}/METADATA +2 -2
- mcpforunityserver-9.0.0.dist-info/RECORD +72 -0
- {mcpforunityserver-8.7.0.dist-info → mcpforunityserver-9.0.0.dist-info}/top_level.txt +0 -1
- services/custom_tool_service.py +13 -8
- services/resources/active_tool.py +1 -1
- services/resources/custom_tools.py +2 -2
- services/resources/editor_state.py +283 -30
- 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 +3 -4
- 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 +13 -44
- services/tools/manage_components.py +131 -0
- services/tools/manage_editor.py +9 -8
- services/tools/manage_gameobject.py +115 -79
- services/tools/manage_material.py +80 -31
- services/tools/manage_prefabs.py +7 -1
- services/tools/manage_scene.py +30 -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 +15 -12
- services/tools/read_console.py +31 -14
- services/tools/refresh_unity.py +28 -18
- services/tools/run_tests.py +162 -53
- 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 +102 -17
- transport/plugin_hub.py +68 -24
- transport/unity_instance_middleware.py +4 -3
- transport/unity_transport.py +2 -1
- mcpforunityserver-8.7.0.dist-info/RECORD +0 -71
- routes/__init__.py +0 -0
- services/resources/editor_state_v2.py +0 -270
- services/tools/test_jobs.py +0 -94
- {mcpforunityserver-8.7.0.dist-info → mcpforunityserver-9.0.0.dist-info}/WHEEL +0 -0
- {mcpforunityserver-8.7.0.dist-info → mcpforunityserver-9.0.0.dist-info}/entry_points.txt +0 -0
- {mcpforunityserver-8.7.0.dist-info → mcpforunityserver-9.0.0.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
|
|
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:
|
|
@@ -686,28 +686,46 @@ def get_unity_connection(instance_identifier: str | None = None) -> UnityConnect
|
|
|
686
686
|
# Centralized retry helpers
|
|
687
687
|
# -----------------------------
|
|
688
688
|
|
|
689
|
-
def
|
|
690
|
-
"""
|
|
689
|
+
def _extract_response_reason(resp: object) -> str | None:
|
|
690
|
+
"""Extract a normalized (lowercase) reason string from a response.
|
|
691
691
|
|
|
692
|
-
|
|
693
|
-
by
|
|
692
|
+
Returns lowercase reason values to enable case-insensitive comparisons
|
|
693
|
+
by callers (e.g. _is_reloading_response, refresh_unity).
|
|
694
694
|
"""
|
|
695
|
-
# Structured MCPResponse from preflight/transport
|
|
696
695
|
if isinstance(resp, MCPResponse):
|
|
697
|
-
|
|
698
|
-
if
|
|
699
|
-
|
|
696
|
+
data = getattr(resp, "data", None)
|
|
697
|
+
if isinstance(data, dict):
|
|
698
|
+
reason = data.get("reason")
|
|
699
|
+
if isinstance(reason, str):
|
|
700
|
+
return reason.lower()
|
|
700
701
|
message_text = f"{resp.message or ''} {resp.error or ''}".lower()
|
|
701
|
-
|
|
702
|
+
if "reload" in message_text:
|
|
703
|
+
return "reloading"
|
|
704
|
+
return None
|
|
702
705
|
|
|
703
|
-
# Raw Unity payloads
|
|
704
706
|
if isinstance(resp, dict):
|
|
705
707
|
if resp.get("state") == "reloading":
|
|
706
|
-
return
|
|
708
|
+
return "reloading"
|
|
709
|
+
data = resp.get("data")
|
|
710
|
+
if isinstance(data, dict):
|
|
711
|
+
reason = data.get("reason")
|
|
712
|
+
if isinstance(reason, str):
|
|
713
|
+
return reason.lower()
|
|
707
714
|
message_text = (resp.get("message") or resp.get("error") or "").lower()
|
|
708
|
-
|
|
715
|
+
if "reload" in message_text:
|
|
716
|
+
return "reloading"
|
|
717
|
+
return None
|
|
709
718
|
|
|
710
|
-
return
|
|
719
|
+
return None
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
def _is_reloading_response(resp: object) -> bool:
|
|
723
|
+
"""Return True if the Unity response indicates the editor is reloading.
|
|
724
|
+
|
|
725
|
+
Supports both raw dict payloads from Unity and MCPResponse objects returned
|
|
726
|
+
by preflight checks or transport helpers.
|
|
727
|
+
"""
|
|
728
|
+
return _extract_response_reason(resp) == "reloading"
|
|
711
729
|
|
|
712
730
|
|
|
713
731
|
def send_command_with_retry(
|
|
@@ -738,15 +756,82 @@ def send_command_with_retry(
|
|
|
738
756
|
max_retries = getattr(config, "reload_max_retries", 40)
|
|
739
757
|
if retry_ms is None:
|
|
740
758
|
retry_ms = getattr(config, "reload_retry_ms", 250)
|
|
759
|
+
try:
|
|
760
|
+
max_wait_s = float(os.environ.get(
|
|
761
|
+
"UNITY_MCP_RELOAD_MAX_WAIT_S", "2.0"))
|
|
762
|
+
except ValueError as e:
|
|
763
|
+
raw_val = os.environ.get("UNITY_MCP_RELOAD_MAX_WAIT_S", "2.0")
|
|
764
|
+
logger.warning(
|
|
765
|
+
"Invalid UNITY_MCP_RELOAD_MAX_WAIT_S=%r, using default 2.0: %s",
|
|
766
|
+
raw_val, e)
|
|
767
|
+
max_wait_s = 2.0
|
|
768
|
+
# Clamp to [0, 30] to prevent misconfiguration from causing excessive waits
|
|
769
|
+
max_wait_s = max(0.0, min(max_wait_s, 30.0))
|
|
741
770
|
|
|
742
771
|
response = conn.send_command(command_type, params)
|
|
743
772
|
retries = 0
|
|
773
|
+
wait_started = None
|
|
774
|
+
reason = _extract_response_reason(response)
|
|
744
775
|
while _is_reloading_response(response) and retries < max_retries:
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
776
|
+
if wait_started is None:
|
|
777
|
+
wait_started = time.monotonic()
|
|
778
|
+
logger.debug(
|
|
779
|
+
"Unity reload wait started: command=%s instance=%s reason=%s max_wait_s=%.2f",
|
|
780
|
+
command_type,
|
|
781
|
+
instance_id or "default",
|
|
782
|
+
reason or "reloading",
|
|
783
|
+
max_wait_s,
|
|
784
|
+
)
|
|
785
|
+
if max_wait_s <= 0:
|
|
786
|
+
break
|
|
787
|
+
elapsed = time.monotonic() - wait_started
|
|
788
|
+
if elapsed >= max_wait_s:
|
|
789
|
+
break
|
|
790
|
+
delay_ms = retry_ms
|
|
791
|
+
if isinstance(response, dict):
|
|
792
|
+
retry_after = response.get("retry_after_ms")
|
|
793
|
+
if retry_after is None and isinstance(response.get("data"), dict):
|
|
794
|
+
retry_after = response["data"].get("retry_after_ms")
|
|
795
|
+
if retry_after is not None:
|
|
796
|
+
delay_ms = int(retry_after)
|
|
797
|
+
sleep_ms = max(50, min(int(delay_ms), 250))
|
|
798
|
+
logger.debug(
|
|
799
|
+
"Unity reload wait retry: command=%s instance=%s reason=%s retry_after_ms=%s sleep_ms=%s",
|
|
800
|
+
command_type,
|
|
801
|
+
instance_id or "default",
|
|
802
|
+
reason or "reloading",
|
|
803
|
+
delay_ms,
|
|
804
|
+
sleep_ms,
|
|
805
|
+
)
|
|
806
|
+
time.sleep(max(0.0, sleep_ms / 1000.0))
|
|
748
807
|
retries += 1
|
|
749
808
|
response = conn.send_command(command_type, params)
|
|
809
|
+
reason = _extract_response_reason(response)
|
|
810
|
+
|
|
811
|
+
if wait_started is not None:
|
|
812
|
+
waited = time.monotonic() - wait_started
|
|
813
|
+
if _is_reloading_response(response):
|
|
814
|
+
logger.debug(
|
|
815
|
+
"Unity reload wait exceeded budget: command=%s instance=%s waited_s=%.3f",
|
|
816
|
+
command_type,
|
|
817
|
+
instance_id or "default",
|
|
818
|
+
waited,
|
|
819
|
+
)
|
|
820
|
+
return MCPResponse(
|
|
821
|
+
success=False,
|
|
822
|
+
error="Unity is reloading; please retry",
|
|
823
|
+
hint="retry",
|
|
824
|
+
data={
|
|
825
|
+
"reason": "reloading",
|
|
826
|
+
"retry_after_ms": min(250, max(50, retry_ms)),
|
|
827
|
+
},
|
|
828
|
+
)
|
|
829
|
+
logger.debug(
|
|
830
|
+
"Unity reload wait completed: command=%s instance=%s waited_s=%.3f",
|
|
831
|
+
command_type,
|
|
832
|
+
instance_id or "default",
|
|
833
|
+
waited,
|
|
834
|
+
)
|
|
750
835
|
return response
|
|
751
836
|
|
|
752
837
|
|
transport/plugin_hub.py
CHANGED
|
@@ -34,6 +34,10 @@ class PluginDisconnectedError(RuntimeError):
|
|
|
34
34
|
"""Raised when a plugin WebSocket disconnects while commands are in flight."""
|
|
35
35
|
|
|
36
36
|
|
|
37
|
+
class NoUnitySessionError(RuntimeError):
|
|
38
|
+
"""Raised when no Unity plugins are available."""
|
|
39
|
+
|
|
40
|
+
|
|
37
41
|
class PluginHub(WebSocketEndpoint):
|
|
38
42
|
"""Manages persistent WebSocket connections to Unity plugins."""
|
|
39
43
|
|
|
@@ -41,10 +45,14 @@ class PluginHub(WebSocketEndpoint):
|
|
|
41
45
|
KEEP_ALIVE_INTERVAL = 15
|
|
42
46
|
SERVER_TIMEOUT = 30
|
|
43
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
|
|
44
51
|
# Fast-path commands should never block the client for long; return a retry hint instead.
|
|
45
52
|
# This helps avoid the Cursor-side ~30s tool-call timeout when Unity is compiling/reloading
|
|
46
53
|
# or is throttled while unfocused.
|
|
47
|
-
_FAST_FAIL_COMMANDS: set[str] = {
|
|
54
|
+
_FAST_FAIL_COMMANDS: set[str] = {
|
|
55
|
+
"read_console", "get_editor_state", "ping"}
|
|
48
56
|
|
|
49
57
|
_registry: PluginRegistry | None = None
|
|
50
58
|
_connections: dict[str, WebSocket] = {}
|
|
@@ -114,7 +122,8 @@ class PluginHub(WebSocketEndpoint):
|
|
|
114
122
|
]
|
|
115
123
|
for command_id in pending_ids:
|
|
116
124
|
entry = cls._pending.get(command_id)
|
|
117
|
-
future = entry.get("future") if isinstance(
|
|
125
|
+
future = entry.get("future") if isinstance(
|
|
126
|
+
entry, dict) else None
|
|
118
127
|
if future and not future.done():
|
|
119
128
|
future.set_exception(
|
|
120
129
|
PluginDisconnectedError(
|
|
@@ -136,18 +145,15 @@ class PluginHub(WebSocketEndpoint):
|
|
|
136
145
|
future: asyncio.Future = asyncio.get_running_loop().create_future()
|
|
137
146
|
# Compute a per-command timeout:
|
|
138
147
|
# - fast-path commands: short timeout (encourage retry)
|
|
139
|
-
# - long-running commands
|
|
148
|
+
# - long-running commands: allow caller to request a longer timeout via params
|
|
140
149
|
unity_timeout_s = float(cls.COMMAND_TIMEOUT)
|
|
141
150
|
server_wait_s = float(cls.COMMAND_TIMEOUT)
|
|
142
151
|
if command_type in cls._FAST_FAIL_COMMANDS:
|
|
143
|
-
|
|
144
|
-
fast_timeout = float(os.environ.get("UNITY_MCP_FAST_COMMAND_TIMEOUT", "3"))
|
|
145
|
-
except Exception:
|
|
146
|
-
fast_timeout = 3.0
|
|
152
|
+
fast_timeout = float(cls.FAST_FAIL_TIMEOUT)
|
|
147
153
|
unity_timeout_s = fast_timeout
|
|
148
154
|
server_wait_s = fast_timeout
|
|
149
155
|
else:
|
|
150
|
-
# Common tools pass a requested timeout in seconds (e.g.,
|
|
156
|
+
# Common tools pass a requested timeout in seconds (e.g., timeout_seconds=900).
|
|
151
157
|
requested = None
|
|
152
158
|
try:
|
|
153
159
|
if isinstance(params, dict):
|
|
@@ -176,7 +182,8 @@ class PluginHub(WebSocketEndpoint):
|
|
|
176
182
|
if command_id in cls._pending:
|
|
177
183
|
raise RuntimeError(
|
|
178
184
|
f"Duplicate command id generated: {command_id}")
|
|
179
|
-
cls._pending[command_id] = {
|
|
185
|
+
cls._pending[command_id] = {
|
|
186
|
+
"future": future, "session_id": session_id}
|
|
180
187
|
|
|
181
188
|
try:
|
|
182
189
|
msg = ExecuteCommandMessage(
|
|
@@ -361,11 +368,21 @@ class PluginHub(WebSocketEndpoint):
|
|
|
361
368
|
if cls._registry is None:
|
|
362
369
|
raise RuntimeError("Plugin registry not configured")
|
|
363
370
|
|
|
364
|
-
#
|
|
365
|
-
|
|
366
|
-
|
|
371
|
+
# Bound waiting for Unity sessions so calls fail fast when editors are not ready.
|
|
372
|
+
try:
|
|
373
|
+
max_wait_s = float(
|
|
374
|
+
os.environ.get("UNITY_MCP_SESSION_RESOLVE_MAX_WAIT_S", "2.0"))
|
|
375
|
+
except ValueError as e:
|
|
376
|
+
raw_val = os.environ.get(
|
|
377
|
+
"UNITY_MCP_SESSION_RESOLVE_MAX_WAIT_S", "2.0")
|
|
378
|
+
logger.warning(
|
|
379
|
+
"Invalid UNITY_MCP_SESSION_RESOLVE_MAX_WAIT_S=%r, using default 2.0: %s",
|
|
380
|
+
raw_val, e)
|
|
381
|
+
max_wait_s = 2.0
|
|
382
|
+
# Clamp to [0, 30] to prevent misconfiguration from causing excessive waits
|
|
383
|
+
max_wait_s = max(0.0, min(max_wait_s, 30.0))
|
|
367
384
|
retry_ms = float(getattr(config, "reload_retry_ms", 250))
|
|
368
|
-
sleep_seconds = max(0.05, retry_ms / 1000.0)
|
|
385
|
+
sleep_seconds = max(0.05, min(0.25, retry_ms / 1000.0))
|
|
369
386
|
|
|
370
387
|
# Allow callers to provide either just the hash or Name@hash
|
|
371
388
|
target_hash: str | None = None
|
|
@@ -394,7 +411,7 @@ class PluginHub(WebSocketEndpoint):
|
|
|
394
411
|
return None, count
|
|
395
412
|
|
|
396
413
|
session_id, session_count = await _try_once()
|
|
397
|
-
deadline = time.monotonic() +
|
|
414
|
+
deadline = time.monotonic() + max_wait_s
|
|
398
415
|
wait_started = None
|
|
399
416
|
|
|
400
417
|
# If there is no active plugin yet (e.g., Unity starting up or reloading),
|
|
@@ -403,33 +420,40 @@ class PluginHub(WebSocketEndpoint):
|
|
|
403
420
|
if not target_hash and session_count > 1:
|
|
404
421
|
raise RuntimeError(
|
|
405
422
|
"Multiple Unity instances are connected. "
|
|
406
|
-
"Call set_active_instance with Name@hash from
|
|
423
|
+
"Call set_active_instance with Name@hash from mcpforunity://instances."
|
|
407
424
|
)
|
|
408
425
|
if wait_started is None:
|
|
409
426
|
wait_started = time.monotonic()
|
|
410
427
|
logger.debug(
|
|
411
|
-
|
|
428
|
+
"No plugin session available (instance=%s); waiting up to %.2fs",
|
|
429
|
+
unity_instance or "default",
|
|
430
|
+
max_wait_s,
|
|
412
431
|
)
|
|
413
432
|
await asyncio.sleep(sleep_seconds)
|
|
414
433
|
session_id, session_count = await _try_once()
|
|
415
434
|
|
|
416
435
|
if session_id is not None and wait_started is not None:
|
|
417
436
|
logger.debug(
|
|
418
|
-
|
|
437
|
+
"Plugin session restored after %.3fs (instance=%s)",
|
|
438
|
+
time.monotonic() - wait_started,
|
|
439
|
+
unity_instance or "default",
|
|
419
440
|
)
|
|
420
441
|
if session_id is None and not target_hash and session_count > 1:
|
|
421
442
|
raise RuntimeError(
|
|
422
443
|
"Multiple Unity instances are connected. "
|
|
423
|
-
"Call set_active_instance with Name@hash from
|
|
444
|
+
"Call set_active_instance with Name@hash from mcpforunity://instances."
|
|
424
445
|
)
|
|
425
446
|
|
|
426
447
|
if session_id is None:
|
|
427
448
|
logger.warning(
|
|
428
|
-
|
|
449
|
+
"No Unity plugin reconnected within %.2fs (instance=%s)",
|
|
450
|
+
max_wait_s,
|
|
451
|
+
unity_instance or "default",
|
|
429
452
|
)
|
|
430
453
|
# At this point we've given the plugin ample time to reconnect; surface
|
|
431
454
|
# a clear error so the client can prompt the user to open Unity.
|
|
432
|
-
raise
|
|
455
|
+
raise NoUnitySessionError(
|
|
456
|
+
"No Unity plugins are currently connected")
|
|
433
457
|
|
|
434
458
|
return session_id
|
|
435
459
|
|
|
@@ -440,7 +464,20 @@ class PluginHub(WebSocketEndpoint):
|
|
|
440
464
|
command_type: str,
|
|
441
465
|
params: dict[str, Any],
|
|
442
466
|
) -> dict[str, Any]:
|
|
443
|
-
|
|
467
|
+
try:
|
|
468
|
+
session_id = await cls._resolve_session_id(unity_instance)
|
|
469
|
+
except NoUnitySessionError:
|
|
470
|
+
logger.debug(
|
|
471
|
+
"Unity session unavailable; returning retry: command=%s instance=%s",
|
|
472
|
+
command_type,
|
|
473
|
+
unity_instance or "default",
|
|
474
|
+
)
|
|
475
|
+
return MCPResponse(
|
|
476
|
+
success=False,
|
|
477
|
+
error="Unity session not available; please retry",
|
|
478
|
+
hint="retry",
|
|
479
|
+
data={"reason": "no_unity_session", "retry_after_ms": 250},
|
|
480
|
+
).model_dump()
|
|
444
481
|
|
|
445
482
|
# During domain reload / immediate reconnect windows, the plugin may be connected but not yet
|
|
446
483
|
# ready to process execute commands on the Unity main thread (which can be further delayed when
|
|
@@ -449,8 +486,14 @@ class PluginHub(WebSocketEndpoint):
|
|
|
449
486
|
# register_tools (which can be delayed by EditorApplication.delayCall).
|
|
450
487
|
if command_type in cls._FAST_FAIL_COMMANDS and command_type != "ping":
|
|
451
488
|
try:
|
|
452
|
-
max_wait_s = float(os.environ.get(
|
|
453
|
-
|
|
489
|
+
max_wait_s = float(os.environ.get(
|
|
490
|
+
"UNITY_MCP_SESSION_READY_WAIT_SECONDS", "6"))
|
|
491
|
+
except ValueError as e:
|
|
492
|
+
raw_val = os.environ.get(
|
|
493
|
+
"UNITY_MCP_SESSION_READY_WAIT_SECONDS", "6")
|
|
494
|
+
logger.warning(
|
|
495
|
+
"Invalid UNITY_MCP_SESSION_READY_WAIT_SECONDS=%r, using default 6.0: %s",
|
|
496
|
+
raw_val, e)
|
|
454
497
|
max_wait_s = 6.0
|
|
455
498
|
max_wait_s = max(0.0, min(max_wait_s, 30.0))
|
|
456
499
|
if max_wait_s > 0:
|
|
@@ -463,7 +506,8 @@ class PluginHub(WebSocketEndpoint):
|
|
|
463
506
|
|
|
464
507
|
# The Unity-side dispatcher responds with {status:"success", result:{message:"pong"}}
|
|
465
508
|
if isinstance(probe, dict) and probe.get("status") == "success":
|
|
466
|
-
result = probe.get("result") if isinstance(
|
|
509
|
+
result = probe.get("result") if isinstance(
|
|
510
|
+
probe.get("result"), dict) else {}
|
|
467
511
|
if result.get("message") == "pong":
|
|
468
512
|
break
|
|
469
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(
|
|
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.
|
transport/unity_transport.py
CHANGED
|
@@ -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,
|
|
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.0.dist-info/licenses/LICENSE,sha256=bv5lDJZQEqxBgjjc1rkRbkEwpSIHF-8N-1Od0VnEJFw,1066
|
|
9
|
-
models/__init__.py,sha256=JlscZkGWE9TRmSoBi99v_LSl8OAFNGmr8463PYkXin4,179
|
|
10
|
-
models/models.py,sha256=heXuvdBtdats1SGwW8wKFFHM0qR4hA6A7qETn5s9BZ0,1827
|
|
11
|
-
models/unity_response.py,sha256=oJ1PTsnNc5VBC-9OgM59C0C-R9N-GdmEdmz_yph4GSU,1454
|
|
12
|
-
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=MdQcrnVXra9PLu5AFkmARjriObT0miExtQKkFaANznU,4662
|
|
50
|
-
services/tools/refresh_unity.py,sha256=anTEuEzxKTFse6ldZxTsk43zI6ahRBDv3Sg_pMHYRYA,3719
|
|
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=g_DOhCThgJ9Oco_z3m2qpwDeUcFvvt7Z47xMS0diihw,21497
|
|
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=ujUX9WX7Gb-fxQveHts3uiepTPzFq8i7-XG7u5gSPuM,32668
|
|
65
|
-
utils/module_discovery.py,sha256=My48ofB1BUqxiBoAZAGbEaLQYdsrDhMm8MayBP_bUSQ,2005
|
|
66
|
-
utils/reload_sentinel.py,sha256=s1xMWhl-r2XwN0OUbiUv_VGUy8TvLtV5bkql-5n2DT0,373
|
|
67
|
-
mcpforunityserver-8.7.0.dist-info/METADATA,sha256=m3U2_aFTIAFPWg8YE3v2KcMJmP-Ffz0R-EJsJwoD6pA,5712
|
|
68
|
-
mcpforunityserver-8.7.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
69
|
-
mcpforunityserver-8.7.0.dist-info/entry_points.txt,sha256=vCtkqw-J9t4pj7JwUZcB54_keVx7DpAR3fYzK6i-s6g,44
|
|
70
|
-
mcpforunityserver-8.7.0.dist-info/top_level.txt,sha256=YKU5e5dREMfCnoVpmlsTm9bku7oqnrzSZ9FeTgjoxJw,58
|
|
71
|
-
mcpforunityserver-8.7.0.dist-info/RECORD,,
|
routes/__init__.py
DELETED
|
File without changes
|