mcpforunityserver 9.4.0b20260203025228__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 +84 -0
- cli/commands/asset.py +280 -0
- cli/commands/audio.py +125 -0
- cli/commands/batch.py +171 -0
- cli/commands/code.py +182 -0
- cli/commands/component.py +190 -0
- cli/commands/editor.py +447 -0
- cli/commands/gameobject.py +487 -0
- cli/commands/instance.py +93 -0
- cli/commands/lighting.py +123 -0
- cli/commands/material.py +239 -0
- cli/commands/prefab.py +248 -0
- cli/commands/scene.py +231 -0
- cli/commands/script.py +222 -0
- cli/commands/shader.py +226 -0
- cli/commands/texture.py +540 -0
- cli/commands/tool.py +58 -0
- cli/commands/ui.py +258 -0
- cli/commands/vfx.py +421 -0
- cli/main.py +281 -0
- cli/utils/__init__.py +31 -0
- cli/utils/config.py +58 -0
- cli/utils/confirmation.py +37 -0
- cli/utils/connection.py +254 -0
- cli/utils/constants.py +23 -0
- cli/utils/output.py +195 -0
- cli/utils/parsers.py +112 -0
- cli/utils/suggestions.py +34 -0
- core/__init__.py +0 -0
- core/config.py +67 -0
- core/constants.py +4 -0
- core/logging_decorator.py +37 -0
- core/telemetry.py +551 -0
- core/telemetry_decorator.py +164 -0
- main.py +845 -0
- mcpforunityserver-9.4.0b20260203025228.dist-info/METADATA +328 -0
- mcpforunityserver-9.4.0b20260203025228.dist-info/RECORD +105 -0
- mcpforunityserver-9.4.0b20260203025228.dist-info/WHEEL +5 -0
- mcpforunityserver-9.4.0b20260203025228.dist-info/entry_points.txt +3 -0
- mcpforunityserver-9.4.0b20260203025228.dist-info/licenses/LICENSE +21 -0
- mcpforunityserver-9.4.0b20260203025228.dist-info/top_level.txt +7 -0
- models/__init__.py +4 -0
- models/models.py +56 -0
- models/unity_response.py +70 -0
- services/__init__.py +0 -0
- services/api_key_service.py +235 -0
- services/custom_tool_service.py +499 -0
- services/registry/__init__.py +22 -0
- services/registry/resource_registry.py +53 -0
- services/registry/tool_registry.py +51 -0
- services/resources/__init__.py +86 -0
- services/resources/active_tool.py +48 -0
- services/resources/custom_tools.py +57 -0
- services/resources/editor_state.py +304 -0
- services/resources/gameobject.py +243 -0
- services/resources/layers.py +30 -0
- services/resources/menu_items.py +35 -0
- services/resources/prefab.py +191 -0
- services/resources/prefab_stage.py +40 -0
- services/resources/project_info.py +40 -0
- services/resources/selection.py +56 -0
- services/resources/tags.py +31 -0
- services/resources/tests.py +88 -0
- services/resources/unity_instances.py +125 -0
- services/resources/windows.py +48 -0
- services/state/external_changes_scanner.py +245 -0
- services/tools/__init__.py +83 -0
- services/tools/batch_execute.py +93 -0
- services/tools/debug_request_context.py +86 -0
- services/tools/execute_custom_tool.py +43 -0
- services/tools/execute_menu_item.py +32 -0
- services/tools/find_gameobjects.py +110 -0
- services/tools/find_in_file.py +181 -0
- services/tools/manage_asset.py +119 -0
- services/tools/manage_components.py +131 -0
- services/tools/manage_editor.py +64 -0
- services/tools/manage_gameobject.py +260 -0
- services/tools/manage_material.py +111 -0
- services/tools/manage_prefabs.py +209 -0
- services/tools/manage_scene.py +111 -0
- services/tools/manage_script.py +645 -0
- services/tools/manage_scriptable_object.py +87 -0
- services/tools/manage_shader.py +71 -0
- services/tools/manage_texture.py +581 -0
- services/tools/manage_vfx.py +120 -0
- services/tools/preflight.py +110 -0
- services/tools/read_console.py +151 -0
- services/tools/refresh_unity.py +153 -0
- services/tools/run_tests.py +317 -0
- services/tools/script_apply_edits.py +1006 -0
- services/tools/set_active_instance.py +120 -0
- services/tools/utils.py +348 -0
- transport/__init__.py +0 -0
- transport/legacy/port_discovery.py +329 -0
- transport/legacy/stdio_port_registry.py +65 -0
- transport/legacy/unity_connection.py +910 -0
- transport/models.py +68 -0
- transport/plugin_hub.py +787 -0
- transport/plugin_registry.py +182 -0
- transport/unity_instance_middleware.py +262 -0
- transport/unity_transport.py +94 -0
- utils/focus_nudge.py +589 -0
- utils/module_discovery.py +55 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
from typing import Annotated, Any, Literal
|
|
2
|
+
|
|
3
|
+
from fastmcp import Context
|
|
4
|
+
from mcp.types import ToolAnnotations
|
|
5
|
+
|
|
6
|
+
from services.registry import mcp_for_unity_tool
|
|
7
|
+
from services.tools import get_unity_instance_from_context
|
|
8
|
+
from transport.unity_transport import send_with_unity_instance
|
|
9
|
+
from transport.legacy.unity_connection import async_send_command_with_retry
|
|
10
|
+
|
|
11
|
+
# All possible actions grouped by component type
|
|
12
|
+
PARTICLE_ACTIONS = [
|
|
13
|
+
"particle_get_info", "particle_set_main", "particle_set_emission", "particle_set_shape",
|
|
14
|
+
"particle_set_color_over_lifetime", "particle_set_size_over_lifetime",
|
|
15
|
+
"particle_set_velocity_over_lifetime", "particle_set_noise", "particle_set_renderer",
|
|
16
|
+
"particle_enable_module", "particle_play", "particle_stop", "particle_pause",
|
|
17
|
+
"particle_restart", "particle_clear", "particle_add_burst", "particle_clear_bursts"
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
VFX_ACTIONS = [
|
|
21
|
+
# Asset management
|
|
22
|
+
"vfx_create_asset", "vfx_assign_asset", "vfx_list_templates", "vfx_list_assets",
|
|
23
|
+
# Runtime control
|
|
24
|
+
"vfx_get_info", "vfx_set_float", "vfx_set_int", "vfx_set_bool",
|
|
25
|
+
"vfx_set_vector2", "vfx_set_vector3", "vfx_set_vector4", "vfx_set_color",
|
|
26
|
+
"vfx_set_gradient", "vfx_set_texture", "vfx_set_mesh", "vfx_set_curve",
|
|
27
|
+
"vfx_send_event", "vfx_play", "vfx_stop", "vfx_pause", "vfx_reinit",
|
|
28
|
+
"vfx_set_playback_speed", "vfx_set_seed"
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
LINE_ACTIONS = [
|
|
32
|
+
"line_get_info", "line_set_positions", "line_add_position", "line_set_position",
|
|
33
|
+
"line_set_width", "line_set_color", "line_set_material", "line_set_properties",
|
|
34
|
+
"line_clear", "line_create_line", "line_create_circle", "line_create_arc", "line_create_bezier"
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
TRAIL_ACTIONS = [
|
|
38
|
+
"trail_get_info", "trail_set_time", "trail_set_width", "trail_set_color",
|
|
39
|
+
"trail_set_material", "trail_set_properties", "trail_clear", "trail_emit"
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
ALL_ACTIONS = ["ping"] + PARTICLE_ACTIONS + VFX_ACTIONS + LINE_ACTIONS + TRAIL_ACTIONS
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@mcp_for_unity_tool(
|
|
46
|
+
description=(
|
|
47
|
+
"Manage Unity VFX components (ParticleSystem, VisualEffect, LineRenderer, TrailRenderer). "
|
|
48
|
+
"Action prefixes: particle_*, vfx_*, line_*, trail_*. "
|
|
49
|
+
"Action-specific parameters go in `properties` (keys match ManageVFX.cs)."
|
|
50
|
+
),
|
|
51
|
+
annotations=ToolAnnotations(
|
|
52
|
+
title="Manage VFX",
|
|
53
|
+
destructiveHint=True,
|
|
54
|
+
),
|
|
55
|
+
)
|
|
56
|
+
async def manage_vfx(
|
|
57
|
+
ctx: Context,
|
|
58
|
+
action: Annotated[str, "Action to perform (prefix: particle_, vfx_, line_, trail_)."],
|
|
59
|
+
target: Annotated[str | None, "Target GameObject (name/path/id)."] = None,
|
|
60
|
+
search_method: Annotated[
|
|
61
|
+
Literal["by_id", "by_name", "by_path", "by_tag", "by_layer"] | None,
|
|
62
|
+
"How to find the target GameObject.",
|
|
63
|
+
] = None,
|
|
64
|
+
properties: Annotated[
|
|
65
|
+
dict[str, Any] | str | None,
|
|
66
|
+
"Action-specific parameters (dict or JSON string).",
|
|
67
|
+
] = None,
|
|
68
|
+
) -> dict[str, Any]:
|
|
69
|
+
"""Unified VFX management tool."""
|
|
70
|
+
|
|
71
|
+
# Normalize action to lowercase to match Unity-side behavior
|
|
72
|
+
action_normalized = action.lower()
|
|
73
|
+
|
|
74
|
+
# Validate action against known actions using normalized value
|
|
75
|
+
if action_normalized not in ALL_ACTIONS:
|
|
76
|
+
# Provide helpful error with closest matches by prefix
|
|
77
|
+
prefix = action_normalized.split(
|
|
78
|
+
"_")[0] + "_" if "_" in action_normalized else ""
|
|
79
|
+
available_by_prefix = {
|
|
80
|
+
"particle_": PARTICLE_ACTIONS,
|
|
81
|
+
"vfx_": VFX_ACTIONS,
|
|
82
|
+
"line_": LINE_ACTIONS,
|
|
83
|
+
"trail_": TRAIL_ACTIONS,
|
|
84
|
+
}
|
|
85
|
+
suggestions = available_by_prefix.get(prefix, [])
|
|
86
|
+
if suggestions:
|
|
87
|
+
return {
|
|
88
|
+
"success": False,
|
|
89
|
+
"message": f"Unknown action '{action}'. Available {prefix}* actions: {', '.join(suggestions)}",
|
|
90
|
+
}
|
|
91
|
+
else:
|
|
92
|
+
return {
|
|
93
|
+
"success": False,
|
|
94
|
+
"message": (
|
|
95
|
+
f"Unknown action '{action}'. Use prefixes: "
|
|
96
|
+
"particle_*, vfx_*, line_*, trail_*. Run with action='ping' to test connection."
|
|
97
|
+
),
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
unity_instance = get_unity_instance_from_context(ctx)
|
|
101
|
+
|
|
102
|
+
params_dict: dict[str, Any] = {"action": action_normalized}
|
|
103
|
+
if properties is not None:
|
|
104
|
+
params_dict["properties"] = properties
|
|
105
|
+
if target is not None:
|
|
106
|
+
params_dict["target"] = target
|
|
107
|
+
if search_method is not None:
|
|
108
|
+
params_dict["searchMethod"] = search_method
|
|
109
|
+
|
|
110
|
+
params_dict = {k: v for k, v in params_dict.items() if v is not None}
|
|
111
|
+
|
|
112
|
+
# Send to Unity
|
|
113
|
+
result = await send_with_unity_instance(
|
|
114
|
+
async_send_command_with_retry,
|
|
115
|
+
unity_instance,
|
|
116
|
+
"manage_vfx",
|
|
117
|
+
params_dict,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
return result if isinstance(result, dict) else {"success": False, "message": str(result)}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import os
|
|
5
|
+
import time
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from models import MCPResponse
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _in_pytest() -> bool:
|
|
12
|
+
# Integration tests in this repo stub transports and do not run against a live Unity editor.
|
|
13
|
+
# Preflight must be a no-op in that environment to avoid breaking the existing test suite.
|
|
14
|
+
return bool(os.environ.get("PYTEST_CURRENT_TEST"))
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _busy(reason: str, retry_after_ms: int) -> MCPResponse:
|
|
18
|
+
return MCPResponse(
|
|
19
|
+
success=False,
|
|
20
|
+
error="busy",
|
|
21
|
+
message=reason,
|
|
22
|
+
hint="retry",
|
|
23
|
+
data={"reason": reason, "retry_after_ms": int(retry_after_ms)},
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
async def preflight(
|
|
28
|
+
ctx,
|
|
29
|
+
*,
|
|
30
|
+
requires_no_tests: bool = False,
|
|
31
|
+
wait_for_no_compile: bool = False,
|
|
32
|
+
refresh_if_dirty: bool = False,
|
|
33
|
+
max_wait_s: float = 30.0,
|
|
34
|
+
) -> MCPResponse | None:
|
|
35
|
+
"""
|
|
36
|
+
Server-side preflight guard used by tools so they behave safely even if the client never reads resources.
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
- MCPResponse busy/retry payload when the tool should not proceed right now
|
|
40
|
+
- None when the tool should proceed normally
|
|
41
|
+
"""
|
|
42
|
+
if _in_pytest():
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
# Load canonical editor state (server enriches advice + staleness).
|
|
46
|
+
try:
|
|
47
|
+
from services.resources.editor_state import get_editor_state
|
|
48
|
+
state_resp = await get_editor_state(ctx)
|
|
49
|
+
state = state_resp.model_dump() if hasattr(
|
|
50
|
+
state_resp, "model_dump") else state_resp
|
|
51
|
+
except Exception:
|
|
52
|
+
# If we cannot determine readiness, fall back to proceeding (tools already contain retry logic).
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
if not isinstance(state, dict) or not state.get("success", False):
|
|
56
|
+
# Unknown state; proceed rather than blocking (avoids false positives when Unity is reachable but status isn't).
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
data = state.get("data")
|
|
60
|
+
if not isinstance(data, dict):
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
# Optional refresh-if-dirty
|
|
64
|
+
if refresh_if_dirty:
|
|
65
|
+
assets = data.get("assets")
|
|
66
|
+
if isinstance(assets, dict) and assets.get("external_changes_dirty") is True:
|
|
67
|
+
try:
|
|
68
|
+
from services.tools.refresh_unity import refresh_unity
|
|
69
|
+
await refresh_unity(ctx, mode="if_dirty", scope="all", compile="request", wait_for_ready=True)
|
|
70
|
+
except Exception:
|
|
71
|
+
# Best-effort only; fall through to normal tool dispatch.
|
|
72
|
+
pass
|
|
73
|
+
|
|
74
|
+
# Tests running: fail fast for tools that require exclusivity.
|
|
75
|
+
if requires_no_tests:
|
|
76
|
+
tests = data.get("tests")
|
|
77
|
+
if isinstance(tests, dict) and tests.get("is_running") is True:
|
|
78
|
+
return _busy("tests_running", 5000)
|
|
79
|
+
|
|
80
|
+
# Compilation: optionally wait for a bounded time.
|
|
81
|
+
if wait_for_no_compile:
|
|
82
|
+
deadline = time.monotonic() + float(max_wait_s)
|
|
83
|
+
while True:
|
|
84
|
+
compilation = data.get("compilation") if isinstance(
|
|
85
|
+
data, dict) else None
|
|
86
|
+
is_compiling = isinstance(compilation, dict) and compilation.get(
|
|
87
|
+
"is_compiling") is True
|
|
88
|
+
is_domain_reload_pending = isinstance(compilation, dict) and compilation.get(
|
|
89
|
+
"is_domain_reload_pending") is True
|
|
90
|
+
if not is_compiling and not is_domain_reload_pending:
|
|
91
|
+
break
|
|
92
|
+
if time.monotonic() >= deadline:
|
|
93
|
+
return _busy("compiling", 500)
|
|
94
|
+
await asyncio.sleep(0.25)
|
|
95
|
+
|
|
96
|
+
# Refresh state for the next loop iteration.
|
|
97
|
+
try:
|
|
98
|
+
from services.resources.editor_state import get_editor_state
|
|
99
|
+
state_resp = await get_editor_state(ctx)
|
|
100
|
+
state = state_resp.model_dump() if hasattr(
|
|
101
|
+
state_resp, "model_dump") else state_resp
|
|
102
|
+
data = state.get("data") if isinstance(state, dict) else None
|
|
103
|
+
if not isinstance(data, dict):
|
|
104
|
+
return None
|
|
105
|
+
except Exception:
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
# Staleness: if the snapshot is stale, proceed (tools will still run), but callers that read resources can back off.
|
|
109
|
+
# In future we may make this strict for some tools.
|
|
110
|
+
return None
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Defines the read_console tool for accessing Unity Editor console messages.
|
|
3
|
+
"""
|
|
4
|
+
from typing import Annotated, Any, Literal
|
|
5
|
+
|
|
6
|
+
from fastmcp import Context
|
|
7
|
+
from mcp.types import ToolAnnotations
|
|
8
|
+
|
|
9
|
+
from services.registry import mcp_for_unity_tool
|
|
10
|
+
from services.tools import get_unity_instance_from_context
|
|
11
|
+
from services.tools.utils import coerce_int, coerce_bool, parse_json_payload
|
|
12
|
+
from transport.unity_transport import send_with_unity_instance
|
|
13
|
+
from transport.legacy.unity_connection import async_send_command_with_retry
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _strip_stacktrace_from_list(items: list) -> None:
|
|
17
|
+
"""Remove stacktrace fields from a list of log entries."""
|
|
18
|
+
for item in items:
|
|
19
|
+
if isinstance(item, dict) and "stacktrace" in item:
|
|
20
|
+
item.pop("stacktrace", None)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@mcp_for_unity_tool(
|
|
24
|
+
description="Gets messages from or clears the Unity Editor console. Defaults to 10 most recent entries. Use page_size/cursor for paging. Note: For maximum client compatibility, pass count as a quoted string (e.g., '5'). The 'get' action is read-only; 'clear' modifies ephemeral UI state (not project data).",
|
|
25
|
+
annotations=ToolAnnotations(
|
|
26
|
+
title="Read Console",
|
|
27
|
+
),
|
|
28
|
+
)
|
|
29
|
+
async def read_console(
|
|
30
|
+
ctx: Context,
|
|
31
|
+
action: Annotated[Literal['get', 'clear'],
|
|
32
|
+
"Get or clear the Unity Editor console. Defaults to 'get' if omitted."] | None = None,
|
|
33
|
+
types: Annotated[list[Literal['error', 'warning',
|
|
34
|
+
'log', 'all']] | str,
|
|
35
|
+
"Message types to get (accepts list or JSON string)"] | None = None,
|
|
36
|
+
count: Annotated[int | str,
|
|
37
|
+
"Max messages to return in non-paging mode (accepts int or string, e.g., 5 or '5'). Ignored when paging with page_size/cursor."] | None = None,
|
|
38
|
+
filter_text: Annotated[str, "Text filter for messages"] | None = None,
|
|
39
|
+
since_timestamp: Annotated[str,
|
|
40
|
+
"Get messages after this timestamp (ISO 8601)"] | None = None,
|
|
41
|
+
page_size: Annotated[int | str,
|
|
42
|
+
"Page size for paginated console reads. Defaults to 50 when omitted."] | None = None,
|
|
43
|
+
cursor: Annotated[int | str,
|
|
44
|
+
"Opaque cursor for paging (0-based offset). Defaults to 0."] | None = None,
|
|
45
|
+
format: Annotated[Literal['plain', 'detailed',
|
|
46
|
+
'json'], "Output format"] | None = None,
|
|
47
|
+
include_stacktrace: Annotated[bool | str,
|
|
48
|
+
"Include stack traces in output (accepts true/false or 'true'/'false')"] | None = None,
|
|
49
|
+
) -> dict[str, Any]:
|
|
50
|
+
# Get active instance from session state
|
|
51
|
+
# Removed session_state import
|
|
52
|
+
unity_instance = get_unity_instance_from_context(ctx)
|
|
53
|
+
# Set defaults if values are None
|
|
54
|
+
action = action if action is not None else 'get'
|
|
55
|
+
|
|
56
|
+
# Parse types if it's a JSON string (handles client compatibility issue #561)
|
|
57
|
+
if isinstance(types, str):
|
|
58
|
+
types = parse_json_payload(types)
|
|
59
|
+
# Validate types is a list after parsing
|
|
60
|
+
if types is not None and not isinstance(types, list):
|
|
61
|
+
return {
|
|
62
|
+
"success": False,
|
|
63
|
+
"message": (
|
|
64
|
+
f"types must be a list, got {type(types).__name__}. "
|
|
65
|
+
"If passing as JSON string, use format: '[\"error\", \"warning\"]'"
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
if types is not None:
|
|
69
|
+
allowed_types = {"error", "warning", "log", "all"}
|
|
70
|
+
normalized_types = []
|
|
71
|
+
for entry in types:
|
|
72
|
+
if not isinstance(entry, str):
|
|
73
|
+
return {
|
|
74
|
+
"success": False,
|
|
75
|
+
"message": f"types entries must be strings, got {type(entry).__name__}"
|
|
76
|
+
}
|
|
77
|
+
normalized = entry.strip().lower()
|
|
78
|
+
if normalized not in allowed_types:
|
|
79
|
+
return {
|
|
80
|
+
"success": False,
|
|
81
|
+
"message": (
|
|
82
|
+
f"invalid types entry '{entry}'. "
|
|
83
|
+
f"Allowed values: {sorted(allowed_types)}"
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
normalized_types.append(normalized)
|
|
87
|
+
types = normalized_types
|
|
88
|
+
else:
|
|
89
|
+
types = ['error', 'warning', 'log']
|
|
90
|
+
|
|
91
|
+
format = format if format is not None else 'plain'
|
|
92
|
+
# Coerce booleans defensively (strings like 'true'/'false')
|
|
93
|
+
|
|
94
|
+
include_stacktrace = coerce_bool(include_stacktrace, default=False)
|
|
95
|
+
coerced_page_size = coerce_int(page_size, default=None)
|
|
96
|
+
coerced_cursor = coerce_int(cursor, default=None)
|
|
97
|
+
|
|
98
|
+
# Normalize action if it's a string
|
|
99
|
+
if isinstance(action, str):
|
|
100
|
+
action = action.lower()
|
|
101
|
+
|
|
102
|
+
# Coerce count defensively (string/float -> int).
|
|
103
|
+
# Important: leaving count unset previously meant "return all console entries", which can be extremely slow
|
|
104
|
+
# (and can exceed the plugin command timeout when Unity has a large console).
|
|
105
|
+
# To keep the tool responsive by default, we cap the default to a reasonable number of most-recent entries.
|
|
106
|
+
# If a client truly wants everything, it can pass count="all" (or count="*") explicitly.
|
|
107
|
+
if isinstance(count, str) and count.strip().lower() in ("all", "*"):
|
|
108
|
+
count = None
|
|
109
|
+
else:
|
|
110
|
+
count = coerce_int(count)
|
|
111
|
+
|
|
112
|
+
if action == "get" and count is None:
|
|
113
|
+
count = 10
|
|
114
|
+
|
|
115
|
+
# Prepare parameters for the C# handler
|
|
116
|
+
params_dict = {
|
|
117
|
+
"action": action,
|
|
118
|
+
"types": types,
|
|
119
|
+
"count": count,
|
|
120
|
+
"filterText": filter_text,
|
|
121
|
+
"sinceTimestamp": since_timestamp,
|
|
122
|
+
"pageSize": coerced_page_size,
|
|
123
|
+
"cursor": coerced_cursor,
|
|
124
|
+
"format": format.lower() if isinstance(format, str) else format,
|
|
125
|
+
"includeStacktrace": include_stacktrace
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
# Remove None values unless it's 'count' (as None might mean 'all')
|
|
129
|
+
params_dict = {k: v for k, v in params_dict.items()
|
|
130
|
+
if v is not None or k == 'count'}
|
|
131
|
+
|
|
132
|
+
# Add count back if it was None, explicitly sending null might be important for C# logic
|
|
133
|
+
if 'count' not in params_dict:
|
|
134
|
+
params_dict['count'] = None
|
|
135
|
+
|
|
136
|
+
# Use centralized retry helper with instance routing
|
|
137
|
+
resp = await send_with_unity_instance(async_send_command_with_retry, unity_instance, "read_console", params_dict)
|
|
138
|
+
if isinstance(resp, dict) and resp.get("success") and not include_stacktrace:
|
|
139
|
+
# Strip stacktrace fields from returned lines if present
|
|
140
|
+
try:
|
|
141
|
+
data = resp.get("data")
|
|
142
|
+
if isinstance(data, dict):
|
|
143
|
+
for key in ("lines", "items"):
|
|
144
|
+
if key in data and isinstance(data[key], list):
|
|
145
|
+
_strip_stacktrace_from_list(data[key])
|
|
146
|
+
break
|
|
147
|
+
elif isinstance(data, list):
|
|
148
|
+
_strip_stacktrace_from_list(data)
|
|
149
|
+
except Exception:
|
|
150
|
+
pass
|
|
151
|
+
return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
import time
|
|
6
|
+
from typing import Annotated, Any, Literal
|
|
7
|
+
|
|
8
|
+
from fastmcp import Context
|
|
9
|
+
from mcp.types import ToolAnnotations
|
|
10
|
+
|
|
11
|
+
from models import MCPResponse
|
|
12
|
+
from services.registry import mcp_for_unity_tool
|
|
13
|
+
from services.tools import get_unity_instance_from_context
|
|
14
|
+
import transport.unity_transport as unity_transport
|
|
15
|
+
from transport.legacy.unity_connection import async_send_command_with_retry, _extract_response_reason
|
|
16
|
+
from services.state.external_changes_scanner import external_changes_scanner
|
|
17
|
+
import services.resources.editor_state as editor_state
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@mcp_for_unity_tool(
|
|
23
|
+
description="Request a Unity asset database refresh and optionally a script compilation. Can optionally wait for readiness.",
|
|
24
|
+
annotations=ToolAnnotations(
|
|
25
|
+
title="Refresh Unity",
|
|
26
|
+
destructiveHint=True,
|
|
27
|
+
),
|
|
28
|
+
)
|
|
29
|
+
async def refresh_unity(
|
|
30
|
+
ctx: Context,
|
|
31
|
+
mode: Annotated[Literal["if_dirty", "force"], "Refresh mode"] = "if_dirty",
|
|
32
|
+
scope: Annotated[Literal["assets", "scripts", "all"],
|
|
33
|
+
"Refresh scope"] = "all",
|
|
34
|
+
compile: Annotated[Literal["none", "request"],
|
|
35
|
+
"Whether to request compilation"] = "none",
|
|
36
|
+
wait_for_ready: Annotated[bool,
|
|
37
|
+
"If true, wait until editor_state.advice.ready_for_tools is true"] = True,
|
|
38
|
+
) -> MCPResponse | dict[str, Any]:
|
|
39
|
+
unity_instance = get_unity_instance_from_context(ctx)
|
|
40
|
+
|
|
41
|
+
params: dict[str, Any] = {
|
|
42
|
+
"mode": mode,
|
|
43
|
+
"scope": scope,
|
|
44
|
+
"compile": compile,
|
|
45
|
+
"wait_for_ready": bool(wait_for_ready),
|
|
46
|
+
}
|
|
47
|
+
|
|
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)
|
|
51
|
+
response = await unity_transport.send_with_unity_instance(
|
|
52
|
+
async_send_command_with_retry,
|
|
53
|
+
unity_instance,
|
|
54
|
+
"refresh_unity",
|
|
55
|
+
params,
|
|
56
|
+
retry_on_reload=False,
|
|
57
|
+
)
|
|
58
|
+
|
|
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
|
|
73
|
+
or "disconnected" in err
|
|
74
|
+
or "aborted" in err # WinError 10053: connection aborted
|
|
75
|
+
or "timeout" in err
|
|
76
|
+
or reason == "reloading"
|
|
77
|
+
)
|
|
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)")
|
|
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)
|
|
96
|
+
|
|
97
|
+
# Optional server-side wait loop (defensive): if Unity tool doesn't wait or returns quickly,
|
|
98
|
+
# poll the canonical editor_state resource until ready or timeout.
|
|
99
|
+
ready_confirmed = False
|
|
100
|
+
if wait_for_ready:
|
|
101
|
+
timeout_s = 60.0
|
|
102
|
+
start = time.monotonic()
|
|
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
|
+
|
|
108
|
+
while time.monotonic() - start < timeout_s:
|
|
109
|
+
state_resp = await editor_state.get_editor_state(ctx)
|
|
110
|
+
state = state_resp.model_dump() if hasattr(
|
|
111
|
+
state_resp, "model_dump") else state_resp
|
|
112
|
+
data = (state or {}).get("data") if isinstance(
|
|
113
|
+
state, dict) else None
|
|
114
|
+
advice = (data or {}).get(
|
|
115
|
+
"advice") if isinstance(data, dict) else None
|
|
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
|
|
127
|
+
await asyncio.sleep(0.25)
|
|
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
|
+
|
|
138
|
+
# After readiness is restored, clear any external-dirty flag for this instance so future tools can proceed cleanly.
|
|
139
|
+
try:
|
|
140
|
+
inst = unity_instance or await editor_state.infer_single_instance_id(ctx)
|
|
141
|
+
if inst:
|
|
142
|
+
external_changes_scanner.clear_dirty(inst)
|
|
143
|
+
except Exception:
|
|
144
|
+
pass
|
|
145
|
+
|
|
146
|
+
if recovered_from_disconnect:
|
|
147
|
+
return MCPResponse(
|
|
148
|
+
success=True,
|
|
149
|
+
message="Refresh recovered after Unity disconnect/retry; editor is ready.",
|
|
150
|
+
data={"recovered_from_disconnect": True},
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
return MCPResponse(**response_dict) if isinstance(response, dict) else response
|