Skip to content

Add OAuth 2.1 to the MCP server#61

Merged
JanSchm merged 1 commit into
mainfrom
mcp-oauth-claude-desktop
Jun 9, 2026
Merged

Add OAuth 2.1 to the MCP server#61
JanSchm merged 1 commit into
mainfrom
mcp-oauth-claude-desktop

Conversation

@JanSchm

@JanSchm JanSchm commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

Adds OAuth 2.1 support to the MCP server (POST /api/v1/mcp/) so it can be connected from Claude Desktop (and other native OAuth MCP connectors), not just Claude Code. Existing bb_studio_ API keys keep working unchanged.

  • apps/oauth_server/ (new) — an OAuth 2.1 Authorization Server on django-oauth-toolkit: Dynamic Client Registration (RFC 7591, POST /oauth/register), RFC 8414/9728 discovery documents (/.well-known/oauth-authorization-server, /.well-known/oauth-protected-resource[/api/v1/mcp]), and an S256-only PKCE validator.
  • McpAuth (apps/api/auth.py) — attached only to the MCP router. A bb_studio_ token takes the existing ApiKeyAuth path verbatim; any other bearer is resolved as a DOT access token (by indexed token_checksum + is_valid(['mcp'])), mapped to the user's active workspace, and exposed to the handlers via an OAuthMcpActor shim that carries that user's own workspace permissions.
  • 401 + WWW-Authenticate on the MCP path (apps/api/api.py) — the challenge header that triggers Desktop's OAuth login.
  • AuditApiKeyAuditLog.api_key is now nullable, with actor_user/actor_label added so OAuth calls are attributed to the user (migration 0003).
  • Docs (README.md, .env.example) + tests.

Why?

The MCP server only accepted a static Authorization: Bearer bb_studio_… key and returned a bare 401. Claude Code can inject a static header (claude mcp add --header …), so it connected; Claude Desktop's remote-connector flow has no static-token field — it reads .well-known discovery metadata and runs a browser OAuth login, kicked off by a 401 + WWW-Authenticate. Studio emitted neither.

Both clients already speak Streamable HTTP, so the gap was authentication, not transport. This mirrors the approach already proven in the sibling social-intelligence-app repo.

Access model: any Studio user can connect; they act only with their own WorkspaceMembership permissions (read-only roles get the read tools; create/schedule/upload require the matching permission), operating on their last-active workspace.

How to test

pip install -r requirements.txt
python manage.py migrate
pytest apps/oauth_server apps/mcp apps/api      # 184 passed
ruff check . && ruff format --check .

Live (with the dev server running):

  • GET /.well-known/oauth-authorization-server and /.well-known/oauth-protected-resource/api/v1/mcp → 200 JSON.
  • POST /api/v1/mcp/ with no auth → 401 carrying WWW-Authenticate: Bearer resource_metadata="…".
  • POST /api/v1/mcp/ with Authorization: Bearer bb_studio_…200 (no regression).
  • POST /oauth/register with an https redirect_uris201 (public client).

End-to-end (needs Studio at a public https URL): Claude Desktop → Settings → Connectors → Add custom connector → https://<studio>/api/v1/mcp → browser login → tools appear.

Reviewer notes

  • OAuthMcpActor (apps/api/auth.py) duck-types the ApiKey surface the handlers read (no DB row). apps/mcp/tests/test_oauth_auth.py includes a drift sentinel that drives every registered tool over the OAuth path so a future handler reading an un-shimmed attribute fails CI.
  • The new AuthenticationError handler normalizes the 401 body for the whole Agent API (REST included) from Ninja's default {"detail": "Unauthorized"} to {"error": "unauthorized", "detail": "…"} (matches the existing HttpError envelope). The WWW-Authenticate header is MCP-path-scoped only.
  • MCP_PUBLIC_BASE_URL / MCP_OAUTH_ISSUER_URL default to APP_URL (Studio is single-host); override only for a split app/api deployment.
  • Adds the django-oauth-toolkit>=3.0,<4.0 dependency.

Checklist

  • Tests pass (pytest) — 184 passed
  • Lint passes (ruff check . and ruff format --check .)
  • Documentation updated (README + .env.example)

The /api/v1/mcp endpoint previously accepted only a static bb_studio_ API
key and returned a bare 401. Claude Code can inject a static header, so it
connected fine; Claude Desktop's remote connector needs an OAuth login flow
(it has no static-token field), so it could not. Both clients already speak
Streamable HTTP — the gap was authentication, not transport.

This adds an OAuth 2.1 Authorization Server (django-oauth-toolkit) and makes
the MCP endpoint accept OAuth tokens alongside the existing API keys:

- apps/oauth_server: Dynamic Client Registration (RFC 7591), RFC 8414/9728
  discovery metadata, and an S256-only PKCE validator.
- McpAuth (apps/api/auth.py): bb_studio_ tokens take the existing key path
  unchanged; any other bearer resolves a DOT access token, maps the user to
  their active workspace, and runs the handlers via an OAuthMcpActor shim
  carrying that user's own permissions.
- A 401 + WWW-Authenticate challenge on the MCP path to start the OAuth flow.
- ApiKeyAuditLog gains a nullable api_key plus actor_user/actor_label so
  OAuth calls are audited against the user.

Any Studio user can connect; they act only with their own workspace
permissions (read-only roles get read tools; writes require the matching
permission). Mirrors social-intelligence-app's OAuth server.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@JanSchm JanSchm changed the title Add OAuth 2.1 to the MCP server so Claude Desktop can connect Add OAuth 2.1 to the MCP server Jun 9, 2026
@JanSchm JanSchm merged commit f9e7840 into main Jun 9, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant