mcpforunityserver 8.7.0__py3-none-any.whl → 9.0.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 +4 -3
- {mcpforunityserver-8.7.0.dist-info → mcpforunityserver-9.0.0.dist-info}/METADATA +2 -2
- mcpforunityserver-9.0.0.dist-info/RECORD +72 -0
- {mcpforunityserver-8.7.0.dist-info → mcpforunityserver-9.0.0.dist-info}/top_level.txt +0 -1
- services/custom_tool_service.py +13 -8
- 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/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 +31 -14
- services/tools/refresh_unity.py +28 -18
- services/tools/run_tests.py +162 -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 +102 -17
- transport/plugin_hub.py +68 -24
- transport/unity_instance_middleware.py +4 -3
- transport/unity_transport.py +2 -1
- 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.0.0.dist-info}/WHEEL +0 -0
- {mcpforunityserver-8.7.0.dist-info → mcpforunityserver-9.0.0.dist-info}/entry_points.txt +0 -0
- {mcpforunityserver-8.7.0.dist-info → mcpforunityserver-9.0.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,6 +4,8 @@ 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
11
|
from services.tools.utils import coerce_int, coerce_bool
|
|
@@ -11,8 +13,18 @@ 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,
|
|
@@ -21,10 +33,14 @@ async def read_console(
|
|
|
21
33
|
types: Annotated[list[Literal['error', 'warning',
|
|
22
34
|
'log', 'all']], "Message types to get"] | None = None,
|
|
23
35
|
count: Annotated[int | str,
|
|
24
|
-
"Max messages to return (accepts int or string, e.g., 5 or '5')"] | None = None,
|
|
36
|
+
"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
37
|
filter_text: Annotated[str, "Text filter for messages"] | None = None,
|
|
26
38
|
since_timestamp: Annotated[str,
|
|
27
39
|
"Get messages after this timestamp (ISO 8601)"] | None = None,
|
|
40
|
+
page_size: Annotated[int | str,
|
|
41
|
+
"Page size for paginated console reads. Defaults to 50 when omitted."] | None = None,
|
|
42
|
+
cursor: Annotated[int | str,
|
|
43
|
+
"Opaque cursor for paging (0-based offset). Defaults to 0."] | None = None,
|
|
28
44
|
format: Annotated[Literal['plain', 'detailed',
|
|
29
45
|
'json'], "Output format"] | None = None,
|
|
30
46
|
include_stacktrace: Annotated[bool | str,
|
|
@@ -36,10 +52,12 @@ async def read_console(
|
|
|
36
52
|
# Set defaults if values are None
|
|
37
53
|
action = action if action is not None else 'get'
|
|
38
54
|
types = types if types is not None else ['error', 'warning', 'log']
|
|
39
|
-
format = format if format is not None else '
|
|
55
|
+
format = format if format is not None else 'plain'
|
|
40
56
|
# Coerce booleans defensively (strings like 'true'/'false')
|
|
41
57
|
|
|
42
|
-
include_stacktrace = coerce_bool(include_stacktrace, default=
|
|
58
|
+
include_stacktrace = coerce_bool(include_stacktrace, default=False)
|
|
59
|
+
coerced_page_size = coerce_int(page_size, default=None)
|
|
60
|
+
coerced_cursor = coerce_int(cursor, default=None)
|
|
43
61
|
|
|
44
62
|
# Normalize action if it's a string
|
|
45
63
|
if isinstance(action, str):
|
|
@@ -56,7 +74,7 @@ async def read_console(
|
|
|
56
74
|
count = coerce_int(count)
|
|
57
75
|
|
|
58
76
|
if action == "get" and count is None:
|
|
59
|
-
count =
|
|
77
|
+
count = 10
|
|
60
78
|
|
|
61
79
|
# Prepare parameters for the C# handler
|
|
62
80
|
params_dict = {
|
|
@@ -65,6 +83,8 @@ async def read_console(
|
|
|
65
83
|
"count": count,
|
|
66
84
|
"filterText": filter_text,
|
|
67
85
|
"sinceTimestamp": since_timestamp,
|
|
86
|
+
"pageSize": coerced_page_size,
|
|
87
|
+
"cursor": coerced_cursor,
|
|
68
88
|
"format": format.lower() if isinstance(format, str) else format,
|
|
69
89
|
"includeStacktrace": include_stacktrace
|
|
70
90
|
}
|
|
@@ -83,16 +103,13 @@ async def read_console(
|
|
|
83
103
|
# Strip stacktrace fields from returned lines if present
|
|
84
104
|
try:
|
|
85
105
|
data = resp.get("data")
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
# Handle legacy/direct list format if any
|
|
106
|
+
if isinstance(data, dict):
|
|
107
|
+
for key in ("lines", "items"):
|
|
108
|
+
if key in data and isinstance(data[key], list):
|
|
109
|
+
_strip_stacktrace_from_list(data[key])
|
|
110
|
+
break
|
|
92
111
|
elif isinstance(data, list):
|
|
93
|
-
|
|
94
|
-
if isinstance(line, dict) and "stacktrace" in line:
|
|
95
|
-
line.pop("stacktrace", None)
|
|
112
|
+
_strip_stacktrace_from_list(data)
|
|
96
113
|
except Exception:
|
|
97
114
|
pass
|
|
98
115
|
return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)}
|
services/tools/refresh_unity.py
CHANGED
|
@@ -5,24 +5,33 @@ import time
|
|
|
5
5
|
from typing import Annotated, Any, Literal
|
|
6
6
|
|
|
7
7
|
from fastmcp import Context
|
|
8
|
+
from mcp.types import ToolAnnotations
|
|
8
9
|
|
|
9
10
|
from models import MCPResponse
|
|
10
11
|
from services.registry import mcp_for_unity_tool
|
|
11
12
|
from services.tools import get_unity_instance_from_context
|
|
12
13
|
import transport.unity_transport as unity_transport
|
|
13
|
-
from transport.legacy.unity_connection import async_send_command_with_retry
|
|
14
|
+
from transport.legacy.unity_connection import async_send_command_with_retry, _extract_response_reason
|
|
14
15
|
from services.state.external_changes_scanner import external_changes_scanner
|
|
16
|
+
import services.resources.editor_state as editor_state
|
|
15
17
|
|
|
16
18
|
|
|
17
19
|
@mcp_for_unity_tool(
|
|
18
|
-
description="Request a Unity asset database refresh and optionally a script compilation. Can optionally wait for readiness."
|
|
20
|
+
description="Request a Unity asset database refresh and optionally a script compilation. Can optionally wait for readiness.",
|
|
21
|
+
annotations=ToolAnnotations(
|
|
22
|
+
title="Refresh Unity",
|
|
23
|
+
destructiveHint=True,
|
|
24
|
+
),
|
|
19
25
|
)
|
|
20
26
|
async def refresh_unity(
|
|
21
27
|
ctx: Context,
|
|
22
28
|
mode: Annotated[Literal["if_dirty", "force"], "Refresh mode"] = "if_dirty",
|
|
23
|
-
scope: Annotated[Literal["assets", "scripts", "all"],
|
|
24
|
-
|
|
25
|
-
|
|
29
|
+
scope: Annotated[Literal["assets", "scripts", "all"],
|
|
30
|
+
"Refresh scope"] = "all",
|
|
31
|
+
compile: Annotated[Literal["none", "request"],
|
|
32
|
+
"Whether to request compilation"] = "none",
|
|
33
|
+
wait_for_ready: Annotated[bool,
|
|
34
|
+
"If true, wait until editor_state.advice.ready_for_tools is true"] = True,
|
|
26
35
|
) -> MCPResponse | dict[str, Any]:
|
|
27
36
|
unity_instance = get_unity_instance_from_context(ctx)
|
|
28
37
|
|
|
@@ -47,32 +56,35 @@ async def refresh_unity(
|
|
|
47
56
|
if isinstance(response, dict) and not response.get("success", True):
|
|
48
57
|
hint = response.get("hint")
|
|
49
58
|
err = (response.get("error") or response.get("message") or "")
|
|
50
|
-
|
|
59
|
+
reason = _extract_response_reason(response)
|
|
60
|
+
is_retryable = (hint == "retry") or (
|
|
61
|
+
"disconnected" in str(err).lower())
|
|
51
62
|
if (not wait_for_ready) or (not is_retryable):
|
|
52
63
|
return MCPResponse(**response)
|
|
53
|
-
|
|
64
|
+
if reason not in {"reloading", "no_unity_session"}:
|
|
65
|
+
recovered_from_disconnect = True
|
|
54
66
|
|
|
55
67
|
# Optional server-side wait loop (defensive): if Unity tool doesn't wait or returns quickly,
|
|
56
|
-
# poll the canonical editor_state
|
|
68
|
+
# poll the canonical editor_state resource until ready or timeout.
|
|
57
69
|
if wait_for_ready:
|
|
58
70
|
timeout_s = 60.0
|
|
59
71
|
start = time.monotonic()
|
|
60
|
-
from services.resources.editor_state_v2 import get_editor_state_v2
|
|
61
72
|
|
|
62
73
|
while time.monotonic() - start < timeout_s:
|
|
63
|
-
state_resp = await
|
|
64
|
-
state = state_resp.model_dump() if hasattr(
|
|
65
|
-
|
|
66
|
-
|
|
74
|
+
state_resp = await editor_state.get_editor_state(ctx)
|
|
75
|
+
state = state_resp.model_dump() if hasattr(
|
|
76
|
+
state_resp, "model_dump") else state_resp
|
|
77
|
+
data = (state or {}).get("data") if isinstance(
|
|
78
|
+
state, dict) else None
|
|
79
|
+
advice = (data or {}).get(
|
|
80
|
+
"advice") if isinstance(data, dict) else None
|
|
67
81
|
if isinstance(advice, dict) and advice.get("ready_for_tools") is True:
|
|
68
82
|
break
|
|
69
83
|
await asyncio.sleep(0.25)
|
|
70
84
|
|
|
71
85
|
# After readiness is restored, clear any external-dirty flag for this instance so future tools can proceed cleanly.
|
|
72
86
|
try:
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
inst = unity_instance or await _infer_single_instance_id(ctx)
|
|
87
|
+
inst = unity_instance or await editor_state.infer_single_instance_id(ctx)
|
|
76
88
|
if inst:
|
|
77
89
|
external_changes_scanner.clear_dirty(inst)
|
|
78
90
|
except Exception:
|
|
@@ -86,5 +98,3 @@ async def refresh_unity(
|
|
|
86
98
|
)
|
|
87
99
|
|
|
88
100
|
return MCPResponse(**response) if isinstance(response, dict) else response
|
|
89
|
-
|
|
90
|
-
|
services/tools/run_tests.py
CHANGED
|
@@ -1,16 +1,19 @@
|
|
|
1
|
-
"""
|
|
2
|
-
from
|
|
1
|
+
"""Async Unity Test Runner jobs: start + poll."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
from typing import Annotated, Any, Literal
|
|
3
6
|
|
|
4
7
|
from fastmcp import Context
|
|
5
|
-
from
|
|
8
|
+
from mcp.types import ToolAnnotations
|
|
9
|
+
from pydantic import BaseModel
|
|
6
10
|
|
|
7
11
|
from models import MCPResponse
|
|
8
12
|
from services.registry import mcp_for_unity_tool
|
|
9
13
|
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
14
|
from services.tools.preflight import preflight
|
|
15
|
+
import transport.unity_transport as unity_transport
|
|
16
|
+
from transport.legacy.unity_connection import async_send_command_with_retry
|
|
14
17
|
|
|
15
18
|
|
|
16
19
|
class RunTestsSummary(BaseModel):
|
|
@@ -38,31 +41,83 @@ class RunTestsResult(BaseModel):
|
|
|
38
41
|
results: list[RunTestsTestResult] | None = None
|
|
39
42
|
|
|
40
43
|
|
|
41
|
-
class
|
|
42
|
-
|
|
44
|
+
class RunTestsStartData(BaseModel):
|
|
45
|
+
job_id: str
|
|
46
|
+
status: str
|
|
47
|
+
mode: str | None = None
|
|
48
|
+
include_details: bool | None = None
|
|
49
|
+
include_failed_tests: bool | None = None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class RunTestsStartResponse(MCPResponse):
|
|
53
|
+
data: RunTestsStartData | None = None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class TestJobFailure(BaseModel):
|
|
57
|
+
full_name: str | None = None
|
|
58
|
+
message: str | None = None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class TestJobProgress(BaseModel):
|
|
62
|
+
completed: int | None = None
|
|
63
|
+
total: int | None = None
|
|
64
|
+
current_test_full_name: str | None = None
|
|
65
|
+
current_test_started_unix_ms: int | None = None
|
|
66
|
+
last_finished_test_full_name: str | None = None
|
|
67
|
+
last_finished_unix_ms: int | None = None
|
|
68
|
+
stuck_suspected: bool | None = None
|
|
69
|
+
editor_is_focused: bool | None = None
|
|
70
|
+
blocked_reason: str | None = None
|
|
71
|
+
failures_so_far: list[TestJobFailure] | None = None
|
|
72
|
+
failures_capped: bool | None = None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class GetTestJobData(BaseModel):
|
|
76
|
+
job_id: str
|
|
77
|
+
status: str
|
|
78
|
+
mode: str | None = None
|
|
79
|
+
started_unix_ms: int | None = None
|
|
80
|
+
finished_unix_ms: int | None = None
|
|
81
|
+
last_update_unix_ms: int | None = None
|
|
82
|
+
progress: TestJobProgress | None = None
|
|
83
|
+
error: str | None = None
|
|
84
|
+
result: RunTestsResult | None = None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class GetTestJobResponse(MCPResponse):
|
|
88
|
+
data: GetTestJobData | None = None
|
|
43
89
|
|
|
44
90
|
|
|
45
91
|
@mcp_for_unity_tool(
|
|
46
|
-
description="
|
|
92
|
+
description="Starts a Unity test run asynchronously and returns a job_id immediately. Poll with get_test_job for progress.",
|
|
93
|
+
annotations=ToolAnnotations(
|
|
94
|
+
title="Run Tests",
|
|
95
|
+
destructiveHint=True,
|
|
96
|
+
),
|
|
47
97
|
)
|
|
48
98
|
async def run_tests(
|
|
49
99
|
ctx: Context,
|
|
50
|
-
mode: Annotated[Literal["EditMode", "PlayMode"],
|
|
51
|
-
|
|
52
|
-
test_names: Annotated[list[str] | str,
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
100
|
+
mode: Annotated[Literal["EditMode", "PlayMode"],
|
|
101
|
+
"Unity test mode to run"] = "EditMode",
|
|
102
|
+
test_names: Annotated[list[str] | str,
|
|
103
|
+
"Full names of specific tests to run"] | None = None,
|
|
104
|
+
group_names: Annotated[list[str] | str,
|
|
105
|
+
"Same as test_names, except it allows for Regex"] | None = None,
|
|
106
|
+
category_names: Annotated[list[str] | str,
|
|
107
|
+
"NUnit category names to filter by"] | None = None,
|
|
108
|
+
assembly_names: Annotated[list[str] | str,
|
|
109
|
+
"Assembly names to filter tests by"] | None = None,
|
|
110
|
+
include_failed_tests: Annotated[bool,
|
|
111
|
+
"Include details for failed/skipped tests only (default: false)"] = False,
|
|
112
|
+
include_details: Annotated[bool,
|
|
113
|
+
"Include details for all tests (default: false)"] = False,
|
|
114
|
+
) -> RunTestsStartResponse | MCPResponse:
|
|
59
115
|
unity_instance = get_unity_instance_from_context(ctx)
|
|
60
116
|
|
|
61
117
|
gate = await preflight(ctx, requires_no_tests=True, wait_for_no_compile=True, refresh_if_dirty=True)
|
|
62
118
|
if isinstance(gate, MCPResponse):
|
|
63
119
|
return gate
|
|
64
120
|
|
|
65
|
-
# Coerce string or list to list of strings
|
|
66
121
|
def _coerce_string_list(value) -> list[str] | None:
|
|
67
122
|
if value is None:
|
|
68
123
|
return None
|
|
@@ -74,47 +129,101 @@ async def run_tests(
|
|
|
74
129
|
return None
|
|
75
130
|
|
|
76
131
|
params: dict[str, Any] = {"mode": mode}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
132
|
+
if (t := _coerce_string_list(test_names)):
|
|
133
|
+
params["testNames"] = t
|
|
134
|
+
if (g := _coerce_string_list(group_names)):
|
|
135
|
+
params["groupNames"] = g
|
|
136
|
+
if (c := _coerce_string_list(category_names)):
|
|
137
|
+
params["categoryNames"] = c
|
|
138
|
+
if (a := _coerce_string_list(assembly_names)):
|
|
139
|
+
params["assemblyNames"] = a
|
|
140
|
+
if include_failed_tests:
|
|
141
|
+
params["includeFailedTests"] = True
|
|
142
|
+
if include_details:
|
|
143
|
+
params["includeDetails"] = True
|
|
80
144
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
145
|
+
response = await unity_transport.send_with_unity_instance(
|
|
146
|
+
async_send_command_with_retry,
|
|
147
|
+
unity_instance,
|
|
148
|
+
"run_tests",
|
|
149
|
+
params,
|
|
150
|
+
)
|
|
85
151
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
152
|
+
if isinstance(response, dict):
|
|
153
|
+
if not response.get("success", True):
|
|
154
|
+
return MCPResponse(**response)
|
|
155
|
+
return RunTestsStartResponse(**response)
|
|
156
|
+
return MCPResponse(success=False, error=str(response))
|
|
89
157
|
|
|
90
|
-
category_names_list = _coerce_string_list(category_names)
|
|
91
|
-
if category_names_list:
|
|
92
|
-
params["categoryNames"] = category_names_list
|
|
93
158
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
159
|
+
@mcp_for_unity_tool(
|
|
160
|
+
description="Polls an async Unity test job by job_id.",
|
|
161
|
+
annotations=ToolAnnotations(
|
|
162
|
+
title="Get Test Job",
|
|
163
|
+
readOnlyHint=True,
|
|
164
|
+
),
|
|
165
|
+
)
|
|
166
|
+
async def get_test_job(
|
|
167
|
+
ctx: Context,
|
|
168
|
+
job_id: Annotated[str, "Job id returned by run_tests"],
|
|
169
|
+
include_failed_tests: Annotated[bool,
|
|
170
|
+
"Include details for failed/skipped tests only (default: false)"] = False,
|
|
171
|
+
include_details: Annotated[bool,
|
|
172
|
+
"Include details for all tests (default: false)"] = False,
|
|
173
|
+
wait_timeout: Annotated[int | None,
|
|
174
|
+
"If set, wait up to this many seconds for tests to complete before returning. "
|
|
175
|
+
"Reduces polling frequency and avoids client-side loop detection. "
|
|
176
|
+
"Recommended: 30-60 seconds. Returns immediately if tests complete sooner."] = None,
|
|
177
|
+
) -> GetTestJobResponse | MCPResponse:
|
|
178
|
+
unity_instance = get_unity_instance_from_context(ctx)
|
|
97
179
|
|
|
98
|
-
|
|
180
|
+
params: dict[str, Any] = {"job_id": job_id}
|
|
99
181
|
if include_failed_tests:
|
|
100
182
|
params["includeFailedTests"] = True
|
|
101
183
|
if include_details:
|
|
102
184
|
params["includeDetails"] = True
|
|
103
185
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
186
|
+
async def _fetch_status() -> dict[str, Any]:
|
|
187
|
+
return await unity_transport.send_with_unity_instance(
|
|
188
|
+
async_send_command_with_retry,
|
|
189
|
+
unity_instance,
|
|
190
|
+
"get_test_job",
|
|
191
|
+
params,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
# If wait_timeout is specified, poll server-side until complete or timeout
|
|
195
|
+
if wait_timeout and wait_timeout > 0:
|
|
196
|
+
deadline = asyncio.get_event_loop().time() + wait_timeout
|
|
197
|
+
poll_interval = 2.0 # Poll Unity every 2 seconds
|
|
198
|
+
|
|
199
|
+
while True:
|
|
200
|
+
response = await _fetch_status()
|
|
201
|
+
|
|
202
|
+
if not isinstance(response, dict):
|
|
203
|
+
return MCPResponse(success=False, error=str(response))
|
|
204
|
+
|
|
205
|
+
if not response.get("success", True):
|
|
206
|
+
return MCPResponse(**response)
|
|
207
|
+
|
|
208
|
+
# Check if tests are done
|
|
209
|
+
data = response.get("data", {})
|
|
210
|
+
status = data.get("status", "")
|
|
211
|
+
if status in ("succeeded", "failed", "cancelled"):
|
|
212
|
+
return GetTestJobResponse(**response)
|
|
213
|
+
|
|
214
|
+
# Check timeout
|
|
215
|
+
remaining = deadline - asyncio.get_event_loop().time()
|
|
216
|
+
if remaining <= 0:
|
|
217
|
+
# Timeout reached, return current status
|
|
218
|
+
return GetTestJobResponse(**response)
|
|
219
|
+
|
|
220
|
+
# Wait before next poll (but don't exceed remaining time)
|
|
221
|
+
await asyncio.sleep(min(poll_interval, remaining))
|
|
222
|
+
|
|
223
|
+
# No wait_timeout - return immediately (original behavior)
|
|
224
|
+
response = await _fetch_status()
|
|
225
|
+
if isinstance(response, dict):
|
|
226
|
+
if not response.get("success", True):
|
|
227
|
+
return MCPResponse(**response)
|
|
228
|
+
return GetTestJobResponse(**response)
|
|
229
|
+
return MCPResponse(success=False, error=str(response))
|
|
@@ -4,6 +4,7 @@ import re
|
|
|
4
4
|
from typing import Annotated, Any, Union
|
|
5
5
|
|
|
6
6
|
from fastmcp import Context
|
|
7
|
+
from mcp.types import ToolAnnotations
|
|
7
8
|
|
|
8
9
|
from services.registry import mcp_for_unity_tool
|
|
9
10
|
from services.tools import get_unity_instance_from_context
|
|
@@ -228,7 +229,7 @@ def _normalize_script_locator(name: str, path: str) -> tuple[str, str]:
|
|
|
228
229
|
- name = "SmartReach.cs", path = "Assets/Scripts/Interaction"
|
|
229
230
|
- name = "Assets/Scripts/Interaction/SmartReach.cs", path = ""
|
|
230
231
|
- path = "Assets/Scripts/Interaction/SmartReach.cs" (name empty)
|
|
231
|
-
- name or path using uri prefixes:
|
|
232
|
+
- name or path using uri prefixes: mcpforunity://path/..., file://...
|
|
232
233
|
- accidental duplicates like "Assets/.../SmartReach.cs/SmartReach.cs"
|
|
233
234
|
|
|
234
235
|
Returns (name_without_extension, directory_path_under_Assets).
|
|
@@ -237,8 +238,8 @@ def _normalize_script_locator(name: str, path: str) -> tuple[str, str]:
|
|
|
237
238
|
p = (path or "").strip()
|
|
238
239
|
|
|
239
240
|
def strip_prefix(s: str) -> str:
|
|
240
|
-
if s.startswith("
|
|
241
|
-
return s[len("
|
|
241
|
+
if s.startswith("mcpforunity://path/"):
|
|
242
|
+
return s[len("mcpforunity://path/"):]
|
|
242
243
|
if s.startswith("file://"):
|
|
243
244
|
return s[len("file://"):]
|
|
244
245
|
return s
|
|
@@ -309,8 +310,10 @@ def _err(code: str, message: str, *, expected: dict[str, Any] | None = None, rew
|
|
|
309
310
|
# Natural-language parsing removed; clients should send structured edits.
|
|
310
311
|
|
|
311
312
|
|
|
312
|
-
@mcp_for_unity_tool(
|
|
313
|
-
""
|
|
313
|
+
@mcp_for_unity_tool(
|
|
314
|
+
name="script_apply_edits",
|
|
315
|
+
description=(
|
|
316
|
+
"""Structured C# edits (methods/classes) with safer boundaries - prefer this over raw text.
|
|
314
317
|
Best practices:
|
|
315
318
|
- Prefer anchor_* ops for pattern-based insert/replace near stable markers
|
|
316
319
|
- Use replace_method/delete_method for whole-method changes (keeps signatures balanced)
|
|
@@ -356,7 +359,12 @@ def _err(code: str, message: str, *, expected: dict[str, Any] | None = None, rew
|
|
|
356
359
|
],
|
|
357
360
|
}
|
|
358
361
|
]"""
|
|
359
|
-
)
|
|
362
|
+
),
|
|
363
|
+
annotations=ToolAnnotations(
|
|
364
|
+
title="Script Apply Edits",
|
|
365
|
+
destructiveHint=True,
|
|
366
|
+
),
|
|
367
|
+
)
|
|
360
368
|
async def script_apply_edits(
|
|
361
369
|
ctx: Context,
|
|
362
370
|
name: Annotated[str, "Name of the script to edit"],
|
|
@@ -372,7 +380,7 @@ async def script_apply_edits(
|
|
|
372
380
|
unity_instance = get_unity_instance_from_context(ctx)
|
|
373
381
|
await ctx.info(
|
|
374
382
|
f"Processing script_apply_edits: {name} (unity_instance={unity_instance or 'default'})")
|
|
375
|
-
|
|
383
|
+
|
|
376
384
|
# Parse edits if they came as a stringified JSON
|
|
377
385
|
edits = parse_json_payload(edits)
|
|
378
386
|
if not isinstance(edits, list):
|
|
@@ -2,6 +2,8 @@ from typing import Annotated, Any
|
|
|
2
2
|
from types import SimpleNamespace
|
|
3
3
|
|
|
4
4
|
from fastmcp import Context
|
|
5
|
+
from mcp.types import ToolAnnotations
|
|
6
|
+
|
|
5
7
|
from services.registry import mcp_for_unity_tool
|
|
6
8
|
from transport.legacy.unity_connection import get_unity_connection_pool
|
|
7
9
|
from transport.unity_instance_middleware import get_unity_instance_middleware
|
|
@@ -10,7 +12,10 @@ from transport.unity_transport import _current_transport
|
|
|
10
12
|
|
|
11
13
|
|
|
12
14
|
@mcp_for_unity_tool(
|
|
13
|
-
description="Set the active Unity instance for this client/session. Accepts Name@hash or hash."
|
|
15
|
+
description="Set the active Unity instance for this client/session. Accepts Name@hash or hash.",
|
|
16
|
+
annotations=ToolAnnotations(
|
|
17
|
+
title="Set Active Instance",
|
|
18
|
+
),
|
|
14
19
|
)
|
|
15
20
|
async def set_active_instance(
|
|
16
21
|
ctx: Context,
|
|
@@ -51,7 +56,7 @@ async def set_active_instance(
|
|
|
51
56
|
return {
|
|
52
57
|
"success": False,
|
|
53
58
|
"error": "Instance identifier is required. "
|
|
54
|
-
"Use
|
|
59
|
+
"Use mcpforunity://instances to copy a Name@hash or provide a hash prefix."
|
|
55
60
|
}
|
|
56
61
|
resolved = None
|
|
57
62
|
if "@" in value:
|
|
@@ -60,7 +65,7 @@ async def set_active_instance(
|
|
|
60
65
|
return {
|
|
61
66
|
"success": False,
|
|
62
67
|
"error": f"Instance '{value}' not found. "
|
|
63
|
-
|
|
68
|
+
"Use mcpforunity://instances to copy an exact Name@hash."
|
|
64
69
|
}
|
|
65
70
|
else:
|
|
66
71
|
lookup = value.lower()
|
|
@@ -75,7 +80,7 @@ async def set_active_instance(
|
|
|
75
80
|
return {
|
|
76
81
|
"success": False,
|
|
77
82
|
"error": f"Instance hash '{value}' does not match any running Unity editors. "
|
|
78
|
-
|
|
83
|
+
"Use mcpforunity://instances to confirm the available hashes."
|
|
79
84
|
}
|
|
80
85
|
if len(matches) > 1:
|
|
81
86
|
matching_ids = ", ".join(
|
|
@@ -84,10 +89,10 @@ async def set_active_instance(
|
|
|
84
89
|
return {
|
|
85
90
|
"success": False,
|
|
86
91
|
"error": f"Instance hash '{value}' is ambiguous ({matching_ids}). "
|
|
87
|
-
|
|
92
|
+
"Provide the full Name@hash from mcpforunity://instances."
|
|
88
93
|
}
|
|
89
94
|
resolved = matches[0]
|
|
90
|
-
|
|
95
|
+
|
|
91
96
|
if resolved is None:
|
|
92
97
|
# Should be unreachable due to logic above, but satisfies static analysis
|
|
93
98
|
return {
|
|
@@ -101,7 +106,7 @@ async def set_active_instance(
|
|
|
101
106
|
# The session key is an internal detail but useful for debugging response.
|
|
102
107
|
middleware.set_active_instance(ctx, resolved.id)
|
|
103
108
|
session_key = middleware.get_session_key(ctx)
|
|
104
|
-
|
|
109
|
+
|
|
105
110
|
return {
|
|
106
111
|
"success": True,
|
|
107
112
|
"message": f"Active instance set to {resolved.id}",
|