mcpforunityserver 8.7.0__py3-none-any.whl → 9.1.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.
Files changed (81) hide show
  1. cli/__init__.py +3 -0
  2. cli/commands/__init__.py +3 -0
  3. cli/commands/animation.py +87 -0
  4. cli/commands/asset.py +310 -0
  5. cli/commands/audio.py +133 -0
  6. cli/commands/batch.py +184 -0
  7. cli/commands/code.py +189 -0
  8. cli/commands/component.py +212 -0
  9. cli/commands/editor.py +487 -0
  10. cli/commands/gameobject.py +510 -0
  11. cli/commands/instance.py +101 -0
  12. cli/commands/lighting.py +128 -0
  13. cli/commands/material.py +268 -0
  14. cli/commands/prefab.py +144 -0
  15. cli/commands/scene.py +255 -0
  16. cli/commands/script.py +240 -0
  17. cli/commands/shader.py +238 -0
  18. cli/commands/ui.py +263 -0
  19. cli/commands/vfx.py +439 -0
  20. cli/main.py +248 -0
  21. cli/utils/__init__.py +31 -0
  22. cli/utils/config.py +58 -0
  23. cli/utils/connection.py +191 -0
  24. cli/utils/output.py +195 -0
  25. main.py +177 -62
  26. {mcpforunityserver-8.7.0.dist-info → mcpforunityserver-9.1.0.dist-info}/METADATA +4 -2
  27. mcpforunityserver-9.1.0.dist-info/RECORD +96 -0
  28. {mcpforunityserver-8.7.0.dist-info → mcpforunityserver-9.1.0.dist-info}/WHEEL +1 -1
  29. {mcpforunityserver-8.7.0.dist-info → mcpforunityserver-9.1.0.dist-info}/entry_points.txt +1 -0
  30. {mcpforunityserver-8.7.0.dist-info → mcpforunityserver-9.1.0.dist-info}/top_level.txt +1 -2
  31. services/custom_tool_service.py +179 -19
  32. services/resources/__init__.py +6 -1
  33. services/resources/active_tool.py +1 -1
  34. services/resources/custom_tools.py +2 -2
  35. services/resources/editor_state.py +283 -30
  36. services/resources/gameobject.py +243 -0
  37. services/resources/layers.py +1 -1
  38. services/resources/prefab_stage.py +1 -1
  39. services/resources/project_info.py +1 -1
  40. services/resources/selection.py +1 -1
  41. services/resources/tags.py +1 -1
  42. services/resources/unity_instances.py +1 -1
  43. services/resources/windows.py +1 -1
  44. services/state/external_changes_scanner.py +3 -4
  45. services/tools/__init__.py +6 -1
  46. services/tools/batch_execute.py +24 -9
  47. services/tools/debug_request_context.py +8 -2
  48. services/tools/execute_custom_tool.py +6 -1
  49. services/tools/execute_menu_item.py +6 -3
  50. services/tools/find_gameobjects.py +89 -0
  51. services/tools/find_in_file.py +26 -19
  52. services/tools/manage_asset.py +13 -44
  53. services/tools/manage_components.py +131 -0
  54. services/tools/manage_editor.py +9 -8
  55. services/tools/manage_gameobject.py +115 -79
  56. services/tools/manage_material.py +80 -31
  57. services/tools/manage_prefabs.py +7 -1
  58. services/tools/manage_scene.py +30 -13
  59. services/tools/manage_script.py +62 -19
  60. services/tools/manage_scriptable_object.py +22 -10
  61. services/tools/manage_shader.py +8 -1
  62. services/tools/manage_vfx.py +738 -0
  63. services/tools/preflight.py +15 -12
  64. services/tools/read_console.py +70 -17
  65. services/tools/refresh_unity.py +92 -29
  66. services/tools/run_tests.py +187 -53
  67. services/tools/script_apply_edits.py +15 -7
  68. services/tools/set_active_instance.py +12 -7
  69. services/tools/utils.py +60 -6
  70. transport/legacy/port_discovery.py +2 -2
  71. transport/legacy/unity_connection.py +129 -26
  72. transport/plugin_hub.py +85 -24
  73. transport/unity_instance_middleware.py +4 -3
  74. transport/unity_transport.py +2 -1
  75. utils/focus_nudge.py +321 -0
  76. __init__.py +0 -0
  77. mcpforunityserver-8.7.0.dist-info/RECORD +0 -71
  78. routes/__init__.py +0 -0
  79. services/resources/editor_state_v2.py +0 -270
  80. services/tools/test_jobs.py +0 -94
  81. {mcpforunityserver-8.7.0.dist-info → mcpforunityserver-9.1.0.dist-info}/licenses/LICENSE +0 -0
transport/plugin_hub.py CHANGED
@@ -34,6 +34,10 @@ class PluginDisconnectedError(RuntimeError):
34
34
  """Raised when a plugin WebSocket disconnects while commands are in flight."""
35
35
 
36
36
 
37
+ class NoUnitySessionError(RuntimeError):
38
+ """Raised when no Unity plugins are available."""
39
+
40
+
37
41
  class PluginHub(WebSocketEndpoint):
38
42
  """Manages persistent WebSocket connections to Unity plugins."""
39
43
 
@@ -41,10 +45,14 @@ class PluginHub(WebSocketEndpoint):
41
45
  KEEP_ALIVE_INTERVAL = 15
42
46
  SERVER_TIMEOUT = 30
43
47
  COMMAND_TIMEOUT = 30
48
+ # Timeout (seconds) for fast-fail commands like ping/read_console/get_editor_state.
49
+ # Keep short so MCP clients aren't blocked during Unity compilation/reload/unfocused throttling.
50
+ FAST_FAIL_TIMEOUT = 2.0
44
51
  # Fast-path commands should never block the client for long; return a retry hint instead.
45
52
  # This helps avoid the Cursor-side ~30s tool-call timeout when Unity is compiling/reloading
46
53
  # or is throttled while unfocused.
47
- _FAST_FAIL_COMMANDS: set[str] = {"read_console", "get_editor_state", "ping"}
54
+ _FAST_FAIL_COMMANDS: set[str] = {
55
+ "read_console", "get_editor_state", "ping"}
48
56
 
49
57
  _registry: PluginRegistry | None = None
50
58
  _connections: dict[str, WebSocket] = {}
@@ -114,7 +122,8 @@ class PluginHub(WebSocketEndpoint):
114
122
  ]
115
123
  for command_id in pending_ids:
116
124
  entry = cls._pending.get(command_id)
117
- future = entry.get("future") if isinstance(entry, dict) else None
125
+ future = entry.get("future") if isinstance(
126
+ entry, dict) else None
118
127
  if future and not future.done():
119
128
  future.set_exception(
120
129
  PluginDisconnectedError(
@@ -136,18 +145,15 @@ class PluginHub(WebSocketEndpoint):
136
145
  future: asyncio.Future = asyncio.get_running_loop().create_future()
137
146
  # Compute a per-command timeout:
138
147
  # - fast-path commands: short timeout (encourage retry)
139
- # - long-running commands (e.g., run_tests): allow caller to request a longer timeout via params
148
+ # - long-running commands: allow caller to request a longer timeout via params
140
149
  unity_timeout_s = float(cls.COMMAND_TIMEOUT)
141
150
  server_wait_s = float(cls.COMMAND_TIMEOUT)
142
151
  if command_type in cls._FAST_FAIL_COMMANDS:
143
- try:
144
- fast_timeout = float(os.environ.get("UNITY_MCP_FAST_COMMAND_TIMEOUT", "3"))
145
- except Exception:
146
- fast_timeout = 3.0
152
+ fast_timeout = float(cls.FAST_FAIL_TIMEOUT)
147
153
  unity_timeout_s = fast_timeout
148
154
  server_wait_s = fast_timeout
149
155
  else:
150
- # Common tools pass a requested timeout in seconds (e.g., run_tests(timeout_seconds=900)).
156
+ # Common tools pass a requested timeout in seconds (e.g., timeout_seconds=900).
151
157
  requested = None
152
158
  try:
153
159
  if isinstance(params, dict):
@@ -176,7 +182,8 @@ class PluginHub(WebSocketEndpoint):
176
182
  if command_id in cls._pending:
177
183
  raise RuntimeError(
178
184
  f"Duplicate command id generated: {command_id}")
179
- cls._pending[command_id] = {"future": future, "session_id": session_id}
185
+ cls._pending[command_id] = {
186
+ "future": future, "session_id": session_id}
180
187
 
181
188
  try:
182
189
  msg = ExecuteCommandMessage(
@@ -308,6 +315,23 @@ class PluginHub(WebSocketEndpoint):
308
315
  logger.info(
309
316
  f"Registered {len(payload.tools)} tools for session {session_id}")
310
317
 
318
+ try:
319
+ from services.custom_tool_service import CustomToolService
320
+
321
+ service = CustomToolService.get_instance()
322
+ service.register_global_tools(payload.tools)
323
+ except RuntimeError as exc:
324
+ logger.debug(
325
+ "Skipping global custom tool registration: CustomToolService not initialized yet (%s)",
326
+ exc,
327
+ )
328
+ except Exception as exc:
329
+ logger.warning(
330
+ "Unexpected error during global custom tool registration; "
331
+ "custom tools may not be available globally",
332
+ exc_info=exc,
333
+ )
334
+
311
335
  async def _handle_command_result(self, payload: CommandResultMessage) -> None:
312
336
  cls = type(self)
313
337
  lock = cls._lock
@@ -361,11 +385,21 @@ class PluginHub(WebSocketEndpoint):
361
385
  if cls._registry is None:
362
386
  raise RuntimeError("Plugin registry not configured")
363
387
 
364
- # Use the same defaults as the stdio transport reload handling so that
365
- # HTTP/WebSocket and TCP behave consistently without per-project env.
366
- max_retries = max(1, int(getattr(config, "reload_max_retries", 40)))
388
+ # Bound waiting for Unity sessions so calls fail fast when editors are not ready.
389
+ try:
390
+ max_wait_s = float(
391
+ os.environ.get("UNITY_MCP_SESSION_RESOLVE_MAX_WAIT_S", "2.0"))
392
+ except ValueError as e:
393
+ raw_val = os.environ.get(
394
+ "UNITY_MCP_SESSION_RESOLVE_MAX_WAIT_S", "2.0")
395
+ logger.warning(
396
+ "Invalid UNITY_MCP_SESSION_RESOLVE_MAX_WAIT_S=%r, using default 2.0: %s",
397
+ raw_val, e)
398
+ max_wait_s = 2.0
399
+ # Clamp to [0, 30] to prevent misconfiguration from causing excessive waits
400
+ max_wait_s = max(0.0, min(max_wait_s, 30.0))
367
401
  retry_ms = float(getattr(config, "reload_retry_ms", 250))
368
- sleep_seconds = max(0.05, retry_ms / 1000.0)
402
+ sleep_seconds = max(0.05, min(0.25, retry_ms / 1000.0))
369
403
 
370
404
  # Allow callers to provide either just the hash or Name@hash
371
405
  target_hash: str | None = None
@@ -394,7 +428,7 @@ class PluginHub(WebSocketEndpoint):
394
428
  return None, count
395
429
 
396
430
  session_id, session_count = await _try_once()
397
- deadline = time.monotonic() + (max_retries * sleep_seconds)
431
+ deadline = time.monotonic() + max_wait_s
398
432
  wait_started = None
399
433
 
400
434
  # If there is no active plugin yet (e.g., Unity starting up or reloading),
@@ -403,33 +437,40 @@ class PluginHub(WebSocketEndpoint):
403
437
  if not target_hash and session_count > 1:
404
438
  raise RuntimeError(
405
439
  "Multiple Unity instances are connected. "
406
- "Call set_active_instance with Name@hash from unity://instances."
440
+ "Call set_active_instance with Name@hash from mcpforunity://instances."
407
441
  )
408
442
  if wait_started is None:
409
443
  wait_started = time.monotonic()
410
444
  logger.debug(
411
- f"No plugin session available (instance={unity_instance or 'default'}); waiting up to {deadline - wait_started:.2f}s",
445
+ "No plugin session available (instance=%s); waiting up to %.2fs",
446
+ unity_instance or "default",
447
+ max_wait_s,
412
448
  )
413
449
  await asyncio.sleep(sleep_seconds)
414
450
  session_id, session_count = await _try_once()
415
451
 
416
452
  if session_id is not None and wait_started is not None:
417
453
  logger.debug(
418
- f"Plugin session restored after {time.monotonic() - wait_started:.3f}s (instance={unity_instance or 'default'})",
454
+ "Plugin session restored after %.3fs (instance=%s)",
455
+ time.monotonic() - wait_started,
456
+ unity_instance or "default",
419
457
  )
420
458
  if session_id is None and not target_hash and session_count > 1:
421
459
  raise RuntimeError(
422
460
  "Multiple Unity instances are connected. "
423
- "Call set_active_instance with Name@hash from unity://instances."
461
+ "Call set_active_instance with Name@hash from mcpforunity://instances."
424
462
  )
425
463
 
426
464
  if session_id is None:
427
465
  logger.warning(
428
- f"No Unity plugin reconnected within {max_retries * sleep_seconds:.2f}s (instance={unity_instance or 'default'})",
466
+ "No Unity plugin reconnected within %.2fs (instance=%s)",
467
+ max_wait_s,
468
+ unity_instance or "default",
429
469
  )
430
470
  # At this point we've given the plugin ample time to reconnect; surface
431
471
  # a clear error so the client can prompt the user to open Unity.
432
- raise RuntimeError("No Unity plugins are currently connected")
472
+ raise NoUnitySessionError(
473
+ "No Unity plugins are currently connected")
433
474
 
434
475
  return session_id
435
476
 
@@ -440,7 +481,20 @@ class PluginHub(WebSocketEndpoint):
440
481
  command_type: str,
441
482
  params: dict[str, Any],
442
483
  ) -> dict[str, Any]:
443
- session_id = await cls._resolve_session_id(unity_instance)
484
+ try:
485
+ session_id = await cls._resolve_session_id(unity_instance)
486
+ except NoUnitySessionError:
487
+ logger.debug(
488
+ "Unity session unavailable; returning retry: command=%s instance=%s",
489
+ command_type,
490
+ unity_instance or "default",
491
+ )
492
+ return MCPResponse(
493
+ success=False,
494
+ error="Unity session not available; please retry",
495
+ hint="retry",
496
+ data={"reason": "no_unity_session", "retry_after_ms": 250},
497
+ ).model_dump()
444
498
 
445
499
  # During domain reload / immediate reconnect windows, the plugin may be connected but not yet
446
500
  # ready to process execute commands on the Unity main thread (which can be further delayed when
@@ -449,8 +503,14 @@ class PluginHub(WebSocketEndpoint):
449
503
  # register_tools (which can be delayed by EditorApplication.delayCall).
450
504
  if command_type in cls._FAST_FAIL_COMMANDS and command_type != "ping":
451
505
  try:
452
- max_wait_s = float(os.environ.get("UNITY_MCP_SESSION_READY_WAIT_SECONDS", "6"))
453
- except Exception:
506
+ max_wait_s = float(os.environ.get(
507
+ "UNITY_MCP_SESSION_READY_WAIT_SECONDS", "6"))
508
+ except ValueError as e:
509
+ raw_val = os.environ.get(
510
+ "UNITY_MCP_SESSION_READY_WAIT_SECONDS", "6")
511
+ logger.warning(
512
+ "Invalid UNITY_MCP_SESSION_READY_WAIT_SECONDS=%r, using default 6.0: %s",
513
+ raw_val, e)
454
514
  max_wait_s = 6.0
455
515
  max_wait_s = max(0.0, min(max_wait_s, 30.0))
456
516
  if max_wait_s > 0:
@@ -463,7 +523,8 @@ class PluginHub(WebSocketEndpoint):
463
523
 
464
524
  # The Unity-side dispatcher responds with {status:"success", result:{message:"pong"}}
465
525
  if isinstance(probe, dict) and probe.get("status") == "success":
466
- result = probe.get("result") if isinstance(probe.get("result"), dict) else {}
526
+ result = probe.get("result") if isinstance(
527
+ probe.get("result"), dict) else {}
467
528
  if result.get("message") == "pong":
468
529
  break
469
530
  await asyncio.sleep(0.1)
@@ -27,7 +27,7 @@ def get_unity_instance_middleware() -> 'UnityInstanceMiddleware':
27
27
  if _unity_instance_middleware is None:
28
28
  # Auto-initialize if not set (lazy singleton) to handle import order or test cases
29
29
  _unity_instance_middleware = UnityInstanceMiddleware()
30
-
30
+
31
31
  return _unity_instance_middleware
32
32
 
33
33
 
@@ -102,7 +102,8 @@ class UnityInstanceMiddleware(Middleware):
102
102
  sessions = sessions_data.sessions or {}
103
103
  ids: list[str] = []
104
104
  for session_info in sessions.values():
105
- project = getattr(session_info, "project", None) or "Unknown"
105
+ project = getattr(
106
+ session_info, "project", None) or "Unknown"
106
107
  hash_value = getattr(session_info, "hash", None)
107
108
  if hash_value:
108
109
  ids.append(f"{project}@{hash_value}")
@@ -183,7 +184,7 @@ class UnityInstanceMiddleware(Middleware):
183
184
  # But for stdio transport (no PluginHub needed or maybe partially configured),
184
185
  # we should be careful not to clear instance just because PluginHub can't resolve it.
185
186
  # The 'active_instance' (Name@hash) might be valid for stdio even if PluginHub fails.
186
-
187
+
187
188
  session_id: str | None = None
188
189
  # Only validate via PluginHub if we are actually using HTTP transport
189
190
  # OR if we want to support hybrid mode. For now, let's be permissive.
@@ -105,7 +105,8 @@ async def send_with_unity_instance(
105
105
  # Fail fast with a retry hint instead of hanging for COMMAND_TIMEOUT.
106
106
  # The client can decide whether retrying is appropriate for the command.
107
107
  return normalize_unity_response(
108
- MCPResponse(success=False, error=err, hint="retry").model_dump()
108
+ MCPResponse(success=False, error=err,
109
+ hint="retry").model_dump()
109
110
  )
110
111
 
111
112
  if unity_instance:
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
__init__.py DELETED
File without changes
@@ -1,71 +0,0 @@
1
- __init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
- main.py,sha256=ITxelXUAmr9BoasG0knZifXKp3lWJyml_RLaIwvEyVs,19184
3
- core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
- core/config.py,sha256=czkTtNji1crQcQbUvmdx4OL7f-RBqkVhj_PtHh-w7rs,1623
5
- core/logging_decorator.py,sha256=D9CD7rFvQz-MBG-G4inizQj0Ivr6dfc9RBmTrw7q8mI,1383
6
- core/telemetry.py,sha256=eHjYgzd8f7eTwSwF2Kbi8D4TtJIcdaDjKLeo1c-0hVA,19829
7
- core/telemetry_decorator.py,sha256=ycSTrzVNCDQHSd-xmIWOpVfKFURPxpiZe_XkOQAGDAo,6705
8
- mcpforunityserver-8.7.0.dist-info/licenses/LICENSE,sha256=bv5lDJZQEqxBgjjc1rkRbkEwpSIHF-8N-1Od0VnEJFw,1066
9
- models/__init__.py,sha256=JlscZkGWE9TRmSoBi99v_LSl8OAFNGmr8463PYkXin4,179
10
- models/models.py,sha256=heXuvdBtdats1SGwW8wKFFHM0qR4hA6A7qETn5s9BZ0,1827
11
- models/unity_response.py,sha256=oJ1PTsnNc5VBC-9OgM59C0C-R9N-GdmEdmz_yph4GSU,1454
12
- routes/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
13
- services/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
14
- services/custom_tool_service.py,sha256=ZOb6LsOQc3C9n3iLn5b7QaG3WJ25M-Id3WG_iE1YhMY,11960
15
- services/registry/__init__.py,sha256=QCwcYThvGF0kBt3WR6DBskdyxkegJC7NymEChgJA-YM,470
16
- services/registry/resource_registry.py,sha256=T_Kznqgvt5kKgV7mU85nb0LlFuB4rg-Tm4Cjhxt-IcI,1467
17
- services/registry/tool_registry.py,sha256=9tMwOP07JE92QFYUS4KvoysO0qC9pkBD5B79kjRsSPw,1304
18
- services/resources/__init__.py,sha256=O5heeMcgCswnQX1qG2nNtMeAZIaLut734qD7t5UsA0k,2801
19
- services/resources/active_tool.py,sha256=YTbsiy_hmnKH2q7IoM7oYD7pJkoveZTszRiL1PlhO9M,1474
20
- services/resources/custom_tools.py,sha256=8lyryGhN3vD2LwMt6ZyKIp5ONtxdI1nfcCAlYjlfQnQ,1704
21
- services/resources/editor_state.py,sha256=8hrNnskSFdsvdKagAYEeZGJ0Oz9QRlkWJjpM4q0XeNo,2013
22
- services/resources/editor_state_v2.py,sha256=zgss1EEhJo7oZeHnjOXsdJPuFQsHLwpsZmzcDy3ybq0,10874
23
- services/resources/layers.py,sha256=q4UQ5PUVUVhmM5l3oXID1wa_wOWAS8l5BGXadBgFuwY,1080
24
- services/resources/menu_items.py,sha256=9SNycjwTXoeS1ZHra0Y1fTyCjSEdPCo34JyxtuqauG8,1021
25
- services/resources/prefab_stage.py,sha256=C3mn3UapKYVOA8QUNmLsYreG5YiXdlvGm9ypHQeKBeQ,1382
26
- services/resources/project_info.py,sha256=gSVSFfwP0u2FmxSowOkdbNoSSQHxfQtLfndvoCXTVSw,1323
27
- services/resources/selection.py,sha256=4rI5Bdkes4uxtMc_5jQhUaqUl-iprhIiTWqnOJl8tmg,1839
28
- services/resources/tags.py,sha256=7EhmQjMotz85DSSr7cVKYIy7LPT5mmPfrEySr1mTE6w,1049
29
- services/resources/tests.py,sha256=xDvvgesPSU93nLD_ERQopOpkpq69pbMEqmFsJd0jekI,2063
30
- services/resources/unity_instances.py,sha256=fR0cVopGQnmF41IFDycwlo2XniKstfJWLGobgJeiabE,4348
31
- services/resources/windows.py,sha256=--QVsb0oyoBpSjK2D4kPcZFSe2zdR-t_KSHP-e2QNoY,1427
32
- services/state/external_changes_scanner.py,sha256=qwdiriHR1D11aPiLUbpS7COXtfVOjNj9DpzcSDK067o,9042
33
- services/tools/__init__.py,sha256=3Qav7fAowZ1_TbDRdZQQQES53gv2lTs-2D7PGECnlbM,2353
34
- services/tools/batch_execute.py,sha256=_ByjffeXQB9j64mcjaxJmrnbSJrMn0f9_6Zh9BBI_2c,2898
35
- services/tools/debug_request_context.py,sha256=WQBtQdXSH5stw2MAwIM32H6jGwUVQOgU2r35VUWLlYo,2765
36
- services/tools/execute_custom_tool.py,sha256=K2qaO4-FTPz0_3j53hhDP9idjC002ugc8C03FtHGTbY,1376
37
- services/tools/execute_menu_item.py,sha256=FAC-1v_TwOcy6GSxkogDsVxeRtdap0DsPlIngf8uJdU,1184
38
- services/tools/find_in_file.py,sha256=xp80lqRN2cdZc3XGJWlCpeQEy6WnwyKOj2l5WiHNx0Q,6379
39
- services/tools/manage_asset.py,sha256=6YjWOl2b58vRwjp-9XbQE9e1l3ajhGookVY8ncQN0wo,7877
40
- services/tools/manage_editor.py,sha256=_HZRT-_hBakH0g6p7BpxTv3gWpxsaV6KNGRol-qknwo,3243
41
- services/tools/manage_gameobject.py,sha256=OgFIsoPGiWHOj6-d3Lmtp3xlAW9Tr0c38tV4atAaFAU,14400
42
- services/tools/manage_material.py,sha256=wZB2H4orhL6wG9TTnmnk-Lj2Gj_zvg7koxW3t319BLU,3545
43
- services/tools/manage_prefabs.py,sha256=73XzznjFNOm1SazW_Y7l6uGIE7wosMpAIVQs8xpvK9A,3037
44
- services/tools/manage_scene.py,sha256=oJ1qDX0T06mINZ1hX2AcDp3ItHo-oUz7Uck0yujI9eA,4834
45
- services/tools/manage_script.py,sha256=lPA5HcS4Al0RiQVz-S6qahFTcPqsk3GSLLXJWHri8P4,27557
46
- services/tools/manage_scriptable_object.py,sha256=Oi03CJLgepaWR59V-nJiAjnCC8at4YqFhRGpACruqgw,3150
47
- services/tools/manage_shader.py,sha256=HHnHKh7vLij3p8FAinNsPdZGEKivgwSUTxdgDydfmbs,2882
48
- services/tools/preflight.py,sha256=VJn61h-9pvoVaCyKL7DTKLfbpoZfNK4fnRmj91c2o8M,4093
49
- services/tools/read_console.py,sha256=MdQcrnVXra9PLu5AFkmARjriObT0miExtQKkFaANznU,4662
50
- services/tools/refresh_unity.py,sha256=anTEuEzxKTFse6ldZxTsk43zI6ahRBDv3Sg_pMHYRYA,3719
51
- services/tools/run_tests.py,sha256=8CqmgRN6Bata666ytF_S9no4gaFmHCmeZM82ZwNQJ68,4666
52
- services/tools/script_apply_edits.py,sha256=qPm_PsmsK3mYXnziX_btyk8CaB66LTqpDFA2Y4ebZ4U,47504
53
- services/tools/set_active_instance.py,sha256=B18Y8Jga0pKsx9mFywXr1tWfy0cJVopIMXYO-UJ1jOU,4136
54
- services/tools/test_jobs.py,sha256=K6HjkzWPjJNldrp-Vq5gPH7oBkCq_sJZYXkK_Vg6I_I,4059
55
- services/tools/utils.py,sha256=4ZgfIu178eXZqRyzs8X77B5lKLP1f73OZoGBSDNokJ4,2409
56
- transport/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
57
- transport/models.py,sha256=6wp7wsmSaeeJEvUGXPF1m6zuJnxJ1NJlCC4YZ9oQIq0,1226
58
- transport/plugin_hub.py,sha256=g_DOhCThgJ9Oco_z3m2qpwDeUcFvvt7Z47xMS0diihw,21497
59
- transport/plugin_registry.py,sha256=nW-7O7PN0QUgSWivZTkpAVKKq9ZOe2b2yeIdpaNt_3I,4359
60
- transport/unity_instance_middleware.py,sha256=kf1QeA138r7PaC98dcMDYtUPGWZ4EUmZGESc2DdiWQs,10429
61
- transport/unity_transport.py,sha256=_cFVgD3pzFZRcDXANq4oPFYSoI6jntSGaN22dJC8LRU,3880
62
- transport/legacy/port_discovery.py,sha256=qM_mtndbYjAj4qPSZEWVeXFOt5_nKczG9pQqORXTBJ0,12768
63
- transport/legacy/stdio_port_registry.py,sha256=j4iARuP6wetppNDG8qKeuvo1bJKcSlgEhZvSyl_uf0A,2313
64
- transport/legacy/unity_connection.py,sha256=ujUX9WX7Gb-fxQveHts3uiepTPzFq8i7-XG7u5gSPuM,32668
65
- utils/module_discovery.py,sha256=My48ofB1BUqxiBoAZAGbEaLQYdsrDhMm8MayBP_bUSQ,2005
66
- utils/reload_sentinel.py,sha256=s1xMWhl-r2XwN0OUbiUv_VGUy8TvLtV5bkql-5n2DT0,373
67
- mcpforunityserver-8.7.0.dist-info/METADATA,sha256=m3U2_aFTIAFPWg8YE3v2KcMJmP-Ffz0R-EJsJwoD6pA,5712
68
- mcpforunityserver-8.7.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
69
- mcpforunityserver-8.7.0.dist-info/entry_points.txt,sha256=vCtkqw-J9t4pj7JwUZcB54_keVx7DpAR3fYzK6i-s6g,44
70
- mcpforunityserver-8.7.0.dist-info/top_level.txt,sha256=YKU5e5dREMfCnoVpmlsTm9bku7oqnrzSZ9FeTgjoxJw,58
71
- mcpforunityserver-8.7.0.dist-info/RECORD,,
routes/__init__.py DELETED
File without changes