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
@@ -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)
@@ -186,14 +214,16 @@ class UnityInstanceMiddleware(Middleware):
186
214
  # The 'active_instance' (Name@hash) might be valid for stdio even if PluginHub fails.
187
215
 
188
216
  session_id: str | None = None
189
- # Only validate via PluginHub if we are actually using HTTP transport
190
- # OR if we want to support hybrid mode. For now, let's be permissive.
191
- if PluginHub.is_configured():
217
+ # Only validate via PluginHub if we are actually using HTTP transport.
218
+ # For stdio transport, skip PluginHub entirely - we only need the instance ID.
219
+ from transport.unity_transport import _is_http_transport
220
+ if _is_http_transport() and PluginHub.is_configured():
192
221
  try:
193
222
  # resolving session_id might fail if the plugin disconnected
194
223
  # We only need session_id for HTTP transport routing.
195
224
  # For stdio, we just need the instance ID.
196
- session_id = await PluginHub._resolve_session_id(active_instance)
225
+ # Pass user_id for remote-hosted mode session isolation
226
+ session_id = await PluginHub._resolve_session_id(active_instance, user_id=user_id)
197
227
  except (ConnectionError, ValueError, KeyError, TimeoutError) as exc:
198
228
  # If resolution fails, it means the Unity instance is not reachable via HTTP/WS.
199
229
  # If we are in stdio mode, this might still be fine if the user is just setting state?
@@ -1,34 +1,49 @@
1
1
  """Transport helpers for routing commands to Unity."""
2
2
  from __future__ import annotations
3
3
 
4
- import asyncio
5
- import inspect
6
- import os
4
+ import logging
7
5
  from typing import Awaitable, Callable, TypeVar
8
6
 
9
- from fastmcp import Context
10
-
11
7
  from transport.plugin_hub import PluginHub
8
+ from core.config import config
9
+ from core.constants import API_KEY_HEADER
10
+ from services.api_key_service import ApiKeyService
12
11
  from models.models import MCPResponse
13
12
  from models.unity_response import normalize_unity_response
14
- from services.tools import get_unity_instance_from_context
15
13
 
14
+ logger = logging.getLogger(__name__)
16
15
  T = TypeVar("T")
17
16
 
18
17
 
19
18
  def _is_http_transport() -> bool:
20
- return os.environ.get("UNITY_MCP_TRANSPORT", "stdio").lower() == "http"
19
+ return config.transport_mode.lower() == "http"
21
20
 
22
21
 
23
- def _current_transport() -> str:
24
- """Expose the active transport mode as a simple string identifier."""
25
- return "http" if _is_http_transport() else "stdio"
22
+ async def _resolve_user_id_from_request() -> str | None:
23
+ """Extract user_id from the current HTTP request's API key header."""
24
+ if not config.http_remote_hosted:
25
+ return None
26
+ if not ApiKeyService.is_initialized():
27
+ return None
28
+ try:
29
+ from fastmcp.server.dependencies import get_http_headers
30
+ headers = get_http_headers(include_all=True)
31
+ api_key = headers.get(API_KEY_HEADER.lower())
32
+ if not api_key:
33
+ return None
34
+ service = ApiKeyService.get_instance()
35
+ result = await service.validate(api_key)
36
+ return result.user_id if result.valid else None
37
+ except Exception as e:
38
+ logger.debug("Failed to resolve user_id from HTTP request: %s", e)
39
+ return None
26
40
 
27
41
 
28
42
  async def send_with_unity_instance(
29
43
  send_fn: Callable[..., Awaitable[T]],
30
44
  unity_instance: str | None,
31
45
  *args,
46
+ user_id: str | None = None,
32
47
  **kwargs,
33
48
  ) -> T:
34
49
  if _is_http_transport():
@@ -41,11 +56,27 @@ async def send_with_unity_instance(
41
56
  if not isinstance(params, dict):
42
57
  raise TypeError(
43
58
  "Command parameters must be a dict for HTTP transport")
59
+
60
+ # Auto-resolve user_id from HTTP request API key (remote-hosted mode)
61
+ if user_id is None:
62
+ user_id = await _resolve_user_id_from_request()
63
+
64
+ # Auth check
65
+ if config.http_remote_hosted and not user_id:
66
+ return normalize_unity_response(
67
+ MCPResponse(
68
+ success=False,
69
+ error="auth_required",
70
+ message="API key required",
71
+ ).model_dump()
72
+ )
73
+
44
74
  try:
45
75
  raw = await PluginHub.send_command_for_instance(
46
76
  unity_instance,
47
77
  command_type,
48
78
  params,
79
+ user_id=user_id,
49
80
  )
50
81
  return normalize_unity_response(raw)
51
82
  except Exception as exc: