mcpforunityserver 9.3.0b20260129104751__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 (103) 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 +258 -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 +52 -0
  33. core/logging_decorator.py +37 -0
  34. core/telemetry.py +551 -0
  35. core/telemetry_decorator.py +164 -0
  36. main.py +713 -0
  37. mcpforunityserver-9.3.0b20260129104751.dist-info/METADATA +216 -0
  38. mcpforunityserver-9.3.0b20260129104751.dist-info/RECORD +103 -0
  39. mcpforunityserver-9.3.0b20260129104751.dist-info/WHEEL +5 -0
  40. mcpforunityserver-9.3.0b20260129104751.dist-info/entry_points.txt +3 -0
  41. mcpforunityserver-9.3.0b20260129104751.dist-info/licenses/LICENSE +21 -0
  42. mcpforunityserver-9.3.0b20260129104751.dist-info/top_level.txt +7 -0
  43. models/__init__.py +4 -0
  44. models/models.py +56 -0
  45. models/unity_response.py +47 -0
  46. services/__init__.py +0 -0
  47. services/custom_tool_service.py +499 -0
  48. services/registry/__init__.py +22 -0
  49. services/registry/resource_registry.py +53 -0
  50. services/registry/tool_registry.py +51 -0
  51. services/resources/__init__.py +86 -0
  52. services/resources/active_tool.py +47 -0
  53. services/resources/custom_tools.py +57 -0
  54. services/resources/editor_state.py +304 -0
  55. services/resources/gameobject.py +243 -0
  56. services/resources/layers.py +29 -0
  57. services/resources/menu_items.py +34 -0
  58. services/resources/prefab.py +191 -0
  59. services/resources/prefab_stage.py +39 -0
  60. services/resources/project_info.py +39 -0
  61. services/resources/selection.py +55 -0
  62. services/resources/tags.py +30 -0
  63. services/resources/tests.py +87 -0
  64. services/resources/unity_instances.py +122 -0
  65. services/resources/windows.py +47 -0
  66. services/state/external_changes_scanner.py +245 -0
  67. services/tools/__init__.py +83 -0
  68. services/tools/batch_execute.py +93 -0
  69. services/tools/debug_request_context.py +86 -0
  70. services/tools/execute_custom_tool.py +43 -0
  71. services/tools/execute_menu_item.py +32 -0
  72. services/tools/find_gameobjects.py +110 -0
  73. services/tools/find_in_file.py +181 -0
  74. services/tools/manage_asset.py +119 -0
  75. services/tools/manage_components.py +131 -0
  76. services/tools/manage_editor.py +64 -0
  77. services/tools/manage_gameobject.py +260 -0
  78. services/tools/manage_material.py +111 -0
  79. services/tools/manage_prefabs.py +174 -0
  80. services/tools/manage_scene.py +111 -0
  81. services/tools/manage_script.py +645 -0
  82. services/tools/manage_scriptable_object.py +87 -0
  83. services/tools/manage_shader.py +71 -0
  84. services/tools/manage_texture.py +581 -0
  85. services/tools/manage_vfx.py +120 -0
  86. services/tools/preflight.py +110 -0
  87. services/tools/read_console.py +151 -0
  88. services/tools/refresh_unity.py +153 -0
  89. services/tools/run_tests.py +317 -0
  90. services/tools/script_apply_edits.py +1006 -0
  91. services/tools/set_active_instance.py +117 -0
  92. services/tools/utils.py +348 -0
  93. transport/__init__.py +0 -0
  94. transport/legacy/port_discovery.py +329 -0
  95. transport/legacy/stdio_port_registry.py +65 -0
  96. transport/legacy/unity_connection.py +888 -0
  97. transport/models.py +63 -0
  98. transport/plugin_hub.py +585 -0
  99. transport/plugin_registry.py +126 -0
  100. transport/unity_instance_middleware.py +232 -0
  101. transport/unity_transport.py +63 -0
  102. utils/focus_nudge.py +589 -0
  103. utils/module_discovery.py +55 -0
utils/focus_nudge.py ADDED
@@ -0,0 +1,589 @@
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 os
14
+ import platform
15
+ import shutil
16
+ import subprocess
17
+ import time
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ def _parse_env_float(env_var: str, default: float) -> float:
23
+ """Safely parse environment variable as float, logging warnings on failure."""
24
+ value = os.environ.get(env_var)
25
+ if value is None:
26
+ return default
27
+ try:
28
+ parsed = float(value)
29
+ if parsed <= 0:
30
+ logger.warning(f"Invalid {env_var}={value!r}, using default {default}: must be > 0")
31
+ return default
32
+ return parsed
33
+ except (ValueError, TypeError) as e:
34
+ logger.warning(f"Invalid {env_var}={value!r}, using default {default}: {e}")
35
+ return default
36
+
37
+
38
+ # Base interval between nudges (exponentially increases with consecutive nudges)
39
+ # Can be overridden via UNITY_MCP_NUDGE_BASE_INTERVAL_S environment variable
40
+ _BASE_NUDGE_INTERVAL_S = _parse_env_float("UNITY_MCP_NUDGE_BASE_INTERVAL_S", 1.0)
41
+
42
+ # Maximum interval between nudges (cap for exponential backoff)
43
+ # Can be overridden via UNITY_MCP_NUDGE_MAX_INTERVAL_S environment variable
44
+ _MAX_NUDGE_INTERVAL_S = _parse_env_float("UNITY_MCP_NUDGE_MAX_INTERVAL_S", 10.0)
45
+
46
+ # Default duration to keep Unity focused during a nudge
47
+ # Can be overridden via UNITY_MCP_NUDGE_DURATION_S environment variable
48
+ _DEFAULT_FOCUS_DURATION_S = _parse_env_float("UNITY_MCP_NUDGE_DURATION_S", 3.0)
49
+
50
+ _last_nudge_time: float = 0.0
51
+ _consecutive_nudges: int = 0
52
+ _last_progress_time: float = 0.0
53
+
54
+
55
+ def _is_available() -> bool:
56
+ """Check if focus nudging is available on this platform."""
57
+ system = platform.system()
58
+ if system == "Darwin":
59
+ return shutil.which("osascript") is not None
60
+ elif system == "Windows":
61
+ # PowerShell is typically available on Windows
62
+ return shutil.which("powershell") is not None
63
+ elif system == "Linux":
64
+ return shutil.which("xdotool") is not None
65
+ return False
66
+
67
+
68
+ def _get_current_nudge_interval() -> float:
69
+ """
70
+ Calculate current nudge interval using exponential backoff.
71
+
72
+ Returns interval based on consecutive nudges without progress:
73
+ - 0 nudges: base interval (1.0s)
74
+ - 1 nudge: base * 2 (2.0s)
75
+ - 2 nudges: base * 4 (4.0s)
76
+ - 3+ nudges: base * 8 (8.0s, capped at max)
77
+ """
78
+ if _consecutive_nudges == 0:
79
+ return _BASE_NUDGE_INTERVAL_S
80
+
81
+ # Exponential backoff: interval = base * (2 ^ consecutive_nudges)
82
+ interval = _BASE_NUDGE_INTERVAL_S * (2 ** _consecutive_nudges)
83
+ return min(interval, _MAX_NUDGE_INTERVAL_S)
84
+
85
+
86
+ def _get_current_focus_duration() -> float:
87
+ """
88
+ Calculate current focus duration using exponential backoff.
89
+
90
+ Base durations (3, 5, 8, 12 seconds) are scaled proportionally by the
91
+ configured UNITY_MCP_NUDGE_DURATION_S relative to _DEFAULT_FOCUS_DURATION_S.
92
+ For example, if UNITY_MCP_NUDGE_DURATION_S=6.0 (2x default), all durations
93
+ are doubled: (6, 10, 16, 24 seconds).
94
+ """
95
+ # Base durations for each nudge level
96
+ base_durations = [3.0, 5.0, 8.0, 12.0]
97
+ base_duration = base_durations[min(_consecutive_nudges, len(base_durations) - 1)]
98
+
99
+ # Scale by ratio of configured to default duration (if UNITY_MCP_NUDGE_DURATION_S is set)
100
+ scale = 1.0
101
+ if os.environ.get("UNITY_MCP_NUDGE_DURATION_S") is not None:
102
+ configured_duration = _parse_env_float("UNITY_MCP_NUDGE_DURATION_S", _DEFAULT_FOCUS_DURATION_S)
103
+ if _DEFAULT_FOCUS_DURATION_S > 0:
104
+ scale = configured_duration / _DEFAULT_FOCUS_DURATION_S
105
+ duration = base_duration * scale
106
+ if duration <= 0:
107
+ return _DEFAULT_FOCUS_DURATION_S
108
+ return duration
109
+
110
+
111
+ def reset_nudge_backoff() -> None:
112
+ """
113
+ Reset exponential backoff when progress is detected.
114
+
115
+ Call this when test job makes progress to reset the nudge interval
116
+ back to the base interval for quick response to future stalls.
117
+ """
118
+ global _consecutive_nudges, _last_progress_time
119
+ _consecutive_nudges = 0
120
+ _last_progress_time = time.monotonic()
121
+
122
+
123
+ def _get_frontmost_app_macos() -> str | None:
124
+ """Get the name of the frontmost application on macOS."""
125
+ try:
126
+ result = subprocess.run(
127
+ [
128
+ "osascript", "-e",
129
+ 'tell application "System Events" to get name of first process whose frontmost is true'
130
+ ],
131
+ capture_output=True,
132
+ text=True,
133
+ timeout=5,
134
+ )
135
+ if result.returncode == 0:
136
+ return result.stdout.strip()
137
+ except Exception as e:
138
+ logger.debug(f"Failed to get frontmost app: {e}")
139
+ return None
140
+
141
+
142
+ def _find_unity_pid_by_project_path(project_path: str) -> int | None:
143
+ """Find Unity Editor PID by matching project path in command line args.
144
+
145
+ Args:
146
+ project_path: Full path to Unity project root, OR just the project name.
147
+ - Full path: "/Users/name/Projects/MyGame"
148
+ - Project name: "MyGame" (will match any path ending with this)
149
+
150
+ Returns:
151
+ PID of matching Unity process, or None if not found
152
+ """
153
+ try:
154
+ # Use ps to find Unity processes with -projectpath argument
155
+ result = subprocess.run(
156
+ ["ps", "aux"],
157
+ capture_output=True,
158
+ text=True,
159
+ timeout=5,
160
+ )
161
+ if result.returncode != 0:
162
+ return None
163
+
164
+ # Determine if project_path is a full path or just a name
165
+ is_full_path = "/" in project_path or "\\" in project_path
166
+
167
+ # Look for Unity.app processes with matching -projectpath
168
+ for line in result.stdout.splitlines():
169
+ if "Unity.app/Contents/MacOS/Unity" not in line:
170
+ continue
171
+
172
+ # Check for -projectpath argument
173
+ if "-projectpath" not in line:
174
+ continue
175
+
176
+ if is_full_path:
177
+ # Exact match for full path
178
+ if f"-projectpath {project_path}" not in line:
179
+ continue
180
+ else:
181
+ # Match if path ends with project name (e.g., ".../UnityMCPTests")
182
+ if "-projectpath" in line:
183
+ # Extract the path after -projectpath
184
+ try:
185
+ parts = line.split("-projectpath", 1)[1].split()[0]
186
+ if not parts.endswith(f"/{project_path}") and not parts.endswith(f"\\{project_path}") and parts != project_path:
187
+ continue
188
+ except (IndexError, ValueError):
189
+ continue
190
+
191
+ # Extract PID (second column in ps aux output)
192
+ parts = line.split()
193
+ if len(parts) >= 2:
194
+ try:
195
+ pid = int(parts[1])
196
+ logger.debug(f"Found Unity PID {pid} for project path/name {project_path}")
197
+ return pid
198
+ except ValueError:
199
+ continue
200
+
201
+ logger.warning(f"No Unity process found with project path/name {project_path}")
202
+ return None
203
+ except Exception as e:
204
+ logger.debug(f"Failed to find Unity PID: {e}")
205
+ return None
206
+
207
+
208
+ def _focus_app_macos(app_name: str, unity_project_path: str | None = None) -> bool:
209
+ """Focus an application by name on macOS.
210
+
211
+ For Unity, can target a specific instance by project path (multi-instance support).
212
+
213
+ Args:
214
+ app_name: Application name to focus ("Unity" or specific app name)
215
+ unity_project_path: For Unity apps, the full project root path to match against
216
+ -projectpath command line arg (e.g., "/path/to/project" NOT "/path/to/project/Assets")
217
+ """
218
+ try:
219
+ # For Unity, use PID-based activation for precise targeting
220
+ if app_name == "Unity":
221
+ if unity_project_path:
222
+ # Find specific Unity instance by project path
223
+ pid = _find_unity_pid_by_project_path(unity_project_path)
224
+ if pid is None:
225
+ logger.warning(f"Could not find Unity PID for project {unity_project_path}, falling back to any Unity")
226
+ return _focus_any_unity_macos()
227
+
228
+ # Two-step activation for full Unity wake-up:
229
+ # 1. Bring window to front
230
+ # 2. Activate the application bundle (triggers full app activation like cmd+tab or clicking)
231
+ script = f'''
232
+ tell application "System Events"
233
+ set targetProc to first process whose unix id is {pid}
234
+ set frontmost of targetProc to true
235
+
236
+ -- Get bundle identifier to activate the app properly
237
+ set bundleID to bundle identifier of targetProc
238
+ end tell
239
+
240
+ -- Activate using bundle identifier (ensures Unity wakes up and starts processing)
241
+ tell application id bundleID to activate
242
+ '''
243
+ result = subprocess.run(
244
+ ["osascript", "-e", script],
245
+ capture_output=True,
246
+ text=True,
247
+ timeout=5,
248
+ )
249
+ if result.returncode != 0:
250
+ logger.debug(f"Failed to activate Unity PID {pid}: {result.stderr}")
251
+ return False
252
+ logger.info(f"Activated Unity instance with PID {pid} for project {unity_project_path}")
253
+ return True
254
+ else:
255
+ # No project path provided - activate any Unity process
256
+ return _focus_any_unity_macos()
257
+ else:
258
+ # For other apps, use direct activation
259
+ # Escape double quotes in app_name to prevent AppleScript injection
260
+ escaped_app_name = app_name.replace('"', '\\"')
261
+ result = subprocess.run(
262
+ ["osascript", "-e", f'tell application "{escaped_app_name}" to activate'],
263
+ capture_output=True,
264
+ text=True,
265
+ timeout=5,
266
+ )
267
+ return result.returncode == 0
268
+ except Exception as e:
269
+ logger.debug(f"Failed to focus app {app_name}: {e}")
270
+ return False
271
+
272
+
273
+ def _focus_any_unity_macos() -> bool:
274
+ """Focus any Unity process on macOS (fallback when no project path specified)."""
275
+ try:
276
+ script = '''
277
+ tell application "System Events"
278
+ set unityProc to first process whose name contains "Unity"
279
+ set frontmost of unityProc to true
280
+ end tell
281
+ '''
282
+ result = subprocess.run(
283
+ ["osascript", "-e", script],
284
+ capture_output=True,
285
+ text=True,
286
+ timeout=5,
287
+ )
288
+ if result.returncode != 0:
289
+ logger.debug(f"Failed to activate Unity via System Events: {result.stderr}")
290
+ return False
291
+ return True
292
+ except Exception as e:
293
+ logger.debug(f"Failed to focus Unity: {e}")
294
+ return False
295
+
296
+
297
+ def _get_frontmost_app_windows() -> str | None:
298
+ """Get the title of the frontmost window on Windows."""
299
+ try:
300
+ # PowerShell command to get active window title
301
+ script = '''
302
+ Add-Type @"
303
+ using System;
304
+ using System.Runtime.InteropServices;
305
+ public class Win32 {
306
+ [DllImport("user32.dll")]
307
+ public static extern IntPtr GetForegroundWindow();
308
+ [DllImport("user32.dll")]
309
+ public static extern int GetWindowText(IntPtr hWnd, System.Text.StringBuilder text, int count);
310
+ }
311
+ "@
312
+ $hwnd = [Win32]::GetForegroundWindow()
313
+ $sb = New-Object System.Text.StringBuilder 256
314
+ [Win32]::GetWindowText($hwnd, $sb, 256)
315
+ $sb.ToString()
316
+ '''
317
+ result = subprocess.run(
318
+ ["powershell", "-Command", script],
319
+ capture_output=True,
320
+ text=True,
321
+ timeout=5,
322
+ )
323
+ if result.returncode == 0:
324
+ return result.stdout.strip()
325
+ except Exception as e:
326
+ logger.debug(f"Failed to get frontmost window: {e}")
327
+ return None
328
+
329
+
330
+ def _focus_app_windows(window_title: str) -> bool:
331
+ """Focus a window by title on Windows. For Unity, uses Unity Editor pattern."""
332
+ try:
333
+ # For Unity, we use a pattern match since the title varies
334
+ if window_title == "Unity":
335
+ script = '''
336
+ Add-Type @"
337
+ using System;
338
+ using System.Runtime.InteropServices;
339
+ public class Win32 {
340
+ [DllImport("user32.dll")]
341
+ public static extern bool SetForegroundWindow(IntPtr hWnd);
342
+ [DllImport("user32.dll")]
343
+ public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
344
+ }
345
+ "@
346
+ $unity = Get-Process | Where-Object {$_.MainWindowTitle -like "*Unity*"} | Select-Object -First 1
347
+ if ($unity) {
348
+ [Win32]::ShowWindow($unity.MainWindowHandle, 9)
349
+ [Win32]::SetForegroundWindow($unity.MainWindowHandle)
350
+ }
351
+ '''
352
+ else:
353
+ # Try to find window by title - escape special PowerShell characters
354
+ safe_title = window_title.replace("'", "''").replace("`", "``")
355
+ script = f'''
356
+ Add-Type @"
357
+ using System;
358
+ using System.Runtime.InteropServices;
359
+ public class Win32 {{
360
+ [DllImport("user32.dll")]
361
+ public static extern bool SetForegroundWindow(IntPtr hWnd);
362
+ [DllImport("user32.dll")]
363
+ public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
364
+ }}
365
+ "@
366
+ $proc = Get-Process | Where-Object {{$_.MainWindowTitle -eq '{safe_title}'}} | Select-Object -First 1
367
+ if ($proc) {{
368
+ [Win32]::ShowWindow($proc.MainWindowHandle, 9)
369
+ [Win32]::SetForegroundWindow($proc.MainWindowHandle)
370
+ }}
371
+ '''
372
+ result = subprocess.run(
373
+ ["powershell", "-Command", script],
374
+ capture_output=True,
375
+ text=True,
376
+ timeout=5,
377
+ )
378
+ return result.returncode == 0
379
+ except Exception as e:
380
+ logger.debug(f"Failed to focus window {window_title}: {e}")
381
+ return False
382
+
383
+
384
+ def _get_frontmost_app_linux() -> str | None:
385
+ """Get the window ID of the frontmost window on Linux."""
386
+ try:
387
+ result = subprocess.run(
388
+ ["xdotool", "getactivewindow"],
389
+ capture_output=True,
390
+ text=True,
391
+ timeout=5,
392
+ )
393
+ if result.returncode == 0:
394
+ return result.stdout.strip()
395
+ except Exception as e:
396
+ logger.debug(f"Failed to get active window: {e}")
397
+ return None
398
+
399
+
400
+ def _focus_app_linux(window_id: str) -> bool:
401
+ """Focus a window by ID on Linux, or Unity by name."""
402
+ try:
403
+ if window_id == "Unity":
404
+ # Find Unity window by name pattern
405
+ result = subprocess.run(
406
+ ["xdotool", "search", "--name", "Unity"],
407
+ capture_output=True,
408
+ text=True,
409
+ timeout=5,
410
+ )
411
+ if result.returncode == 0 and result.stdout.strip():
412
+ window_id = result.stdout.strip().split("\n")[0]
413
+ else:
414
+ return False
415
+
416
+ result = subprocess.run(
417
+ ["xdotool", "windowactivate", window_id],
418
+ capture_output=True,
419
+ text=True,
420
+ timeout=5,
421
+ )
422
+ return result.returncode == 0
423
+ except Exception as e:
424
+ logger.debug(f"Failed to focus window {window_id}: {e}")
425
+ return False
426
+
427
+
428
+ def _get_frontmost_app() -> str | None:
429
+ """Get the frontmost application/window (platform-specific)."""
430
+ system = platform.system()
431
+ if system == "Darwin":
432
+ return _get_frontmost_app_macos()
433
+ elif system == "Windows":
434
+ return _get_frontmost_app_windows()
435
+ elif system == "Linux":
436
+ return _get_frontmost_app_linux()
437
+ return None
438
+
439
+
440
+ def _focus_app(app_or_window: str, unity_project_path: str | None = None) -> bool:
441
+ """Focus an application/window (platform-specific).
442
+
443
+ Args:
444
+ app_or_window: Application name to focus
445
+ unity_project_path: For Unity apps on macOS, the full project root path for
446
+ multi-instance support
447
+ """
448
+ system = platform.system()
449
+ if system == "Darwin":
450
+ return _focus_app_macos(app_or_window, unity_project_path)
451
+ elif system == "Windows":
452
+ return _focus_app_windows(app_or_window)
453
+ elif system == "Linux":
454
+ return _focus_app_linux(app_or_window)
455
+ return False
456
+
457
+
458
+ async def nudge_unity_focus(
459
+ focus_duration_s: float | None = None,
460
+ force: bool = False,
461
+ unity_project_path: str | None = None,
462
+ ) -> bool:
463
+ """
464
+ Temporarily focus Unity to allow it to process, then return focus.
465
+
466
+ Uses exponential backoff for both interval and duration:
467
+ - Interval: 1s, 2s, 4s, 8s, 10s (time between nudges)
468
+ - Duration: 3s, 5s, 8s, 12s (how long Unity stays focused)
469
+ Resets on progress.
470
+
471
+ Args:
472
+ focus_duration_s: How long to keep Unity focused (seconds).
473
+ If None, uses exponential backoff (3s/5s/8s/12s based on consecutive nudges).
474
+ Can be overridden with UNITY_MCP_NUDGE_DURATION_S env var.
475
+ force: If True, ignore the minimum interval between nudges
476
+ unity_project_path: Full path to Unity project root for multi-instance support.
477
+ e.g., "/Users/name/project" (NOT "/Users/name/project/Assets")
478
+ If None, targets any Unity process.
479
+
480
+ Returns:
481
+ True if nudge was performed, False if skipped or failed
482
+ """
483
+ if focus_duration_s is None:
484
+ # Use exponential backoff for focus duration
485
+ focus_duration_s = _get_current_focus_duration()
486
+ if focus_duration_s <= 0:
487
+ focus_duration_s = _DEFAULT_FOCUS_DURATION_S
488
+ global _last_nudge_time, _consecutive_nudges
489
+
490
+ if not _is_available():
491
+ logger.debug("Focus nudging not available on this platform")
492
+ return False
493
+
494
+ # Rate limit nudges using exponential backoff
495
+ now = time.monotonic()
496
+ current_interval = _get_current_nudge_interval()
497
+ if not force and (now - _last_nudge_time) < current_interval:
498
+ logger.debug(f"Skipping nudge - too soon since last nudge (interval: {current_interval:.1f}s)")
499
+ return False
500
+
501
+ # Get current frontmost app
502
+ original_app = _get_frontmost_app()
503
+ if original_app is None:
504
+ logger.debug("Could not determine frontmost app")
505
+ return False
506
+
507
+ # Check if Unity is already focused (no nudge needed)
508
+ if "Unity" in original_app:
509
+ logger.debug("Unity already focused, no nudge needed")
510
+ return False
511
+
512
+ project_info = f" for {unity_project_path}" if unity_project_path else ""
513
+ logger.info(f"Nudging Unity focus{project_info} (interval: {current_interval:.1f}s, consecutive: {_consecutive_nudges}, duration: {focus_duration_s:.1f}s, will return to {original_app})")
514
+
515
+ # Focus Unity (with optional project path for multi-instance support)
516
+ if not _focus_app("Unity", unity_project_path):
517
+ logger.warning(f"Failed to focus Unity{project_info}")
518
+ return False
519
+
520
+ # Wait for window switch animation to complete before starting timer
521
+ # macOS activate is asynchronous, so Unity might not be visible yet
522
+ await asyncio.sleep(0.5)
523
+
524
+ # Verify Unity is actually focused now
525
+ current_app = _get_frontmost_app()
526
+ if current_app and "Unity" not in current_app:
527
+ logger.warning(f"Unity activation didn't complete - current app is {current_app}")
528
+ # Continue anyway in case Unity is processing in background
529
+
530
+ # Only update state after successful activation attempt
531
+ _last_nudge_time = now
532
+ _consecutive_nudges += 1
533
+
534
+ # Wait for Unity to process (actual working time)
535
+ await asyncio.sleep(focus_duration_s)
536
+
537
+ # Return focus to original app
538
+ if original_app and original_app != "Unity":
539
+ if _focus_app(original_app):
540
+ logger.info(f"Returned focus to {original_app} after {focus_duration_s:.1f}s Unity focus")
541
+ else:
542
+ logger.warning(f"Failed to return focus to {original_app}")
543
+
544
+ return True
545
+
546
+
547
+ def should_nudge(
548
+ status: str,
549
+ editor_is_focused: bool,
550
+ last_update_unix_ms: int | None,
551
+ current_time_ms: int | None = None,
552
+ stall_threshold_ms: int = 3_000,
553
+ ) -> bool:
554
+ """
555
+ Determine if we should nudge Unity based on test job state.
556
+
557
+ Works with exponential backoff in nudge_unity_focus():
558
+ - First nudge happens after 3s of no progress
559
+ - Subsequent nudges use exponential backoff (1s, 2s, 4s, 8s, 10s max)
560
+ - Backoff resets when progress is detected (call reset_nudge_backoff())
561
+
562
+ Args:
563
+ status: Job status ("running", "succeeded", "failed")
564
+ editor_is_focused: Whether Unity reports being focused
565
+ last_update_unix_ms: Last time the job was updated (Unix ms)
566
+ current_time_ms: Current time (Unix ms), or None to use current time
567
+ stall_threshold_ms: How long without updates before considering it stalled
568
+ (default 3s for quick stall detection with exponential backoff)
569
+
570
+ Returns:
571
+ True if conditions suggest a nudge would help
572
+ """
573
+ # Only nudge running jobs
574
+ if status != "running":
575
+ return False
576
+
577
+ # Only nudge unfocused Unity
578
+ if editor_is_focused:
579
+ return False
580
+
581
+ # Check if job appears stalled
582
+ if last_update_unix_ms is None:
583
+ return True # No updates yet, might be stuck at start
584
+
585
+ if current_time_ms is None:
586
+ current_time_ms = int(time.time() * 1000)
587
+
588
+ time_since_update_ms = current_time_ms - last_update_unix_ms
589
+ return time_since_update_ms > stall_threshold_ms
@@ -0,0 +1,55 @@
1
+ """
2
+ Shared module discovery utilities for auto-registering tools and resources.
3
+ """
4
+ import importlib
5
+ import logging
6
+ from pathlib import Path
7
+ import pkgutil
8
+ from typing import Generator
9
+
10
+ logger = logging.getLogger("mcp-for-unity-server")
11
+
12
+
13
+ def discover_modules(base_dir: Path, package_name: str) -> Generator[str, None, None]:
14
+ """
15
+ Discover and import all Python modules in a directory and its subdirectories.
16
+
17
+ Args:
18
+ base_dir: The base directory to search for modules
19
+ package_name: The package name to use for relative imports (e.g., 'tools' or 'resources')
20
+
21
+ Yields:
22
+ Full module names that were successfully imported
23
+ """
24
+ # Discover modules in the top level
25
+ for _, module_name, _ in pkgutil.iter_modules([str(base_dir)]):
26
+ # Skip private modules and __init__
27
+ if module_name.startswith('_'):
28
+ continue
29
+
30
+ try:
31
+ full_module_name = f'.{module_name}'
32
+ importlib.import_module(full_module_name, package_name)
33
+ yield full_module_name
34
+ except Exception as e:
35
+ logger.warning(f"Failed to import module {module_name}: {e}")
36
+
37
+ # Discover modules in subdirectories (one level deep)
38
+ for subdir in base_dir.iterdir():
39
+ if not subdir.is_dir() or subdir.name.startswith('_') or subdir.name.startswith('.'):
40
+ continue
41
+
42
+ # Check if subdirectory contains Python modules
43
+ for _, module_name, _ in pkgutil.iter_modules([str(subdir)]):
44
+ # Skip private modules and __init__
45
+ if module_name.startswith('_'):
46
+ continue
47
+
48
+ try:
49
+ # Import as package.subdirname.modulename
50
+ full_module_name = f'.{subdir.name}.{module_name}'
51
+ importlib.import_module(full_module_name, package_name)
52
+ yield full_module_name
53
+ except Exception as e:
54
+ logger.warning(
55
+ f"Failed to import module {subdir.name}.{module_name}: {e}")