mcpforunityserver 8.7.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 +177 -62
- {mcpforunityserver-8.7.0.dist-info → mcpforunityserver-9.1.0.dist-info}/METADATA +4 -2
- mcpforunityserver-9.1.0.dist-info/RECORD +96 -0
- {mcpforunityserver-8.7.0.dist-info → mcpforunityserver-9.1.0.dist-info}/WHEEL +1 -1
- {mcpforunityserver-8.7.0.dist-info → mcpforunityserver-9.1.0.dist-info}/entry_points.txt +1 -0
- {mcpforunityserver-8.7.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 -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/__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 +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 +70 -17
- services/tools/refresh_unity.py +92 -29
- services/tools/run_tests.py +187 -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 +129 -26
- transport/plugin_hub.py +85 -24
- transport/unity_instance_middleware.py +4 -3
- transport/unity_transport.py +2 -1
- utils/focus_nudge.py +321 -0
- __init__.py +0 -0
- 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.1.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -4,6 +4,7 @@ import re
|
|
|
4
4
|
from typing import Annotated, Any, Union
|
|
5
5
|
|
|
6
6
|
from fastmcp import Context
|
|
7
|
+
from mcp.types import ToolAnnotations
|
|
7
8
|
|
|
8
9
|
from services.registry import mcp_for_unity_tool
|
|
9
10
|
from services.tools import get_unity_instance_from_context
|
|
@@ -228,7 +229,7 @@ def _normalize_script_locator(name: str, path: str) -> tuple[str, str]:
|
|
|
228
229
|
- name = "SmartReach.cs", path = "Assets/Scripts/Interaction"
|
|
229
230
|
- name = "Assets/Scripts/Interaction/SmartReach.cs", path = ""
|
|
230
231
|
- path = "Assets/Scripts/Interaction/SmartReach.cs" (name empty)
|
|
231
|
-
- name or path using uri prefixes:
|
|
232
|
+
- name or path using uri prefixes: mcpforunity://path/..., file://...
|
|
232
233
|
- accidental duplicates like "Assets/.../SmartReach.cs/SmartReach.cs"
|
|
233
234
|
|
|
234
235
|
Returns (name_without_extension, directory_path_under_Assets).
|
|
@@ -237,8 +238,8 @@ def _normalize_script_locator(name: str, path: str) -> tuple[str, str]:
|
|
|
237
238
|
p = (path or "").strip()
|
|
238
239
|
|
|
239
240
|
def strip_prefix(s: str) -> str:
|
|
240
|
-
if s.startswith("
|
|
241
|
-
return s[len("
|
|
241
|
+
if s.startswith("mcpforunity://path/"):
|
|
242
|
+
return s[len("mcpforunity://path/"):]
|
|
242
243
|
if s.startswith("file://"):
|
|
243
244
|
return s[len("file://"):]
|
|
244
245
|
return s
|
|
@@ -309,8 +310,10 @@ def _err(code: str, message: str, *, expected: dict[str, Any] | None = None, rew
|
|
|
309
310
|
# Natural-language parsing removed; clients should send structured edits.
|
|
310
311
|
|
|
311
312
|
|
|
312
|
-
@mcp_for_unity_tool(
|
|
313
|
-
""
|
|
313
|
+
@mcp_for_unity_tool(
|
|
314
|
+
name="script_apply_edits",
|
|
315
|
+
description=(
|
|
316
|
+
"""Structured C# edits (methods/classes) with safer boundaries - prefer this over raw text.
|
|
314
317
|
Best practices:
|
|
315
318
|
- Prefer anchor_* ops for pattern-based insert/replace near stable markers
|
|
316
319
|
- Use replace_method/delete_method for whole-method changes (keeps signatures balanced)
|
|
@@ -356,7 +359,12 @@ def _err(code: str, message: str, *, expected: dict[str, Any] | None = None, rew
|
|
|
356
359
|
],
|
|
357
360
|
}
|
|
358
361
|
]"""
|
|
359
|
-
)
|
|
362
|
+
),
|
|
363
|
+
annotations=ToolAnnotations(
|
|
364
|
+
title="Script Apply Edits",
|
|
365
|
+
destructiveHint=True,
|
|
366
|
+
),
|
|
367
|
+
)
|
|
360
368
|
async def script_apply_edits(
|
|
361
369
|
ctx: Context,
|
|
362
370
|
name: Annotated[str, "Name of the script to edit"],
|
|
@@ -372,7 +380,7 @@ async def script_apply_edits(
|
|
|
372
380
|
unity_instance = get_unity_instance_from_context(ctx)
|
|
373
381
|
await ctx.info(
|
|
374
382
|
f"Processing script_apply_edits: {name} (unity_instance={unity_instance or 'default'})")
|
|
375
|
-
|
|
383
|
+
|
|
376
384
|
# Parse edits if they came as a stringified JSON
|
|
377
385
|
edits = parse_json_payload(edits)
|
|
378
386
|
if not isinstance(edits, list):
|
|
@@ -2,6 +2,8 @@ from typing import Annotated, Any
|
|
|
2
2
|
from types import SimpleNamespace
|
|
3
3
|
|
|
4
4
|
from fastmcp import Context
|
|
5
|
+
from mcp.types import ToolAnnotations
|
|
6
|
+
|
|
5
7
|
from services.registry import mcp_for_unity_tool
|
|
6
8
|
from transport.legacy.unity_connection import get_unity_connection_pool
|
|
7
9
|
from transport.unity_instance_middleware import get_unity_instance_middleware
|
|
@@ -10,7 +12,10 @@ from transport.unity_transport import _current_transport
|
|
|
10
12
|
|
|
11
13
|
|
|
12
14
|
@mcp_for_unity_tool(
|
|
13
|
-
description="Set the active Unity instance for this client/session. Accepts Name@hash or hash."
|
|
15
|
+
description="Set the active Unity instance for this client/session. Accepts Name@hash or hash.",
|
|
16
|
+
annotations=ToolAnnotations(
|
|
17
|
+
title="Set Active Instance",
|
|
18
|
+
),
|
|
14
19
|
)
|
|
15
20
|
async def set_active_instance(
|
|
16
21
|
ctx: Context,
|
|
@@ -51,7 +56,7 @@ async def set_active_instance(
|
|
|
51
56
|
return {
|
|
52
57
|
"success": False,
|
|
53
58
|
"error": "Instance identifier is required. "
|
|
54
|
-
"Use
|
|
59
|
+
"Use mcpforunity://instances to copy a Name@hash or provide a hash prefix."
|
|
55
60
|
}
|
|
56
61
|
resolved = None
|
|
57
62
|
if "@" in value:
|
|
@@ -60,7 +65,7 @@ async def set_active_instance(
|
|
|
60
65
|
return {
|
|
61
66
|
"success": False,
|
|
62
67
|
"error": f"Instance '{value}' not found. "
|
|
63
|
-
|
|
68
|
+
"Use mcpforunity://instances to copy an exact Name@hash."
|
|
64
69
|
}
|
|
65
70
|
else:
|
|
66
71
|
lookup = value.lower()
|
|
@@ -75,7 +80,7 @@ async def set_active_instance(
|
|
|
75
80
|
return {
|
|
76
81
|
"success": False,
|
|
77
82
|
"error": f"Instance hash '{value}' does not match any running Unity editors. "
|
|
78
|
-
|
|
83
|
+
"Use mcpforunity://instances to confirm the available hashes."
|
|
79
84
|
}
|
|
80
85
|
if len(matches) > 1:
|
|
81
86
|
matching_ids = ", ".join(
|
|
@@ -84,10 +89,10 @@ async def set_active_instance(
|
|
|
84
89
|
return {
|
|
85
90
|
"success": False,
|
|
86
91
|
"error": f"Instance hash '{value}' is ambiguous ({matching_ids}). "
|
|
87
|
-
|
|
92
|
+
"Provide the full Name@hash from mcpforunity://instances."
|
|
88
93
|
}
|
|
89
94
|
resolved = matches[0]
|
|
90
|
-
|
|
95
|
+
|
|
91
96
|
if resolved is None:
|
|
92
97
|
# Should be unreachable due to logic above, but satisfies static analysis
|
|
93
98
|
return {
|
|
@@ -101,7 +106,7 @@ async def set_active_instance(
|
|
|
101
106
|
# The session key is an internal detail but useful for debugging response.
|
|
102
107
|
middleware.set_active_instance(ctx, resolved.id)
|
|
103
108
|
session_key = middleware.get_session_key(ctx)
|
|
104
|
-
|
|
109
|
+
|
|
105
110
|
return {
|
|
106
111
|
"success": True,
|
|
107
112
|
"message": f"Active instance set to {resolved.id}",
|
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:
|
|
@@ -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))
|