# 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()` when `config.http_remote_hosted` and `config.api_key_validation_url` are 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-Key` from `websocket.headers` - Validates via `ApiKeyService.validate()` - Stores `user_id` and `api_key_metadata` on `websocket.state` for 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. Raises `ValueError` if `user_id` is `None` while `config.http_remote_hosted` is `True`, 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: 1. Calls `_resolve_user_id()` to extract the user identity from the HTTP request. 2. If remote-hosted mode is active and no `user_id` is resolved, raises `RuntimeError` (surfaces as MCP error). 3. Sets `ctx.set_state("user_id", user_id)`. 4. Looks up or auto-selects the active Unity instance. 5. 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: 1. **HTTP request arrives** at `/mcp` with `X-API-Key: ` header. 2. **FastMCP dispatches** the MCP tool call through its middleware chain. 3. **`UnityInstanceMiddleware.on_call_tool`** is invoked. 4. **`_inject_unity_instance`** runs: - Calls `_resolve_user_id()`, which calls `_resolve_user_id_from_request()`. - The request function imports `get_http_headers` from FastMCP and reads the `x-api-key` header. - `ApiKeyService.validate()` checks the cache or calls the external auth endpoint. - If valid, `user_id` is returned. If invalid or missing, `None` is returned. - In remote-hosted mode, `None` causes a `RuntimeError`. 5. **`user_id` stored in context** via `ctx.set_state("user_id", user_id)`. 6. **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. 7. **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). 8. **Instance injected** via `ctx.set_state("unity_instance", active_instance)`. 9. **Tool executes**, reading the instance from `ctx.get_state("unity_instance")`. 10. **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)`. ## 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: ```python # 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 raises `RuntimeError` before this point if no `user_id` is 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 |