docs/reference/REMOTE_SERVER_AUTH_ARCHITECTURE.md
This document describes the internal design of the API key authentication system used when the MCP for Unity server runs in remote-hosted mode. It is intended for contributors and maintainers.
MCP Client MCP Server External Auth
(Cursor, etc.) (Python) Service
| | |
| X-API-Key: abc123 | |
| POST /mcp (tool call) | |
|-------------------------->| |
| | |
| UnityInstanceMiddleware.on_call_tool |
| | |
| _resolve_user_id() |
| | |
| | POST /validate |
| | {"api_key": "abc123"} |
| |------------------------------>|
| | |
| | {"valid":true, |
| | "user_id":"user-42"} |
| |<------------------------------|
| | |
| Cache result (TTL) |
| | |
| ctx.set_state("user_id", "user-42") |
| ctx.set_state("unity_instance", "Proj@hash") |
| | |
| PluginHub.send_command_for_instance |
| (user_id scoped session lookup) |
| | |
| Tool result | |
|<--------------------------| |
Unity Plugin MCP Server External Auth
(C# WebSocket) (Python) Service
| | |
| WS /hub/plugin | |
| X-API-Key: abc123 | |
|-------------------------->| |
| | |
| PluginHub.on_connect |
| | POST /validate |
| |------------------------------>|
| | {"valid":true, ...} |
| |<------------------------------|
| | |
| accept() | |
| websocket.state.user_id = "user-42" |
|<--------------------------| |
| | |
| {"type":"register", ...} | |
|-------------------------->| |
| | |
| PluginRegistry.register( |
| ..., user_id="user-42") |
| _user_hash_to_session[("user-42","hash")] = sid |
| | |
| {"type":"registered"} | |
|<--------------------------| |
File: Server/src/services/api_key_service.py
Singleton service that validates API keys against an external HTTP endpoint.
ApiKeyService.get_instance() / ApiKeyService.is_initialized()create_mcp_server() when config.http_remote_hosted and config.api_key_validation_url are both set.async validate(api_key) -> ValidationResult(valid, user_id, metadata, expires_at).ValidationResult(valid=False).File: Server/src/transport/plugin_hub.py
The on_connect method validates the API key from the WebSocket handshake headers before accepting the connection.
X-API-Key from websocket.headersApiKeyService.validate()user_id and api_key_metadata on websocket.state for use during registration4401 (missing), 4403 (invalid), 1013 (service unavailable)The _handle_register method reads websocket.state.user_id and passes it to PluginRegistry.register().
The get_sessions(user_id=None) and _resolve_session_id(unity_instance, user_id=None) methods accept an optional user_id to scope session queries in remote-hosted mode.
File: Server/src/transport/plugin_registry.py
In-memory registry of connected Unity plugin sessions. Maintains two parallel index maps:
| Index | Key | Used In |
|---|---|---|
_hash_to_session | project_hash -> session_id | Local mode |
_user_hash_to_session | (user_id, project_hash) -> session_id | Remote-hosted mode |
Both indexes are updated during register() and cleaned up during unregister().
Key methods:
register(session_id, project_name, project_hash, unity_version, user_id=None) - Registers a session and updates the appropriate index. If an existing session claims the same key, it is evicted.get_session_id_by_hash(project_hash) - Local-mode lookup.get_session_id_by_hash(project_hash, user_id) - Remote-mode lookup.list_sessions(user_id=None) - Returns sessions filtered by user. Raises ValueError if user_id is None while config.http_remote_hosted is True, preventing accidental cross-user leaks.File: Server/src/transport/unity_instance_middleware.py
FastMCP middleware that intercepts all tool and resource calls to inject the active Unity instance and user identity into the request-scoped context.
Entry points:
on_call_tool(context, call_next) - Intercepts tool calls.on_read_resource(context, call_next) - Intercepts resource reads.Both delegate to _inject_unity_instance(context), which:
_resolve_user_id() to extract the user identity from the HTTP request.user_id is resolved, raises RuntimeError (surfaces as MCP error).ctx.set_state("user_id", user_id).ctx.set_state("unity_instance", active_instance).File: Server/src/transport/unity_transport.py
Extracts the user_id from the current HTTP request's X-API-Key header.
_resolve_user_id_from_request()
-> if not config.http_remote_hosted: return None
-> if not ApiKeyService.is_initialized(): return None
-> get_http_headers() from FastMCP dependencies
-> extract "x-api-key" header
-> ApiKeyService.validate(api_key)
-> return result.user_id if valid, else None
The middleware calls this indirectly through _resolve_user_id(), which adds an early return when not in remote-hosted mode (avoiding the import of FastMCP internals in local mode).
A complete authenticated MCP tool call follows this path:
HTTP request arrives at /mcp with X-API-Key: <key> header.
FastMCP dispatches the MCP tool call through its middleware chain.
UnityInstanceMiddleware.on_call_tool is invoked.
_inject_unity_instance runs:
_resolve_user_id(), which calls _resolve_user_id_from_request().get_http_headers from FastMCP and reads the x-api-key header.ApiKeyService.validate() checks the cache or calls the external auth endpoint.user_id is returned. If invalid or missing, None is returned.None causes a RuntimeError.user_id stored in context via ctx.set_state("user_id", user_id).
Session key derived by get_session_key(ctx):
client_id (if available) > user:{user_id} > "global".user:{user_id} fallback ensures session isolation when MCP transports don't provide stable client IDs.Active Unity instance looked up from _active_by_key dict using the session key. If none is set, _maybe_autoselect_instance is called (but returns None in remote-hosted mode).
Instance injected via ctx.set_state("unity_instance", active_instance).
Tool executes, reading the instance from ctx.get_state("unity_instance").
Command routed through PluginHub.send_command_for_instance(unity_instance, ..., user_id=user_id), which resolves the session using PluginRegistry.get_session_id_by_hash(project_hash, user_id).
When a Unity plugin connects via WebSocket:
Plugin -> WS /hub/plugin (with X-API-Key header)
|
v
PluginHub.on_connect()
|
+-- config.http_remote_hosted && ApiKeyService.is_initialized()?
| |
| +-- No -> accept() (local mode, no auth needed)
| |
| +-- Yes -> read X-API-Key from headers
| |
| +-- No key -> close(4401, "API key required")
| |
| +-- ApiKeyService.validate(key)
| |
| +-- valid=True -> websocket.state.user_id = user_id
| | accept()
| |
| +-- valid=False, "unavailable" in error
| | -> close(1013, "Try again later")
| |
| +-- valid=False -> close(4403, "Invalid API key")
After acceptance, when the plugin sends a register message, _handle_register reads websocket.state.user_id and passes it to PluginRegistry.register().
project_hash -> session_id
"abc123" -> "uuid-1"
"def456" -> "uuid-2"
A single _hash_to_session dict. Any user can see any session. list_sessions(user_id=None) returns all sessions.
(user_id, project_hash) -> session_id
("user-A", "abc123") -> "uuid-1"
("user-B", "abc123") -> "uuid-3" (same project, different user)
("user-A", "def456") -> "uuid-2"
A separate _user_hash_to_session dict with composite keys. Two users working on cloned repos (same project_hash) get independent sessions.
When a Unity editor reconnects (e.g., after domain reload), register() detects the existing mapping for the same key and evicts the old session before inserting the new one. This ensures the latest WebSocket connection always wins.
list_sessions(user_id=None) raises ValueError when config.http_remote_hosted is True. This prevents code paths from accidentally listing all users' sessions. Every call site in remote-hosted mode must pass an explicit user_id.
ApiKeyService maintains an in-memory cache:
# api_key -> (valid, user_id, metadata, expires_at)
_cache: dict[str, tuple[bool, str | None, dict | None, float]]
| Response | Cached? | Rationale |
|---|---|---|
200 + valid: true | Yes | Definitive valid result |
200 + valid: false | Yes | Definitive invalid result |
| 401 status | Yes | Definitive rejection |
| 5xx status | No | Transient; retry on next request |
| Timeout | No | Transient; retry on next request |
| Connection error | No | Transient; retry on next request |
| Unexpected exception | No | Transient; retry on next request |
Non-cacheable results use ValidationResult(cacheable=False).
--api-key-cache-ttl (default: 300 seconds).invalidate_cache(api_key) removes a single key. clear_cache() removes all.asyncio.Lock.A revoked key continues to work for up to cache_ttl seconds. Lower the TTL for faster revocation at the cost of more validation requests.
The system fails closed at every boundary:
| Component | Failure | Behaviour |
|---|---|---|
ApiKeyService._validate_external | Timeout after retries | valid=False, cacheable=False |
ApiKeyService._validate_external | Connection error after retries | valid=False, cacheable=False |
ApiKeyService._validate_external | 5xx status | valid=False, cacheable=False |
ApiKeyService._validate_external | Unexpected exception | valid=False, cacheable=False |
PluginHub.on_connect | Auth service unavailable | Close 1013 (retry hint) |
UnityInstanceMiddleware._inject_unity_instance | No user_id in remote-hosted mode | RuntimeError |
API keys are never logged in full. Keys longer than 8 characters are redacted to xxxx...yyyy in log messages.
UnityInstanceMiddleware.get_session_key(ctx) determines which dict key to use for storing/retrieving the active Unity instance per session:
1. client_id (string, non-empty) -> return client_id
2. ctx.get_state("user_id") -> return "user:{user_id}"
3. fallback -> return "global"
client_id: Stable per MCP client connection. Preferred when available.user:{user_id}: Used in remote-hosted mode when the MCP transport doesn't provide a stable client ID. Ensures different users don't share instance selections."global": Local-dev fallback for single-user scenarios. Unreachable in remote-hosted mode because the auth enforcement raises RuntimeError before this point if no user_id is available.| Feature | Local Mode | Remote-Hosted Mode | Reason |
|---|---|---|---|
| Auto-select sole instance | Enabled | Disabled | Implicit behaviour is dangerous with multiple users |
| CLI REST routes | Enabled | Disabled | No auth layer on these endpoints |
list_sessions(user_id=None) | Returns all | Raises ValueError | Prevents accidental cross-user session leaks |
CLI args / env vars
|
v
main.py: parser.parse_args()
|
+-- config.http_remote_hosted = args or env
+-- config.api_key_validation_url = args or env
+-- config.api_key_login_url = args or env
+-- config.api_key_cache_ttl = args or env (float)
+-- config.api_key_service_token_header = args or env
+-- config.api_key_service_token = args or env
|
+-- Validate: remote-hosted requires validation URL
| (exits with code 1 if missing)
|
v
create_mcp_server()
|
+-- get_unity_instance_middleware() -> registers middleware
|
+-- if remote-hosted + validation URL:
| ApiKeyService(
| validation_url, cache_ttl,
| service_token_header, service_token
| )
|
+-- WebSocketRoute("/hub/plugin", PluginHub)
|
+-- if not remote-hosted:
register CLI routes (/api/command, /api/instances, /api/custom-tools)
| File | Role |
|---|---|
Server/src/core/config.py | ServerConfig dataclass with auth fields |
Server/src/main.py | CLI argument parsing, startup validation, service initialization |
Server/src/services/api_key_service.py | API key validation singleton with caching and retry |
Server/src/transport/plugin_hub.py | WebSocket auth gate, user-scoped session queries |
Server/src/transport/plugin_registry.py | Dual-index session storage (local + user-scoped) |
Server/src/transport/unity_instance_middleware.py | Per-request user_id and instance injection |
Server/src/transport/unity_transport.py | _resolve_user_id_from_request helper |