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.
Files changed (31) hide show
  1. cli/utils/connection.py +28 -32
  2. core/config.py +15 -0
  3. core/constants.py +4 -0
  4. main.py +306 -174
  5. {mcpforunityserver-9.3.0b20260129121506.dist-info → mcpforunityserver-9.3.0b20260131004250.dist-info}/METADATA +117 -5
  6. {mcpforunityserver-9.3.0b20260129121506.dist-info → mcpforunityserver-9.3.0b20260131004250.dist-info}/RECORD +31 -29
  7. models/__init__.py +2 -2
  8. models/unity_response.py +24 -1
  9. services/api_key_service.py +235 -0
  10. services/resources/active_tool.py +2 -1
  11. services/resources/editor_state.py +7 -7
  12. services/resources/layers.py +2 -1
  13. services/resources/menu_items.py +2 -1
  14. services/resources/prefab_stage.py +2 -1
  15. services/resources/project_info.py +2 -1
  16. services/resources/selection.py +2 -1
  17. services/resources/tags.py +2 -1
  18. services/resources/tests.py +3 -2
  19. services/resources/unity_instances.py +6 -3
  20. services/resources/windows.py +2 -1
  21. services/tools/set_active_instance.py +6 -3
  22. transport/legacy/unity_connection.py +29 -7
  23. transport/models.py +5 -0
  24. transport/plugin_hub.py +236 -34
  25. transport/plugin_registry.py +75 -19
  26. transport/unity_instance_middleware.py +42 -12
  27. transport/unity_transport.py +41 -10
  28. {mcpforunityserver-9.3.0b20260129121506.dist-info → mcpforunityserver-9.3.0b20260131004250.dist-info}/WHEEL +0 -0
  29. {mcpforunityserver-9.3.0b20260129121506.dist-info → mcpforunityserver-9.3.0b20260131004250.dist-info}/entry_points.txt +0 -0
  30. {mcpforunityserver-9.3.0b20260129121506.dist-info → mcpforunityserver-9.3.0b20260131004250.dist-info}/licenses/LICENSE +0 -0
  31. {mcpforunityserver-9.3.0b20260129121506.dist-info → mcpforunityserver-9.3.0b20260131004250.dist-info}/top_level.txt +0 -0
@@ -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 GetTestsResponse(**response) if isinstance(response, dict) else response
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 GetTestsResponse(**response) if isinstance(response, dict) else response
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 transport.unity_transport import _current_transport
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 = _current_transport()
39
+ transport = (config.transport_mode or "stdio").lower()
40
40
  if transport == "http":
41
41
  # HTTP/WebSocket transport: query PluginHub
42
- sessions_data = await PluginHub.get_sessions()
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 = []
@@ -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 WindowsResponse(**response) if isinstance(response, dict) else response
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 transport.unity_transport import _current_transport
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 = _current_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
- sessions_data = await PluginHub.get_sessions()
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
- cap = 0.8
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", "2.0"))
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", "2.0")
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 2.0: %s",
796
+ "Invalid UNITY_MCP_RELOAD_MAX_WAIT_S=%r, using default 20.0: %s",
776
797
  raw_val, e)
777
- max_wait_s = 2.0
778
- # Clamp to [0, 30] to prevent misconfiguration from causing excessive waits
779
- max_wait_s = max(0.0, min(max_wait_s, 30.0))
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
@@ -23,6 +23,11 @@ class ExecuteCommandMessage(BaseModel):
23
23
  params: dict[str, Any]
24
24
  timeout: float
25
25
 
26
+
27
+ class PingMessage(BaseModel):
28
+ """Server-initiated ping to detect dead connections."""
29
+ type: str = "ping"
30
+
26
31
  # Incoming (Plugin -> Server)
27
32
 
28
33
 
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("mcp-for-unity-server")
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
- logger.info(f"Plugin registered: {project_name} ({project_hash})")
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 so calls fail fast when editors are not ready.
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", "2.0"))
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", "2.0")
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 2.0: %s",
578
+ "Invalid UNITY_MCP_SESSION_RESOLVE_MAX_WAIT_S=%r, using default 20.0: %s",
398
579
  raw_val, e)
399
- max_wait_s = 2.0
400
- # Clamp to [0, 30] to prevent misconfiguration from causing excessive waits
401
- max_wait_s = max(0.0, min(max_wait_s, 30.0))
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
- session_id = await cls._registry.get_session_id_by_hash(target_hash)
418
- sessions = await cls._registry.list_sessions()
419
- return session_id, len(sessions)
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 = await cls._registry.list_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 RuntimeError(
440
- "Multiple Unity instances are connected. "
441
- "Call set_active_instance with Name@hash from mcpforunity://instances."
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 RuntimeError(
461
- "Multiple Unity instances are connected. "
462
- "Call set_active_instance with Name@hash from mcpforunity://instances."
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, 30.0))
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: