mcpforunityserver 9.3.0b20260129121506__py3-none-any.whl → 9.3.0b20260131004250__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/utils/connection.py +28 -32
- core/config.py +15 -0
- core/constants.py +4 -0
- main.py +306 -174
- {mcpforunityserver-9.3.0b20260129121506.dist-info → mcpforunityserver-9.3.0b20260131004250.dist-info}/METADATA +117 -5
- {mcpforunityserver-9.3.0b20260129121506.dist-info → mcpforunityserver-9.3.0b20260131004250.dist-info}/RECORD +31 -29
- models/__init__.py +2 -2
- models/unity_response.py +24 -1
- services/api_key_service.py +235 -0
- services/resources/active_tool.py +2 -1
- services/resources/editor_state.py +7 -7
- services/resources/layers.py +2 -1
- services/resources/menu_items.py +2 -1
- services/resources/prefab_stage.py +2 -1
- services/resources/project_info.py +2 -1
- services/resources/selection.py +2 -1
- services/resources/tags.py +2 -1
- services/resources/tests.py +3 -2
- services/resources/unity_instances.py +6 -3
- services/resources/windows.py +2 -1
- services/tools/set_active_instance.py +6 -3
- transport/legacy/unity_connection.py +29 -7
- transport/models.py +5 -0
- transport/plugin_hub.py +236 -34
- transport/plugin_registry.py +75 -19
- transport/unity_instance_middleware.py +42 -12
- transport/unity_transport.py +41 -10
- {mcpforunityserver-9.3.0b20260129121506.dist-info → mcpforunityserver-9.3.0b20260131004250.dist-info}/WHEEL +0 -0
- {mcpforunityserver-9.3.0b20260129121506.dist-info → mcpforunityserver-9.3.0b20260131004250.dist-info}/entry_points.txt +0 -0
- {mcpforunityserver-9.3.0b20260129121506.dist-info → mcpforunityserver-9.3.0b20260131004250.dist-info}/licenses/LICENSE +0 -0
- {mcpforunityserver-9.3.0b20260129121506.dist-info → mcpforunityserver-9.3.0b20260131004250.dist-info}/top_level.txt +0 -0
services/resources/tests.py
CHANGED
|
@@ -4,6 +4,7 @@ from pydantic import BaseModel, Field
|
|
|
4
4
|
from fastmcp import Context
|
|
5
5
|
|
|
6
6
|
from models import MCPResponse
|
|
7
|
+
from models.unity_response import parse_resource_response
|
|
7
8
|
from services.registry import mcp_for_unity_resource
|
|
8
9
|
from services.tools import get_unity_instance_from_context
|
|
9
10
|
from transport.unity_transport import send_with_unity_instance
|
|
@@ -53,7 +54,7 @@ async def get_tests(ctx: Context) -> GetTestsResponse | MCPResponse:
|
|
|
53
54
|
"get_tests",
|
|
54
55
|
{},
|
|
55
56
|
)
|
|
56
|
-
return
|
|
57
|
+
return parse_resource_response(response, GetTestsResponse)
|
|
57
58
|
|
|
58
59
|
|
|
59
60
|
@mcp_for_unity_resource(
|
|
@@ -84,4 +85,4 @@ async def get_tests_for_mode(
|
|
|
84
85
|
"get_tests_for_mode",
|
|
85
86
|
{"mode": mode},
|
|
86
87
|
)
|
|
87
|
-
return
|
|
88
|
+
return parse_resource_response(response, GetTestsResponse)
|
|
@@ -7,7 +7,7 @@ from fastmcp import Context
|
|
|
7
7
|
from services.registry import mcp_for_unity_resource
|
|
8
8
|
from transport.legacy.unity_connection import get_unity_connection_pool
|
|
9
9
|
from transport.plugin_hub import PluginHub
|
|
10
|
-
from
|
|
10
|
+
from core.config import config
|
|
11
11
|
|
|
12
12
|
|
|
13
13
|
@mcp_for_unity_resource(
|
|
@@ -36,10 +36,13 @@ async def unity_instances(ctx: Context) -> dict[str, Any]:
|
|
|
36
36
|
await ctx.info("Listing Unity instances")
|
|
37
37
|
|
|
38
38
|
try:
|
|
39
|
-
transport =
|
|
39
|
+
transport = (config.transport_mode or "stdio").lower()
|
|
40
40
|
if transport == "http":
|
|
41
41
|
# HTTP/WebSocket transport: query PluginHub
|
|
42
|
-
|
|
42
|
+
# In remote-hosted mode, filter sessions by user_id
|
|
43
|
+
user_id = ctx.get_state(
|
|
44
|
+
"user_id") if config.http_remote_hosted else None
|
|
45
|
+
sessions_data = await PluginHub.get_sessions(user_id=user_id)
|
|
43
46
|
sessions = sessions_data.sessions
|
|
44
47
|
|
|
45
48
|
instances = []
|
services/resources/windows.py
CHANGED
|
@@ -2,6 +2,7 @@ from pydantic import BaseModel
|
|
|
2
2
|
from fastmcp import Context
|
|
3
3
|
|
|
4
4
|
from models import MCPResponse
|
|
5
|
+
from models.unity_response import parse_resource_response
|
|
5
6
|
from services.registry import mcp_for_unity_resource
|
|
6
7
|
from services.tools import get_unity_instance_from_context
|
|
7
8
|
from transport.unity_transport import send_with_unity_instance
|
|
@@ -44,4 +45,4 @@ async def get_windows(ctx: Context) -> WindowsResponse | MCPResponse:
|
|
|
44
45
|
"get_windows",
|
|
45
46
|
{}
|
|
46
47
|
)
|
|
47
|
-
return
|
|
48
|
+
return parse_resource_response(response, WindowsResponse)
|
|
@@ -8,7 +8,7 @@ from services.registry import mcp_for_unity_tool
|
|
|
8
8
|
from transport.legacy.unity_connection import get_unity_connection_pool
|
|
9
9
|
from transport.unity_instance_middleware import get_unity_instance_middleware
|
|
10
10
|
from transport.plugin_hub import PluginHub
|
|
11
|
-
from
|
|
11
|
+
from core.config import config
|
|
12
12
|
|
|
13
13
|
|
|
14
14
|
@mcp_for_unity_tool(
|
|
@@ -21,11 +21,14 @@ async def set_active_instance(
|
|
|
21
21
|
ctx: Context,
|
|
22
22
|
instance: Annotated[str, "Target instance (Name@hash or hash prefix)"]
|
|
23
23
|
) -> dict[str, Any]:
|
|
24
|
-
transport =
|
|
24
|
+
transport = (config.transport_mode or "stdio").lower()
|
|
25
25
|
|
|
26
26
|
# Discover running instances based on transport
|
|
27
27
|
if transport == "http":
|
|
28
|
-
|
|
28
|
+
# In remote-hosted mode, filter sessions by user_id
|
|
29
|
+
user_id = ctx.get_state(
|
|
30
|
+
"user_id") if config.http_remote_hosted else None
|
|
31
|
+
sessions_data = await PluginHub.get_sessions(user_id=user_id)
|
|
29
32
|
sessions = sessions_data.sessions
|
|
30
33
|
instances = []
|
|
31
34
|
for session_id, session in sessions.items():
|
|
@@ -306,8 +306,10 @@ class UnityConnection:
|
|
|
306
306
|
for attempt in range(attempts + 1):
|
|
307
307
|
try:
|
|
308
308
|
# Ensure connected (handshake occurs within connect())
|
|
309
|
+
t_conn_start = time.time()
|
|
309
310
|
if not self.sock and not self.connect():
|
|
310
311
|
raise ConnectionError("Could not connect to Unity")
|
|
312
|
+
logger.info("[TIMING-STDIO] connect took %.3fs command=%s", time.time() - t_conn_start, command_type)
|
|
311
313
|
|
|
312
314
|
# Build payload
|
|
313
315
|
if command_type == 'ping':
|
|
@@ -324,12 +326,14 @@ class UnityConnection:
|
|
|
324
326
|
with contextlib.suppress(Exception):
|
|
325
327
|
logger.debug(
|
|
326
328
|
f"send {len(payload)} bytes; mode={mode}; head={payload[:32].decode('utf-8', 'ignore')}")
|
|
329
|
+
t_send_start = time.time()
|
|
327
330
|
if self.use_framing:
|
|
328
331
|
header = struct.pack('>Q', len(payload))
|
|
329
332
|
self.sock.sendall(header)
|
|
330
333
|
self.sock.sendall(payload)
|
|
331
334
|
else:
|
|
332
335
|
self.sock.sendall(payload)
|
|
336
|
+
logger.info("[TIMING-STDIO] sendall took %.3fs command=%s", time.time() - t_send_start, command_type)
|
|
333
337
|
|
|
334
338
|
# During retry bursts use a short receive timeout and ensure restoration
|
|
335
339
|
restore_timeout = None
|
|
@@ -337,7 +341,9 @@ class UnityConnection:
|
|
|
337
341
|
restore_timeout = self.sock.gettimeout()
|
|
338
342
|
self.sock.settimeout(1.0)
|
|
339
343
|
try:
|
|
344
|
+
t_recv_start = time.time()
|
|
340
345
|
response_data = self.receive_full_response(self.sock)
|
|
346
|
+
logger.info("[TIMING-STDIO] receive took %.3fs command=%s len=%d", time.time() - t_recv_start, command_type, len(response_data))
|
|
341
347
|
with contextlib.suppress(Exception):
|
|
342
348
|
logger.debug(
|
|
343
349
|
f"recv {len(response_data)} bytes; mode={mode}")
|
|
@@ -419,7 +425,8 @@ class UnityConnection:
|
|
|
419
425
|
|
|
420
426
|
# Cap backoff depending on state
|
|
421
427
|
if status and status.get('reloading'):
|
|
422
|
-
|
|
428
|
+
# Domain reload can take 10-20s; use longer waits
|
|
429
|
+
cap = 5.0
|
|
423
430
|
elif fast_error:
|
|
424
431
|
cap = 0.25
|
|
425
432
|
else:
|
|
@@ -761,22 +768,36 @@ def send_command_with_retry(
|
|
|
761
768
|
Uses config.reload_retry_ms and config.reload_max_retries by default. Preserves the
|
|
762
769
|
structured failure if retries are exhausted.
|
|
763
770
|
"""
|
|
771
|
+
t_retry_start = time.time()
|
|
772
|
+
logger.info("[TIMING-STDIO] send_command_with_retry START command=%s", command_type)
|
|
773
|
+
t_get_conn = time.time()
|
|
764
774
|
conn = get_unity_connection(instance_id)
|
|
775
|
+
logger.info("[TIMING-STDIO] get_unity_connection took %.3fs command=%s", time.time() - t_get_conn, command_type)
|
|
765
776
|
if max_retries is None:
|
|
766
777
|
max_retries = getattr(config, "reload_max_retries", 40)
|
|
767
778
|
if retry_ms is None:
|
|
768
779
|
retry_ms = getattr(config, "reload_retry_ms", 250)
|
|
780
|
+
# Default to 20s to handle domain reloads (which can take 10-20s after tests or script changes).
|
|
781
|
+
#
|
|
782
|
+
# NOTE: This wait can impact agentic workflows where domain reloads happen
|
|
783
|
+
# frequently (e.g., after test runs, script compilation). The 20s default
|
|
784
|
+
# balances handling slow reloads vs. avoiding unnecessary delays.
|
|
785
|
+
#
|
|
786
|
+
# TODO: Make this more deterministic by detecting Unity's actual reload state
|
|
787
|
+
# rather than blindly waiting up to 20s. See Issue #657.
|
|
788
|
+
#
|
|
789
|
+
# Configurable via: UNITY_MCP_RELOAD_MAX_WAIT_S (default: 20.0, max: 20.0)
|
|
769
790
|
try:
|
|
770
791
|
max_wait_s = float(os.environ.get(
|
|
771
|
-
"UNITY_MCP_RELOAD_MAX_WAIT_S", "
|
|
792
|
+
"UNITY_MCP_RELOAD_MAX_WAIT_S", "20.0"))
|
|
772
793
|
except ValueError as e:
|
|
773
|
-
raw_val = os.environ.get("UNITY_MCP_RELOAD_MAX_WAIT_S", "
|
|
794
|
+
raw_val = os.environ.get("UNITY_MCP_RELOAD_MAX_WAIT_S", "20.0")
|
|
774
795
|
logger.warning(
|
|
775
|
-
"Invalid UNITY_MCP_RELOAD_MAX_WAIT_S=%r, using default
|
|
796
|
+
"Invalid UNITY_MCP_RELOAD_MAX_WAIT_S=%r, using default 20.0: %s",
|
|
776
797
|
raw_val, e)
|
|
777
|
-
max_wait_s =
|
|
778
|
-
# Clamp to [0,
|
|
779
|
-
max_wait_s = max(0.0, min(max_wait_s,
|
|
798
|
+
max_wait_s = 20.0
|
|
799
|
+
# Clamp to [0, 20] to prevent misconfiguration from causing excessive waits
|
|
800
|
+
max_wait_s = max(0.0, min(max_wait_s, 20.0))
|
|
780
801
|
|
|
781
802
|
# If retry_on_reload=False, disable connection-level retries too (issue #577)
|
|
782
803
|
# Commands that trigger compilation/reload shouldn't retry on disconnect
|
|
@@ -847,6 +868,7 @@ def send_command_with_retry(
|
|
|
847
868
|
instance_id or "default",
|
|
848
869
|
waited,
|
|
849
870
|
)
|
|
871
|
+
logger.info("[TIMING-STDIO] send_command_with_retry DONE total=%.3fs command=%s", time.time() - t_retry_start, command_type)
|
|
850
872
|
return response
|
|
851
873
|
|
|
852
874
|
|
transport/models.py
CHANGED
transport/plugin_hub.py
CHANGED
|
@@ -7,18 +7,21 @@ import logging
|
|
|
7
7
|
import os
|
|
8
8
|
import time
|
|
9
9
|
import uuid
|
|
10
|
-
from typing import Any
|
|
10
|
+
from typing import Any, ClassVar
|
|
11
11
|
|
|
12
12
|
from starlette.endpoints import WebSocketEndpoint
|
|
13
13
|
from starlette.websockets import WebSocket
|
|
14
14
|
|
|
15
15
|
from core.config import config
|
|
16
|
+
from core.constants import API_KEY_HEADER
|
|
16
17
|
from models.models import MCPResponse
|
|
17
18
|
from transport.plugin_registry import PluginRegistry
|
|
19
|
+
from services.api_key_service import ApiKeyService
|
|
18
20
|
from transport.models import (
|
|
19
21
|
WelcomeMessage,
|
|
20
22
|
RegisteredMessage,
|
|
21
23
|
ExecuteCommandMessage,
|
|
24
|
+
PingMessage,
|
|
22
25
|
RegisterMessage,
|
|
23
26
|
RegisterToolsMessage,
|
|
24
27
|
PongMessage,
|
|
@@ -27,7 +30,7 @@ from transport.models import (
|
|
|
27
30
|
SessionDetails,
|
|
28
31
|
)
|
|
29
32
|
|
|
30
|
-
logger = logging.getLogger(
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
31
34
|
|
|
32
35
|
|
|
33
36
|
class PluginDisconnectedError(RuntimeError):
|
|
@@ -38,6 +41,22 @@ class NoUnitySessionError(RuntimeError):
|
|
|
38
41
|
"""Raised when no Unity plugins are available."""
|
|
39
42
|
|
|
40
43
|
|
|
44
|
+
class InstanceSelectionRequiredError(RuntimeError):
|
|
45
|
+
"""Raised when the caller must explicitly select a Unity instance."""
|
|
46
|
+
|
|
47
|
+
_SELECTION_REQUIRED = (
|
|
48
|
+
"Unity instance selection is required. "
|
|
49
|
+
"Call set_active_instance with Name@hash from mcpforunity://instances."
|
|
50
|
+
)
|
|
51
|
+
_MULTIPLE_INSTANCES = (
|
|
52
|
+
"Multiple Unity instances are connected. "
|
|
53
|
+
"Call set_active_instance with Name@hash from mcpforunity://instances."
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
def __init__(self, message: str | None = None):
|
|
57
|
+
super().__init__(message or self._SELECTION_REQUIRED)
|
|
58
|
+
|
|
59
|
+
|
|
41
60
|
class PluginHub(WebSocketEndpoint):
|
|
42
61
|
"""Manages persistent WebSocket connections to Unity plugins."""
|
|
43
62
|
|
|
@@ -45,6 +64,10 @@ class PluginHub(WebSocketEndpoint):
|
|
|
45
64
|
KEEP_ALIVE_INTERVAL = 15
|
|
46
65
|
SERVER_TIMEOUT = 30
|
|
47
66
|
COMMAND_TIMEOUT = 30
|
|
67
|
+
# Server-side ping interval (seconds) - how often to send pings to Unity
|
|
68
|
+
PING_INTERVAL = 10
|
|
69
|
+
# Max time (seconds) to wait for pong before considering connection dead
|
|
70
|
+
PING_TIMEOUT = 20
|
|
48
71
|
# Timeout (seconds) for fast-fail commands like ping/read_console/get_editor_state.
|
|
49
72
|
# Keep short so MCP clients aren't blocked during Unity compilation/reload/unfocused throttling.
|
|
50
73
|
FAST_FAIL_TIMEOUT = 2.0
|
|
@@ -60,6 +83,10 @@ class PluginHub(WebSocketEndpoint):
|
|
|
60
83
|
_pending: dict[str, dict[str, Any]] = {}
|
|
61
84
|
_lock: asyncio.Lock | None = None
|
|
62
85
|
_loop: asyncio.AbstractEventLoop | None = None
|
|
86
|
+
# session_id -> last pong timestamp (monotonic)
|
|
87
|
+
_last_pong: ClassVar[dict[str, float]] = {}
|
|
88
|
+
# session_id -> ping task
|
|
89
|
+
_ping_tasks: ClassVar[dict[str, asyncio.Task]] = {}
|
|
63
90
|
|
|
64
91
|
@classmethod
|
|
65
92
|
def configure(
|
|
@@ -77,6 +104,50 @@ class PluginHub(WebSocketEndpoint):
|
|
|
77
104
|
return cls._registry is not None and cls._lock is not None
|
|
78
105
|
|
|
79
106
|
async def on_connect(self, websocket: WebSocket) -> None:
|
|
107
|
+
# Validate API key in remote-hosted mode (fail closed)
|
|
108
|
+
if config.http_remote_hosted:
|
|
109
|
+
if not ApiKeyService.is_initialized():
|
|
110
|
+
logger.debug(
|
|
111
|
+
"WebSocket connection rejected: auth service not initialized")
|
|
112
|
+
await websocket.close(code=1013, reason="Try again later")
|
|
113
|
+
return
|
|
114
|
+
|
|
115
|
+
api_key = websocket.headers.get(API_KEY_HEADER)
|
|
116
|
+
|
|
117
|
+
if not api_key:
|
|
118
|
+
logger.debug("WebSocket connection rejected: API key required")
|
|
119
|
+
await websocket.close(code=4401, reason="API key required")
|
|
120
|
+
return
|
|
121
|
+
|
|
122
|
+
service = ApiKeyService.get_instance()
|
|
123
|
+
result = await service.validate(api_key)
|
|
124
|
+
|
|
125
|
+
if not result.valid:
|
|
126
|
+
# Transient auth failures are retryable (1013)
|
|
127
|
+
if result.error and any(
|
|
128
|
+
indicator in result.error.lower()
|
|
129
|
+
for indicator in ("unavailable", "timeout", "service error")
|
|
130
|
+
):
|
|
131
|
+
logger.debug(
|
|
132
|
+
"WebSocket connection rejected: auth service unavailable")
|
|
133
|
+
await websocket.close(code=1013, reason="Try again later")
|
|
134
|
+
return
|
|
135
|
+
|
|
136
|
+
logger.debug("WebSocket connection rejected: invalid API key")
|
|
137
|
+
await websocket.close(code=4403, reason="Invalid API key")
|
|
138
|
+
return
|
|
139
|
+
|
|
140
|
+
# Both valid and user_id must be present to accept
|
|
141
|
+
if not result.user_id:
|
|
142
|
+
logger.debug(
|
|
143
|
+
"WebSocket connection rejected: validated key missing user_id")
|
|
144
|
+
await websocket.close(code=4403, reason="Invalid API key")
|
|
145
|
+
return
|
|
146
|
+
|
|
147
|
+
# Store user_id in websocket state for later use during registration
|
|
148
|
+
websocket.state.user_id = result.user_id
|
|
149
|
+
websocket.state.api_key_metadata = result.metadata
|
|
150
|
+
|
|
80
151
|
await websocket.accept()
|
|
81
152
|
msg = WelcomeMessage(
|
|
82
153
|
serverTimeout=self.SERVER_TIMEOUT,
|
|
@@ -114,12 +185,20 @@ class PluginHub(WebSocketEndpoint):
|
|
|
114
185
|
(sid for sid, ws in cls._connections.items() if ws is websocket), None)
|
|
115
186
|
if session_id:
|
|
116
187
|
cls._connections.pop(session_id, None)
|
|
188
|
+
# Stop the ping loop for this session
|
|
189
|
+
ping_task = cls._ping_tasks.pop(session_id, None)
|
|
190
|
+
if ping_task and not ping_task.done():
|
|
191
|
+
ping_task.cancel()
|
|
192
|
+
# Clean up last pong tracking
|
|
193
|
+
cls._last_pong.pop(session_id, None)
|
|
117
194
|
# Fail-fast any in-flight commands for this session to avoid waiting for COMMAND_TIMEOUT.
|
|
118
195
|
pending_ids = [
|
|
119
196
|
command_id
|
|
120
197
|
for command_id, entry in cls._pending.items()
|
|
121
198
|
if entry.get("session_id") == session_id
|
|
122
199
|
]
|
|
200
|
+
if pending_ids:
|
|
201
|
+
logger.debug(f"Cancelling {len(pending_ids)} pending commands for disconnected session")
|
|
123
202
|
for command_id in pending_ids:
|
|
124
203
|
entry = cls._pending.get(command_id)
|
|
125
204
|
future = entry.get("future") if isinstance(
|
|
@@ -217,10 +296,15 @@ class PluginHub(WebSocketEndpoint):
|
|
|
217
296
|
cls._pending.pop(command_id, None)
|
|
218
297
|
|
|
219
298
|
@classmethod
|
|
220
|
-
async def get_sessions(cls) -> SessionList:
|
|
299
|
+
async def get_sessions(cls, user_id: str | None = None) -> SessionList:
|
|
300
|
+
"""Get all active plugin sessions.
|
|
301
|
+
|
|
302
|
+
Args:
|
|
303
|
+
user_id: If provided (remote-hosted mode), only return sessions for this user.
|
|
304
|
+
"""
|
|
221
305
|
if cls._registry is None:
|
|
222
306
|
return SessionList(sessions={})
|
|
223
|
-
sessions = await cls._registry.list_sessions()
|
|
307
|
+
sessions = await cls._registry.list_sessions(user_id=user_id)
|
|
224
308
|
return SessionList(
|
|
225
309
|
sessions={
|
|
226
310
|
session_id: SessionDetails(
|
|
@@ -286,15 +370,31 @@ class PluginHub(WebSocketEndpoint):
|
|
|
286
370
|
raise ValueError(
|
|
287
371
|
"Plugin registration missing project_hash")
|
|
288
372
|
|
|
373
|
+
# Get user_id from websocket state (set during API key validation)
|
|
374
|
+
user_id = getattr(websocket.state, "user_id", None)
|
|
375
|
+
|
|
289
376
|
session_id = str(uuid.uuid4())
|
|
290
377
|
# Inform the plugin of its assigned session ID
|
|
291
378
|
response = RegisteredMessage(session_id=session_id)
|
|
292
379
|
await websocket.send_json(response.model_dump())
|
|
293
380
|
|
|
294
|
-
session = await registry.register(session_id, project_name, project_hash, unity_version, project_path)
|
|
381
|
+
session = await registry.register(session_id, project_name, project_hash, unity_version, project_path, user_id=user_id)
|
|
295
382
|
async with lock:
|
|
296
383
|
cls._connections[session.session_id] = websocket
|
|
297
|
-
|
|
384
|
+
# Initialize last pong time and start ping loop for this session
|
|
385
|
+
cls._last_pong[session_id] = time.monotonic()
|
|
386
|
+
# Cancel any existing ping task for this session (shouldn't happen, but be safe)
|
|
387
|
+
old_task = cls._ping_tasks.pop(session_id, None)
|
|
388
|
+
if old_task and not old_task.done():
|
|
389
|
+
old_task.cancel()
|
|
390
|
+
# Start the server-side ping loop
|
|
391
|
+
ping_task = asyncio.create_task(cls._ping_loop(session_id, websocket))
|
|
392
|
+
cls._ping_tasks[session_id] = ping_task
|
|
393
|
+
|
|
394
|
+
if user_id:
|
|
395
|
+
logger.info(f"Plugin registered: {project_name} ({project_hash}) for user {user_id}")
|
|
396
|
+
else:
|
|
397
|
+
logger.info(f"Plugin registered: {project_name} ({project_hash})")
|
|
298
398
|
|
|
299
399
|
async def _handle_register_tools(self, websocket: WebSocket, payload: RegisterToolsMessage) -> None:
|
|
300
400
|
cls = type(self)
|
|
@@ -354,11 +454,77 @@ class PluginHub(WebSocketEndpoint):
|
|
|
354
454
|
async def _handle_pong(self, payload: PongMessage) -> None:
|
|
355
455
|
cls = type(self)
|
|
356
456
|
registry = cls._registry
|
|
457
|
+
lock = cls._lock
|
|
357
458
|
if registry is None:
|
|
358
459
|
return
|
|
359
460
|
session_id = payload.session_id
|
|
360
461
|
if session_id:
|
|
361
462
|
await registry.touch(session_id)
|
|
463
|
+
# Record last pong time for staleness detection (under lock for consistency)
|
|
464
|
+
if lock is not None:
|
|
465
|
+
async with lock:
|
|
466
|
+
cls._last_pong[session_id] = time.monotonic()
|
|
467
|
+
|
|
468
|
+
@classmethod
|
|
469
|
+
async def _ping_loop(cls, session_id: str, websocket: WebSocket) -> None:
|
|
470
|
+
"""Server-initiated ping loop to detect dead connections.
|
|
471
|
+
|
|
472
|
+
Sends periodic pings to the Unity client. If no pong is received within
|
|
473
|
+
PING_TIMEOUT seconds, the connection is considered dead and closed.
|
|
474
|
+
This helps detect connections that die silently (e.g., Windows OSError 64).
|
|
475
|
+
"""
|
|
476
|
+
logger.debug(f"[Ping] Starting ping loop for session {session_id}")
|
|
477
|
+
try:
|
|
478
|
+
while True:
|
|
479
|
+
await asyncio.sleep(cls.PING_INTERVAL)
|
|
480
|
+
|
|
481
|
+
# Check if we're still supposed to be running and get last pong time (under lock)
|
|
482
|
+
lock = cls._lock
|
|
483
|
+
if lock is None:
|
|
484
|
+
break
|
|
485
|
+
async with lock:
|
|
486
|
+
if session_id not in cls._connections:
|
|
487
|
+
logger.debug(f"[Ping] Session {session_id} no longer in connections, stopping ping loop")
|
|
488
|
+
break
|
|
489
|
+
# Read last pong time under lock for consistency
|
|
490
|
+
last_pong = cls._last_pong.get(session_id, 0)
|
|
491
|
+
|
|
492
|
+
# Check staleness: has it been too long since we got a pong?
|
|
493
|
+
elapsed = time.monotonic() - last_pong
|
|
494
|
+
if elapsed > cls.PING_TIMEOUT:
|
|
495
|
+
logger.warning(
|
|
496
|
+
f"[Ping] Session {session_id} stale: no pong for {elapsed:.1f}s "
|
|
497
|
+
f"(timeout={cls.PING_TIMEOUT}s). Closing connection."
|
|
498
|
+
)
|
|
499
|
+
try:
|
|
500
|
+
await websocket.close(code=1001) # Going away
|
|
501
|
+
except Exception as close_ex:
|
|
502
|
+
logger.debug(f"[Ping] Error closing stale websocket: {close_ex}")
|
|
503
|
+
break
|
|
504
|
+
|
|
505
|
+
# Send a ping to the client
|
|
506
|
+
try:
|
|
507
|
+
ping_msg = PingMessage()
|
|
508
|
+
await websocket.send_json(ping_msg.model_dump())
|
|
509
|
+
logger.debug(f"[Ping] Sent ping to session {session_id}")
|
|
510
|
+
except Exception as send_ex:
|
|
511
|
+
# Send failed - connection is dead
|
|
512
|
+
logger.warning(
|
|
513
|
+
f"[Ping] Failed to send ping to session {session_id}: {send_ex}. "
|
|
514
|
+
"Connection likely dead."
|
|
515
|
+
)
|
|
516
|
+
try:
|
|
517
|
+
await websocket.close(code=1006) # Abnormal closure
|
|
518
|
+
except Exception:
|
|
519
|
+
pass
|
|
520
|
+
break
|
|
521
|
+
|
|
522
|
+
except asyncio.CancelledError:
|
|
523
|
+
logger.debug(f"[Ping] Ping loop cancelled for session {session_id}")
|
|
524
|
+
except Exception as ex:
|
|
525
|
+
logger.warning(f"[Ping] Ping loop error for session {session_id}: {ex}")
|
|
526
|
+
finally:
|
|
527
|
+
logger.debug(f"[Ping] Ping loop ended for session {session_id}")
|
|
362
528
|
|
|
363
529
|
@classmethod
|
|
364
530
|
async def _get_connection(cls, session_id: str) -> WebSocket:
|
|
@@ -375,30 +541,45 @@ class PluginHub(WebSocketEndpoint):
|
|
|
375
541
|
# Session resolution helpers
|
|
376
542
|
# ------------------------------------------------------------------
|
|
377
543
|
@classmethod
|
|
378
|
-
async def _resolve_session_id(cls, unity_instance: str | None) -> str:
|
|
544
|
+
async def _resolve_session_id(cls, unity_instance: str | None, user_id: str | None = None) -> str:
|
|
379
545
|
"""Resolve a project hash (Unity instance id) to an active plugin session.
|
|
380
546
|
|
|
381
547
|
During Unity domain reloads the plugin's WebSocket session is torn down
|
|
382
548
|
and reconnected shortly afterwards. Instead of failing immediately when
|
|
383
549
|
no sessions are available, we wait for a bounded period for a plugin
|
|
384
550
|
to reconnect so in-flight MCP calls can succeed transparently.
|
|
551
|
+
|
|
552
|
+
Args:
|
|
553
|
+
unity_instance: Target instance (Name@hash or hash)
|
|
554
|
+
user_id: User ID from API key validation (for remote-hosted mode session isolation)
|
|
385
555
|
"""
|
|
386
556
|
if cls._registry is None:
|
|
387
557
|
raise RuntimeError("Plugin registry not configured")
|
|
388
558
|
|
|
389
|
-
# Bound waiting for Unity sessions
|
|
559
|
+
# Bound waiting for Unity sessions. Default to 20s to handle domain reloads
|
|
560
|
+
# (which can take 10-20s after test runs or script changes).
|
|
561
|
+
#
|
|
562
|
+
# NOTE: This wait can impact agentic workflows where domain reloads happen
|
|
563
|
+
# frequently (e.g., after test runs, script compilation). The 20s default
|
|
564
|
+
# balances handling slow reloads vs. avoiding unnecessary delays.
|
|
565
|
+
#
|
|
566
|
+
# TODO: Make this more deterministic by detecting Unity's actual reload state
|
|
567
|
+
# (e.g., via status file, heartbeat, or explicit "reloading" signal from Unity)
|
|
568
|
+
# rather than blindly waiting up to 20s. See Issue #657.
|
|
569
|
+
#
|
|
570
|
+
# Configurable via: UNITY_MCP_SESSION_RESOLVE_MAX_WAIT_S (default: 20.0, max: 20.0)
|
|
390
571
|
try:
|
|
391
572
|
max_wait_s = float(
|
|
392
|
-
os.environ.get("UNITY_MCP_SESSION_RESOLVE_MAX_WAIT_S", "
|
|
573
|
+
os.environ.get("UNITY_MCP_SESSION_RESOLVE_MAX_WAIT_S", "20.0"))
|
|
393
574
|
except ValueError as e:
|
|
394
575
|
raw_val = os.environ.get(
|
|
395
|
-
"UNITY_MCP_SESSION_RESOLVE_MAX_WAIT_S", "
|
|
576
|
+
"UNITY_MCP_SESSION_RESOLVE_MAX_WAIT_S", "20.0")
|
|
396
577
|
logger.warning(
|
|
397
|
-
"Invalid UNITY_MCP_SESSION_RESOLVE_MAX_WAIT_S=%r, using default
|
|
578
|
+
"Invalid UNITY_MCP_SESSION_RESOLVE_MAX_WAIT_S=%r, using default 20.0: %s",
|
|
398
579
|
raw_val, e)
|
|
399
|
-
max_wait_s =
|
|
400
|
-
# Clamp to [0,
|
|
401
|
-
max_wait_s = max(0.0, min(max_wait_s,
|
|
580
|
+
max_wait_s = 20.0
|
|
581
|
+
# Clamp to [0, 20] to prevent misconfiguration from causing excessive waits
|
|
582
|
+
max_wait_s = max(0.0, min(max_wait_s, 20.0))
|
|
402
583
|
retry_ms = float(getattr(config, "reload_retry_ms", 250))
|
|
403
584
|
sleep_seconds = max(0.05, min(0.25, retry_ms / 1000.0))
|
|
404
585
|
|
|
@@ -411,24 +592,35 @@ class PluginHub(WebSocketEndpoint):
|
|
|
411
592
|
else:
|
|
412
593
|
target_hash = unity_instance
|
|
413
594
|
|
|
414
|
-
async def _try_once() -> tuple[str | None, int]:
|
|
595
|
+
async def _try_once() -> tuple[str | None, int, bool]:
|
|
596
|
+
explicit_required = config.http_remote_hosted
|
|
415
597
|
# Prefer a specific Unity instance if one was requested
|
|
416
598
|
if target_hash:
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
599
|
+
# In remote-hosted mode with user_id, use user-scoped lookup
|
|
600
|
+
if config.http_remote_hosted and user_id:
|
|
601
|
+
session_id = await cls._registry.get_session_id_by_hash(target_hash, user_id)
|
|
602
|
+
sessions = await cls._registry.list_sessions(user_id=user_id)
|
|
603
|
+
else:
|
|
604
|
+
session_id = await cls._registry.get_session_id_by_hash(target_hash)
|
|
605
|
+
sessions = await cls._registry.list_sessions(user_id=user_id)
|
|
606
|
+
return session_id, len(sessions), explicit_required
|
|
420
607
|
|
|
421
608
|
# No target provided: determine if we can auto-select
|
|
422
|
-
sessions
|
|
609
|
+
# In remote-hosted mode, filter sessions by user_id
|
|
610
|
+
sessions = await cls._registry.list_sessions(user_id=user_id)
|
|
423
611
|
count = len(sessions)
|
|
424
612
|
if count == 0:
|
|
425
|
-
return None, count
|
|
613
|
+
return None, count, explicit_required
|
|
614
|
+
if explicit_required:
|
|
615
|
+
return None, count, explicit_required
|
|
426
616
|
if count == 1:
|
|
427
|
-
return next(iter(sessions.keys())), count
|
|
617
|
+
return next(iter(sessions.keys())), count, explicit_required
|
|
428
618
|
# Multiple sessions but no explicit target is ambiguous
|
|
429
|
-
return None, count
|
|
619
|
+
return None, count, explicit_required
|
|
430
620
|
|
|
431
|
-
session_id, session_count = await _try_once()
|
|
621
|
+
session_id, session_count, explicit_required = await _try_once()
|
|
622
|
+
if session_id is None and explicit_required and not target_hash and session_count > 0:
|
|
623
|
+
raise InstanceSelectionRequiredError()
|
|
432
624
|
deadline = time.monotonic() + max_wait_s
|
|
433
625
|
wait_started = None
|
|
434
626
|
|
|
@@ -436,10 +628,10 @@ class PluginHub(WebSocketEndpoint):
|
|
|
436
628
|
# wait politely for a session to appear before surfacing an error.
|
|
437
629
|
while session_id is None and time.monotonic() < deadline:
|
|
438
630
|
if not target_hash and session_count > 1:
|
|
439
|
-
raise
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
)
|
|
631
|
+
raise InstanceSelectionRequiredError(
|
|
632
|
+
InstanceSelectionRequiredError._MULTIPLE_INSTANCES)
|
|
633
|
+
if session_id is None and explicit_required and not target_hash and session_count > 0:
|
|
634
|
+
raise InstanceSelectionRequiredError()
|
|
443
635
|
if wait_started is None:
|
|
444
636
|
wait_started = time.monotonic()
|
|
445
637
|
logger.debug(
|
|
@@ -448,7 +640,7 @@ class PluginHub(WebSocketEndpoint):
|
|
|
448
640
|
max_wait_s,
|
|
449
641
|
)
|
|
450
642
|
await asyncio.sleep(sleep_seconds)
|
|
451
|
-
session_id, session_count = await _try_once()
|
|
643
|
+
session_id, session_count, explicit_required = await _try_once()
|
|
452
644
|
|
|
453
645
|
if session_id is not None and wait_started is not None:
|
|
454
646
|
logger.debug(
|
|
@@ -457,10 +649,11 @@ class PluginHub(WebSocketEndpoint):
|
|
|
457
649
|
unity_instance or "default",
|
|
458
650
|
)
|
|
459
651
|
if session_id is None and not target_hash and session_count > 1:
|
|
460
|
-
raise
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
652
|
+
raise InstanceSelectionRequiredError(
|
|
653
|
+
InstanceSelectionRequiredError._MULTIPLE_INSTANCES)
|
|
654
|
+
|
|
655
|
+
if session_id is None and explicit_required and not target_hash and session_count > 0:
|
|
656
|
+
raise InstanceSelectionRequiredError()
|
|
464
657
|
|
|
465
658
|
if session_id is None:
|
|
466
659
|
logger.warning(
|
|
@@ -481,9 +674,18 @@ class PluginHub(WebSocketEndpoint):
|
|
|
481
674
|
unity_instance: str | None,
|
|
482
675
|
command_type: str,
|
|
483
676
|
params: dict[str, Any],
|
|
677
|
+
user_id: str | None = None,
|
|
484
678
|
) -> dict[str, Any]:
|
|
679
|
+
"""Send a command to a Unity instance.
|
|
680
|
+
|
|
681
|
+
Args:
|
|
682
|
+
unity_instance: Target instance (Name@hash or hash)
|
|
683
|
+
command_type: Command type to execute
|
|
684
|
+
params: Command parameters
|
|
685
|
+
user_id: User ID for session isolation in remote-hosted mode
|
|
686
|
+
"""
|
|
485
687
|
try:
|
|
486
|
-
session_id = await cls._resolve_session_id(unity_instance)
|
|
688
|
+
session_id = await cls._resolve_session_id(unity_instance, user_id=user_id)
|
|
487
689
|
except NoUnitySessionError:
|
|
488
690
|
logger.debug(
|
|
489
691
|
"Unity session unavailable; returning retry: command=%s instance=%s",
|
|
@@ -513,7 +715,7 @@ class PluginHub(WebSocketEndpoint):
|
|
|
513
715
|
"Invalid UNITY_MCP_SESSION_READY_WAIT_SECONDS=%r, using default 6.0: %s",
|
|
514
716
|
raw_val, e)
|
|
515
717
|
max_wait_s = 6.0
|
|
516
|
-
max_wait_s = max(0.0, min(max_wait_s,
|
|
718
|
+
max_wait_s = max(0.0, min(max_wait_s, 20.0))
|
|
517
719
|
if max_wait_s > 0:
|
|
518
720
|
deadline = time.monotonic() + max_wait_s
|
|
519
721
|
while time.monotonic() < deadline:
|