mcpforunityserver 8.6.0__py3-none-any.whl → 8.7.1__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.
- {mcpforunityserver-8.6.0.dist-info → mcpforunityserver-8.7.1.dist-info}/METADATA +2 -2
- {mcpforunityserver-8.6.0.dist-info → mcpforunityserver-8.7.1.dist-info}/RECORD +18 -13
- services/resources/editor_state_v2.py +270 -0
- services/state/external_changes_scanner.py +246 -0
- services/tools/manage_asset.py +7 -0
- services/tools/manage_gameobject.py +5 -0
- services/tools/manage_scene.py +4 -0
- services/tools/preflight.py +107 -0
- services/tools/read_console.py +25 -15
- services/tools/refresh_unity.py +90 -0
- services/tools/run_tests.py +22 -3
- services/tools/test_jobs.py +94 -0
- transport/legacy/unity_connection.py +101 -16
- transport/plugin_hub.py +47 -11
- {mcpforunityserver-8.6.0.dist-info → mcpforunityserver-8.7.1.dist-info}/WHEEL +0 -0
- {mcpforunityserver-8.6.0.dist-info → mcpforunityserver-8.7.1.dist-info}/entry_points.txt +0 -0
- {mcpforunityserver-8.6.0.dist-info → mcpforunityserver-8.7.1.dist-info}/licenses/LICENSE +0 -0
- {mcpforunityserver-8.6.0.dist-info → mcpforunityserver-8.7.1.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,107 @@
|
|
|
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 v2 state (server enriches advice + staleness).
|
|
46
|
+
try:
|
|
47
|
+
from services.resources.editor_state_v2 import get_editor_state_v2
|
|
48
|
+
state_resp = await get_editor_state_v2(ctx)
|
|
49
|
+
state = state_resp.model_dump() if hasattr(state_resp, "model_dump") else state_resp
|
|
50
|
+
except Exception:
|
|
51
|
+
# If we cannot determine readiness, fall back to proceeding (tools already contain retry logic).
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
if not isinstance(state, dict) or not state.get("success", False):
|
|
55
|
+
# Unknown state; proceed rather than blocking (avoids false positives when Unity is reachable but status isn't).
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
data = state.get("data")
|
|
59
|
+
if not isinstance(data, dict):
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
# Optional refresh-if-dirty
|
|
63
|
+
if refresh_if_dirty:
|
|
64
|
+
assets = data.get("assets")
|
|
65
|
+
if isinstance(assets, dict) and assets.get("external_changes_dirty") is True:
|
|
66
|
+
try:
|
|
67
|
+
from services.tools.refresh_unity import refresh_unity
|
|
68
|
+
await refresh_unity(ctx, mode="if_dirty", scope="all", compile="request", wait_for_ready=True)
|
|
69
|
+
except Exception:
|
|
70
|
+
# Best-effort only; fall through to normal tool dispatch.
|
|
71
|
+
pass
|
|
72
|
+
|
|
73
|
+
# Tests running: fail fast for tools that require exclusivity.
|
|
74
|
+
if requires_no_tests:
|
|
75
|
+
tests = data.get("tests")
|
|
76
|
+
if isinstance(tests, dict) and tests.get("is_running") is True:
|
|
77
|
+
return _busy("tests_running", 5000)
|
|
78
|
+
|
|
79
|
+
# Compilation: optionally wait for a bounded time.
|
|
80
|
+
if wait_for_no_compile:
|
|
81
|
+
deadline = time.monotonic() + float(max_wait_s)
|
|
82
|
+
while True:
|
|
83
|
+
compilation = data.get("compilation") if isinstance(data, dict) else None
|
|
84
|
+
is_compiling = isinstance(compilation, dict) and compilation.get("is_compiling") is True
|
|
85
|
+
is_domain_reload_pending = isinstance(compilation, dict) and compilation.get("is_domain_reload_pending") is True
|
|
86
|
+
if not is_compiling and not is_domain_reload_pending:
|
|
87
|
+
break
|
|
88
|
+
if time.monotonic() >= deadline:
|
|
89
|
+
return _busy("compiling", 500)
|
|
90
|
+
await asyncio.sleep(0.25)
|
|
91
|
+
|
|
92
|
+
# Refresh state for the next loop iteration.
|
|
93
|
+
try:
|
|
94
|
+
from services.resources.editor_state_v2 import get_editor_state_v2
|
|
95
|
+
state_resp = await get_editor_state_v2(ctx)
|
|
96
|
+
state = state_resp.model_dump() if hasattr(state_resp, "model_dump") else state_resp
|
|
97
|
+
data = state.get("data") if isinstance(state, dict) else None
|
|
98
|
+
if not isinstance(data, dict):
|
|
99
|
+
return None
|
|
100
|
+
except Exception:
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
# Staleness: if the snapshot is stale, proceed (tools will still run), but callers that read resources can back off.
|
|
104
|
+
# In future we may make this strict for some tools.
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
|
services/tools/read_console.py
CHANGED
|
@@ -11,8 +11,15 @@ from transport.unity_transport import send_with_unity_instance
|
|
|
11
11
|
from transport.legacy.unity_connection import async_send_command_with_retry
|
|
12
12
|
|
|
13
13
|
|
|
14
|
+
def _strip_stacktrace_from_list(items: list) -> None:
|
|
15
|
+
"""Remove stacktrace fields from a list of log entries."""
|
|
16
|
+
for item in items:
|
|
17
|
+
if isinstance(item, dict) and "stacktrace" in item:
|
|
18
|
+
item.pop("stacktrace", None)
|
|
19
|
+
|
|
20
|
+
|
|
14
21
|
@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')."
|
|
22
|
+
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')."
|
|
16
23
|
)
|
|
17
24
|
async def read_console(
|
|
18
25
|
ctx: Context,
|
|
@@ -21,10 +28,12 @@ async def read_console(
|
|
|
21
28
|
types: Annotated[list[Literal['error', 'warning',
|
|
22
29
|
'log', 'all']], "Message types to get"] | None = None,
|
|
23
30
|
count: Annotated[int | str,
|
|
24
|
-
"Max messages to return (accepts int or string, e.g., 5 or '5')"] | None = None,
|
|
31
|
+
"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
32
|
filter_text: Annotated[str, "Text filter for messages"] | None = None,
|
|
26
33
|
since_timestamp: Annotated[str,
|
|
27
34
|
"Get messages after this timestamp (ISO 8601)"] | None = None,
|
|
35
|
+
page_size: Annotated[int | str, "Page size for paginated console reads. Defaults to 50 when omitted."] | None = None,
|
|
36
|
+
cursor: Annotated[int | str, "Opaque cursor for paging (0-based offset). Defaults to 0."] | None = None,
|
|
28
37
|
format: Annotated[Literal['plain', 'detailed',
|
|
29
38
|
'json'], "Output format"] | None = None,
|
|
30
39
|
include_stacktrace: Annotated[bool | str,
|
|
@@ -35,11 +44,13 @@ async def read_console(
|
|
|
35
44
|
unity_instance = get_unity_instance_from_context(ctx)
|
|
36
45
|
# Set defaults if values are None
|
|
37
46
|
action = action if action is not None else 'get'
|
|
38
|
-
types = types if types is not None else ['error', 'warning'
|
|
39
|
-
format = format if format is not None else '
|
|
47
|
+
types = types if types is not None else ['error', 'warning']
|
|
48
|
+
format = format if format is not None else 'plain'
|
|
40
49
|
# Coerce booleans defensively (strings like 'true'/'false')
|
|
41
50
|
|
|
42
|
-
include_stacktrace = coerce_bool(include_stacktrace, default=
|
|
51
|
+
include_stacktrace = coerce_bool(include_stacktrace, default=False)
|
|
52
|
+
coerced_page_size = coerce_int(page_size, default=None)
|
|
53
|
+
coerced_cursor = coerce_int(cursor, default=None)
|
|
43
54
|
|
|
44
55
|
# Normalize action if it's a string
|
|
45
56
|
if isinstance(action, str):
|
|
@@ -56,7 +67,7 @@ async def read_console(
|
|
|
56
67
|
count = coerce_int(count)
|
|
57
68
|
|
|
58
69
|
if action == "get" and count is None:
|
|
59
|
-
count =
|
|
70
|
+
count = 10
|
|
60
71
|
|
|
61
72
|
# Prepare parameters for the C# handler
|
|
62
73
|
params_dict = {
|
|
@@ -65,6 +76,8 @@ async def read_console(
|
|
|
65
76
|
"count": count,
|
|
66
77
|
"filterText": filter_text,
|
|
67
78
|
"sinceTimestamp": since_timestamp,
|
|
79
|
+
"pageSize": coerced_page_size,
|
|
80
|
+
"cursor": coerced_cursor,
|
|
68
81
|
"format": format.lower() if isinstance(format, str) else format,
|
|
69
82
|
"includeStacktrace": include_stacktrace
|
|
70
83
|
}
|
|
@@ -83,16 +96,13 @@ async def read_console(
|
|
|
83
96
|
# Strip stacktrace fields from returned lines if present
|
|
84
97
|
try:
|
|
85
98
|
data = resp.get("data")
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
# Handle legacy/direct list format if any
|
|
99
|
+
if isinstance(data, dict):
|
|
100
|
+
for key in ("lines", "items"):
|
|
101
|
+
if key in data and isinstance(data[key], list):
|
|
102
|
+
_strip_stacktrace_from_list(data[key])
|
|
103
|
+
break
|
|
92
104
|
elif isinstance(data, list):
|
|
93
|
-
|
|
94
|
-
if isinstance(line, dict) and "stacktrace" in line:
|
|
95
|
-
line.pop("stacktrace", None)
|
|
105
|
+
_strip_stacktrace_from_list(data)
|
|
96
106
|
except Exception:
|
|
97
107
|
pass
|
|
98
108
|
return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import time
|
|
5
|
+
from typing import Annotated, Any, Literal
|
|
6
|
+
|
|
7
|
+
from fastmcp import Context
|
|
8
|
+
|
|
9
|
+
from models import MCPResponse
|
|
10
|
+
from services.registry import mcp_for_unity_tool
|
|
11
|
+
from services.tools import get_unity_instance_from_context
|
|
12
|
+
import transport.unity_transport as unity_transport
|
|
13
|
+
from transport.legacy.unity_connection import async_send_command_with_retry, _extract_response_reason
|
|
14
|
+
from services.state.external_changes_scanner import external_changes_scanner
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@mcp_for_unity_tool(
|
|
18
|
+
description="Request a Unity asset database refresh and optionally a script compilation. Can optionally wait for readiness."
|
|
19
|
+
)
|
|
20
|
+
async def refresh_unity(
|
|
21
|
+
ctx: Context,
|
|
22
|
+
mode: Annotated[Literal["if_dirty", "force"], "Refresh mode"] = "if_dirty",
|
|
23
|
+
scope: Annotated[Literal["assets", "scripts", "all"], "Refresh scope"] = "all",
|
|
24
|
+
compile: Annotated[Literal["none", "request"], "Whether to request compilation"] = "none",
|
|
25
|
+
wait_for_ready: Annotated[bool, "If true, wait until editor_state.advice.ready_for_tools is true"] = True,
|
|
26
|
+
) -> MCPResponse | dict[str, Any]:
|
|
27
|
+
unity_instance = get_unity_instance_from_context(ctx)
|
|
28
|
+
|
|
29
|
+
params: dict[str, Any] = {
|
|
30
|
+
"mode": mode,
|
|
31
|
+
"scope": scope,
|
|
32
|
+
"compile": compile,
|
|
33
|
+
"wait_for_ready": bool(wait_for_ready),
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
recovered_from_disconnect = False
|
|
37
|
+
response = await unity_transport.send_with_unity_instance(
|
|
38
|
+
async_send_command_with_retry,
|
|
39
|
+
unity_instance,
|
|
40
|
+
"refresh_unity",
|
|
41
|
+
params,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
# Option A: treat disconnects / retry hints as recoverable when wait_for_ready is true.
|
|
45
|
+
# Unity can legitimately disconnect during refresh/compile/domain reload, so callers should not
|
|
46
|
+
# interpret that as a hard failure (#503-style loops).
|
|
47
|
+
if isinstance(response, dict) and not response.get("success", True):
|
|
48
|
+
hint = response.get("hint")
|
|
49
|
+
err = (response.get("error") or response.get("message") or "")
|
|
50
|
+
reason = _extract_response_reason(response)
|
|
51
|
+
is_retryable = (hint == "retry") or ("disconnected" in str(err).lower())
|
|
52
|
+
if (not wait_for_ready) or (not is_retryable):
|
|
53
|
+
return MCPResponse(**response)
|
|
54
|
+
if reason not in {"reloading", "no_unity_session"}:
|
|
55
|
+
recovered_from_disconnect = True
|
|
56
|
+
|
|
57
|
+
# Optional server-side wait loop (defensive): if Unity tool doesn't wait or returns quickly,
|
|
58
|
+
# poll the canonical editor_state v2 resource until ready or timeout.
|
|
59
|
+
if wait_for_ready:
|
|
60
|
+
timeout_s = 60.0
|
|
61
|
+
start = time.monotonic()
|
|
62
|
+
from services.resources.editor_state_v2 import get_editor_state_v2
|
|
63
|
+
|
|
64
|
+
while time.monotonic() - start < timeout_s:
|
|
65
|
+
state_resp = await get_editor_state_v2(ctx)
|
|
66
|
+
state = state_resp.model_dump() if hasattr(state_resp, "model_dump") else state_resp
|
|
67
|
+
data = (state or {}).get("data") if isinstance(state, dict) else None
|
|
68
|
+
advice = (data or {}).get("advice") if isinstance(data, dict) else None
|
|
69
|
+
if isinstance(advice, dict) and advice.get("ready_for_tools") is True:
|
|
70
|
+
break
|
|
71
|
+
await asyncio.sleep(0.25)
|
|
72
|
+
|
|
73
|
+
# After readiness is restored, clear any external-dirty flag for this instance so future tools can proceed cleanly.
|
|
74
|
+
try:
|
|
75
|
+
from services.resources.editor_state_v2 import _infer_single_instance_id
|
|
76
|
+
|
|
77
|
+
inst = unity_instance or await _infer_single_instance_id(ctx)
|
|
78
|
+
if inst:
|
|
79
|
+
external_changes_scanner.clear_dirty(inst)
|
|
80
|
+
except Exception:
|
|
81
|
+
pass
|
|
82
|
+
|
|
83
|
+
if recovered_from_disconnect:
|
|
84
|
+
return MCPResponse(
|
|
85
|
+
success=True,
|
|
86
|
+
message="Refresh recovered after Unity disconnect/retry; editor is ready.",
|
|
87
|
+
data={"recovered_from_disconnect": True},
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
return MCPResponse(**response) if isinstance(response, dict) else response
|
services/tools/run_tests.py
CHANGED
|
@@ -10,6 +10,7 @@ from services.tools import get_unity_instance_from_context
|
|
|
10
10
|
from services.tools.utils import coerce_int
|
|
11
11
|
from transport.unity_transport import send_with_unity_instance
|
|
12
12
|
from transport.legacy.unity_connection import async_send_command_with_retry
|
|
13
|
+
from services.tools.preflight import preflight
|
|
13
14
|
|
|
14
15
|
|
|
15
16
|
class RunTestsSummary(BaseModel):
|
|
@@ -42,7 +43,7 @@ class RunTestsResponse(MCPResponse):
|
|
|
42
43
|
|
|
43
44
|
|
|
44
45
|
@mcp_for_unity_tool(
|
|
45
|
-
description="Runs Unity tests for
|
|
46
|
+
description="Runs Unity tests synchronously (blocks until complete). Prefer run_tests_async for non-blocking execution with progress polling."
|
|
46
47
|
)
|
|
47
48
|
async def run_tests(
|
|
48
49
|
ctx: Context,
|
|
@@ -54,9 +55,13 @@ async def run_tests(
|
|
|
54
55
|
assembly_names: Annotated[list[str] | str, "Assembly names to filter tests by"] | None = None,
|
|
55
56
|
include_failed_tests: Annotated[bool, "Include details for failed/skipped tests only (default: false)"] = False,
|
|
56
57
|
include_details: Annotated[bool, "Include details for all tests (default: false)"] = False,
|
|
57
|
-
) -> RunTestsResponse:
|
|
58
|
+
) -> RunTestsResponse | MCPResponse:
|
|
58
59
|
unity_instance = get_unity_instance_from_context(ctx)
|
|
59
60
|
|
|
61
|
+
gate = await preflight(ctx, requires_no_tests=True, wait_for_no_compile=True, refresh_if_dirty=True)
|
|
62
|
+
if isinstance(gate, MCPResponse):
|
|
63
|
+
return gate
|
|
64
|
+
|
|
60
65
|
# Coerce string or list to list of strings
|
|
61
66
|
def _coerce_string_list(value) -> list[str] | None:
|
|
62
67
|
if value is None:
|
|
@@ -97,5 +102,19 @@ async def run_tests(
|
|
|
97
102
|
params["includeDetails"] = True
|
|
98
103
|
|
|
99
104
|
response = await send_with_unity_instance(async_send_command_with_retry, unity_instance, "run_tests", params)
|
|
100
|
-
|
|
105
|
+
|
|
106
|
+
# If Unity indicates a run is already active, return a structured "busy" response rather than
|
|
107
|
+
# letting clients interpret this as a generic failure (avoids #503 retry loops).
|
|
108
|
+
if isinstance(response, dict) and not response.get("success", True):
|
|
109
|
+
err = (response.get("error") or response.get("message") or "").strip()
|
|
110
|
+
if "test run is already in progress" in err.lower():
|
|
111
|
+
return MCPResponse(
|
|
112
|
+
success=False,
|
|
113
|
+
error="tests_running",
|
|
114
|
+
message=err,
|
|
115
|
+
hint="retry",
|
|
116
|
+
data={"reason": "tests_running", "retry_after_ms": 5000},
|
|
117
|
+
)
|
|
118
|
+
return MCPResponse(**response)
|
|
119
|
+
|
|
101
120
|
return RunTestsResponse(**response) if isinstance(response, dict) else response
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Async Unity Test Runner jobs: start + poll."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Annotated, Any, Literal
|
|
5
|
+
|
|
6
|
+
from fastmcp import Context
|
|
7
|
+
|
|
8
|
+
from models import MCPResponse
|
|
9
|
+
from services.registry import mcp_for_unity_tool
|
|
10
|
+
from services.tools import get_unity_instance_from_context
|
|
11
|
+
from services.tools.preflight import preflight
|
|
12
|
+
import transport.unity_transport as unity_transport
|
|
13
|
+
from transport.legacy.unity_connection import async_send_command_with_retry
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@mcp_for_unity_tool(description="Starts a Unity test run asynchronously and returns a job_id immediately. Preferred over run_tests for long-running suites. Poll with get_test_job for progress.")
|
|
17
|
+
async def run_tests_async(
|
|
18
|
+
ctx: Context,
|
|
19
|
+
mode: Annotated[Literal["EditMode", "PlayMode"], "Unity test mode to run"] = "EditMode",
|
|
20
|
+
test_names: Annotated[list[str] | str, "Full names of specific tests to run"] | None = None,
|
|
21
|
+
group_names: Annotated[list[str] | str, "Same as test_names, except it allows for Regex"] | None = None,
|
|
22
|
+
category_names: Annotated[list[str] | str, "NUnit category names to filter by"] | None = None,
|
|
23
|
+
assembly_names: Annotated[list[str] | str, "Assembly names to filter tests by"] | None = None,
|
|
24
|
+
include_failed_tests: Annotated[bool, "Include details for failed/skipped tests only (default: false)"] = False,
|
|
25
|
+
include_details: Annotated[bool, "Include details for all tests (default: false)"] = False,
|
|
26
|
+
) -> dict[str, Any] | MCPResponse:
|
|
27
|
+
unity_instance = get_unity_instance_from_context(ctx)
|
|
28
|
+
|
|
29
|
+
gate = await preflight(ctx, requires_no_tests=True, wait_for_no_compile=True, refresh_if_dirty=True)
|
|
30
|
+
if isinstance(gate, MCPResponse):
|
|
31
|
+
return gate
|
|
32
|
+
|
|
33
|
+
def _coerce_string_list(value) -> list[str] | None:
|
|
34
|
+
if value is None:
|
|
35
|
+
return None
|
|
36
|
+
if isinstance(value, str):
|
|
37
|
+
return [value] if value.strip() else None
|
|
38
|
+
if isinstance(value, list):
|
|
39
|
+
result = [str(v).strip() for v in value if v and str(v).strip()]
|
|
40
|
+
return result if result else None
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
params: dict[str, Any] = {"mode": mode}
|
|
44
|
+
if (t := _coerce_string_list(test_names)):
|
|
45
|
+
params["testNames"] = t
|
|
46
|
+
if (g := _coerce_string_list(group_names)):
|
|
47
|
+
params["groupNames"] = g
|
|
48
|
+
if (c := _coerce_string_list(category_names)):
|
|
49
|
+
params["categoryNames"] = c
|
|
50
|
+
if (a := _coerce_string_list(assembly_names)):
|
|
51
|
+
params["assemblyNames"] = a
|
|
52
|
+
if include_failed_tests:
|
|
53
|
+
params["includeFailedTests"] = True
|
|
54
|
+
if include_details:
|
|
55
|
+
params["includeDetails"] = True
|
|
56
|
+
|
|
57
|
+
response = await unity_transport.send_with_unity_instance(
|
|
58
|
+
async_send_command_with_retry,
|
|
59
|
+
unity_instance,
|
|
60
|
+
"run_tests_async",
|
|
61
|
+
params,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
if isinstance(response, dict) and not response.get("success", True):
|
|
65
|
+
return MCPResponse(**response)
|
|
66
|
+
return response if isinstance(response, dict) else MCPResponse(success=False, error=str(response)).model_dump()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@mcp_for_unity_tool(description="Polls an async Unity test job by job_id.")
|
|
70
|
+
async def get_test_job(
|
|
71
|
+
ctx: Context,
|
|
72
|
+
job_id: Annotated[str, "Job id returned by run_tests_async"],
|
|
73
|
+
include_failed_tests: Annotated[bool, "Include details for failed/skipped tests only (default: false)"] = False,
|
|
74
|
+
include_details: Annotated[bool, "Include details for all tests (default: false)"] = False,
|
|
75
|
+
) -> dict[str, Any] | MCPResponse:
|
|
76
|
+
unity_instance = get_unity_instance_from_context(ctx)
|
|
77
|
+
|
|
78
|
+
params: dict[str, Any] = {"job_id": job_id}
|
|
79
|
+
if include_failed_tests:
|
|
80
|
+
params["includeFailedTests"] = True
|
|
81
|
+
if include_details:
|
|
82
|
+
params["includeDetails"] = True
|
|
83
|
+
|
|
84
|
+
response = await unity_transport.send_with_unity_instance(
|
|
85
|
+
async_send_command_with_retry,
|
|
86
|
+
unity_instance,
|
|
87
|
+
"get_test_job",
|
|
88
|
+
params,
|
|
89
|
+
)
|
|
90
|
+
if isinstance(response, dict) and not response.get("success", True):
|
|
91
|
+
return MCPResponse(**response)
|
|
92
|
+
return response if isinstance(response, dict) else MCPResponse(success=False, error=str(response)).model_dump()
|
|
93
|
+
|
|
94
|
+
|
|
@@ -686,28 +686,46 @@ def get_unity_connection(instance_identifier: str | None = None) -> UnityConnect
|
|
|
686
686
|
# Centralized retry helpers
|
|
687
687
|
# -----------------------------
|
|
688
688
|
|
|
689
|
-
def
|
|
690
|
-
"""
|
|
689
|
+
def _extract_response_reason(resp: object) -> str | None:
|
|
690
|
+
"""Extract a normalized (lowercase) reason string from a response.
|
|
691
691
|
|
|
692
|
-
|
|
693
|
-
by
|
|
692
|
+
Returns lowercase reason values to enable case-insensitive comparisons
|
|
693
|
+
by callers (e.g. _is_reloading_response, refresh_unity).
|
|
694
694
|
"""
|
|
695
|
-
# Structured MCPResponse from preflight/transport
|
|
696
695
|
if isinstance(resp, MCPResponse):
|
|
697
|
-
|
|
698
|
-
if
|
|
699
|
-
|
|
696
|
+
data = getattr(resp, "data", None)
|
|
697
|
+
if isinstance(data, dict):
|
|
698
|
+
reason = data.get("reason")
|
|
699
|
+
if isinstance(reason, str):
|
|
700
|
+
return reason.lower()
|
|
700
701
|
message_text = f"{resp.message or ''} {resp.error or ''}".lower()
|
|
701
|
-
|
|
702
|
+
if "reload" in message_text:
|
|
703
|
+
return "reloading"
|
|
704
|
+
return None
|
|
702
705
|
|
|
703
|
-
# Raw Unity payloads
|
|
704
706
|
if isinstance(resp, dict):
|
|
705
707
|
if resp.get("state") == "reloading":
|
|
706
|
-
return
|
|
708
|
+
return "reloading"
|
|
709
|
+
data = resp.get("data")
|
|
710
|
+
if isinstance(data, dict):
|
|
711
|
+
reason = data.get("reason")
|
|
712
|
+
if isinstance(reason, str):
|
|
713
|
+
return reason.lower()
|
|
707
714
|
message_text = (resp.get("message") or resp.get("error") or "").lower()
|
|
708
|
-
|
|
715
|
+
if "reload" in message_text:
|
|
716
|
+
return "reloading"
|
|
717
|
+
return None
|
|
709
718
|
|
|
710
|
-
return
|
|
719
|
+
return None
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
def _is_reloading_response(resp: object) -> bool:
|
|
723
|
+
"""Return True if the Unity response indicates the editor is reloading.
|
|
724
|
+
|
|
725
|
+
Supports both raw dict payloads from Unity and MCPResponse objects returned
|
|
726
|
+
by preflight checks or transport helpers.
|
|
727
|
+
"""
|
|
728
|
+
return _extract_response_reason(resp) == "reloading"
|
|
711
729
|
|
|
712
730
|
|
|
713
731
|
def send_command_with_retry(
|
|
@@ -738,15 +756,82 @@ def send_command_with_retry(
|
|
|
738
756
|
max_retries = getattr(config, "reload_max_retries", 40)
|
|
739
757
|
if retry_ms is None:
|
|
740
758
|
retry_ms = getattr(config, "reload_retry_ms", 250)
|
|
759
|
+
try:
|
|
760
|
+
max_wait_s = float(os.environ.get(
|
|
761
|
+
"UNITY_MCP_RELOAD_MAX_WAIT_S", "2.0"))
|
|
762
|
+
except ValueError as e:
|
|
763
|
+
raw_val = os.environ.get("UNITY_MCP_RELOAD_MAX_WAIT_S", "2.0")
|
|
764
|
+
logger.warning(
|
|
765
|
+
"Invalid UNITY_MCP_RELOAD_MAX_WAIT_S=%r, using default 2.0: %s",
|
|
766
|
+
raw_val, e)
|
|
767
|
+
max_wait_s = 2.0
|
|
768
|
+
# Clamp to [0, 30] to prevent misconfiguration from causing excessive waits
|
|
769
|
+
max_wait_s = max(0.0, min(max_wait_s, 30.0))
|
|
741
770
|
|
|
742
771
|
response = conn.send_command(command_type, params)
|
|
743
772
|
retries = 0
|
|
773
|
+
wait_started = None
|
|
774
|
+
reason = _extract_response_reason(response)
|
|
744
775
|
while _is_reloading_response(response) and retries < max_retries:
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
776
|
+
if wait_started is None:
|
|
777
|
+
wait_started = time.monotonic()
|
|
778
|
+
logger.debug(
|
|
779
|
+
"Unity reload wait started: command=%s instance=%s reason=%s max_wait_s=%.2f",
|
|
780
|
+
command_type,
|
|
781
|
+
instance_id or "default",
|
|
782
|
+
reason or "reloading",
|
|
783
|
+
max_wait_s,
|
|
784
|
+
)
|
|
785
|
+
if max_wait_s <= 0:
|
|
786
|
+
break
|
|
787
|
+
elapsed = time.monotonic() - wait_started
|
|
788
|
+
if elapsed >= max_wait_s:
|
|
789
|
+
break
|
|
790
|
+
delay_ms = retry_ms
|
|
791
|
+
if isinstance(response, dict):
|
|
792
|
+
retry_after = response.get("retry_after_ms")
|
|
793
|
+
if retry_after is None and isinstance(response.get("data"), dict):
|
|
794
|
+
retry_after = response["data"].get("retry_after_ms")
|
|
795
|
+
if retry_after is not None:
|
|
796
|
+
delay_ms = int(retry_after)
|
|
797
|
+
sleep_ms = max(50, min(int(delay_ms), 250))
|
|
798
|
+
logger.debug(
|
|
799
|
+
"Unity reload wait retry: command=%s instance=%s reason=%s retry_after_ms=%s sleep_ms=%s",
|
|
800
|
+
command_type,
|
|
801
|
+
instance_id or "default",
|
|
802
|
+
reason or "reloading",
|
|
803
|
+
delay_ms,
|
|
804
|
+
sleep_ms,
|
|
805
|
+
)
|
|
806
|
+
time.sleep(max(0.0, sleep_ms / 1000.0))
|
|
748
807
|
retries += 1
|
|
749
808
|
response = conn.send_command(command_type, params)
|
|
809
|
+
reason = _extract_response_reason(response)
|
|
810
|
+
|
|
811
|
+
if wait_started is not None:
|
|
812
|
+
waited = time.monotonic() - wait_started
|
|
813
|
+
if _is_reloading_response(response):
|
|
814
|
+
logger.debug(
|
|
815
|
+
"Unity reload wait exceeded budget: command=%s instance=%s waited_s=%.3f",
|
|
816
|
+
command_type,
|
|
817
|
+
instance_id or "default",
|
|
818
|
+
waited,
|
|
819
|
+
)
|
|
820
|
+
return MCPResponse(
|
|
821
|
+
success=False,
|
|
822
|
+
error="Unity is reloading; please retry",
|
|
823
|
+
hint="retry",
|
|
824
|
+
data={
|
|
825
|
+
"reason": "reloading",
|
|
826
|
+
"retry_after_ms": min(250, max(50, retry_ms)),
|
|
827
|
+
},
|
|
828
|
+
)
|
|
829
|
+
logger.debug(
|
|
830
|
+
"Unity reload wait completed: command=%s instance=%s waited_s=%.3f",
|
|
831
|
+
command_type,
|
|
832
|
+
instance_id or "default",
|
|
833
|
+
waited,
|
|
834
|
+
)
|
|
750
835
|
return response
|
|
751
836
|
|
|
752
837
|
|