mcpforunityserver 8.5.0__py3-none-any.whl → 8.7.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.
- main.py +30 -0
- {mcpforunityserver-8.5.0.dist-info → mcpforunityserver-8.7.0.dist-info}/METADATA +2 -2
- {mcpforunityserver-8.5.0.dist-info → mcpforunityserver-8.7.0.dist-info}/RECORD +21 -16
- services/resources/editor_state.py +10 -1
- 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 +12 -2
- services/tools/refresh_unity.py +90 -0
- services/tools/run_tests.py +31 -4
- services/tools/test_jobs.py +94 -0
- transport/plugin_hub.py +118 -7
- transport/unity_instance_middleware.py +90 -0
- transport/unity_transport.py +16 -6
- {mcpforunityserver-8.5.0.dist-info → mcpforunityserver-8.7.0.dist-info}/WHEEL +0 -0
- {mcpforunityserver-8.5.0.dist-info → mcpforunityserver-8.7.0.dist-info}/entry_points.txt +0 -0
- {mcpforunityserver-8.5.0.dist-info → mcpforunityserver-8.7.0.dist-info}/licenses/LICENSE +0 -0
- {mcpforunityserver-8.5.0.dist-info → mcpforunityserver-8.7.0.dist-info}/top_level.txt +0 -0
services/tools/manage_asset.py
CHANGED
|
@@ -12,6 +12,7 @@ from services.tools import get_unity_instance_from_context
|
|
|
12
12
|
from services.tools.utils import parse_json_payload, coerce_int
|
|
13
13
|
from transport.unity_transport import send_with_unity_instance
|
|
14
14
|
from transport.legacy.unity_connection import async_send_command_with_retry
|
|
15
|
+
from services.tools.preflight import preflight
|
|
15
16
|
|
|
16
17
|
|
|
17
18
|
@mcp_for_unity_tool(
|
|
@@ -47,6 +48,12 @@ async def manage_asset(
|
|
|
47
48
|
) -> dict[str, Any]:
|
|
48
49
|
unity_instance = get_unity_instance_from_context(ctx)
|
|
49
50
|
|
|
51
|
+
# Best-effort guard: if Unity is compiling/reloading or known external changes are pending,
|
|
52
|
+
# wait/refresh to avoid stale reads and flaky timeouts.
|
|
53
|
+
gate = await preflight(ctx, wait_for_no_compile=True, refresh_if_dirty=True)
|
|
54
|
+
if gate is not None:
|
|
55
|
+
return gate.model_dump()
|
|
56
|
+
|
|
50
57
|
def _parse_properties_string(raw: str) -> tuple[dict[str, Any] | None, str | None]:
|
|
51
58
|
try:
|
|
52
59
|
parsed = json.loads(raw)
|
|
@@ -8,6 +8,7 @@ from services.tools import get_unity_instance_from_context
|
|
|
8
8
|
from transport.unity_transport import send_with_unity_instance
|
|
9
9
|
from transport.legacy.unity_connection import async_send_command_with_retry
|
|
10
10
|
from services.tools.utils import coerce_bool, parse_json_payload, coerce_int
|
|
11
|
+
from services.tools.preflight import preflight
|
|
11
12
|
|
|
12
13
|
|
|
13
14
|
@mcp_for_unity_tool(
|
|
@@ -92,6 +93,10 @@ async def manage_gameobject(
|
|
|
92
93
|
# Removed session_state import
|
|
93
94
|
unity_instance = get_unity_instance_from_context(ctx)
|
|
94
95
|
|
|
96
|
+
gate = await preflight(ctx, wait_for_no_compile=True, refresh_if_dirty=True)
|
|
97
|
+
if gate is not None:
|
|
98
|
+
return gate.model_dump()
|
|
99
|
+
|
|
95
100
|
if action is None:
|
|
96
101
|
return {
|
|
97
102
|
"success": False,
|
services/tools/manage_scene.py
CHANGED
|
@@ -6,6 +6,7 @@ from services.tools import get_unity_instance_from_context
|
|
|
6
6
|
from services.tools.utils import coerce_int, coerce_bool
|
|
7
7
|
from transport.unity_transport import send_with_unity_instance
|
|
8
8
|
from transport.legacy.unity_connection import async_send_command_with_retry
|
|
9
|
+
from services.tools.preflight import preflight
|
|
9
10
|
|
|
10
11
|
|
|
11
12
|
@mcp_for_unity_tool(
|
|
@@ -40,6 +41,9 @@ async def manage_scene(
|
|
|
40
41
|
# Get active instance from session state
|
|
41
42
|
# Removed session_state import
|
|
42
43
|
unity_instance = get_unity_instance_from_context(ctx)
|
|
44
|
+
gate = await preflight(ctx, wait_for_no_compile=True, refresh_if_dirty=True)
|
|
45
|
+
if gate is not None:
|
|
46
|
+
return gate.model_dump()
|
|
43
47
|
try:
|
|
44
48
|
coerced_build_index = coerce_int(build_index, default=None)
|
|
45
49
|
coerced_super_size = coerce_int(screenshot_super_size, default=None)
|
|
@@ -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
|
@@ -45,8 +45,18 @@ async def read_console(
|
|
|
45
45
|
if isinstance(action, str):
|
|
46
46
|
action = action.lower()
|
|
47
47
|
|
|
48
|
-
# Coerce count defensively (string/float -> int)
|
|
49
|
-
count
|
|
48
|
+
# Coerce count defensively (string/float -> int).
|
|
49
|
+
# Important: leaving count unset previously meant "return all console entries", which can be extremely slow
|
|
50
|
+
# (and can exceed the plugin command timeout when Unity has a large console).
|
|
51
|
+
# To keep the tool responsive by default, we cap the default to a reasonable number of most-recent entries.
|
|
52
|
+
# If a client truly wants everything, it can pass count="all" (or count="*") explicitly.
|
|
53
|
+
if isinstance(count, str) and count.strip().lower() in ("all", "*"):
|
|
54
|
+
count = None
|
|
55
|
+
else:
|
|
56
|
+
count = coerce_int(count)
|
|
57
|
+
|
|
58
|
+
if action == "get" and count is None:
|
|
59
|
+
count = 200
|
|
50
60
|
|
|
51
61
|
# Prepare parameters for the C# handler
|
|
52
62
|
params_dict = {
|
|
@@ -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
|
|
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
|
+
is_retryable = (hint == "retry") or ("disconnected" in str(err).lower())
|
|
51
|
+
if (not wait_for_ready) or (not is_retryable):
|
|
52
|
+
return MCPResponse(**response)
|
|
53
|
+
recovered_from_disconnect = True
|
|
54
|
+
|
|
55
|
+
# Optional server-side wait loop (defensive): if Unity tool doesn't wait or returns quickly,
|
|
56
|
+
# poll the canonical editor_state v2 resource until ready or timeout.
|
|
57
|
+
if wait_for_ready:
|
|
58
|
+
timeout_s = 60.0
|
|
59
|
+
start = time.monotonic()
|
|
60
|
+
from services.resources.editor_state_v2 import get_editor_state_v2
|
|
61
|
+
|
|
62
|
+
while time.monotonic() - start < timeout_s:
|
|
63
|
+
state_resp = await get_editor_state_v2(ctx)
|
|
64
|
+
state = state_resp.model_dump() if hasattr(state_resp, "model_dump") else state_resp
|
|
65
|
+
data = (state or {}).get("data") if isinstance(state, dict) else None
|
|
66
|
+
advice = (data or {}).get("advice") if isinstance(data, dict) else None
|
|
67
|
+
if isinstance(advice, dict) and advice.get("ready_for_tools") is True:
|
|
68
|
+
break
|
|
69
|
+
await asyncio.sleep(0.25)
|
|
70
|
+
|
|
71
|
+
# After readiness is restored, clear any external-dirty flag for this instance so future tools can proceed cleanly.
|
|
72
|
+
try:
|
|
73
|
+
from services.resources.editor_state_v2 import _infer_single_instance_id
|
|
74
|
+
|
|
75
|
+
inst = unity_instance or await _infer_single_instance_id(ctx)
|
|
76
|
+
if inst:
|
|
77
|
+
external_changes_scanner.clear_dirty(inst)
|
|
78
|
+
except Exception:
|
|
79
|
+
pass
|
|
80
|
+
|
|
81
|
+
if recovered_from_disconnect:
|
|
82
|
+
return MCPResponse(
|
|
83
|
+
success=True,
|
|
84
|
+
message="Refresh recovered after Unity disconnect/retry; editor is ready.",
|
|
85
|
+
data={"recovered_from_disconnect": True},
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
return MCPResponse(**response) if isinstance(response, dict) else response
|
|
89
|
+
|
|
90
|
+
|
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):
|
|
@@ -34,7 +35,7 @@ class RunTestsTestResult(BaseModel):
|
|
|
34
35
|
class RunTestsResult(BaseModel):
|
|
35
36
|
mode: str
|
|
36
37
|
summary: RunTestsSummary
|
|
37
|
-
results: list[RunTestsTestResult]
|
|
38
|
+
results: list[RunTestsTestResult] | None = None
|
|
38
39
|
|
|
39
40
|
|
|
40
41
|
class RunTestsResponse(MCPResponse):
|
|
@@ -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,
|
|
@@ -52,9 +53,15 @@ async def run_tests(
|
|
|
52
53
|
group_names: Annotated[list[str] | str, "Same as test_names, except it allows for Regex"] | None = None,
|
|
53
54
|
category_names: Annotated[list[str] | str, "NUnit category names to filter by (tests marked with [Category] attribute)"] | None = None,
|
|
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,
|
|
57
|
+
include_details: Annotated[bool, "Include details for all tests (default: false)"] = False,
|
|
58
|
+
) -> RunTestsResponse | MCPResponse:
|
|
56
59
|
unity_instance = get_unity_instance_from_context(ctx)
|
|
57
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
|
+
|
|
58
65
|
# Coerce string or list to list of strings
|
|
59
66
|
def _coerce_string_list(value) -> list[str] | None:
|
|
60
67
|
if value is None:
|
|
@@ -88,6 +95,26 @@ async def run_tests(
|
|
|
88
95
|
if assembly_names_list:
|
|
89
96
|
params["assemblyNames"] = assembly_names_list
|
|
90
97
|
|
|
98
|
+
# Add verbosity parameters
|
|
99
|
+
if include_failed_tests:
|
|
100
|
+
params["includeFailedTests"] = True
|
|
101
|
+
if include_details:
|
|
102
|
+
params["includeDetails"] = True
|
|
103
|
+
|
|
91
104
|
response = await send_with_unity_instance(async_send_command_with_retry, unity_instance, "run_tests", params)
|
|
92
|
-
|
|
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
|
+
|
|
93
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
|
+
|
transport/plugin_hub.py
CHANGED
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import asyncio
|
|
6
6
|
import logging
|
|
7
|
+
import os
|
|
7
8
|
import time
|
|
8
9
|
import uuid
|
|
9
10
|
from typing import Any
|
|
@@ -12,6 +13,7 @@ from starlette.endpoints import WebSocketEndpoint
|
|
|
12
13
|
from starlette.websockets import WebSocket
|
|
13
14
|
|
|
14
15
|
from core.config import config
|
|
16
|
+
from models.models import MCPResponse
|
|
15
17
|
from transport.plugin_registry import PluginRegistry
|
|
16
18
|
from transport.models import (
|
|
17
19
|
WelcomeMessage,
|
|
@@ -28,6 +30,10 @@ from transport.models import (
|
|
|
28
30
|
logger = logging.getLogger("mcp-for-unity-server")
|
|
29
31
|
|
|
30
32
|
|
|
33
|
+
class PluginDisconnectedError(RuntimeError):
|
|
34
|
+
"""Raised when a plugin WebSocket disconnects while commands are in flight."""
|
|
35
|
+
|
|
36
|
+
|
|
31
37
|
class PluginHub(WebSocketEndpoint):
|
|
32
38
|
"""Manages persistent WebSocket connections to Unity plugins."""
|
|
33
39
|
|
|
@@ -35,10 +41,15 @@ class PluginHub(WebSocketEndpoint):
|
|
|
35
41
|
KEEP_ALIVE_INTERVAL = 15
|
|
36
42
|
SERVER_TIMEOUT = 30
|
|
37
43
|
COMMAND_TIMEOUT = 30
|
|
44
|
+
# Fast-path commands should never block the client for long; return a retry hint instead.
|
|
45
|
+
# This helps avoid the Cursor-side ~30s tool-call timeout when Unity is compiling/reloading
|
|
46
|
+
# or is throttled while unfocused.
|
|
47
|
+
_FAST_FAIL_COMMANDS: set[str] = {"read_console", "get_editor_state", "ping"}
|
|
38
48
|
|
|
39
49
|
_registry: PluginRegistry | None = None
|
|
40
50
|
_connections: dict[str, WebSocket] = {}
|
|
41
|
-
|
|
51
|
+
# command_id -> {"future": Future, "session_id": str}
|
|
52
|
+
_pending: dict[str, dict[str, Any]] = {}
|
|
42
53
|
_lock: asyncio.Lock | None = None
|
|
43
54
|
_loop: asyncio.AbstractEventLoop | None = None
|
|
44
55
|
|
|
@@ -95,6 +106,21 @@ class PluginHub(WebSocketEndpoint):
|
|
|
95
106
|
(sid for sid, ws in cls._connections.items() if ws is websocket), None)
|
|
96
107
|
if session_id:
|
|
97
108
|
cls._connections.pop(session_id, None)
|
|
109
|
+
# Fail-fast any in-flight commands for this session to avoid waiting for COMMAND_TIMEOUT.
|
|
110
|
+
pending_ids = [
|
|
111
|
+
command_id
|
|
112
|
+
for command_id, entry in cls._pending.items()
|
|
113
|
+
if entry.get("session_id") == session_id
|
|
114
|
+
]
|
|
115
|
+
for command_id in pending_ids:
|
|
116
|
+
entry = cls._pending.get(command_id)
|
|
117
|
+
future = entry.get("future") if isinstance(entry, dict) else None
|
|
118
|
+
if future and not future.done():
|
|
119
|
+
future.set_exception(
|
|
120
|
+
PluginDisconnectedError(
|
|
121
|
+
f"Unity plugin session {session_id} disconnected while awaiting command_result"
|
|
122
|
+
)
|
|
123
|
+
)
|
|
98
124
|
if cls._registry:
|
|
99
125
|
await cls._registry.unregister(session_id)
|
|
100
126
|
logger.info(
|
|
@@ -108,6 +134,39 @@ class PluginHub(WebSocketEndpoint):
|
|
|
108
134
|
websocket = await cls._get_connection(session_id)
|
|
109
135
|
command_id = str(uuid.uuid4())
|
|
110
136
|
future: asyncio.Future = asyncio.get_running_loop().create_future()
|
|
137
|
+
# Compute a per-command timeout:
|
|
138
|
+
# - fast-path commands: short timeout (encourage retry)
|
|
139
|
+
# - long-running commands (e.g., run_tests): allow caller to request a longer timeout via params
|
|
140
|
+
unity_timeout_s = float(cls.COMMAND_TIMEOUT)
|
|
141
|
+
server_wait_s = float(cls.COMMAND_TIMEOUT)
|
|
142
|
+
if command_type in cls._FAST_FAIL_COMMANDS:
|
|
143
|
+
try:
|
|
144
|
+
fast_timeout = float(os.environ.get("UNITY_MCP_FAST_COMMAND_TIMEOUT", "3"))
|
|
145
|
+
except Exception:
|
|
146
|
+
fast_timeout = 3.0
|
|
147
|
+
unity_timeout_s = fast_timeout
|
|
148
|
+
server_wait_s = fast_timeout
|
|
149
|
+
else:
|
|
150
|
+
# Common tools pass a requested timeout in seconds (e.g., run_tests(timeout_seconds=900)).
|
|
151
|
+
requested = None
|
|
152
|
+
try:
|
|
153
|
+
if isinstance(params, dict):
|
|
154
|
+
requested = params.get("timeout_seconds", None)
|
|
155
|
+
if requested is None:
|
|
156
|
+
requested = params.get("timeoutSeconds", None)
|
|
157
|
+
except Exception:
|
|
158
|
+
requested = None
|
|
159
|
+
|
|
160
|
+
if requested is not None:
|
|
161
|
+
try:
|
|
162
|
+
requested_s = float(requested)
|
|
163
|
+
# Clamp to a sane upper bound to avoid accidental infinite hangs.
|
|
164
|
+
requested_s = max(1.0, min(requested_s, 60.0 * 60.0))
|
|
165
|
+
unity_timeout_s = max(unity_timeout_s, requested_s)
|
|
166
|
+
# Give the server a small cushion beyond the Unity-side timeout to account for transport overhead.
|
|
167
|
+
server_wait_s = max(server_wait_s, requested_s + 5.0)
|
|
168
|
+
except Exception:
|
|
169
|
+
pass
|
|
111
170
|
|
|
112
171
|
lock = cls._lock
|
|
113
172
|
if lock is None:
|
|
@@ -117,18 +176,35 @@ class PluginHub(WebSocketEndpoint):
|
|
|
117
176
|
if command_id in cls._pending:
|
|
118
177
|
raise RuntimeError(
|
|
119
178
|
f"Duplicate command id generated: {command_id}")
|
|
120
|
-
cls._pending[command_id] = future
|
|
179
|
+
cls._pending[command_id] = {"future": future, "session_id": session_id}
|
|
121
180
|
|
|
122
181
|
try:
|
|
123
182
|
msg = ExecuteCommandMessage(
|
|
124
183
|
id=command_id,
|
|
125
184
|
name=command_type,
|
|
126
185
|
params=params,
|
|
127
|
-
timeout=
|
|
186
|
+
timeout=unity_timeout_s,
|
|
128
187
|
)
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
188
|
+
try:
|
|
189
|
+
await websocket.send_json(msg.model_dump())
|
|
190
|
+
except Exception as exc:
|
|
191
|
+
# If send fails (socket already closing), fail the future so callers don't hang.
|
|
192
|
+
if not future.done():
|
|
193
|
+
future.set_exception(exc)
|
|
194
|
+
raise
|
|
195
|
+
try:
|
|
196
|
+
result = await asyncio.wait_for(future, timeout=server_wait_s)
|
|
197
|
+
return result
|
|
198
|
+
except PluginDisconnectedError as exc:
|
|
199
|
+
return MCPResponse(success=False, error=str(exc), hint="retry").model_dump()
|
|
200
|
+
except asyncio.TimeoutError:
|
|
201
|
+
if command_type in cls._FAST_FAIL_COMMANDS:
|
|
202
|
+
return MCPResponse(
|
|
203
|
+
success=False,
|
|
204
|
+
error=f"Unity did not respond to '{command_type}' within {server_wait_s:.1f}s; please retry",
|
|
205
|
+
hint="retry",
|
|
206
|
+
).model_dump()
|
|
207
|
+
raise
|
|
132
208
|
finally:
|
|
133
209
|
async with lock:
|
|
134
210
|
cls._pending.pop(command_id, None)
|
|
@@ -245,7 +321,8 @@ class PluginHub(WebSocketEndpoint):
|
|
|
245
321
|
return
|
|
246
322
|
|
|
247
323
|
async with lock:
|
|
248
|
-
|
|
324
|
+
entry = cls._pending.get(command_id)
|
|
325
|
+
future = entry.get("future") if isinstance(entry, dict) else None
|
|
249
326
|
if future and not future.done():
|
|
250
327
|
future.set_result(result)
|
|
251
328
|
|
|
@@ -364,6 +441,40 @@ class PluginHub(WebSocketEndpoint):
|
|
|
364
441
|
params: dict[str, Any],
|
|
365
442
|
) -> dict[str, Any]:
|
|
366
443
|
session_id = await cls._resolve_session_id(unity_instance)
|
|
444
|
+
|
|
445
|
+
# During domain reload / immediate reconnect windows, the plugin may be connected but not yet
|
|
446
|
+
# ready to process execute commands on the Unity main thread (which can be further delayed when
|
|
447
|
+
# the Unity Editor is unfocused). For fast-path commands, we do a bounded readiness probe using
|
|
448
|
+
# a main-thread ping command (handled by TransportCommandDispatcher) rather than waiting on
|
|
449
|
+
# register_tools (which can be delayed by EditorApplication.delayCall).
|
|
450
|
+
if command_type in cls._FAST_FAIL_COMMANDS and command_type != "ping":
|
|
451
|
+
try:
|
|
452
|
+
max_wait_s = float(os.environ.get("UNITY_MCP_SESSION_READY_WAIT_SECONDS", "6"))
|
|
453
|
+
except Exception:
|
|
454
|
+
max_wait_s = 6.0
|
|
455
|
+
max_wait_s = max(0.0, min(max_wait_s, 30.0))
|
|
456
|
+
if max_wait_s > 0:
|
|
457
|
+
deadline = time.monotonic() + max_wait_s
|
|
458
|
+
while time.monotonic() < deadline:
|
|
459
|
+
try:
|
|
460
|
+
probe = await cls.send_command(session_id, "ping", {})
|
|
461
|
+
except Exception:
|
|
462
|
+
probe = None
|
|
463
|
+
|
|
464
|
+
# The Unity-side dispatcher responds with {status:"success", result:{message:"pong"}}
|
|
465
|
+
if isinstance(probe, dict) and probe.get("status") == "success":
|
|
466
|
+
result = probe.get("result") if isinstance(probe.get("result"), dict) else {}
|
|
467
|
+
if result.get("message") == "pong":
|
|
468
|
+
break
|
|
469
|
+
await asyncio.sleep(0.1)
|
|
470
|
+
else:
|
|
471
|
+
# Not ready within the bounded window: return retry hint without sending.
|
|
472
|
+
return MCPResponse(
|
|
473
|
+
success=False,
|
|
474
|
+
error=f"Unity session not ready for '{command_type}' (ping not answered); please retry",
|
|
475
|
+
hint="retry",
|
|
476
|
+
).model_dump()
|
|
477
|
+
|
|
367
478
|
return await cls.send_command(session_id, command_type, params)
|
|
368
479
|
|
|
369
480
|
# ------------------------------------------------------------------
|