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.
Files changed (105) hide show
  1. cli/__init__.py +3 -0
  2. cli/commands/__init__.py +3 -0
  3. cli/commands/animation.py +84 -0
  4. cli/commands/asset.py +280 -0
  5. cli/commands/audio.py +125 -0
  6. cli/commands/batch.py +171 -0
  7. cli/commands/code.py +182 -0
  8. cli/commands/component.py +190 -0
  9. cli/commands/editor.py +447 -0
  10. cli/commands/gameobject.py +487 -0
  11. cli/commands/instance.py +93 -0
  12. cli/commands/lighting.py +123 -0
  13. cli/commands/material.py +239 -0
  14. cli/commands/prefab.py +248 -0
  15. cli/commands/scene.py +231 -0
  16. cli/commands/script.py +222 -0
  17. cli/commands/shader.py +226 -0
  18. cli/commands/texture.py +540 -0
  19. cli/commands/tool.py +58 -0
  20. cli/commands/ui.py +258 -0
  21. cli/commands/vfx.py +421 -0
  22. cli/main.py +281 -0
  23. cli/utils/__init__.py +31 -0
  24. cli/utils/config.py +58 -0
  25. cli/utils/confirmation.py +37 -0
  26. cli/utils/connection.py +254 -0
  27. cli/utils/constants.py +23 -0
  28. cli/utils/output.py +195 -0
  29. cli/utils/parsers.py +112 -0
  30. cli/utils/suggestions.py +34 -0
  31. core/__init__.py +0 -0
  32. core/config.py +67 -0
  33. core/constants.py +4 -0
  34. core/logging_decorator.py +37 -0
  35. core/telemetry.py +551 -0
  36. core/telemetry_decorator.py +164 -0
  37. main.py +845 -0
  38. mcpforunityserver-9.4.0b20260203025228.dist-info/METADATA +328 -0
  39. mcpforunityserver-9.4.0b20260203025228.dist-info/RECORD +105 -0
  40. mcpforunityserver-9.4.0b20260203025228.dist-info/WHEEL +5 -0
  41. mcpforunityserver-9.4.0b20260203025228.dist-info/entry_points.txt +3 -0
  42. mcpforunityserver-9.4.0b20260203025228.dist-info/licenses/LICENSE +21 -0
  43. mcpforunityserver-9.4.0b20260203025228.dist-info/top_level.txt +7 -0
  44. models/__init__.py +4 -0
  45. models/models.py +56 -0
  46. models/unity_response.py +70 -0
  47. services/__init__.py +0 -0
  48. services/api_key_service.py +235 -0
  49. services/custom_tool_service.py +499 -0
  50. services/registry/__init__.py +22 -0
  51. services/registry/resource_registry.py +53 -0
  52. services/registry/tool_registry.py +51 -0
  53. services/resources/__init__.py +86 -0
  54. services/resources/active_tool.py +48 -0
  55. services/resources/custom_tools.py +57 -0
  56. services/resources/editor_state.py +304 -0
  57. services/resources/gameobject.py +243 -0
  58. services/resources/layers.py +30 -0
  59. services/resources/menu_items.py +35 -0
  60. services/resources/prefab.py +191 -0
  61. services/resources/prefab_stage.py +40 -0
  62. services/resources/project_info.py +40 -0
  63. services/resources/selection.py +56 -0
  64. services/resources/tags.py +31 -0
  65. services/resources/tests.py +88 -0
  66. services/resources/unity_instances.py +125 -0
  67. services/resources/windows.py +48 -0
  68. services/state/external_changes_scanner.py +245 -0
  69. services/tools/__init__.py +83 -0
  70. services/tools/batch_execute.py +93 -0
  71. services/tools/debug_request_context.py +86 -0
  72. services/tools/execute_custom_tool.py +43 -0
  73. services/tools/execute_menu_item.py +32 -0
  74. services/tools/find_gameobjects.py +110 -0
  75. services/tools/find_in_file.py +181 -0
  76. services/tools/manage_asset.py +119 -0
  77. services/tools/manage_components.py +131 -0
  78. services/tools/manage_editor.py +64 -0
  79. services/tools/manage_gameobject.py +260 -0
  80. services/tools/manage_material.py +111 -0
  81. services/tools/manage_prefabs.py +209 -0
  82. services/tools/manage_scene.py +111 -0
  83. services/tools/manage_script.py +645 -0
  84. services/tools/manage_scriptable_object.py +87 -0
  85. services/tools/manage_shader.py +71 -0
  86. services/tools/manage_texture.py +581 -0
  87. services/tools/manage_vfx.py +120 -0
  88. services/tools/preflight.py +110 -0
  89. services/tools/read_console.py +151 -0
  90. services/tools/refresh_unity.py +153 -0
  91. services/tools/run_tests.py +317 -0
  92. services/tools/script_apply_edits.py +1006 -0
  93. services/tools/set_active_instance.py +120 -0
  94. services/tools/utils.py +348 -0
  95. transport/__init__.py +0 -0
  96. transport/legacy/port_discovery.py +329 -0
  97. transport/legacy/stdio_port_registry.py +65 -0
  98. transport/legacy/unity_connection.py +910 -0
  99. transport/models.py +68 -0
  100. transport/plugin_hub.py +787 -0
  101. transport/plugin_registry.py +182 -0
  102. transport/unity_instance_middleware.py +262 -0
  103. transport/unity_transport.py +94 -0
  104. utils/focus_nudge.py +589 -0
  105. 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))