mcpforunityserver 9.0.2__py3-none-any.whl → 9.0.7__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mcpforunityserver
3
- Version: 9.0.2
3
+ Version: 9.0.7
4
4
  Summary: MCP for Unity Server: A Unity package for Unity Editor integration via the Model Context Protocol (MCP).
5
5
  Author-email: Marcus Sanatan <msanatan@gmail.com>, David Sarno <david.sarno@gmail.com>, Wu Shutong <martinwfire@gmail.com>
6
6
  License-Expression: MIT
@@ -35,6 +35,7 @@ Requires-Dist: uvicorn>=0.35.0
35
35
  Provides-Extra: dev
36
36
  Requires-Dist: pytest>=8.0.0; extra == "dev"
37
37
  Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
38
+ Requires-Dist: pytest-cov>=4.1.0; extra == "dev"
38
39
  Dynamic: license-file
39
40
 
40
41
  # MCP for Unity Server
@@ -108,7 +109,7 @@ Use this to run the latest released version from the repository. Change the vers
108
109
  "command": "uvx",
109
110
  "args": [
110
111
  "--from",
111
- "git+https://github.com/CoplayDev/unity-mcp@v9.0.2#subdirectory=Server",
112
+ "git+https://github.com/CoplayDev/unity-mcp@v9.0.7#subdirectory=Server",
112
113
  "mcp-for-unity",
113
114
  "--transport",
114
115
  "stdio"
@@ -5,7 +5,7 @@ core/config.py,sha256=czkTtNji1crQcQbUvmdx4OL7f-RBqkVhj_PtHh-w7rs,1623
5
5
  core/logging_decorator.py,sha256=D9CD7rFvQz-MBG-G4inizQj0Ivr6dfc9RBmTrw7q8mI,1383
6
6
  core/telemetry.py,sha256=eHjYgzd8f7eTwSwF2Kbi8D4TtJIcdaDjKLeo1c-0hVA,19829
7
7
  core/telemetry_decorator.py,sha256=ycSTrzVNCDQHSd-xmIWOpVfKFURPxpiZe_XkOQAGDAo,6705
8
- mcpforunityserver-9.0.2.dist-info/licenses/LICENSE,sha256=bv5lDJZQEqxBgjjc1rkRbkEwpSIHF-8N-1Od0VnEJFw,1066
8
+ mcpforunityserver-9.0.7.dist-info/licenses/LICENSE,sha256=bv5lDJZQEqxBgjjc1rkRbkEwpSIHF-8N-1Od0VnEJFw,1066
9
9
  models/__init__.py,sha256=JlscZkGWE9TRmSoBi99v_LSl8OAFNGmr8463PYkXin4,179
10
10
  models/models.py,sha256=heXuvdBtdats1SGwW8wKFFHM0qR4hA6A7qETn5s9BZ0,1827
11
11
  models/unity_response.py,sha256=oJ1PTsnNc5VBC-9OgM59C0C-R9N-GdmEdmz_yph4GSU,1454
@@ -49,8 +49,8 @@ services/tools/manage_shader.py,sha256=bucRKzQww7opy6DK5nf6isVaEECWWqJ-DVkFulp8C
49
49
  services/tools/manage_vfx.py,sha256=eeqf4xUYw_yT2rALIGHrHLJCpemx9H__S3zCjj_GZsI,34054
50
50
  services/tools/preflight.py,sha256=0nvo0BmZMdIGop1Ha_vypkjn2VLiRvskF0uxh_SlZgE,4162
51
51
  services/tools/read_console.py,sha256=k1brS1yR-wyMgdhL6TPL-j4KJCD0CKSWrHYiTh-gj9Y,5452
52
- services/tools/refresh_unity.py,sha256=IcShwasfveDGxXQ3YdbaQP3ICCt8e8O_q_NsnDa8glw,4054
53
- services/tools/run_tests.py,sha256=9M9noRsZWjqcCfUWo5XVVtGNggxg5HpPvmkobs2lu-A,8082
52
+ services/tools/refresh_unity.py,sha256=ksLcYkLK_es871MsyZSvE8XKUZBP09X2xaD8Qw949a8,4152
53
+ services/tools/run_tests.py,sha256=wg8Ke8vpKHxyz0kqFaJC5feXTL3e6Cxzi0QKNitLDRE,9176
54
54
  services/tools/script_apply_edits.py,sha256=0f-SaP5NUYGuivl4CWHjR8F-CXUpt3-5qkHpf_edn1U,47677
55
55
  services/tools/set_active_instance.py,sha256=pdmC1SxFijyzzjeEyC2N1bXk-GNMu_iXsbCieIpa-R4,4242
56
56
  services/tools/utils.py,sha256=uk--6w_-O0eVAxczackXbgKde2ONmsgci43G3wY7dfA,4258
@@ -63,10 +63,11 @@ transport/unity_transport.py,sha256=G6aMC1qR31YZOBZs4fxQbSQBHuXBP1d5Qn0MJaB3yGs,
63
63
  transport/legacy/port_discovery.py,sha256=JDSCqXLodfTT7fOsE0DFC1jJ3QsU6hVaYQb7x7FgdxY,12728
64
64
  transport/legacy/stdio_port_registry.py,sha256=j4iARuP6wetppNDG8qKeuvo1bJKcSlgEhZvSyl_uf0A,2313
65
65
  transport/legacy/unity_connection.py,sha256=UZ6tztBO9VlXNiV0jN66k5QMrtTIGAOdGxwtcLnkXLU,35808
66
+ utils/focus_nudge.py,sha256=HaTOSI7wzDmdRviodUHx2oQFPIL_jSwubai3YkDJbH0,9910
66
67
  utils/module_discovery.py,sha256=My48ofB1BUqxiBoAZAGbEaLQYdsrDhMm8MayBP_bUSQ,2005
67
68
  utils/reload_sentinel.py,sha256=s1xMWhl-r2XwN0OUbiUv_VGUy8TvLtV5bkql-5n2DT0,373
68
- mcpforunityserver-9.0.2.dist-info/METADATA,sha256=SBjpanrzyKviifHD6RPkbJWT5bCf6Ee5i6fi4uutWZU,5712
69
- mcpforunityserver-9.0.2.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
70
- mcpforunityserver-9.0.2.dist-info/entry_points.txt,sha256=vCtkqw-J9t4pj7JwUZcB54_keVx7DpAR3fYzK6i-s6g,44
71
- mcpforunityserver-9.0.2.dist-info/top_level.txt,sha256=UYGWDnyTlnS7PnuZNw8-gM_jWcdmcHwffK_2yBRl6Cc,51
72
- mcpforunityserver-9.0.2.dist-info/RECORD,,
69
+ mcpforunityserver-9.0.7.dist-info/METADATA,sha256=rgfRwWOoUSBsEeHS3CUCeOtvEuaQq91I8dRSxppYk5o,5761
70
+ mcpforunityserver-9.0.7.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
71
+ mcpforunityserver-9.0.7.dist-info/entry_points.txt,sha256=vCtkqw-J9t4pj7JwUZcB54_keVx7DpAR3fYzK6i-s6g,44
72
+ mcpforunityserver-9.0.7.dist-info/top_level.txt,sha256=UYGWDnyTlnS7PnuZNw8-gM_jWcdmcHwffK_2yBRl6Cc,51
73
+ mcpforunityserver-9.0.7.dist-info/RECORD,,
@@ -55,10 +55,13 @@ async def refresh_unity(
55
55
  # interpret that as a hard failure (#503-style loops).
56
56
  if isinstance(response, dict) and not response.get("success", True):
57
57
  hint = response.get("hint")
58
- err = (response.get("error") or response.get("message") or "")
58
+ err = (response.get("error") or response.get("message") or "").lower()
59
59
  reason = _extract_response_reason(response)
60
- is_retryable = (hint == "retry") or (
61
- "disconnected" in str(err).lower())
60
+ is_retryable = (
61
+ hint == "retry"
62
+ or "disconnected" in err
63
+ or "could not connect" in err # Connection failed during domain reload
64
+ )
62
65
  if (not wait_for_ready) or (not is_retryable):
63
66
  return MCPResponse(**response)
64
67
  if reason not in {"reloading", "no_unity_session"}:
@@ -2,6 +2,8 @@
2
2
  from __future__ import annotations
3
3
 
4
4
  import asyncio
5
+ import logging
6
+ import time
5
7
  from typing import Annotated, Any, Literal
6
8
 
7
9
  from fastmcp import Context
@@ -14,6 +16,9 @@ from services.tools import get_unity_instance_from_context
14
16
  from services.tools.preflight import preflight
15
17
  import transport.unity_transport as unity_transport
16
18
  from transport.legacy.unity_connection import async_send_command_with_retry
19
+ from utils.focus_nudge import nudge_unity_focus, should_nudge
20
+
21
+ logger = logging.getLogger(__name__)
17
22
 
18
23
 
19
24
  class RunTestsSummary(BaseModel):
@@ -195,28 +200,48 @@ async def get_test_job(
195
200
  if wait_timeout and wait_timeout > 0:
196
201
  deadline = asyncio.get_event_loop().time() + wait_timeout
197
202
  poll_interval = 2.0 # Poll Unity every 2 seconds
198
-
203
+
199
204
  while True:
200
205
  response = await _fetch_status()
201
-
206
+
202
207
  if not isinstance(response, dict):
203
208
  return MCPResponse(success=False, error=str(response))
204
-
209
+
205
210
  if not response.get("success", True):
206
211
  return MCPResponse(**response)
207
-
212
+
208
213
  # Check if tests are done
209
214
  data = response.get("data", {})
210
215
  status = data.get("status", "")
211
216
  if status in ("succeeded", "failed", "cancelled"):
212
217
  return GetTestJobResponse(**response)
213
-
218
+
219
+ # Check if Unity needs a focus nudge to make progress
220
+ # This handles OS-level throttling (e.g., macOS App Nap) that can
221
+ # stall PlayMode tests when Unity is in the background.
222
+ progress = data.get("progress", {})
223
+ editor_is_focused = progress.get("editor_is_focused", True)
224
+ last_update_unix_ms = data.get("last_update_unix_ms")
225
+ current_time_ms = int(time.time() * 1000)
226
+
227
+ if should_nudge(
228
+ status=status,
229
+ editor_is_focused=editor_is_focused,
230
+ last_update_unix_ms=last_update_unix_ms,
231
+ current_time_ms=current_time_ms,
232
+ stall_threshold_ms=10_000, # 10 seconds without progress
233
+ ):
234
+ logger.info(f"Test job {job_id} appears stalled (unfocused Unity), attempting nudge...")
235
+ nudged = await nudge_unity_focus(focus_duration_s=0.5)
236
+ if nudged:
237
+ logger.info(f"Test job {job_id} nudge completed")
238
+
214
239
  # Check timeout
215
240
  remaining = deadline - asyncio.get_event_loop().time()
216
241
  if remaining <= 0:
217
242
  # Timeout reached, return current status
218
243
  return GetTestJobResponse(**response)
219
-
244
+
220
245
  # Wait before next poll (but don't exceed remaining time)
221
246
  await asyncio.sleep(min(poll_interval, remaining))
222
247
 
utils/focus_nudge.py ADDED
@@ -0,0 +1,321 @@
1
+ """
2
+ Focus nudge utility for handling OS-level throttling of background Unity.
3
+
4
+ When Unity is unfocused, the OS (especially macOS App Nap) can heavily throttle
5
+ the process, causing PlayMode tests to stall. This utility temporarily brings
6
+ Unity to focus, allows it to process, then returns focus to the original app.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import logging
13
+ import platform
14
+ import shutil
15
+ import subprocess
16
+ import time
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ # Minimum seconds between nudges to avoid focus thrashing
21
+ _MIN_NUDGE_INTERVAL_S = 5.0
22
+ _last_nudge_time: float = 0.0
23
+
24
+
25
+ def _is_available() -> bool:
26
+ """Check if focus nudging is available on this platform."""
27
+ system = platform.system()
28
+ if system == "Darwin":
29
+ return shutil.which("osascript") is not None
30
+ elif system == "Windows":
31
+ # PowerShell is typically available on Windows
32
+ return shutil.which("powershell") is not None
33
+ elif system == "Linux":
34
+ return shutil.which("xdotool") is not None
35
+ return False
36
+
37
+
38
+ def _get_frontmost_app_macos() -> str | None:
39
+ """Get the name of the frontmost application on macOS."""
40
+ try:
41
+ result = subprocess.run(
42
+ [
43
+ "osascript", "-e",
44
+ 'tell application "System Events" to get name of first process whose frontmost is true'
45
+ ],
46
+ capture_output=True,
47
+ text=True,
48
+ timeout=5,
49
+ )
50
+ if result.returncode == 0:
51
+ return result.stdout.strip()
52
+ except Exception as e:
53
+ logger.debug(f"Failed to get frontmost app: {e}")
54
+ return None
55
+
56
+
57
+ def _focus_app_macos(app_name: str) -> bool:
58
+ """Focus an application by name on macOS."""
59
+ try:
60
+ result = subprocess.run(
61
+ ["osascript", "-e", f'tell application "{app_name}" to activate'],
62
+ capture_output=True,
63
+ text=True,
64
+ timeout=5,
65
+ )
66
+ return result.returncode == 0
67
+ except Exception as e:
68
+ logger.debug(f"Failed to focus app {app_name}: {e}")
69
+ return False
70
+
71
+
72
+ def _get_frontmost_app_windows() -> str | None:
73
+ """Get the title of the frontmost window on Windows."""
74
+ try:
75
+ # PowerShell command to get active window title
76
+ script = '''
77
+ Add-Type @"
78
+ using System;
79
+ using System.Runtime.InteropServices;
80
+ public class Win32 {
81
+ [DllImport("user32.dll")]
82
+ public static extern IntPtr GetForegroundWindow();
83
+ [DllImport("user32.dll")]
84
+ public static extern int GetWindowText(IntPtr hWnd, System.Text.StringBuilder text, int count);
85
+ }
86
+ "@
87
+ $hwnd = [Win32]::GetForegroundWindow()
88
+ $sb = New-Object System.Text.StringBuilder 256
89
+ [Win32]::GetWindowText($hwnd, $sb, 256)
90
+ $sb.ToString()
91
+ '''
92
+ result = subprocess.run(
93
+ ["powershell", "-Command", script],
94
+ capture_output=True,
95
+ text=True,
96
+ timeout=5,
97
+ )
98
+ if result.returncode == 0:
99
+ return result.stdout.strip()
100
+ except Exception as e:
101
+ logger.debug(f"Failed to get frontmost window: {e}")
102
+ return None
103
+
104
+
105
+ def _focus_app_windows(window_title: str) -> bool:
106
+ """Focus a window by title on Windows. For Unity, uses Unity Editor pattern."""
107
+ try:
108
+ # For Unity, we use a pattern match since the title varies
109
+ if window_title == "Unity":
110
+ script = '''
111
+ Add-Type @"
112
+ using System;
113
+ using System.Runtime.InteropServices;
114
+ public class Win32 {
115
+ [DllImport("user32.dll")]
116
+ public static extern bool SetForegroundWindow(IntPtr hWnd);
117
+ [DllImport("user32.dll")]
118
+ public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
119
+ }
120
+ "@
121
+ $unity = Get-Process | Where-Object {$_.MainWindowTitle -like "*Unity*"} | Select-Object -First 1
122
+ if ($unity) {
123
+ [Win32]::ShowWindow($unity.MainWindowHandle, 9)
124
+ [Win32]::SetForegroundWindow($unity.MainWindowHandle)
125
+ }
126
+ '''
127
+ else:
128
+ # Try to find window by title - escape special PowerShell characters
129
+ safe_title = window_title.replace("'", "''").replace("`", "``")
130
+ script = f'''
131
+ Add-Type @"
132
+ using System;
133
+ using System.Runtime.InteropServices;
134
+ public class Win32 {{
135
+ [DllImport("user32.dll")]
136
+ public static extern bool SetForegroundWindow(IntPtr hWnd);
137
+ [DllImport("user32.dll")]
138
+ public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
139
+ }}
140
+ "@
141
+ $proc = Get-Process | Where-Object {{$_.MainWindowTitle -eq '{safe_title}'}} | Select-Object -First 1
142
+ if ($proc) {{
143
+ [Win32]::ShowWindow($proc.MainWindowHandle, 9)
144
+ [Win32]::SetForegroundWindow($proc.MainWindowHandle)
145
+ }}
146
+ '''
147
+ result = subprocess.run(
148
+ ["powershell", "-Command", script],
149
+ capture_output=True,
150
+ text=True,
151
+ timeout=5,
152
+ )
153
+ return result.returncode == 0
154
+ except Exception as e:
155
+ logger.debug(f"Failed to focus window {window_title}: {e}")
156
+ return False
157
+
158
+
159
+ def _get_frontmost_app_linux() -> str | None:
160
+ """Get the window ID of the frontmost window on Linux."""
161
+ try:
162
+ result = subprocess.run(
163
+ ["xdotool", "getactivewindow"],
164
+ capture_output=True,
165
+ text=True,
166
+ timeout=5,
167
+ )
168
+ if result.returncode == 0:
169
+ return result.stdout.strip()
170
+ except Exception as e:
171
+ logger.debug(f"Failed to get active window: {e}")
172
+ return None
173
+
174
+
175
+ def _focus_app_linux(window_id: str) -> bool:
176
+ """Focus a window by ID on Linux, or Unity by name."""
177
+ try:
178
+ if window_id == "Unity":
179
+ # Find Unity window by name pattern
180
+ result = subprocess.run(
181
+ ["xdotool", "search", "--name", "Unity"],
182
+ capture_output=True,
183
+ text=True,
184
+ timeout=5,
185
+ )
186
+ if result.returncode == 0 and result.stdout.strip():
187
+ window_id = result.stdout.strip().split("\n")[0]
188
+ else:
189
+ return False
190
+
191
+ result = subprocess.run(
192
+ ["xdotool", "windowactivate", window_id],
193
+ capture_output=True,
194
+ text=True,
195
+ timeout=5,
196
+ )
197
+ return result.returncode == 0
198
+ except Exception as e:
199
+ logger.debug(f"Failed to focus window {window_id}: {e}")
200
+ return False
201
+
202
+
203
+ def _get_frontmost_app() -> str | None:
204
+ """Get the frontmost application/window (platform-specific)."""
205
+ system = platform.system()
206
+ if system == "Darwin":
207
+ return _get_frontmost_app_macos()
208
+ elif system == "Windows":
209
+ return _get_frontmost_app_windows()
210
+ elif system == "Linux":
211
+ return _get_frontmost_app_linux()
212
+ return None
213
+
214
+
215
+ def _focus_app(app_or_window: str) -> bool:
216
+ """Focus an application/window (platform-specific)."""
217
+ system = platform.system()
218
+ if system == "Darwin":
219
+ return _focus_app_macos(app_or_window)
220
+ elif system == "Windows":
221
+ return _focus_app_windows(app_or_window)
222
+ elif system == "Linux":
223
+ return _focus_app_linux(app_or_window)
224
+ return False
225
+
226
+
227
+ async def nudge_unity_focus(
228
+ focus_duration_s: float = 0.5,
229
+ force: bool = False,
230
+ ) -> bool:
231
+ """
232
+ Temporarily focus Unity to allow it to process, then return focus.
233
+
234
+ Args:
235
+ focus_duration_s: How long to keep Unity focused (seconds)
236
+ force: If True, ignore the minimum interval between nudges
237
+
238
+ Returns:
239
+ True if nudge was performed, False if skipped or failed
240
+ """
241
+ global _last_nudge_time
242
+
243
+ if not _is_available():
244
+ logger.debug("Focus nudging not available on this platform")
245
+ return False
246
+
247
+ # Rate limit nudges
248
+ now = time.monotonic()
249
+ if not force and (now - _last_nudge_time) < _MIN_NUDGE_INTERVAL_S:
250
+ logger.info("Skipping nudge - too soon since last nudge")
251
+ return False
252
+
253
+ # Get current frontmost app
254
+ original_app = _get_frontmost_app()
255
+ if original_app is None:
256
+ logger.debug("Could not determine frontmost app")
257
+ return False
258
+
259
+ # Check if Unity is already focused (no nudge needed)
260
+ if "Unity" in original_app:
261
+ logger.debug("Unity already focused, no nudge needed")
262
+ return False
263
+
264
+ logger.info(f"Nudging Unity focus (will return to {original_app})")
265
+ _last_nudge_time = now
266
+
267
+ # Focus Unity
268
+ if not _focus_app("Unity"):
269
+ logger.warning("Failed to focus Unity")
270
+ return False
271
+
272
+ # Wait for Unity to process
273
+ await asyncio.sleep(focus_duration_s)
274
+
275
+ # Return focus to original app
276
+ if original_app and original_app != "Unity":
277
+ if _focus_app(original_app):
278
+ logger.info(f"Returned focus to {original_app}")
279
+ else:
280
+ logger.warning(f"Failed to return focus to {original_app}")
281
+
282
+ return True
283
+
284
+
285
+ def should_nudge(
286
+ status: str,
287
+ editor_is_focused: bool,
288
+ last_update_unix_ms: int | None,
289
+ current_time_ms: int | None = None,
290
+ stall_threshold_ms: int = 10_000,
291
+ ) -> bool:
292
+ """
293
+ Determine if we should nudge Unity based on test job state.
294
+
295
+ Args:
296
+ status: Job status ("running", "succeeded", "failed")
297
+ editor_is_focused: Whether Unity reports being focused
298
+ last_update_unix_ms: Last time the job was updated (Unix ms)
299
+ current_time_ms: Current time (Unix ms), or None to use current time
300
+ stall_threshold_ms: How long without updates before considering it stalled
301
+
302
+ Returns:
303
+ True if conditions suggest a nudge would help
304
+ """
305
+ # Only nudge running jobs
306
+ if status != "running":
307
+ return False
308
+
309
+ # Only nudge unfocused Unity
310
+ if editor_is_focused:
311
+ return False
312
+
313
+ # Check if job appears stalled
314
+ if last_update_unix_ms is None:
315
+ return True # No updates yet, might be stuck at start
316
+
317
+ if current_time_ms is None:
318
+ current_time_ms = int(time.time() * 1000)
319
+
320
+ time_since_update_ms = current_time_ms - last_update_unix_ms
321
+ return time_since_update_ms > stall_threshold_ms