Skip to content

OAuth and per-user buses

The server holds one UserMessageBus per user. Authentication maps each MCP request to a user_id; the bus tools resolve their target bus from that. Users can’t see each other.

POST /auth/login accepts {username, password} and returns:

{
"access_token": "eyJhbGciOi...",
"refresh_token": "eyJhbGciOi...",
"token_type": "Bearer",
"expires_in": 3600,
"user": {"username": "demo", "id": "demo"}
}

The access token is signed with JWT_SECRET (HS256 by default). Its claims include sub (the user id), exp, and iat. The refresh token has a longer TTL and is exchanged via POST /auth/refresh.

The addon’s auth/login.py is a pure-requests implementation — no bpy imports, runnable outside Blender, which is what makes the auth flow testable without a Blender process.

addon/auth/login.py
def login(server_url, username, password, *, timeout=10.0):
url = f"{_auth_base(server_url)}/auth/login"
resp = requests.post(url, json={"username": username, "password": password}, timeout=timeout)
if resp.status_code != 200:
raise LoginError(_detail(resp), status_code=resp.status_code)
return resp.json()

_auth_base() strips trailing /mcp from the panel’s stored URL — the panel holds the MCP endpoint, but /auth/* lives at the FastAPI root.

bus_tools.py declares:

from contextvars import ContextVar
current_user_id: ContextVar[Optional[str]] = ContextVar("current_user_id", default=None)

The FastAPI JWT middleware runs before MCP dispatch. For each request:

  1. Read Authorization: Bearer <token>.
  2. Decode + verify the JWT.
  3. current_user_id.set(claims["sub"]).

Every bus tool resolves the user identity by reading current_user_id.get():

def _resolve_user_id(ctx: Optional[Context]) -> Optional[str]:
uid = current_user_id.get()
if uid:
return uid
if ctx is None:
return None
try:
req = ctx.get_http_request()
return getattr(req.state, "user_id", None) if req else None
except Exception:
return None

The fallback to ctx.get_http_request().state.user_id covers the case where middleware uses request-state assignment instead of (or alongside) ContextVar.

ContextVar works correctly with asyncio — each request gets its own copy, so concurrent requests don’t see each other’s user. This is the whole reason it was chosen over a module-level global.

bus_manager.get_bus(user_id) lazy-creates a UserMessageBus per user. The buses share nothing — separate client registries, separate last_activity clocks, separate everything.

A blender_send_message call from user A operates exclusively on A’s bus. A’s clients can’t see B’s clients. There’s no “cross-bus” routing primitive at all.

When send_message records a dispatched job, it stores both the user and the originator:

# bus_tools.py send_message
_pending_jobs[tracking_id] = (user_id, origin)

When a job_update comes in, the tool resolves the calling user’s id, looks up the entry, and enforces:

owner_user, originator_uuid = entry
if owner_user != user_id:
return json.dumps({"status": "error", "error": "cross_user_job_update"})

This is the defense against a malicious client guessing another user’s job_id and sending a fake completion. The originator’s JWT-resolved user id must match the user id recorded at dispatch time.

_pending_jobs itself is a module-level dict shared across users. It’s keyed by job_id, which clients generate. The same-user check is what makes the shared dict safe — anyone can store a tuple, only the originating user can read or write a reply.

The Login operator (addon/ui/operators.py) calls addon.auth.login.login(...), stores the result in AddonPreferences.jwt_token and jwt_expires_at, then clears Scene.blendermcp_password_tmp. The token is held in prefs (per-user, in userpref.blend) — never in the scene file.

A periodic refresh via addon.auth.login.refresh_token keeps the access token valid for long-running sessions. The refresh token has a 30-day TTL; after that, the user has to log in again.

The server refuses to start without an ADMIN_PASSWORD. There’s no built-in default — the previous "SecureAdmin123!" placeholder was removed because it shipped in this public repo. Two layers enforce it:

  1. Module load in oauth_server.py — raises RuntimeError on import if os.getenv("ADMIN_PASSWORD") is empty. Catches uv run blender-mcp invocations that bypass compose.
  2. Compose${ADMIN_PASSWORD:?} in docker-compose.yml fails the stack at up time if the env var is missing.

The demo user is opt-in: only created when DEMO_PASSWORD is explicitly set. Production deployments leave it unset so no demo account exists. Local dev sets it in .env (or via make dev) when running the quickstart.

OAUTH_SECRET_KEY (the JWT signing key) is generated via make secret-gen — idempotent, only writes if blank, never overwrites an existing key. Rotating it invalidates every issued token.

  • No per-user quotas. A user with valid credentials can register unlimited clients on their bus.
  • No per-job timeouts. A job_dispatch payload runs as long as the addon takes to finish.
  • No capability authorization. Any client on a user’s bus can dispatch to any other client on that bus. Capabilities are advisory, not enforced. See Use capabilities.