16 KiB
Remote Server Auth: Architecture
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.
Overview
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"} | |
|<--------------------------| |
Components
ApiKeyService
File: Server/src/services/api_key_service.py
Singleton service that validates API keys against an external HTTP endpoint.
- Singleton access:
ApiKeyService.get_instance()/ApiKeyService.is_initialized() - Initialization: Constructed in
create_mcp_server()whenconfig.http_remote_hostedandconfig.api_key_validation_urlare both set. - Validation:
async validate(api_key) -> ValidationResult - Caching: In-memory dict keyed by raw API key. Entries store
(valid, user_id, metadata, expires_at). - Retry: 1 retry with 100ms backoff on timeouts and connection errors.
- Fail-closed: Any unrecoverable error returns
ValidationResult(valid=False).
PluginHub (WebSocket Auth Gate)
File: Server/src/transport/plugin_hub.py
The on_connect method validates the API key from the WebSocket handshake headers before accepting the connection.
- Reads
X-API-Keyfromwebsocket.headers - Validates via
ApiKeyService.validate() - Stores
user_idandapi_key_metadataonwebsocket.statefor use during registration - Rejects with close codes:
4401(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.
PluginRegistry (Dual-Index Session Storage)
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. RaisesValueErrorifuser_idisNonewhileconfig.http_remote_hostedisTrue, preventing accidental cross-user leaks.
UnityInstanceMiddleware
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:
- Calls
_resolve_user_id()to extract the user identity from the HTTP request. - If remote-hosted mode is active and no
user_idis resolved, raisesRuntimeError(surfaces as MCP error). - Sets
ctx.set_state("user_id", user_id). - Looks up or auto-selects the active Unity instance.
- Sets
ctx.set_state("unity_instance", active_instance).
_resolve_user_id_from_request
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).
Request Lifecycle
A complete authenticated MCP tool call follows this path:
-
HTTP request arrives at
/mcpwithX-API-Key: <key>header. -
FastMCP dispatches the MCP tool call through its middleware chain.
-
UnityInstanceMiddleware.on_call_toolis invoked. -
_inject_unity_instanceruns:- Calls
_resolve_user_id(), which calls_resolve_user_id_from_request(). - The request function imports
get_http_headersfrom FastMCP and reads thex-api-keyheader. ApiKeyService.validate()checks the cache or calls the external auth endpoint.- If valid,
user_idis returned. If invalid or missing,Noneis returned. - In remote-hosted mode,
Nonecauses aRuntimeError.
- Calls
-
user_idstored in context viactx.set_state("user_id", user_id). -
Session key derived by
get_session_key(ctx):- Priority:
client_id(if available) >user:{user_id}>"global". - The
user:{user_id}fallback ensures session isolation when MCP transports don't provide stable client IDs.
- Priority:
-
Active Unity instance looked up from
_active_by_keydict using the session key. If none is set,_maybe_autoselect_instanceis called (but returnsNonein 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 usingPluginRegistry.get_session_id_by_hash(project_hash, user_id).
WebSocket Auth Flow
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().
Session Registry Design
Local Mode
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.
Remote-Hosted Mode
(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.
Reconnect Handling
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 Guard
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.
Caching Strategy
ApiKeyService maintains an in-memory cache:
# api_key -> (valid, user_id, metadata, expires_at)
_cache: dict[str, tuple[bool, str | None, dict | None, float]]
What Gets Cached
| 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).
Cache Lifecycle
- TTL: Configurable via
--api-key-cache-ttl(default: 300 seconds). - Expiry: Checked on read. Expired entries are deleted and re-validated.
- Invalidation:
invalidate_cache(api_key)removes a single key.clear_cache()removes all. - Concurrency: Protected by
asyncio.Lock.
Revocation Latency
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.
Fail-Closed Behaviour
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.
Session Key Derivation
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 raisesRuntimeErrorbefore this point if nouser_idis available.
Disabled Features in Remote-Hosted Mode
| 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 |
Configuration Flow
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)
Key Files
| 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 |