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.
Files changed (105) hide show
  1. cli/__init__.py +3 -0
  2. cli/commands/__init__.py +3 -0
  3. cli/commands/animation.py +84 -0
  4. cli/commands/asset.py +280 -0
  5. cli/commands/audio.py +125 -0
  6. cli/commands/batch.py +171 -0
  7. cli/commands/code.py +182 -0
  8. cli/commands/component.py +190 -0
  9. cli/commands/editor.py +447 -0
  10. cli/commands/gameobject.py +487 -0
  11. cli/commands/instance.py +93 -0
  12. cli/commands/lighting.py +123 -0
  13. cli/commands/material.py +239 -0
  14. cli/commands/prefab.py +248 -0
  15. cli/commands/scene.py +231 -0
  16. cli/commands/script.py +222 -0
  17. cli/commands/shader.py +226 -0
  18. cli/commands/texture.py +540 -0
  19. cli/commands/tool.py +58 -0
  20. cli/commands/ui.py +258 -0
  21. cli/commands/vfx.py +421 -0
  22. cli/main.py +281 -0
  23. cli/utils/__init__.py +31 -0
  24. cli/utils/config.py +58 -0
  25. cli/utils/confirmation.py +37 -0
  26. cli/utils/connection.py +254 -0
  27. cli/utils/constants.py +23 -0
  28. cli/utils/output.py +195 -0
  29. cli/utils/parsers.py +112 -0
  30. cli/utils/suggestions.py +34 -0
  31. core/__init__.py +0 -0
  32. core/config.py +67 -0
  33. core/constants.py +4 -0
  34. core/logging_decorator.py +37 -0
  35. core/telemetry.py +551 -0
  36. core/telemetry_decorator.py +164 -0
  37. main.py +845 -0
  38. mcpforunityserver-9.4.0b20260203025228.dist-info/METADATA +328 -0
  39. mcpforunityserver-9.4.0b20260203025228.dist-info/RECORD +105 -0
  40. mcpforunityserver-9.4.0b20260203025228.dist-info/WHEEL +5 -0
  41. mcpforunityserver-9.4.0b20260203025228.dist-info/entry_points.txt +3 -0
  42. mcpforunityserver-9.4.0b20260203025228.dist-info/licenses/LICENSE +21 -0
  43. mcpforunityserver-9.4.0b20260203025228.dist-info/top_level.txt +7 -0
  44. models/__init__.py +4 -0
  45. models/models.py +56 -0
  46. models/unity_response.py +70 -0
  47. services/__init__.py +0 -0
  48. services/api_key_service.py +235 -0
  49. services/custom_tool_service.py +499 -0
  50. services/registry/__init__.py +22 -0
  51. services/registry/resource_registry.py +53 -0
  52. services/registry/tool_registry.py +51 -0
  53. services/resources/__init__.py +86 -0
  54. services/resources/active_tool.py +48 -0
  55. services/resources/custom_tools.py +57 -0
  56. services/resources/editor_state.py +304 -0
  57. services/resources/gameobject.py +243 -0
  58. services/resources/layers.py +30 -0
  59. services/resources/menu_items.py +35 -0
  60. services/resources/prefab.py +191 -0
  61. services/resources/prefab_stage.py +40 -0
  62. services/resources/project_info.py +40 -0
  63. services/resources/selection.py +56 -0
  64. services/resources/tags.py +31 -0
  65. services/resources/tests.py +88 -0
  66. services/resources/unity_instances.py +125 -0
  67. services/resources/windows.py +48 -0
  68. services/state/external_changes_scanner.py +245 -0
  69. services/tools/__init__.py +83 -0
  70. services/tools/batch_execute.py +93 -0
  71. services/tools/debug_request_context.py +86 -0
  72. services/tools/execute_custom_tool.py +43 -0
  73. services/tools/execute_menu_item.py +32 -0
  74. services/tools/find_gameobjects.py +110 -0
  75. services/tools/find_in_file.py +181 -0
  76. services/tools/manage_asset.py +119 -0
  77. services/tools/manage_components.py +131 -0
  78. services/tools/manage_editor.py +64 -0
  79. services/tools/manage_gameobject.py +260 -0
  80. services/tools/manage_material.py +111 -0
  81. services/tools/manage_prefabs.py +209 -0
  82. services/tools/manage_scene.py +111 -0
  83. services/tools/manage_script.py +645 -0
  84. services/tools/manage_scriptable_object.py +87 -0
  85. services/tools/manage_shader.py +71 -0
  86. services/tools/manage_texture.py +581 -0
  87. services/tools/manage_vfx.py +120 -0
  88. services/tools/preflight.py +110 -0
  89. services/tools/read_console.py +151 -0
  90. services/tools/refresh_unity.py +153 -0
  91. services/tools/run_tests.py +317 -0
  92. services/tools/script_apply_edits.py +1006 -0
  93. services/tools/set_active_instance.py +120 -0
  94. services/tools/utils.py +348 -0
  95. transport/__init__.py +0 -0
  96. transport/legacy/port_discovery.py +329 -0
  97. transport/legacy/stdio_port_registry.py +65 -0
  98. transport/legacy/unity_connection.py +910 -0
  99. transport/models.py +68 -0
  100. transport/plugin_hub.py +787 -0
  101. transport/plugin_registry.py +182 -0
  102. transport/unity_instance_middleware.py +262 -0
  103. transport/unity_transport.py +94 -0
  104. utils/focus_nudge.py +589 -0
  105. 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