mcpforunityserver 9.3.0b20260129121506__py3-none-any.whl → 9.3.0b20260131003150__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 (29) 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.0b20260131003150.dist-info}/METADATA +117 -5
  6. {mcpforunityserver-9.3.0b20260129121506.dist-info → mcpforunityserver-9.3.0b20260131003150.dist-info}/RECORD +29 -27
  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/plugin_hub.py +124 -24
  23. transport/plugin_registry.py +75 -19
  24. transport/unity_instance_middleware.py +38 -9
  25. transport/unity_transport.py +41 -10
  26. {mcpforunityserver-9.3.0b20260129121506.dist-info → mcpforunityserver-9.3.0b20260131003150.dist-info}/WHEEL +0 -0
  27. {mcpforunityserver-9.3.0b20260129121506.dist-info → mcpforunityserver-9.3.0b20260131003150.dist-info}/entry_points.txt +0 -0
  28. {mcpforunityserver-9.3.0b20260129121506.dist-info → mcpforunityserver-9.3.0b20260131003150.dist-info}/licenses/LICENSE +0 -0
  29. {mcpforunityserver-9.3.0b20260129121506.dist-info → mcpforunityserver-9.3.0b20260131003150.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():
transport/plugin_hub.py CHANGED
@@ -13,8 +13,10 @@ 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,
@@ -38,6 +40,22 @@ class NoUnitySessionError(RuntimeError):
38
40
  """Raised when no Unity plugins are available."""
39
41
 
40
42
 
43
+ class InstanceSelectionRequiredError(RuntimeError):
44
+ """Raised when the caller must explicitly select a Unity instance."""
45
+
46
+ _SELECTION_REQUIRED = (
47
+ "Unity instance selection is required. "
48
+ "Call set_active_instance with Name@hash from mcpforunity://instances."
49
+ )
50
+ _MULTIPLE_INSTANCES = (
51
+ "Multiple Unity instances are connected. "
52
+ "Call set_active_instance with Name@hash from mcpforunity://instances."
53
+ )
54
+
55
+ def __init__(self, message: str | None = None):
56
+ super().__init__(message or self._SELECTION_REQUIRED)
57
+
58
+
41
59
  class PluginHub(WebSocketEndpoint):
42
60
  """Manages persistent WebSocket connections to Unity plugins."""
43
61
 
@@ -77,6 +95,50 @@ class PluginHub(WebSocketEndpoint):
77
95
  return cls._registry is not None and cls._lock is not None
78
96
 
79
97
  async def on_connect(self, websocket: WebSocket) -> None:
98
+ # Validate API key in remote-hosted mode (fail closed)
99
+ if config.http_remote_hosted:
100
+ if not ApiKeyService.is_initialized():
101
+ logger.debug(
102
+ "WebSocket connection rejected: auth service not initialized")
103
+ await websocket.close(code=1013, reason="Try again later")
104
+ return
105
+
106
+ api_key = websocket.headers.get(API_KEY_HEADER)
107
+
108
+ if not api_key:
109
+ logger.debug("WebSocket connection rejected: API key required")
110
+ await websocket.close(code=4401, reason="API key required")
111
+ return
112
+
113
+ service = ApiKeyService.get_instance()
114
+ result = await service.validate(api_key)
115
+
116
+ if not result.valid:
117
+ # Transient auth failures are retryable (1013)
118
+ if result.error and any(
119
+ indicator in result.error.lower()
120
+ for indicator in ("unavailable", "timeout", "service error")
121
+ ):
122
+ logger.debug(
123
+ "WebSocket connection rejected: auth service unavailable")
124
+ await websocket.close(code=1013, reason="Try again later")
125
+ return
126
+
127
+ logger.debug("WebSocket connection rejected: invalid API key")
128
+ await websocket.close(code=4403, reason="Invalid API key")
129
+ return
130
+
131
+ # Both valid and user_id must be present to accept
132
+ if not result.user_id:
133
+ logger.debug(
134
+ "WebSocket connection rejected: validated key missing user_id")
135
+ await websocket.close(code=4403, reason="Invalid API key")
136
+ return
137
+
138
+ # Store user_id in websocket state for later use during registration
139
+ websocket.state.user_id = result.user_id
140
+ websocket.state.api_key_metadata = result.metadata
141
+
80
142
  await websocket.accept()
81
143
  msg = WelcomeMessage(
82
144
  serverTimeout=self.SERVER_TIMEOUT,
@@ -217,10 +279,15 @@ class PluginHub(WebSocketEndpoint):
217
279
  cls._pending.pop(command_id, None)
218
280
 
219
281
  @classmethod
220
- async def get_sessions(cls) -> SessionList:
282
+ async def get_sessions(cls, user_id: str | None = None) -> SessionList:
283
+ """Get all active plugin sessions.
284
+
285
+ Args:
286
+ user_id: If provided (remote-hosted mode), only return sessions for this user.
287
+ """
221
288
  if cls._registry is None:
222
289
  return SessionList(sessions={})
223
- sessions = await cls._registry.list_sessions()
290
+ sessions = await cls._registry.list_sessions(user_id=user_id)
224
291
  return SessionList(
225
292
  sessions={
226
293
  session_id: SessionDetails(
@@ -286,15 +353,23 @@ class PluginHub(WebSocketEndpoint):
286
353
  raise ValueError(
287
354
  "Plugin registration missing project_hash")
288
355
 
356
+ # Get user_id from websocket state (set during API key validation)
357
+ user_id = getattr(websocket.state, "user_id", None)
358
+
289
359
  session_id = str(uuid.uuid4())
290
360
  # Inform the plugin of its assigned session ID
291
361
  response = RegisteredMessage(session_id=session_id)
292
362
  await websocket.send_json(response.model_dump())
293
363
 
294
- session = await registry.register(session_id, project_name, project_hash, unity_version, project_path)
364
+ session = await registry.register(session_id, project_name, project_hash, unity_version, project_path, user_id=user_id)
295
365
  async with lock:
296
366
  cls._connections[session.session_id] = websocket
297
- logger.info(f"Plugin registered: {project_name} ({project_hash})")
367
+
368
+ if user_id:
369
+ logger.info(
370
+ f"Plugin registered: {project_name} ({project_hash}) for user {user_id}")
371
+ else:
372
+ logger.info(f"Plugin registered: {project_name} ({project_hash})")
298
373
 
299
374
  async def _handle_register_tools(self, websocket: WebSocket, payload: RegisterToolsMessage) -> None:
300
375
  cls = type(self)
@@ -375,13 +450,17 @@ class PluginHub(WebSocketEndpoint):
375
450
  # Session resolution helpers
376
451
  # ------------------------------------------------------------------
377
452
  @classmethod
378
- async def _resolve_session_id(cls, unity_instance: str | None) -> str:
453
+ async def _resolve_session_id(cls, unity_instance: str | None, user_id: str | None = None) -> str:
379
454
  """Resolve a project hash (Unity instance id) to an active plugin session.
380
455
 
381
456
  During Unity domain reloads the plugin's WebSocket session is torn down
382
457
  and reconnected shortly afterwards. Instead of failing immediately when
383
458
  no sessions are available, we wait for a bounded period for a plugin
384
459
  to reconnect so in-flight MCP calls can succeed transparently.
460
+
461
+ Args:
462
+ unity_instance: Target instance (Name@hash or hash)
463
+ user_id: User ID from API key validation (for remote-hosted mode session isolation)
385
464
  """
386
465
  if cls._registry is None:
387
466
  raise RuntimeError("Plugin registry not configured")
@@ -411,24 +490,35 @@ class PluginHub(WebSocketEndpoint):
411
490
  else:
412
491
  target_hash = unity_instance
413
492
 
414
- async def _try_once() -> tuple[str | None, int]:
493
+ async def _try_once() -> tuple[str | None, int, bool]:
494
+ explicit_required = config.http_remote_hosted
415
495
  # Prefer a specific Unity instance if one was requested
416
496
  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)
497
+ # In remote-hosted mode with user_id, use user-scoped lookup
498
+ if config.http_remote_hosted and user_id:
499
+ session_id = await cls._registry.get_session_id_by_hash(target_hash, user_id)
500
+ sessions = await cls._registry.list_sessions(user_id=user_id)
501
+ else:
502
+ session_id = await cls._registry.get_session_id_by_hash(target_hash)
503
+ sessions = await cls._registry.list_sessions(user_id=user_id)
504
+ return session_id, len(sessions), explicit_required
420
505
 
421
506
  # No target provided: determine if we can auto-select
422
- sessions = await cls._registry.list_sessions()
507
+ # In remote-hosted mode, filter sessions by user_id
508
+ sessions = await cls._registry.list_sessions(user_id=user_id)
423
509
  count = len(sessions)
424
510
  if count == 0:
425
- return None, count
511
+ return None, count, explicit_required
512
+ if explicit_required:
513
+ return None, count, explicit_required
426
514
  if count == 1:
427
- return next(iter(sessions.keys())), count
515
+ return next(iter(sessions.keys())), count, explicit_required
428
516
  # Multiple sessions but no explicit target is ambiguous
429
- return None, count
517
+ return None, count, explicit_required
430
518
 
431
- session_id, session_count = await _try_once()
519
+ session_id, session_count, explicit_required = await _try_once()
520
+ if session_id is None and explicit_required and not target_hash and session_count > 0:
521
+ raise InstanceSelectionRequiredError()
432
522
  deadline = time.monotonic() + max_wait_s
433
523
  wait_started = None
434
524
 
@@ -436,10 +526,10 @@ class PluginHub(WebSocketEndpoint):
436
526
  # wait politely for a session to appear before surfacing an error.
437
527
  while session_id is None and time.monotonic() < deadline:
438
528
  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
- )
529
+ raise InstanceSelectionRequiredError(
530
+ InstanceSelectionRequiredError._MULTIPLE_INSTANCES)
531
+ if session_id is None and explicit_required and not target_hash and session_count > 0:
532
+ raise InstanceSelectionRequiredError()
443
533
  if wait_started is None:
444
534
  wait_started = time.monotonic()
445
535
  logger.debug(
@@ -448,7 +538,7 @@ class PluginHub(WebSocketEndpoint):
448
538
  max_wait_s,
449
539
  )
450
540
  await asyncio.sleep(sleep_seconds)
451
- session_id, session_count = await _try_once()
541
+ session_id, session_count, explicit_required = await _try_once()
452
542
 
453
543
  if session_id is not None and wait_started is not None:
454
544
  logger.debug(
@@ -457,10 +547,11 @@ class PluginHub(WebSocketEndpoint):
457
547
  unity_instance or "default",
458
548
  )
459
549
  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
- )
550
+ raise InstanceSelectionRequiredError(
551
+ InstanceSelectionRequiredError._MULTIPLE_INSTANCES)
552
+
553
+ if session_id is None and explicit_required and not target_hash and session_count > 0:
554
+ raise InstanceSelectionRequiredError()
464
555
 
465
556
  if session_id is None:
466
557
  logger.warning(
@@ -481,9 +572,18 @@ class PluginHub(WebSocketEndpoint):
481
572
  unity_instance: str | None,
482
573
  command_type: str,
483
574
  params: dict[str, Any],
575
+ user_id: str | None = None,
484
576
  ) -> dict[str, Any]:
577
+ """Send a command to a Unity instance.
578
+
579
+ Args:
580
+ unity_instance: Target instance (Name@hash or hash)
581
+ command_type: Command type to execute
582
+ params: Command parameters
583
+ user_id: User ID for session isolation in remote-hosted mode
584
+ """
485
585
  try:
486
- session_id = await cls._resolve_session_id(unity_instance)
586
+ session_id = await cls._resolve_session_id(unity_instance, user_id=user_id)
487
587
  except NoUnitySessionError:
488
588
  logger.debug(
489
589
  "Unity session unavailable; returning retry: command=%s instance=%s",
@@ -7,6 +7,7 @@ from datetime import datetime, timezone
7
7
 
8
8
  import asyncio
9
9
 
10
+ from core.config import config
10
11
  from models.models import ToolDefinitionModel
11
12
 
12
13
 
@@ -22,7 +23,9 @@ class PluginSession:
22
23
  connected_at: datetime
23
24
  tools: dict[str, ToolDefinitionModel] = field(default_factory=dict)
24
25
  project_id: str | None = None
25
- project_path: str | None = None # Full path to project root (for focus nudging)
26
+ # Full path to project root (for focus nudging)
27
+ project_path: str | None = None
28
+ user_id: str | None = None # Associated user id (None for local mode)
26
29
 
27
30
 
28
31
  class PluginRegistry:
@@ -31,11 +34,17 @@ class PluginRegistry:
31
34
  The registry is optimised for quick lookup by either ``session_id`` or
32
35
  ``project_hash`` (which is used as the canonical "instance id" across the
33
36
  HTTP command routing stack).
37
+
38
+ In remote-hosted mode, sessions are scoped by (user_id, project_hash) composite key
39
+ to ensure session isolation between users.
34
40
  """
35
41
 
36
42
  def __init__(self) -> None:
37
43
  self._sessions: dict[str, PluginSession] = {}
44
+ # In local mode: project_hash -> session_id
45
+ # In remote mode: (user_id, project_hash) -> session_id
38
46
  self._hash_to_session: dict[str, str] = {}
47
+ self._user_hash_to_session: dict[tuple[str, str], str] = {}
39
48
  self._lock = asyncio.Lock()
40
49
 
41
50
  async def register(
@@ -45,13 +54,16 @@ class PluginRegistry:
45
54
  project_hash: str,
46
55
  unity_version: str,
47
56
  project_path: str | None = None,
57
+ user_id: str | None = None,
48
58
  ) -> PluginSession:
49
59
  """Register (or replace) a plugin session.
50
60
 
51
- If an existing session already claims the same ``project_hash`` it will be
52
- replaced, ensuring that reconnect scenarios always map to the latest
53
- WebSocket connection.
61
+ If an existing session already claims the same ``project_hash`` (and ``user_id``
62
+ in remote-hosted mode) it will be replaced, ensuring that reconnect scenarios
63
+ always map to the latest WebSocket connection.
54
64
  """
65
+ if config.http_remote_hosted and not user_id:
66
+ raise ValueError("user_id is required in remote-hosted mode")
55
67
 
56
68
  async with self._lock:
57
69
  now = datetime.now(timezone.utc)
@@ -63,15 +75,26 @@ class PluginRegistry:
63
75
  registered_at=now,
64
76
  connected_at=now,
65
77
  project_path=project_path,
78
+ user_id=user_id,
66
79
  )
67
80
 
68
81
  # Remove old mapping for this hash if it existed under a different session
69
- previous_session_id = self._hash_to_session.get(project_hash)
70
- if previous_session_id and previous_session_id != session_id:
71
- self._sessions.pop(previous_session_id, None)
82
+ if user_id:
83
+ # Remote-hosted mode: use composite key (user_id, project_hash)
84
+ composite_key = (user_id, project_hash)
85
+ previous_session_id = self._user_hash_to_session.get(
86
+ composite_key)
87
+ if previous_session_id and previous_session_id != session_id:
88
+ self._sessions.pop(previous_session_id, None)
89
+ self._user_hash_to_session[composite_key] = session_id
90
+ else:
91
+ # Local mode: use project_hash only
92
+ previous_session_id = self._hash_to_session.get(project_hash)
93
+ if previous_session_id and previous_session_id != session_id:
94
+ self._sessions.pop(previous_session_id, None)
95
+ self._hash_to_session[project_hash] = session_id
72
96
 
73
97
  self._sessions[session_id] = session
74
- self._hash_to_session[project_hash] = session_id
75
98
  return session
76
99
 
77
100
  async def touch(self, session_id: str) -> None:
@@ -87,11 +110,20 @@ class PluginRegistry:
87
110
 
88
111
  async with self._lock:
89
112
  session = self._sessions.pop(session_id, None)
90
- if session and session.project_hash in self._hash_to_session:
91
- # Only delete the mapping if it still points at the removed session.
92
- mapped = self._hash_to_session.get(session.project_hash)
93
- if mapped == session_id:
94
- del self._hash_to_session[session.project_hash]
113
+ if session:
114
+ # Clean up hash mappings
115
+ if session.project_hash in self._hash_to_session:
116
+ mapped = self._hash_to_session.get(session.project_hash)
117
+ if mapped == session_id:
118
+ del self._hash_to_session[session.project_hash]
119
+
120
+ # Clean up user-scoped mappings
121
+ if session.user_id:
122
+ composite_key = (session.user_id, session.project_hash)
123
+ if composite_key in self._user_hash_to_session:
124
+ mapped = self._user_hash_to_session.get(composite_key)
125
+ if mapped == session_id:
126
+ del self._user_hash_to_session[composite_key]
95
127
 
96
128
  async def register_tools_for_session(self, session_id: str, tools: list[ToolDefinitionModel]) -> None:
97
129
  """Register tools for a specific session."""
@@ -110,17 +142,41 @@ class PluginRegistry:
110
142
  async with self._lock:
111
143
  return self._sessions.get(session_id)
112
144
 
113
- async def get_session_id_by_hash(self, project_hash: str) -> str | None:
145
+ async def get_session_id_by_hash(self, project_hash: str, user_id: str | None = None) -> str | None:
114
146
  """Resolve a ``project_hash`` (Unity instance id) to a session id."""
115
147
 
116
- async with self._lock:
117
- return self._hash_to_session.get(project_hash)
148
+ if user_id:
149
+ async with self._lock:
150
+ return self._user_hash_to_session.get((user_id, project_hash))
151
+ else:
152
+ async with self._lock:
153
+ return self._hash_to_session.get(project_hash)
154
+
155
+ async def list_sessions(self, user_id: str | None = None) -> dict[str, PluginSession]:
156
+ """Return a shallow copy of sessions.
118
157
 
119
- async def list_sessions(self) -> dict[str, PluginSession]:
120
- """Return a shallow copy of all known sessions."""
158
+ Args:
159
+ user_id: If provided, only return sessions for this user (remote-hosted mode).
160
+ If None, return all sessions (local mode only).
161
+
162
+ Raises:
163
+ ValueError: If ``user_id`` is None while running in remote-hosted mode.
164
+ This prevents accidentally leaking sessions across users.
165
+ """
166
+ if user_id is None and config.http_remote_hosted:
167
+ raise ValueError(
168
+ "list_sessions requires user_id in remote-hosted mode"
169
+ )
121
170
 
122
171
  async with self._lock:
123
- return dict(self._sessions)
172
+ if user_id is None:
173
+ return dict(self._sessions)
174
+ else:
175
+ return {
176
+ sid: session
177
+ for sid, session in self._sessions.items()
178
+ if session.user_id == user_id
179
+ }
124
180
 
125
181
 
126
182
  __all__ = ["PluginRegistry", "PluginSession"]
@@ -9,6 +9,7 @@ import logging
9
9
 
10
10
  from fastmcp.server.middleware import Middleware, MiddlewareContext
11
11
 
12
+ from core.config import config
12
13
  from transport.plugin_hub import PluginHub
13
14
 
14
15
  logger = logging.getLogger("mcp-for-unity-server")
@@ -32,7 +33,12 @@ def get_unity_instance_middleware() -> 'UnityInstanceMiddleware':
32
33
 
33
34
 
34
35
  def set_unity_instance_middleware(middleware: 'UnityInstanceMiddleware') -> None:
35
- """Set the global Unity instance middleware (called during server initialization)."""
36
+ """Replace the global middleware instance.
37
+
38
+ This is a test seam: production code uses ``get_unity_instance_middleware()``
39
+ which lazy-initialises the singleton. Tests call this function to inject a
40
+ mock or pre-configured middleware before exercising tool/resource code.
41
+ """
36
42
  global _unity_instance_middleware
37
43
  _unity_instance_middleware = middleware
38
44
 
@@ -55,13 +61,18 @@ class UnityInstanceMiddleware(Middleware):
55
61
  Derive a stable key for the calling session.
56
62
 
57
63
  Prioritizes client_id for stability.
58
- If client_id is missing, falls back to 'global' (assuming single-user local mode),
59
- ignoring session_id which can be unstable in some transports/clients.
64
+ In remote-hosted mode, falls back to user_id for session isolation.
65
+ Otherwise falls back to 'global' (assuming single-user local mode).
60
66
  """
61
67
  client_id = getattr(ctx, "client_id", None)
62
68
  if isinstance(client_id, str) and client_id:
63
69
  return client_id
64
70
 
71
+ # In remote-hosted mode, use user_id so different users get isolated instance selections
72
+ user_id = ctx.get_state("user_id")
73
+ if isinstance(user_id, str) and user_id:
74
+ return f"user:{user_id}"
75
+
65
76
  # Fallback to global for local dev stability
66
77
  return "global"
67
78
 
@@ -92,10 +103,10 @@ class UnityInstanceMiddleware(Middleware):
92
103
  to stick for subsequent tool/resource calls in the same session.
93
104
  """
94
105
  try:
95
- # Import here to avoid circular dependencies / optional transport modules.
96
- from transport.unity_transport import _current_transport
97
-
98
- transport = _current_transport()
106
+ transport = (config.transport_mode or "stdio").lower()
107
+ # This implicit behavior works well for solo-users, but is dangerous for multi-user setups
108
+ if transport == "http" and config.http_remote_hosted:
109
+ return None
99
110
  if PluginHub.is_configured():
100
111
  try:
101
112
  sessions_data = await PluginHub.get_sessions()
@@ -172,10 +183,27 @@ class UnityInstanceMiddleware(Middleware):
172
183
 
173
184
  return None
174
185
 
186
+ async def _resolve_user_id(self) -> str | None:
187
+ """Extract user_id from the current HTTP request's API key."""
188
+ if not config.http_remote_hosted:
189
+ return None
190
+ # Lazy import to avoid circular dependencies (same pattern as _maybe_autoselect_instance).
191
+ from transport.unity_transport import _resolve_user_id_from_request
192
+ return await _resolve_user_id_from_request()
193
+
175
194
  async def _inject_unity_instance(self, context: MiddlewareContext) -> None:
176
- """Inject active Unity instance into context if available."""
195
+ """Inject active Unity instance and user_id into context if available."""
177
196
  ctx = context.fastmcp_context
178
197
 
198
+ # Resolve user_id from the HTTP request's API key header
199
+ user_id = await self._resolve_user_id()
200
+ if config.http_remote_hosted and user_id is None:
201
+ raise RuntimeError(
202
+ "API key authentication required. Provide a valid X-API-Key header."
203
+ )
204
+ if user_id:
205
+ ctx.set_state("user_id", user_id)
206
+
179
207
  active_instance = self.get_active_instance(ctx)
180
208
  if not active_instance:
181
209
  active_instance = await self._maybe_autoselect_instance(ctx)
@@ -193,7 +221,8 @@ class UnityInstanceMiddleware(Middleware):
193
221
  # resolving session_id might fail if the plugin disconnected
194
222
  # We only need session_id for HTTP transport routing.
195
223
  # For stdio, we just need the instance ID.
196
- session_id = await PluginHub._resolve_session_id(active_instance)
224
+ # Pass user_id for remote-hosted mode session isolation
225
+ session_id = await PluginHub._resolve_session_id(active_instance, user_id=user_id)
197
226
  except (ConnectionError, ValueError, KeyError, TimeoutError) as exc:
198
227
  # If resolution fails, it means the Unity instance is not reachable via HTTP/WS.
199
228
  # If we are in stdio mode, this might still be fine if the user is just setting state?