mcpforunityserver 9.4.0b20260203025228__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cli/__init__.py +3 -0
- cli/commands/__init__.py +3 -0
- cli/commands/animation.py +84 -0
- cli/commands/asset.py +280 -0
- cli/commands/audio.py +125 -0
- cli/commands/batch.py +171 -0
- cli/commands/code.py +182 -0
- cli/commands/component.py +190 -0
- cli/commands/editor.py +447 -0
- cli/commands/gameobject.py +487 -0
- cli/commands/instance.py +93 -0
- cli/commands/lighting.py +123 -0
- cli/commands/material.py +239 -0
- cli/commands/prefab.py +248 -0
- cli/commands/scene.py +231 -0
- cli/commands/script.py +222 -0
- cli/commands/shader.py +226 -0
- cli/commands/texture.py +540 -0
- cli/commands/tool.py +58 -0
- cli/commands/ui.py +258 -0
- cli/commands/vfx.py +421 -0
- cli/main.py +281 -0
- cli/utils/__init__.py +31 -0
- cli/utils/config.py +58 -0
- cli/utils/confirmation.py +37 -0
- cli/utils/connection.py +254 -0
- cli/utils/constants.py +23 -0
- cli/utils/output.py +195 -0
- cli/utils/parsers.py +112 -0
- cli/utils/suggestions.py +34 -0
- core/__init__.py +0 -0
- core/config.py +67 -0
- core/constants.py +4 -0
- core/logging_decorator.py +37 -0
- core/telemetry.py +551 -0
- core/telemetry_decorator.py +164 -0
- main.py +845 -0
- mcpforunityserver-9.4.0b20260203025228.dist-info/METADATA +328 -0
- mcpforunityserver-9.4.0b20260203025228.dist-info/RECORD +105 -0
- mcpforunityserver-9.4.0b20260203025228.dist-info/WHEEL +5 -0
- mcpforunityserver-9.4.0b20260203025228.dist-info/entry_points.txt +3 -0
- mcpforunityserver-9.4.0b20260203025228.dist-info/licenses/LICENSE +21 -0
- mcpforunityserver-9.4.0b20260203025228.dist-info/top_level.txt +7 -0
- models/__init__.py +4 -0
- models/models.py +56 -0
- models/unity_response.py +70 -0
- services/__init__.py +0 -0
- services/api_key_service.py +235 -0
- services/custom_tool_service.py +499 -0
- services/registry/__init__.py +22 -0
- services/registry/resource_registry.py +53 -0
- services/registry/tool_registry.py +51 -0
- services/resources/__init__.py +86 -0
- services/resources/active_tool.py +48 -0
- services/resources/custom_tools.py +57 -0
- services/resources/editor_state.py +304 -0
- services/resources/gameobject.py +243 -0
- services/resources/layers.py +30 -0
- services/resources/menu_items.py +35 -0
- services/resources/prefab.py +191 -0
- services/resources/prefab_stage.py +40 -0
- services/resources/project_info.py +40 -0
- services/resources/selection.py +56 -0
- services/resources/tags.py +31 -0
- services/resources/tests.py +88 -0
- services/resources/unity_instances.py +125 -0
- services/resources/windows.py +48 -0
- services/state/external_changes_scanner.py +245 -0
- services/tools/__init__.py +83 -0
- services/tools/batch_execute.py +93 -0
- services/tools/debug_request_context.py +86 -0
- services/tools/execute_custom_tool.py +43 -0
- services/tools/execute_menu_item.py +32 -0
- services/tools/find_gameobjects.py +110 -0
- services/tools/find_in_file.py +181 -0
- services/tools/manage_asset.py +119 -0
- services/tools/manage_components.py +131 -0
- services/tools/manage_editor.py +64 -0
- services/tools/manage_gameobject.py +260 -0
- services/tools/manage_material.py +111 -0
- services/tools/manage_prefabs.py +209 -0
- services/tools/manage_scene.py +111 -0
- services/tools/manage_script.py +645 -0
- services/tools/manage_scriptable_object.py +87 -0
- services/tools/manage_shader.py +71 -0
- services/tools/manage_texture.py +581 -0
- services/tools/manage_vfx.py +120 -0
- services/tools/preflight.py +110 -0
- services/tools/read_console.py +151 -0
- services/tools/refresh_unity.py +153 -0
- services/tools/run_tests.py +317 -0
- services/tools/script_apply_edits.py +1006 -0
- services/tools/set_active_instance.py +120 -0
- services/tools/utils.py +348 -0
- transport/__init__.py +0 -0
- transport/legacy/port_discovery.py +329 -0
- transport/legacy/stdio_port_registry.py +65 -0
- transport/legacy/unity_connection.py +910 -0
- transport/models.py +68 -0
- transport/plugin_hub.py +787 -0
- transport/plugin_registry.py +182 -0
- transport/unity_instance_middleware.py +262 -0
- transport/unity_transport.py +94 -0
- utils/focus_nudge.py +589 -0
- utils/module_discovery.py +55 -0
|
@@ -0,0 +1,317 @@
|
|
|
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
|
|
8
|
+
|
|
9
|
+
from fastmcp import Context
|
|
10
|
+
from mcp.types import ToolAnnotations
|
|
11
|
+
from pydantic import BaseModel
|
|
12
|
+
|
|
13
|
+
from models import MCPResponse
|
|
14
|
+
from services.registry import mcp_for_unity_tool
|
|
15
|
+
from services.tools import get_unity_instance_from_context
|
|
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 transport.plugin_hub import PluginHub
|
|
20
|
+
from utils.focus_nudge import nudge_unity_focus, should_nudge, reset_nudge_backoff
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
async def _get_unity_project_path(unity_instance: str | None) -> str | None:
|
|
26
|
+
"""Get the project root path for a Unity instance (for focus nudging).
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
unity_instance: Unity instance hash or "Name@hash" format or None
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
Project root path (e.g., "/Users/name/project"), or falls back to project_name if path unavailable
|
|
33
|
+
"""
|
|
34
|
+
if not unity_instance:
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
registry = PluginHub._registry
|
|
39
|
+
if not registry:
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
# Parse Name@hash format if present (middleware stores instances as "Name@hash")
|
|
43
|
+
target_hash = unity_instance
|
|
44
|
+
if "@" in target_hash:
|
|
45
|
+
_, _, target_hash = target_hash.rpartition("@")
|
|
46
|
+
if not target_hash:
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
# Get session by hash
|
|
50
|
+
session_id = await registry.get_session_id_by_hash(target_hash)
|
|
51
|
+
if not session_id:
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
session = await registry.get_session(session_id)
|
|
55
|
+
if not session:
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
except Exception as e:
|
|
59
|
+
# Re-raise cancellation errors so task cancellation propagates
|
|
60
|
+
if isinstance(e, asyncio.CancelledError):
|
|
61
|
+
raise
|
|
62
|
+
logger.debug(f"Could not get Unity project path: {e}")
|
|
63
|
+
return None
|
|
64
|
+
else:
|
|
65
|
+
# Return full path if available, otherwise fall back to project name
|
|
66
|
+
if session.project_path:
|
|
67
|
+
return session.project_path
|
|
68
|
+
return session.project_name if session.project_name else None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class RunTestsSummary(BaseModel):
|
|
72
|
+
total: int
|
|
73
|
+
passed: int
|
|
74
|
+
failed: int
|
|
75
|
+
skipped: int
|
|
76
|
+
durationSeconds: float
|
|
77
|
+
resultState: str
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class RunTestsTestResult(BaseModel):
|
|
81
|
+
name: str
|
|
82
|
+
fullName: str
|
|
83
|
+
state: str
|
|
84
|
+
durationSeconds: float
|
|
85
|
+
message: str | None = None
|
|
86
|
+
stackTrace: str | None = None
|
|
87
|
+
output: str | None = None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class RunTestsResult(BaseModel):
|
|
91
|
+
mode: str
|
|
92
|
+
summary: RunTestsSummary
|
|
93
|
+
results: list[RunTestsTestResult] | None = None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class RunTestsStartData(BaseModel):
|
|
97
|
+
job_id: str
|
|
98
|
+
status: str
|
|
99
|
+
mode: str | None = None
|
|
100
|
+
include_details: bool | None = None
|
|
101
|
+
include_failed_tests: bool | None = None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class RunTestsStartResponse(MCPResponse):
|
|
105
|
+
data: RunTestsStartData | None = None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class TestJobFailure(BaseModel):
|
|
109
|
+
full_name: str | None = None
|
|
110
|
+
message: str | None = None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class TestJobProgress(BaseModel):
|
|
114
|
+
completed: int | None = None
|
|
115
|
+
total: int | None = None
|
|
116
|
+
current_test_full_name: str | None = None
|
|
117
|
+
current_test_started_unix_ms: int | None = None
|
|
118
|
+
last_finished_test_full_name: str | None = None
|
|
119
|
+
last_finished_unix_ms: int | None = None
|
|
120
|
+
stuck_suspected: bool | None = None
|
|
121
|
+
editor_is_focused: bool | None = None
|
|
122
|
+
blocked_reason: str | None = None
|
|
123
|
+
failures_so_far: list[TestJobFailure] | None = None
|
|
124
|
+
failures_capped: bool | None = None
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class GetTestJobData(BaseModel):
|
|
128
|
+
job_id: str
|
|
129
|
+
status: str
|
|
130
|
+
mode: str | None = None
|
|
131
|
+
started_unix_ms: int | None = None
|
|
132
|
+
finished_unix_ms: int | None = None
|
|
133
|
+
last_update_unix_ms: int | None = None
|
|
134
|
+
progress: TestJobProgress | None = None
|
|
135
|
+
error: str | None = None
|
|
136
|
+
result: RunTestsResult | None = None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class GetTestJobResponse(MCPResponse):
|
|
140
|
+
data: GetTestJobData | None = None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@mcp_for_unity_tool(
|
|
144
|
+
description="Starts a Unity test run asynchronously and returns a job_id immediately. Poll with get_test_job for progress.",
|
|
145
|
+
annotations=ToolAnnotations(
|
|
146
|
+
title="Run Tests",
|
|
147
|
+
destructiveHint=True,
|
|
148
|
+
),
|
|
149
|
+
)
|
|
150
|
+
async def run_tests(
|
|
151
|
+
ctx: Context,
|
|
152
|
+
mode: Annotated[Literal["EditMode", "PlayMode"],
|
|
153
|
+
"Unity test mode to run"] = "EditMode",
|
|
154
|
+
test_names: Annotated[list[str] | str,
|
|
155
|
+
"Full names of specific tests to run"] | None = None,
|
|
156
|
+
group_names: Annotated[list[str] | str,
|
|
157
|
+
"Same as test_names, except it allows for Regex"] | None = None,
|
|
158
|
+
category_names: Annotated[list[str] | str,
|
|
159
|
+
"NUnit category names to filter by"] | None = None,
|
|
160
|
+
assembly_names: Annotated[list[str] | str,
|
|
161
|
+
"Assembly names to filter tests by"] | None = None,
|
|
162
|
+
include_failed_tests: Annotated[bool,
|
|
163
|
+
"Include details for failed/skipped tests only (default: false)"] = False,
|
|
164
|
+
include_details: Annotated[bool,
|
|
165
|
+
"Include details for all tests (default: false)"] = False,
|
|
166
|
+
) -> RunTestsStartResponse | MCPResponse:
|
|
167
|
+
unity_instance = get_unity_instance_from_context(ctx)
|
|
168
|
+
|
|
169
|
+
gate = await preflight(ctx, requires_no_tests=True, wait_for_no_compile=True, refresh_if_dirty=True)
|
|
170
|
+
if isinstance(gate, MCPResponse):
|
|
171
|
+
return gate
|
|
172
|
+
|
|
173
|
+
def _coerce_string_list(value) -> list[str] | None:
|
|
174
|
+
if value is None:
|
|
175
|
+
return None
|
|
176
|
+
if isinstance(value, str):
|
|
177
|
+
return [value] if value.strip() else None
|
|
178
|
+
if isinstance(value, list):
|
|
179
|
+
result = [str(v).strip() for v in value if v and str(v).strip()]
|
|
180
|
+
return result if result else None
|
|
181
|
+
return None
|
|
182
|
+
|
|
183
|
+
params: dict[str, Any] = {"mode": mode}
|
|
184
|
+
if (t := _coerce_string_list(test_names)):
|
|
185
|
+
params["testNames"] = t
|
|
186
|
+
if (g := _coerce_string_list(group_names)):
|
|
187
|
+
params["groupNames"] = g
|
|
188
|
+
if (c := _coerce_string_list(category_names)):
|
|
189
|
+
params["categoryNames"] = c
|
|
190
|
+
if (a := _coerce_string_list(assembly_names)):
|
|
191
|
+
params["assemblyNames"] = a
|
|
192
|
+
if include_failed_tests:
|
|
193
|
+
params["includeFailedTests"] = True
|
|
194
|
+
if include_details:
|
|
195
|
+
params["includeDetails"] = True
|
|
196
|
+
|
|
197
|
+
response = await unity_transport.send_with_unity_instance(
|
|
198
|
+
async_send_command_with_retry,
|
|
199
|
+
unity_instance,
|
|
200
|
+
"run_tests",
|
|
201
|
+
params,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
if isinstance(response, dict):
|
|
205
|
+
if not response.get("success", True):
|
|
206
|
+
return MCPResponse(**response)
|
|
207
|
+
return RunTestsStartResponse(**response)
|
|
208
|
+
return MCPResponse(success=False, error=str(response))
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@mcp_for_unity_tool(
|
|
212
|
+
description="Polls an async Unity test job by job_id.",
|
|
213
|
+
annotations=ToolAnnotations(
|
|
214
|
+
title="Get Test Job",
|
|
215
|
+
readOnlyHint=True,
|
|
216
|
+
),
|
|
217
|
+
)
|
|
218
|
+
async def get_test_job(
|
|
219
|
+
ctx: Context,
|
|
220
|
+
job_id: Annotated[str, "Job id returned by run_tests"],
|
|
221
|
+
include_failed_tests: Annotated[bool,
|
|
222
|
+
"Include details for failed/skipped tests only (default: false)"] = False,
|
|
223
|
+
include_details: Annotated[bool,
|
|
224
|
+
"Include details for all tests (default: false)"] = False,
|
|
225
|
+
wait_timeout: Annotated[int | None,
|
|
226
|
+
"If set, wait up to this many seconds for tests to complete before returning. "
|
|
227
|
+
"Reduces polling frequency and avoids client-side loop detection. "
|
|
228
|
+
"Recommended: 30-60 seconds. Returns immediately if tests complete sooner."] = None,
|
|
229
|
+
) -> GetTestJobResponse | MCPResponse:
|
|
230
|
+
unity_instance = get_unity_instance_from_context(ctx)
|
|
231
|
+
|
|
232
|
+
params: dict[str, Any] = {"job_id": job_id}
|
|
233
|
+
if include_failed_tests:
|
|
234
|
+
params["includeFailedTests"] = True
|
|
235
|
+
if include_details:
|
|
236
|
+
params["includeDetails"] = True
|
|
237
|
+
|
|
238
|
+
async def _fetch_status() -> dict[str, Any]:
|
|
239
|
+
return await unity_transport.send_with_unity_instance(
|
|
240
|
+
async_send_command_with_retry,
|
|
241
|
+
unity_instance,
|
|
242
|
+
"get_test_job",
|
|
243
|
+
params,
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
# If wait_timeout is specified, poll server-side until complete or timeout
|
|
247
|
+
if wait_timeout and wait_timeout > 0:
|
|
248
|
+
deadline = asyncio.get_event_loop().time() + wait_timeout
|
|
249
|
+
poll_interval = 2.0 # Poll Unity every 2 seconds
|
|
250
|
+
prev_last_update_unix_ms = None
|
|
251
|
+
|
|
252
|
+
# Get project path once for focus nudging (multi-instance support)
|
|
253
|
+
project_path = await _get_unity_project_path(unity_instance)
|
|
254
|
+
|
|
255
|
+
while True:
|
|
256
|
+
response = await _fetch_status()
|
|
257
|
+
|
|
258
|
+
if not isinstance(response, dict):
|
|
259
|
+
return MCPResponse(success=False, error=str(response))
|
|
260
|
+
|
|
261
|
+
if not response.get("success", True):
|
|
262
|
+
return MCPResponse(**response)
|
|
263
|
+
|
|
264
|
+
# Check if tests are done
|
|
265
|
+
data = response.get("data", {})
|
|
266
|
+
status = data.get("status", "")
|
|
267
|
+
if status in ("succeeded", "failed", "cancelled"):
|
|
268
|
+
return GetTestJobResponse(**response)
|
|
269
|
+
|
|
270
|
+
# Detect progress and reset exponential backoff
|
|
271
|
+
last_update_unix_ms = data.get("last_update_unix_ms")
|
|
272
|
+
if prev_last_update_unix_ms is not None and last_update_unix_ms != prev_last_update_unix_ms:
|
|
273
|
+
# Progress detected - reset exponential backoff for next potential stall
|
|
274
|
+
reset_nudge_backoff()
|
|
275
|
+
logger.debug(f"Test job {job_id} made progress - reset nudge backoff")
|
|
276
|
+
prev_last_update_unix_ms = last_update_unix_ms
|
|
277
|
+
|
|
278
|
+
# Check if Unity needs a focus nudge to make progress
|
|
279
|
+
# This handles OS-level throttling (e.g., macOS App Nap) that can
|
|
280
|
+
# stall PlayMode tests when Unity is in the background.
|
|
281
|
+
# Uses exponential backoff: 1s, 2s, 4s, 8s, 10s max between nudges.
|
|
282
|
+
progress = data.get("progress", {})
|
|
283
|
+
editor_is_focused = progress.get("editor_is_focused", True)
|
|
284
|
+
current_time_ms = int(time.time() * 1000)
|
|
285
|
+
|
|
286
|
+
if should_nudge(
|
|
287
|
+
status=status,
|
|
288
|
+
editor_is_focused=editor_is_focused,
|
|
289
|
+
last_update_unix_ms=last_update_unix_ms,
|
|
290
|
+
current_time_ms=current_time_ms,
|
|
291
|
+
# Use default stall_threshold_ms (3s)
|
|
292
|
+
):
|
|
293
|
+
logger.info(f"Test job {job_id} appears stalled (unfocused Unity), attempting nudge...")
|
|
294
|
+
# Lazily resolve project path if not yet available (registry may have become ready)
|
|
295
|
+
if project_path is None:
|
|
296
|
+
project_path = await _get_unity_project_path(unity_instance)
|
|
297
|
+
# Pass project path for multi-instance support
|
|
298
|
+
nudged = await nudge_unity_focus(unity_project_path=project_path)
|
|
299
|
+
if nudged:
|
|
300
|
+
logger.info(f"Test job {job_id} nudge completed")
|
|
301
|
+
|
|
302
|
+
# Check timeout
|
|
303
|
+
remaining = deadline - asyncio.get_event_loop().time()
|
|
304
|
+
if remaining <= 0:
|
|
305
|
+
# Timeout reached, return current status
|
|
306
|
+
return GetTestJobResponse(**response)
|
|
307
|
+
|
|
308
|
+
# Wait before next poll (but don't exceed remaining time)
|
|
309
|
+
await asyncio.sleep(min(poll_interval, remaining))
|
|
310
|
+
|
|
311
|
+
# No wait_timeout - return immediately (original behavior)
|
|
312
|
+
response = await _fetch_status()
|
|
313
|
+
if isinstance(response, dict):
|
|
314
|
+
if not response.get("success", True):
|
|
315
|
+
return MCPResponse(**response)
|
|
316
|
+
return GetTestJobResponse(**response)
|
|
317
|
+
return MCPResponse(success=False, error=str(response))
|