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
services/tools/preflight.py
CHANGED
|
@@ -42,11 +42,12 @@ async def preflight(
|
|
|
42
42
|
if _in_pytest():
|
|
43
43
|
return None
|
|
44
44
|
|
|
45
|
-
# Load canonical
|
|
45
|
+
# Load canonical editor state (server enriches advice + staleness).
|
|
46
46
|
try:
|
|
47
|
-
from services.resources.
|
|
48
|
-
state_resp = await
|
|
49
|
-
state = state_resp.model_dump() if hasattr(
|
|
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
|
|
50
51
|
except Exception:
|
|
51
52
|
# If we cannot determine readiness, fall back to proceeding (tools already contain retry logic).
|
|
52
53
|
return None
|
|
@@ -80,9 +81,12 @@ async def preflight(
|
|
|
80
81
|
if wait_for_no_compile:
|
|
81
82
|
deadline = time.monotonic() + float(max_wait_s)
|
|
82
83
|
while True:
|
|
83
|
-
compilation = data.get("compilation") if isinstance(
|
|
84
|
-
|
|
85
|
-
|
|
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
|
|
86
90
|
if not is_compiling and not is_domain_reload_pending:
|
|
87
91
|
break
|
|
88
92
|
if time.monotonic() >= deadline:
|
|
@@ -91,9 +95,10 @@ async def preflight(
|
|
|
91
95
|
|
|
92
96
|
# Refresh state for the next loop iteration.
|
|
93
97
|
try:
|
|
94
|
-
from services.resources.
|
|
95
|
-
state_resp = await
|
|
96
|
-
state = state_resp.model_dump() if hasattr(
|
|
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
|
|
97
102
|
data = state.get("data") if isinstance(state, dict) else None
|
|
98
103
|
if not isinstance(data, dict):
|
|
99
104
|
return None
|
|
@@ -103,5 +108,3 @@ async def preflight(
|
|
|
103
108
|
# Staleness: if the snapshot is stale, proceed (tools will still run), but callers that read resources can back off.
|
|
104
109
|
# In future we may make this strict for some tools.
|
|
105
110
|
return None
|
|
106
|
-
|
|
107
|
-
|
services/tools/read_console.py
CHANGED
|
@@ -4,27 +4,44 @@ Defines the read_console tool for accessing Unity Editor console messages.
|
|
|
4
4
|
from typing import Annotated, Any, Literal
|
|
5
5
|
|
|
6
6
|
from fastmcp import Context
|
|
7
|
+
from mcp.types import ToolAnnotations
|
|
8
|
+
|
|
7
9
|
from services.registry import mcp_for_unity_tool
|
|
8
10
|
from services.tools import get_unity_instance_from_context
|
|
9
|
-
from services.tools.utils import coerce_int, coerce_bool
|
|
11
|
+
from services.tools.utils import coerce_int, coerce_bool, parse_json_payload
|
|
10
12
|
from transport.unity_transport import send_with_unity_instance
|
|
11
13
|
from transport.legacy.unity_connection import async_send_command_with_retry
|
|
12
14
|
|
|
13
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
|
+
|
|
14
23
|
@mcp_for_unity_tool(
|
|
15
|
-
description="Gets messages from or clears the Unity Editor console. Note: For maximum client compatibility, pass count as a quoted string (e.g., '5')."
|
|
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
|
+
),
|
|
16
28
|
)
|
|
17
29
|
async def read_console(
|
|
18
30
|
ctx: Context,
|
|
19
31
|
action: Annotated[Literal['get', 'clear'],
|
|
20
32
|
"Get or clear the Unity Editor console. Defaults to 'get' if omitted."] | None = None,
|
|
21
33
|
types: Annotated[list[Literal['error', 'warning',
|
|
22
|
-
'log', 'all']]
|
|
34
|
+
'log', 'all']] | str,
|
|
35
|
+
"Message types to get (accepts list or JSON string)"] | None = None,
|
|
23
36
|
count: Annotated[int | str,
|
|
24
|
-
"Max messages to return (accepts int or string, e.g., 5 or '5')"] | None = None,
|
|
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,
|
|
25
38
|
filter_text: Annotated[str, "Text filter for messages"] | None = None,
|
|
26
39
|
since_timestamp: Annotated[str,
|
|
27
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,
|
|
28
45
|
format: Annotated[Literal['plain', 'detailed',
|
|
29
46
|
'json'], "Output format"] | None = None,
|
|
30
47
|
include_stacktrace: Annotated[bool | str,
|
|
@@ -35,11 +52,48 @@ async def read_console(
|
|
|
35
52
|
unity_instance = get_unity_instance_from_context(ctx)
|
|
36
53
|
# Set defaults if values are None
|
|
37
54
|
action = action if action is not None else 'get'
|
|
38
|
-
|
|
39
|
-
|
|
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'
|
|
40
92
|
# Coerce booleans defensively (strings like 'true'/'false')
|
|
41
93
|
|
|
42
|
-
include_stacktrace = coerce_bool(include_stacktrace, default=
|
|
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)
|
|
43
97
|
|
|
44
98
|
# Normalize action if it's a string
|
|
45
99
|
if isinstance(action, str):
|
|
@@ -56,7 +110,7 @@ async def read_console(
|
|
|
56
110
|
count = coerce_int(count)
|
|
57
111
|
|
|
58
112
|
if action == "get" and count is None:
|
|
59
|
-
count =
|
|
113
|
+
count = 10
|
|
60
114
|
|
|
61
115
|
# Prepare parameters for the C# handler
|
|
62
116
|
params_dict = {
|
|
@@ -65,6 +119,8 @@ async def read_console(
|
|
|
65
119
|
"count": count,
|
|
66
120
|
"filterText": filter_text,
|
|
67
121
|
"sinceTimestamp": since_timestamp,
|
|
122
|
+
"pageSize": coerced_page_size,
|
|
123
|
+
"cursor": coerced_cursor,
|
|
68
124
|
"format": format.lower() if isinstance(format, str) else format,
|
|
69
125
|
"includeStacktrace": include_stacktrace
|
|
70
126
|
}
|
|
@@ -83,16 +139,13 @@ async def read_console(
|
|
|
83
139
|
# Strip stacktrace fields from returned lines if present
|
|
84
140
|
try:
|
|
85
141
|
data = resp.get("data")
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
# Handle legacy/direct list format if any
|
|
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
|
|
92
147
|
elif isinstance(data, list):
|
|
93
|
-
|
|
94
|
-
if isinstance(line, dict) and "stacktrace" in line:
|
|
95
|
-
line.pop("stacktrace", None)
|
|
148
|
+
_strip_stacktrace_from_list(data)
|
|
96
149
|
except Exception:
|
|
97
150
|
pass
|
|
98
151
|
return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)}
|
services/tools/refresh_unity.py
CHANGED
|
@@ -1,28 +1,40 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
|
+
import logging
|
|
4
5
|
import time
|
|
5
6
|
from typing import Annotated, Any, Literal
|
|
6
7
|
|
|
7
8
|
from fastmcp import Context
|
|
9
|
+
from mcp.types import ToolAnnotations
|
|
8
10
|
|
|
9
11
|
from models import MCPResponse
|
|
10
12
|
from services.registry import mcp_for_unity_tool
|
|
11
13
|
from services.tools import get_unity_instance_from_context
|
|
12
14
|
import transport.unity_transport as unity_transport
|
|
13
|
-
from transport.legacy.unity_connection import async_send_command_with_retry
|
|
15
|
+
from transport.legacy.unity_connection import async_send_command_with_retry, _extract_response_reason
|
|
14
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__)
|
|
15
20
|
|
|
16
21
|
|
|
17
22
|
@mcp_for_unity_tool(
|
|
18
|
-
description="Request a Unity asset database refresh and optionally a script compilation. Can optionally wait for readiness."
|
|
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
|
+
),
|
|
19
28
|
)
|
|
20
29
|
async def refresh_unity(
|
|
21
30
|
ctx: Context,
|
|
22
31
|
mode: Annotated[Literal["if_dirty", "force"], "Refresh mode"] = "if_dirty",
|
|
23
|
-
scope: Annotated[Literal["assets", "scripts", "all"],
|
|
24
|
-
|
|
25
|
-
|
|
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,
|
|
26
38
|
) -> MCPResponse | dict[str, Any]:
|
|
27
39
|
unity_instance = get_unity_instance_from_context(ctx)
|
|
28
40
|
|
|
@@ -34,45 +46,98 @@ async def refresh_unity(
|
|
|
34
46
|
}
|
|
35
47
|
|
|
36
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)
|
|
37
51
|
response = await unity_transport.send_with_unity_instance(
|
|
38
52
|
async_send_command_with_retry,
|
|
39
53
|
unity_instance,
|
|
40
54
|
"refresh_unity",
|
|
41
55
|
params,
|
|
56
|
+
retry_on_reload=False,
|
|
42
57
|
)
|
|
43
58
|
|
|
44
|
-
#
|
|
45
|
-
# Unity
|
|
46
|
-
#
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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)
|
|
54
96
|
|
|
55
97
|
# Optional server-side wait loop (defensive): if Unity tool doesn't wait or returns quickly,
|
|
56
|
-
# poll the canonical editor_state
|
|
98
|
+
# poll the canonical editor_state resource until ready or timeout.
|
|
99
|
+
ready_confirmed = False
|
|
57
100
|
if wait_for_ready:
|
|
58
101
|
timeout_s = 60.0
|
|
59
102
|
start = time.monotonic()
|
|
60
|
-
|
|
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"}
|
|
61
107
|
|
|
62
108
|
while time.monotonic() - start < timeout_s:
|
|
63
|
-
state_resp = await
|
|
64
|
-
state = state_resp.model_dump() if hasattr(
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
|
69
127
|
await asyncio.sleep(0.25)
|
|
70
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
|
+
|
|
71
138
|
# After readiness is restored, clear any external-dirty flag for this instance so future tools can proceed cleanly.
|
|
72
139
|
try:
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
inst = unity_instance or await _infer_single_instance_id(ctx)
|
|
140
|
+
inst = unity_instance or await editor_state.infer_single_instance_id(ctx)
|
|
76
141
|
if inst:
|
|
77
142
|
external_changes_scanner.clear_dirty(inst)
|
|
78
143
|
except Exception:
|
|
@@ -85,6 +150,4 @@ async def refresh_unity(
|
|
|
85
150
|
data={"recovered_from_disconnect": True},
|
|
86
151
|
)
|
|
87
152
|
|
|
88
|
-
return MCPResponse(**
|
|
89
|
-
|
|
90
|
-
|
|
153
|
+
return MCPResponse(**response_dict) if isinstance(response, dict) else response
|
services/tools/run_tests.py
CHANGED
|
@@ -1,16 +1,24 @@
|
|
|
1
|
-
"""
|
|
2
|
-
from
|
|
1
|
+
"""Async Unity Test Runner jobs: start + poll."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import logging
|
|
6
|
+
import time
|
|
7
|
+
from typing import Annotated, Any, Literal
|
|
3
8
|
|
|
4
9
|
from fastmcp import Context
|
|
5
|
-
from
|
|
10
|
+
from mcp.types import ToolAnnotations
|
|
11
|
+
from pydantic import BaseModel
|
|
6
12
|
|
|
7
13
|
from models import MCPResponse
|
|
8
14
|
from services.registry import mcp_for_unity_tool
|
|
9
15
|
from services.tools import get_unity_instance_from_context
|
|
10
|
-
from services.tools.utils import coerce_int
|
|
11
|
-
from transport.unity_transport import send_with_unity_instance
|
|
12
|
-
from transport.legacy.unity_connection import async_send_command_with_retry
|
|
13
16
|
from services.tools.preflight import preflight
|
|
17
|
+
import transport.unity_transport as unity_transport
|
|
18
|
+
from transport.legacy.unity_connection import async_send_command_with_retry
|
|
19
|
+
from utils.focus_nudge import nudge_unity_focus, should_nudge
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
14
22
|
|
|
15
23
|
|
|
16
24
|
class RunTestsSummary(BaseModel):
|
|
@@ -38,31 +46,83 @@ class RunTestsResult(BaseModel):
|
|
|
38
46
|
results: list[RunTestsTestResult] | None = None
|
|
39
47
|
|
|
40
48
|
|
|
41
|
-
class
|
|
42
|
-
|
|
49
|
+
class RunTestsStartData(BaseModel):
|
|
50
|
+
job_id: str
|
|
51
|
+
status: str
|
|
52
|
+
mode: str | None = None
|
|
53
|
+
include_details: bool | None = None
|
|
54
|
+
include_failed_tests: bool | None = None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class RunTestsStartResponse(MCPResponse):
|
|
58
|
+
data: RunTestsStartData | None = None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class TestJobFailure(BaseModel):
|
|
62
|
+
full_name: str | None = None
|
|
63
|
+
message: str | None = None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class TestJobProgress(BaseModel):
|
|
67
|
+
completed: int | None = None
|
|
68
|
+
total: int | None = None
|
|
69
|
+
current_test_full_name: str | None = None
|
|
70
|
+
current_test_started_unix_ms: int | None = None
|
|
71
|
+
last_finished_test_full_name: str | None = None
|
|
72
|
+
last_finished_unix_ms: int | None = None
|
|
73
|
+
stuck_suspected: bool | None = None
|
|
74
|
+
editor_is_focused: bool | None = None
|
|
75
|
+
blocked_reason: str | None = None
|
|
76
|
+
failures_so_far: list[TestJobFailure] | None = None
|
|
77
|
+
failures_capped: bool | None = None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class GetTestJobData(BaseModel):
|
|
81
|
+
job_id: str
|
|
82
|
+
status: str
|
|
83
|
+
mode: str | None = None
|
|
84
|
+
started_unix_ms: int | None = None
|
|
85
|
+
finished_unix_ms: int | None = None
|
|
86
|
+
last_update_unix_ms: int | None = None
|
|
87
|
+
progress: TestJobProgress | None = None
|
|
88
|
+
error: str | None = None
|
|
89
|
+
result: RunTestsResult | None = None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class GetTestJobResponse(MCPResponse):
|
|
93
|
+
data: GetTestJobData | None = None
|
|
43
94
|
|
|
44
95
|
|
|
45
96
|
@mcp_for_unity_tool(
|
|
46
|
-
description="
|
|
97
|
+
description="Starts a Unity test run asynchronously and returns a job_id immediately. Poll with get_test_job for progress.",
|
|
98
|
+
annotations=ToolAnnotations(
|
|
99
|
+
title="Run Tests",
|
|
100
|
+
destructiveHint=True,
|
|
101
|
+
),
|
|
47
102
|
)
|
|
48
103
|
async def run_tests(
|
|
49
104
|
ctx: Context,
|
|
50
|
-
mode: Annotated[Literal["EditMode", "PlayMode"],
|
|
51
|
-
|
|
52
|
-
test_names: Annotated[list[str] | str,
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
105
|
+
mode: Annotated[Literal["EditMode", "PlayMode"],
|
|
106
|
+
"Unity test mode to run"] = "EditMode",
|
|
107
|
+
test_names: Annotated[list[str] | str,
|
|
108
|
+
"Full names of specific tests to run"] | None = None,
|
|
109
|
+
group_names: Annotated[list[str] | str,
|
|
110
|
+
"Same as test_names, except it allows for Regex"] | None = None,
|
|
111
|
+
category_names: Annotated[list[str] | str,
|
|
112
|
+
"NUnit category names to filter by"] | None = None,
|
|
113
|
+
assembly_names: Annotated[list[str] | str,
|
|
114
|
+
"Assembly names to filter tests by"] | None = None,
|
|
115
|
+
include_failed_tests: Annotated[bool,
|
|
116
|
+
"Include details for failed/skipped tests only (default: false)"] = False,
|
|
117
|
+
include_details: Annotated[bool,
|
|
118
|
+
"Include details for all tests (default: false)"] = False,
|
|
119
|
+
) -> RunTestsStartResponse | MCPResponse:
|
|
59
120
|
unity_instance = get_unity_instance_from_context(ctx)
|
|
60
121
|
|
|
61
122
|
gate = await preflight(ctx, requires_no_tests=True, wait_for_no_compile=True, refresh_if_dirty=True)
|
|
62
123
|
if isinstance(gate, MCPResponse):
|
|
63
124
|
return gate
|
|
64
125
|
|
|
65
|
-
# Coerce string or list to list of strings
|
|
66
126
|
def _coerce_string_list(value) -> list[str] | None:
|
|
67
127
|
if value is None:
|
|
68
128
|
return None
|
|
@@ -74,47 +134,121 @@ async def run_tests(
|
|
|
74
134
|
return None
|
|
75
135
|
|
|
76
136
|
params: dict[str, Any] = {"mode": mode}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
137
|
+
if (t := _coerce_string_list(test_names)):
|
|
138
|
+
params["testNames"] = t
|
|
139
|
+
if (g := _coerce_string_list(group_names)):
|
|
140
|
+
params["groupNames"] = g
|
|
141
|
+
if (c := _coerce_string_list(category_names)):
|
|
142
|
+
params["categoryNames"] = c
|
|
143
|
+
if (a := _coerce_string_list(assembly_names)):
|
|
144
|
+
params["assemblyNames"] = a
|
|
145
|
+
if include_failed_tests:
|
|
146
|
+
params["includeFailedTests"] = True
|
|
147
|
+
if include_details:
|
|
148
|
+
params["includeDetails"] = True
|
|
80
149
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
150
|
+
response = await unity_transport.send_with_unity_instance(
|
|
151
|
+
async_send_command_with_retry,
|
|
152
|
+
unity_instance,
|
|
153
|
+
"run_tests",
|
|
154
|
+
params,
|
|
155
|
+
)
|
|
85
156
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
157
|
+
if isinstance(response, dict):
|
|
158
|
+
if not response.get("success", True):
|
|
159
|
+
return MCPResponse(**response)
|
|
160
|
+
return RunTestsStartResponse(**response)
|
|
161
|
+
return MCPResponse(success=False, error=str(response))
|
|
89
162
|
|
|
90
|
-
category_names_list = _coerce_string_list(category_names)
|
|
91
|
-
if category_names_list:
|
|
92
|
-
params["categoryNames"] = category_names_list
|
|
93
163
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
164
|
+
@mcp_for_unity_tool(
|
|
165
|
+
description="Polls an async Unity test job by job_id.",
|
|
166
|
+
annotations=ToolAnnotations(
|
|
167
|
+
title="Get Test Job",
|
|
168
|
+
readOnlyHint=True,
|
|
169
|
+
),
|
|
170
|
+
)
|
|
171
|
+
async def get_test_job(
|
|
172
|
+
ctx: Context,
|
|
173
|
+
job_id: Annotated[str, "Job id returned by run_tests"],
|
|
174
|
+
include_failed_tests: Annotated[bool,
|
|
175
|
+
"Include details for failed/skipped tests only (default: false)"] = False,
|
|
176
|
+
include_details: Annotated[bool,
|
|
177
|
+
"Include details for all tests (default: false)"] = False,
|
|
178
|
+
wait_timeout: Annotated[int | None,
|
|
179
|
+
"If set, wait up to this many seconds for tests to complete before returning. "
|
|
180
|
+
"Reduces polling frequency and avoids client-side loop detection. "
|
|
181
|
+
"Recommended: 30-60 seconds. Returns immediately if tests complete sooner."] = None,
|
|
182
|
+
) -> GetTestJobResponse | MCPResponse:
|
|
183
|
+
unity_instance = get_unity_instance_from_context(ctx)
|
|
97
184
|
|
|
98
|
-
|
|
185
|
+
params: dict[str, Any] = {"job_id": job_id}
|
|
99
186
|
if include_failed_tests:
|
|
100
187
|
params["includeFailedTests"] = True
|
|
101
188
|
if include_details:
|
|
102
189
|
params["includeDetails"] = True
|
|
103
190
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
191
|
+
async def _fetch_status() -> dict[str, Any]:
|
|
192
|
+
return await unity_transport.send_with_unity_instance(
|
|
193
|
+
async_send_command_with_retry,
|
|
194
|
+
unity_instance,
|
|
195
|
+
"get_test_job",
|
|
196
|
+
params,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
# If wait_timeout is specified, poll server-side until complete or timeout
|
|
200
|
+
if wait_timeout and wait_timeout > 0:
|
|
201
|
+
deadline = asyncio.get_event_loop().time() + wait_timeout
|
|
202
|
+
poll_interval = 2.0 # Poll Unity every 2 seconds
|
|
203
|
+
|
|
204
|
+
while True:
|
|
205
|
+
response = await _fetch_status()
|
|
206
|
+
|
|
207
|
+
if not isinstance(response, dict):
|
|
208
|
+
return MCPResponse(success=False, error=str(response))
|
|
209
|
+
|
|
210
|
+
if not response.get("success", True):
|
|
211
|
+
return MCPResponse(**response)
|
|
212
|
+
|
|
213
|
+
# Check if tests are done
|
|
214
|
+
data = response.get("data", {})
|
|
215
|
+
status = data.get("status", "")
|
|
216
|
+
if status in ("succeeded", "failed", "cancelled"):
|
|
217
|
+
return GetTestJobResponse(**response)
|
|
218
|
+
|
|
219
|
+
# Check if Unity needs a focus nudge to make progress
|
|
220
|
+
# This handles OS-level throttling (e.g., macOS App Nap) that can
|
|
221
|
+
# stall PlayMode tests when Unity is in the background.
|
|
222
|
+
progress = data.get("progress", {})
|
|
223
|
+
editor_is_focused = progress.get("editor_is_focused", True)
|
|
224
|
+
last_update_unix_ms = data.get("last_update_unix_ms")
|
|
225
|
+
current_time_ms = int(time.time() * 1000)
|
|
226
|
+
|
|
227
|
+
if should_nudge(
|
|
228
|
+
status=status,
|
|
229
|
+
editor_is_focused=editor_is_focused,
|
|
230
|
+
last_update_unix_ms=last_update_unix_ms,
|
|
231
|
+
current_time_ms=current_time_ms,
|
|
232
|
+
stall_threshold_ms=10_000, # 10 seconds without progress
|
|
233
|
+
):
|
|
234
|
+
logger.info(f"Test job {job_id} appears stalled (unfocused Unity), attempting nudge...")
|
|
235
|
+
nudged = await nudge_unity_focus(focus_duration_s=0.5)
|
|
236
|
+
if nudged:
|
|
237
|
+
logger.info(f"Test job {job_id} nudge completed")
|
|
238
|
+
|
|
239
|
+
# Check timeout
|
|
240
|
+
remaining = deadline - asyncio.get_event_loop().time()
|
|
241
|
+
if remaining <= 0:
|
|
242
|
+
# Timeout reached, return current status
|
|
243
|
+
return GetTestJobResponse(**response)
|
|
244
|
+
|
|
245
|
+
# Wait before next poll (but don't exceed remaining time)
|
|
246
|
+
await asyncio.sleep(min(poll_interval, remaining))
|
|
247
|
+
|
|
248
|
+
# No wait_timeout - return immediately (original behavior)
|
|
249
|
+
response = await _fetch_status()
|
|
250
|
+
if isinstance(response, dict):
|
|
251
|
+
if not response.get("success", True):
|
|
252
|
+
return MCPResponse(**response)
|
|
253
|
+
return GetTestJobResponse(**response)
|
|
254
|
+
return MCPResponse(success=False, error=str(response))
|