Skip to content

feat(mcp): per-user OAuth for HTTP MCP connections - #3013

Open
r0h1tb wants to merge 6 commits into
Chainlit:mainfrom
r0h1tb:feat/mcp-oauth-per-user-tokens
Open

r0h1tb wants to merge 6 commits into
Chainlit:mainfrom
r0h1tb:feat/mcp-oauth-per-user-tokens

Conversation

@r0h1tb

@r0h1tb r0h1tb commented Aug 19, 2026 •

Copy link
Copy Markdown

Part of #2197. Opt-in, additive, and safe to land on its own.

Problem

#2292 shipped the static-credential half of #2197: headers on SSE and Streamable HTTP cover any server where the user already holds a long-lived token. The authorization flow — Chainlit obtaining a token on the user's behalf — is still missing.

Discovery, dynamic client registration and PKCE are already solved by the MCP SDK's OAuthClientProvider, so this does not reimplement them. What the SDK cannot solve is the part that only exists on a multi-user server:

class TokenStorage(Protocol):
    async def get_tokens(self) -> OAuthToken | None: ...

get_tokens() takes no arguments. The SDK assumes one storage per authorization context, which holds for the single-user desktop clients it targets. Chainlit serves many users from one process, so a storage shared across requests hands one user's access token to the next caller.

Fix

chainlit/mcp_oauth.py:

  • McpOAuthTokenStore keys tokens by (user identifier, server) and only exposes them through scoped(user, server), which fixes both halves of the key up front. The SDK receives a view it cannot read outside.
  • canonical_server_key keys a token the way the SDK's RFC 8707 resource URL identifies the server: the exact path (trailing slash included) and the query are kept, since either can select a different server or tenant on one host. Only what RFC 3986 makes equivalent is folded (scheme/host case, a default port, an empty path), and IPv6 hosts keep their brackets. A token issued for /jira is never presented to /confluence.
  • PendingAuthorizations correlates the redirect back to the user who started it. The callback route is shared, so state is the only link: a state belonging to another user is refused rather than completed against the wrong account. States are single-use and expire.

OAuth is opt-in and follows the 2.12 MCP model. A configured SSE or streamable-http server enables it with an oauth table on its [[features.mcp.servers]] entry; a user-provided server enables it with useOAuth on the connect request (refused for a named server). Without either, connections are unaffected.

[[features.mcp.servers]]
name = "jira"
type = "streamable-http"
url = "https://mcp.example.com/mcp"
oauth = { authorization_origins = ["https://auth.example.com"] }

The SDK's discovery, registration and token requests go through the same destination-checked client as the connection: a named server's own origin plus the authorization_origins it grants (bare origins, validated at load), or allowed_urls for a user-provided server. Redirects stay disabled.

Two details worth flagging in review:

  • The state is the SDK's own. The owner is recorded in redirect_handler, keyed by the state in the authorization URL, because the SDK compares the returned state against the one it minted.
  • A refused, abandoned or expired authorization raises McpAuthorizationError with its reason and fails the connect at once, through the same side channel as on_blocked: the SDK transports swallow errors from their send loops. Once the user has been sent to authorize, the connect wait grows by AUTHORIZATION_TIMEOUT (300s) to cover consent in the browser; with a cached token it stays on the ordinary HTTP budget.

The interactive half — surfacing mcp_authorization_required in the UI — is left for a follow-up; the backend emits it today.

Tests

70 tests in backend/tests/test_mcp_oauth.py (canonicalisation, the SDK protocol contract — including a run of the SDK's own _perform_authorization against the provider — isolation, expiry/replay, fail-fast, the config model, the origin grant, the callback route) and 9 in TestConnectMcpOAuth in backend/tests/test_mcp.py (connect wiring, the destination grant, auth required, the fail-fast path, the extended wait and when it does not apply).

The isolation guarantee is mutation-tested. Keying the store on the server alone — the SDK's own assumption — fails 5 tests, including the leak itself:

FAILED TestIsolation::test_a_token_is_not_readable_by_another_user
  - assert OAuthToken(access_token='alice-token', ...) is None

On the 2.12 port, each new guarantee was mutation-checked too (no fail-fast channel, no origin grant, a wait extended without a redirect or never extended, a given-up flow kept, useOAuth on a named server, cancel instead of raise, an unbracketed IPv6 key, a collapsed trailing slash or dropped query, a 2.x-style callback result): every mutant fails a test.

Full backend suite: 1011 passed. ruff check, ruff format --check and mypy are clean on the whole backend.


Summary by cubic

Adds per-user OAuth for HTTP mcp connections so Chainlit can obtain and reuse tokens per logged-in user. Previously only static headers were sent; HTTP transports can now opt into OAuth via useOAuth, default behavior is unchanged, and Stdio remains unsupported.

  • New Features

    • Memory-backed McpOAuthTokenStore keyed by (user identifier, canonical server) and exposed via a scoped TokenStorage.
    • Canonicalizes server keys on scheme/host/path, folding default ports and empty paths while keeping exact paths, trailing slashes, and queries distinct so tokens never cross servers or tenants.
    • Tracks pending flows with PendingAuthorizations keyed on the SDK-generated state; states are single-use, expire, and enforce ownership.
    • Adds GET /mcp/oauth/callback: returns 401 if unauthenticated, 403 if the state belongs to another user, and 400 on provider errors or missing/unknown/expired state/code; errors abandon the owner's flow.
    • Extends ConnectSseMCPRequest and ConnectStreamableHttpMCPRequest with useOAuth: false; when true, builds a per-user OAuthClientProvider, emits mcp_authorization_required with the authorization URL, and attaches it to HTTP clients; flows are scoped to the HTTP-authenticated caller. Once the user is sent to authorize, the connect wait extends by 300s; a server that is down still fails on the ordinary HTTP budget.
  • Migration

    • No migration required; enable by setting useOAuth: true on HTTP MCP connections and handling the mcp_authorization_required event in the UI.

Written for commit 65630d9. Summary will update on new commits.

Review in cubic

Static credential injection (Chainlit#2292) covers servers where the user already
holds a token. This adds the authorization flow itself, opt-in per
connection via `useOAuth` so existing connections are unaffected.

Discovery, dynamic client registration and PKCE come from the MCP SDK's
OAuthClientProvider. What the SDK cannot supply is the part that only
matters on a multi-user server: its TokenStorage takes no arguments, so a
single storage shared across requests would hand one user's access token
to the next caller.

McpOAuthTokenStore keys tokens by (user identifier, server) and hands the
SDK a view fixed to one pair. Servers are compared on scheme, host and
path, so a token issued for one server mounted on a host is never sent to
another mounted beside it.

The redirect returns on a route shared by every user, so PendingAuthorizations
resolves a callback only for the user who started it; a state belonging to
someone else is refused rather than completed against the wrong account.
States are single-use, expire, and are URL-safe — random_secret's alphabet
contains %, /, = and ?, which do not survive a query string.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@dosubot dosubot Bot added size:L This PR changes 100-499 lines, ignoring generated files. backend Pertains to the Python backend. enhancement New feature or request security unit-tests Has unit tests. labels Aug 19, 2026

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 4 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread backend/chainlit/server.py
Comment thread backend/chainlit/mcp_oauth.py Outdated
Comment thread backend/chainlit/server.py Outdated
Comment thread backend/tests/test_mcp_oauth.py Outdated
The pending map was keyed on a state Chainlit generated, but the SDK mints
its own, embeds it in the authorization URL and compares the returned value
with compare_digest. The browser therefore echoed the SDK's state, resolve()
raised KeyError, and no flow could ever complete. Register the owner when the
redirect is handed over, keyed by the state already in that URL, and return
that same state from the callback handler so the SDK's comparison passes.

That also removes the reason for generating a URL-safe state here: the SDK
owns state generation now, so random_secret's alphabet is no longer involved.

A failed or malformed callback now abandons the caller's own flow, so the
waiting connection fails fast instead of hanging until the state expires.
Ownership is checked there for the same reason resolve() checks it: otherwise
anyone holding a state could cancel someone else's connection.

Scope the flow to the HTTP-authenticated caller rather than the session user;
the callback route sees the former, so the two must agree or every callback
is refused as belonging to someone else.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@r0h1tb

r0h1tb commented Aug 19, 2026

Copy link
Copy Markdown
Author

All four addressed in 0fc62eb. The first one was correct and serious — thanks.

P1, mcp_oauth.py:197 — the correlation key was inert. Confirmed against the SDK: OAuthClientProvider._perform_authorization does state = secrets.token_urlsafe(32), puts it in auth_params, and then checks secrets.compare_digest(returned_state, state). My Chainlit-generated state never reached the browser, so every callback looked up a state that was never registered and 400'd. The flow could not complete.

The owner is now recorded in the redirect_handler, keyed by the state parsed out of the authorization URL the SDK just built, and callback_handler returns that same state so the SDK's comparison passes. PendingAuthorizations.start() is replaced by register(state, user, server) — minting a state here was the bug, so the ability to do it is gone rather than left as a trap.

This also retires the URL-safe-state change from the first commit: the SDK owns state generation, so random_secret's alphabet is no longer in the path.

P1, server.py:1306 — a failed callback left the connection hanging. Right: the transport sat in pending.wait() until the 300s TTL. Error and malformed callbacks now abandon the flow so the waiting connection fails fast. abandon() checks ownership for the same reason resolve() does — without it, anyone holding a state could cancel another user's connection, which is covered by test_an_error_cannot_abandon_someone_elses_flow.

P2, server.py:1457 — scope to current_user. Taken as suggested. The callback route authenticates on current_user, so scoping the flow to context.session.user could start something the callback then refuses as belonging to someone else.

P3, test_missing_code_is_rejected — the assertion was not isolating. Correct, the 400 could have come from the unknown-state branch. It now registers a real state, asserts detail == "Missing code or state", and additionally asserts the flow was released.

Verification

Both fixes are mutation-tested. Reverting the state fix to a self-generated value reproduces the failure exactly as described:

FAILED TestProviderStateCorrelation::test_the_flow_is_registered_under_the_sdk_state
  - KeyError: 'Unknown or expired OAuth state.'
FAILED TestProviderStateCorrelation::test_the_callback_handler_returns_the_sdk_state
  - KeyError: 'Unknown or expired OAuth state.'

and dropping the abandon calls fails the two callback tests.

7 new tests (42 total in the file), driving the provider's real redirect_handler / callback_handler rather than the store in isolation — which is the gap that let the original defect through. Backend suite 733 → 740 passed, same 8 pre-existing failures (slack_bolt, botbuilder, polars absent locally). ruff check, ruff format --check and mypy clean on the touched files.

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 3 files (changes from recent commits).

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread backend/chainlit/server.py
@dokterbob

Copy link
Copy Markdown
Collaborator

@r0h1tb This might (or might not) be redundant with the recent release. We had to keep it under quarantine due to the security risks, hence I didn't tell you.

@cyanidium

Copy link
Copy Markdown

OAuth support is still needed, it would be great to see this rebased on the new MCP code and progressed!

@dokterbob

Copy link
Copy Markdown
Collaborator

OAuth support is still needed, it would be great to see this rebased on the new MCP code and progressed!

Makes sense. @r0h1tb any chance for a bump? If not, @cyanidium feel free to shoot a new PR.

@KoenDesplenter

Copy link
Copy Markdown

Any progress on updating this with the new mcp setup? @r0h1tb @cyanidium @dokterbob

r0h1tb and others added 2 commits September 29, 2026 20:39
Brings in the 2.12.0 MCP rework (named servers from config, opt-in user
servers behind allowed_urls, destination-checked httpx clients).
Conflicts resolved in favour of main's request model and transport
wiring; the OAuth wiring is ported onto it in the next commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Rebuilds the OAuth wiring on the named-server / user-server model from
2.12.0 instead of the removed per-transport request models.

- Configured sse and streamable-http servers opt in with an `oauth`
  table on their [[features.mcp.servers]] entry. `useOAuth` remains for
  user-provided servers, and is refused for a named one so the browser
  cannot switch OAuth on for a server the developer configured.
- The SDK's discovery, registration and token requests go through the
  destination-checked client like the connection itself: a named server
  keeps to its own origin plus the `authorization_origins` granted in
  config, and a user-provided server stays inside allowed_urls.
- The connect wait grows by AUTHORIZATION_TIMEOUT when OAuth is on,
  since the user signs in and consents in the browser first.
- A refused, abandoned or expired authorization now raises
  McpAuthorizationError with its reason instead of cancelling a future,
  and fails the connect through the same side channel as a blocked
  destination, because the SDK transports swallow it. A connection that
  gives up drops its pending state, so a late callback is refused.
- connect_mcp takes the Request to build the callback URL; tests that
  call it directly now pass one.

Also clears the formatting and mypy failures CI reported on
test_mcp_oauth.py.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@r0h1tb

r0h1tb commented Sep 29, 2026

Copy link
Copy Markdown
Author

@KoenDesplenter @cyanidium @dokterbob this is now updated to the 2.12 MCP setup. I merged main and ported the OAuth wiring in d807b0e. Sorry it took a while.

How it maps onto 2.12. The browser no longer sends connection details, so OAuth follows the same split:

  • Configured servers opt in on their entry, and the client still sends only the name:
    [[features.mcp.servers]]
    name = "jira"
    type = "streamable-http"
    url = "https://mcp.example.com/mcp"
    oauth = { authorization_origins = ["https://auth.example.com"] }
    useOAuth is refused for a named server, so the browser can't switch OAuth on for a server the developer configured.
  • User-provided servers keep the per-request useOAuth flag.

How it fits the SSRF fix. The SDK's discovery, client registration and token requests run in this process, so they go through the same destination-checked client as the connection. A named server stays pinned to its own origin, and authorization_origins is the explicit grant for an authorization server hosted elsewhere (bare origins only, validated at load). A user-provided server gets no extra grant: its authorization server has to be inside allowed_urls already. Redirects stay off.

Two things the new connect flow needed:

  • With OAuth on, the connect wait gets AUTHORIZATION_TIMEOUT (300s) on top of the 30s HTTP budget, since the user consents in the browser.
  • A refused, abandoned or expired authorization now raises McpAuthorizationError with its reason. It fails the connect through the same side channel as on_blocked, because the SDK transports swallow the error; otherwise a "Deny" click would sit out the whole timeout. A connection that gives up also drops its pending state, so a late callback gets a 400.

cubic's last open finding (a rejected OAuth request evicting a working connection) is covered by 2.12's reordering: eviction happens only after a successful connect, and every OAuth check runs before launch.

Checks: ruff, ruff format and mypy are clean on the whole backend, and the full backend suite passes (1004). The new tests cover the config model, the origin grant, the connect wiring and the fail-fast path, and each new guarantee was mutation-checked (8 of 8 fail a test).

Still open, as before: the UI. The backend emits mcp_authorization_required with the URL, but the stock frontend doesn't act on it yet, so with the default UI a connect would wait out its budget. I'm happy to do that next. Would you prefer a toast carrying the authorization link, or having the connect call return early and finish over the socket? The second avoids holding POST /mcp open for up to five minutes, which some proxies won't allow.

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 issues found across 7 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="backend/chainlit/mcp_oauth.py">

<violation number="1" location="backend/chainlit/mcp_oauth.py:343">
P1: `callback_handler()` returns a `(code, state)` tuple, but the MCP SDK consumes this callback's result as an `AuthorizationCodeResult` object, reading `result.code`, `result.state` and `result.iss` (see `mcp/client/auth/oauth2.py`: `result = await self.context.callback_handler()` then `result.state is None` / `result.code`). After a user successfully authorizes in the browser, the token exchange will crash with `AttributeError: 'tuple' object has no attribute 'state'` (or TypeError against a TypedDict on older 1.x), so the completed OAuth flow never exchanges the code. No test catches this: the transport fakes only compare the returned tuple to itself and never drive the real SDK consumer. Return an `AuthorizationCodeResult` (import it from `mcp.shared.auth`) built from the awaited values, and update `test_the_callback_handler_returns_the_sdk_state` accordingly.</violation>
</file>

<file name="backend/tests/test_mcp.py">

<violation number="1" location="backend/tests/test_mcp.py:2563">
P3: This assertion hardcodes the TestClient host/scheme and assumes `CHAINLIT_URL` is unset. `get_user_facing_url()` (chainlit/server.py:440) rewrites the base URL from the `CHAINLIT_URL` environment variable, so when it is set this exact URI assertion fails even though the redirect is built correctly. Pin the expected URI using `get_user_facing_url(request.url)` semantics, or assert only that the redirect path ends with `/mcp/oauth/callback`.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

if pending is None: # pragma: no cover - the SDK always redirects first
raise RuntimeError("MCP authorization was awaited before it started.")
try:
return await pending.wait()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: callback_handler() returns a (code, state) tuple, but the MCP SDK consumes this callback's result as an AuthorizationCodeResult object, reading result.code, result.state and result.iss (see mcp/client/auth/oauth2.py: result = await self.context.callback_handler() then result.state is None / result.code). After a user successfully authorizes in the browser, the token exchange will crash with AttributeError: 'tuple' object has no attribute 'state' (or TypeError against a TypedDict on older 1.x), so the completed OAuth flow never exchanges the code. No test catches this: the transport fakes only compare the returned tuple to itself and never drive the real SDK consumer. Return an AuthorizationCodeResult (import it from mcp.shared.auth) built from the awaited values, and update test_the_callback_handler_returns_the_sdk_state accordingly.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At backend/chainlit/mcp_oauth.py, line 343:

<comment>`callback_handler()` returns a `(code, state)` tuple, but the MCP SDK consumes this callback's result as an `AuthorizationCodeResult` object, reading `result.code`, `result.state` and `result.iss` (see `mcp/client/auth/oauth2.py`: `result = await self.context.callback_handler()` then `result.state is None` / `result.code`). After a user successfully authorizes in the browser, the token exchange will crash with `AttributeError: 'tuple' object has no attribute 'state'` (or TypeError against a TypedDict on older 1.x), so the completed OAuth flow never exchanges the code. No test catches this: the transport fakes only compare the returned tuple to itself and never drive the real SDK consumer. Return an `AuthorizationCodeResult` (import it from `mcp.shared.auth`) built from the awaited values, and update `test_the_callback_handler_returns_the_sdk_state` accordingly.</comment>

<file context>
@@ -0,0 +1,365 @@
+        if pending is None:  # pragma: no cover - the SDK always redirects first
+            raise RuntimeError("MCP authorization was awaited before it started.")
+        try:
+            return await pending.wait()
+        except McpAuthorizationError as exc:
+            if on_failure is not None:
</file context>

Comment thread backend/tests/test_mcp_oauth.py Outdated
Comment thread backend/chainlit/mcp_oauth.py Outdated
Comment thread backend/chainlit/mcp_oauth.py Outdated
Comment thread backend/chainlit/mcp_oauth.py Outdated
Comment thread backend/chainlit/server.py Outdated
Comment thread backend/tests/test_mcp.py
assert storage.server_key == "https://mcp.example.com/mcp"
redirect_uris = auth.context.client_metadata.redirect_uris or []
assert [str(u) for u in redirect_uris] == [
"http://testserver/mcp/oauth/callback"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: This assertion hardcodes the TestClient host/scheme and assumes CHAINLIT_URL is unset. get_user_facing_url() (chainlit/server.py:440) rewrites the base URL from the CHAINLIT_URL environment variable, so when it is set this exact URI assertion fails even though the redirect is built correctly. Pin the expected URI using get_user_facing_url(request.url) semantics, or assert only that the redirect path ends with /mcp/oauth/callback.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At backend/tests/test_mcp.py, line 2563:

<comment>This assertion hardcodes the TestClient host/scheme and assumes `CHAINLIT_URL` is unset. `get_user_facing_url()` (chainlit/server.py:440) rewrites the base URL from the `CHAINLIT_URL` environment variable, so when it is set this exact URI assertion fails even though the redirect is built correctly. Pin the expected URI using `get_user_facing_url(request.url)` semantics, or assert only that the redirect path ends with `/mcp/oauth/callback`.</comment>

<file context>
@@ -2440,3 +2476,312 @@ def test_stdio_server_secrets_not_disclosed(
+        assert storage.server_key == "https://mcp.example.com/mcp"
+        redirect_uris = auth.context.client_metadata.redirect_uris or []
+        assert [str(u) for u in redirect_uris] == [
+            "http://testserver/mcp/oauth/callback"
+        ]
+
</file context>

Comment thread backend/tests/test_mcp_oauth.py
…consent

Addresses the review of d807b0e.

- canonical_server_key now keeps what the MCP SDK keeps in the RFC 8707
  resource URL: the exact path (trailing slash included) and the query,
  either of which can select a different server or tenant on one host.
  It still folds scheme/host case, a default port and an empty path, and
  brackets an IPv6 host so a port cannot run into the address
  ("[::1]:8443" and "[::1:8443]" no longer share a key).
- The connect wait grows by AUTHORIZATION_TIMEOUT only once the user has
  actually been sent to authorize. With a cached or refreshed token, a
  server that is down fails on the ordinary HTTP budget again.
- A test now runs the SDK's own _perform_authorization against the
  provider, so the callback contract is checked end to end (mcp 1.x
  unpacks a (code, state) tuple; 2.0.0 changes it, noted in code).
- Tests clear the process-wide token store and pending map, and the
  redirect test no longer depends on CHAINLIT_URL being unset.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@r0h1tb

r0h1tb commented Sep 29, 2026 •

Copy link
Copy Markdown
Author

Review findings addressed in 65630d9:

  • The token key now keeps the exact path and query, as the SDK's RFC 8707 resource URL does, and brackets IPv6 hosts. /mcp vs /mcp/, ?tenant=a vs ?tenant=b, and [::1]:8443 vs [::1:8443] no longer share a token.
  • The extra AUTHORIZATION_TIMEOUT only applies once the user has been sent to authorize. With a cached token, a server that's down fails on the 30s budget.
  • Tests clear the token store and pending map between runs, and the redirect test unsets CHAINLIT_URL.

Not changed: returning AuthorizationCodeResult from the callback. That's the mcp 2.x API; every release in the pinned range (mcp>=1.28.1,<2.0.0) unpacks a (code, state) tuple. A new test runs the SDK's own _perform_authorization against the provider, so the 2.x bump will fail it.

Backend suite: 1011 passed.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backend Pertains to the Python backend. enhancement New feature or request security size:L This PR changes 100-499 lines, ignoring generated files. unit-tests Has unit tests.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants