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.
JWT issuance
Section titled “JWT issuance”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.
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.
ContextVar mechanics
Section titled “ContextVar mechanics”bus_tools.py declares:
from contextvars import ContextVarcurrent_user_id: ContextVar[Optional[str]] = ContextVar("current_user_id", default=None)The FastAPI JWT middleware runs before MCP dispatch. For each request:
- Read
Authorization: Bearer <token>. - Decode + verify the JWT.
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 NoneThe 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.
UserMessageBus isolation
Section titled “UserMessageBus isolation”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.
_pending_jobs — the same-user check
Section titled “_pending_jobs — the same-user check”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 = entryif 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.
Token lifecycle in the addon
Section titled “Token lifecycle in the addon”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.
Production defaults — hardened
Section titled “Production defaults — hardened”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:
- Module load in
oauth_server.py— raisesRuntimeErroron import ifos.getenv("ADMIN_PASSWORD")is empty. Catchesuv run blender-mcpinvocations that bypass compose. - Compose —
${ADMIN_PASSWORD:?}indocker-compose.ymlfails the stack atuptime 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.
What’s NOT enforced
Section titled “What’s NOT enforced”- No per-user quotas. A user with valid credentials can register unlimited clients on their bus.
- No per-job timeouts. A
job_dispatchpayload 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.
Related
Section titled “Related”- Architecture — where the middleware sits
- Bus tools — every tool reads
current_user_id - Deploy with Docker + Caddy —
JWT_SECRETand TLS in production