OAuth and per-user buses
A BlenderMCP server holds one UserMessageBus per authenticated user. Every authorized request resolves to a user_id, and every bus operation is implicitly scoped to that user’s bus. Two users can hit the same physical server and never see each other — different buses, different client lists, different _pending_jobs namespaces.
The identity chain
Section titled “The identity chain”OAuth access token ──┐ │ server-side ▼ JWT verification ← Authentik (prod) or in-memory provider (dev) │ ▼ subject claim → user_id │ ▼ ContextVar(current_user_id) │ ▼ bus_manager.get_bus(user_id) → UserMessageBusEach step is invisible from the consumer’s perspective — you send a Bearer token, the server picks the right bus. But the chain matters for understanding what isolation guarantees you actually get.
Step 1 — Token → user_id
Section titled “Step 1 — Token → user_id”The server’s auth pipeline validates the Authorization: Bearer <token> header on every request to /mcp/. Production servers use Authentik via FastMCP’s OIDCProxy; the access token is an RS256 JWT signed by Authentik, and the sub claim becomes the user_id. Dev servers use an in-memory provider that issues opaque tokens backed by a local USERS dict; same outcome, different signing material.
Consumers don’t need to know which backend is running — the discovery URLs are identical, and the token is opaque from your side.
Step 2 — user_id → ContextVar
Section titled “Step 2 — user_id → ContextVar”Once decoded, the user_id lands in a Python ContextVar:
from contextvars import ContextVarcurrent_user_id: ContextVar[str | None] = ContextVar("current_user_id", default=None)ContextVar is asyncio-aware — each request gets its own copy, so concurrent requests with different tokens don’t see each other’s user. This is the whole reason a ContextVar is used instead of a module-level global.
Step 3 — ContextVar → UserMessageBus
Section titled “Step 3 — ContextVar → UserMessageBus”Every bus tool starts with the same line:
user_id = _resolve_user_id(ctx)if not user_id: return json.dumps({"status": "error", "error": "unauthenticated"})
bus = bus_manager.get_bus(user_id)bus_manager.get_bus(user_id) lazy-creates a UserMessageBus per user. The buses share nothing — separate client registries, separate last_activity clocks, separate _pending_jobs namespaces, separate everything. There’s no “cross-bus” routing primitive.
What isolation actually guarantees
Section titled “What isolation actually guarantees”A blender_send_message call from user A operates exclusively on A’s bus. Concrete consequences:
- Client visibility:
blender_list_available_clientsreturns only your registered clients. A’s Blender peers don’t show up in B’s list. - Routing:
target_uuid="blender-X"resolves against your bus only. Ifblender-Xis on a different user’s bus, you getunknown_target. - Job correlation:
_pending_jobsis keyed by(user_id, job_id). Even if you guess another user’sjob_id, callingblender_job_updatereturns{"status": "error", "error": "cross_user_job_update"}— the same-user check fires before any side effect. - Resources:
blender://bus/clientsandblender://bus/statsreturn your bus’s state only.
What isolation does NOT guarantee
Section titled “What isolation does NOT guarantee”- No per-user quotas. A single authenticated user can register an unlimited number of clients on their bus.
- No per-job timeout enforced on the addon side. A
job_dispatchpayload runs as long as the addon takes to finish. The MCP tool’s_timeoutonly bounds how long the server-sideJobWaiterwaits before returning{"status": "timeout"}— the addon may keep running. - No capability authorization. Any client on a user’s bus can dispatch to any other client on that bus. Capabilities declared in
register_clientare advisory metadata, not enforcement.
Why this design
Section titled “Why this design”OAuth-derived identity gives consumers a clean mental model: the same credentials that authenticated you also scope everything you do. No separate “API key for the bus.” No “tenant ID” parameter on every call. The same token resolves consistently across all bus operations, and the server enforces isolation centrally rather than at every call site.
The cost is that the server’s bus state lives in memory, partitioned by user_id. Restarting the server drops every user’s _pending_jobs and disconnects every persistent client. For a clustered deployment, the in-process dict needs to become a shared store (Redis, etc.) — and the same-user check needs to move with it. Single-instance deployment is the current target; the in-memory model is intentional, not vestigial.
Related
Section titled “Related”- Authentication reference — the OAuth 2.1 + PKCE wire details
- Bus tools — every tool resolves
current_user_id - Architecture — where the middleware sits in the request lifecycle
- ClientInfo — what’s in your bus’s client list