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.
- cli/__init__.py +3 -0
- cli/commands/__init__.py +3 -0
- cli/commands/animation.py +87 -0
- cli/commands/asset.py +310 -0
- cli/commands/audio.py +133 -0
- cli/commands/batch.py +184 -0
- cli/commands/code.py +189 -0
- cli/commands/component.py +212 -0
- cli/commands/editor.py +487 -0
- cli/commands/gameobject.py +510 -0
- cli/commands/instance.py +101 -0
- cli/commands/lighting.py +128 -0
- cli/commands/material.py +268 -0
- cli/commands/prefab.py +144 -0
- cli/commands/scene.py +255 -0
- cli/commands/script.py +240 -0
- cli/commands/shader.py +238 -0
- cli/commands/ui.py +263 -0
- cli/commands/vfx.py +439 -0
- cli/main.py +248 -0
- cli/utils/__init__.py +31 -0
- cli/utils/config.py +58 -0
- cli/utils/connection.py +191 -0
- cli/utils/output.py +195 -0
- main.py +177 -62
- {mcpforunityserver-8.7.0.dist-info → mcpforunityserver-9.1.0.dist-info}/METADATA +4 -2
- mcpforunityserver-9.1.0.dist-info/RECORD +96 -0
- {mcpforunityserver-8.7.0.dist-info → mcpforunityserver-9.1.0.dist-info}/WHEEL +1 -1
- {mcpforunityserver-8.7.0.dist-info → mcpforunityserver-9.1.0.dist-info}/entry_points.txt +1 -0
- {mcpforunityserver-8.7.0.dist-info → mcpforunityserver-9.1.0.dist-info}/top_level.txt +1 -2
- services/custom_tool_service.py +179 -19
- services/resources/__init__.py +6 -1
- services/resources/active_tool.py +1 -1
- services/resources/custom_tools.py +2 -2
- services/resources/editor_state.py +283 -30
- services/resources/gameobject.py +243 -0
- services/resources/layers.py +1 -1
- services/resources/prefab_stage.py +1 -1
- services/resources/project_info.py +1 -1
- services/resources/selection.py +1 -1
- services/resources/tags.py +1 -1
- services/resources/unity_instances.py +1 -1
- services/resources/windows.py +1 -1
- services/state/external_changes_scanner.py +3 -4
- services/tools/__init__.py +6 -1
- services/tools/batch_execute.py +24 -9
- services/tools/debug_request_context.py +8 -2
- services/tools/execute_custom_tool.py +6 -1
- services/tools/execute_menu_item.py +6 -3
- services/tools/find_gameobjects.py +89 -0
- services/tools/find_in_file.py +26 -19
- services/tools/manage_asset.py +13 -44
- services/tools/manage_components.py +131 -0
- services/tools/manage_editor.py +9 -8
- services/tools/manage_gameobject.py +115 -79
- services/tools/manage_material.py +80 -31
- services/tools/manage_prefabs.py +7 -1
- services/tools/manage_scene.py +30 -13
- services/tools/manage_script.py +62 -19
- services/tools/manage_scriptable_object.py +22 -10
- services/tools/manage_shader.py +8 -1
- services/tools/manage_vfx.py +738 -0
- services/tools/preflight.py +15 -12
- services/tools/read_console.py +70 -17
- services/tools/refresh_unity.py +92 -29
- services/tools/run_tests.py +187 -53
- services/tools/script_apply_edits.py +15 -7
- services/tools/set_active_instance.py +12 -7
- services/tools/utils.py +60 -6
- transport/legacy/port_discovery.py +2 -2
- transport/legacy/unity_connection.py +129 -26
- transport/plugin_hub.py +85 -24
- transport/unity_instance_middleware.py +4 -3
- transport/unity_transport.py +2 -1
- utils/focus_nudge.py +321 -0
- __init__.py +0 -0
- mcpforunityserver-8.7.0.dist-info/RECORD +0 -71
- routes/__init__.py +0 -0
- services/resources/editor_state_v2.py +0 -270
- services/tools/test_jobs.py +0 -94
- {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] = {
|
|
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(
|
|
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
|
|
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
|
-
|
|
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.,
|
|
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] = {
|
|
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
|
-
#
|
|
365
|
-
|
|
366
|
-
|
|
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() +
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
453
|
-
|
|
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(
|
|
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(
|
|
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.
|
transport/unity_transport.py
CHANGED
|
@@ -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,
|
|
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
|