Skip to content

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.

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) → UserMessageBus

Each 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.

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.

Once decoded, the user_id lands in a Python ContextVar:

from contextvars import ContextVar
current_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.

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.

A blender_send_message call from user A operates exclusively on A’s bus. Concrete consequences:

  • Client visibility: blender_list_available_clients returns 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. If blender-X is on a different user’s bus, you get unknown_target.
  • Job correlation: _pending_jobs is keyed by (user_id, job_id). Even if you guess another user’s job_id, calling blender_job_update returns {"status": "error", "error": "cross_user_job_update"} — the same-user check fires before any side effect.
  • Resources: blender://bus/clients and blender://bus/stats return your bus’s state only.
  • 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_dispatch payload runs as long as the addon takes to finish. The MCP tool’s _timeout only bounds how long the server-side JobWaiter waits 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_client are advisory metadata, not enforcement.

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.