feat(mcp): OAuth 2.1 support for MCP servers (#2250)
* feat(mcp): add OAuth 2.1 support for MCP servers
Implements the MCP authorization spec (2025-06-18) so users can connect
custom MCP servers that require OAuth, not just static API keys.
When a user adds an MCP server URL we probe it; on a 401 with
WWW-Authenticate we walk RFC 9728 (protected-resource metadata) and
RFC 8414 (AS metadata), do RFC 7591 dynamic client registration when
supported, then run an authorization-code flow with PKCE-S256 and the
RFC 8707 resource indicator. Tokens are persisted in localStorage
alongside the existing custom-server data and travel with chat
requests via the existing headers channel. We refresh expiring tokens
just-in-time before each chat send, distinguish AS-side rejections
(invalid_grant) from transport blips so a flaky network does not log
the user out, and surface a "Reconnect" path on hard failure.
UI: AuthorizeStep flow inside the MCP modal handles popup vs full-page
redirect (mobile / popup-blocked) and a manual client-id form for AS
without DCR. ServerCard shows an Authorized pill with live expiry
countdown and a Disconnect button that best-effort-revokes via
RFC 7009.
* mcp oauth hardening (#2450)
* fix(mcp): validate the RFC 9207 iss parameter on the OAuth callback
* prevent mcp oauth connection deadlocks (#2466)
* fix hanging oauth response
* clean
* fix(mcp): tolerate null optional client metadata from DCR (#2467)
* fix(mcp): correct OAuth pill lifetime and stuck "connecting" after reload
- publicOAuthState exposes `refreshable`; the server card treats a refreshable
connection as authorized past the short-lived access token and drops the
misleading "expires in" countdown (kept only for non-refreshable servers).
- The localStorage OAuth sanitizer preserves the new `refreshable` field.
- healthCheckServer writes the stable status before persisting, so the transient
"connecting" is no longer saved and restored as a stuck state on reload.
* chore(mcp): address review — cut 'what' comments, condense the rest to one line
Resolves the 23 inline comments on PR #2250: remove narration/'what' comments
and single-field annotations, and condense the remaining 'why'/spec comments
(discovery, canonicalization, popup/redirect helpers, inline-script escaping) to
one line each. No behaviour change.
* Apply suggestion from @abidlabs
Co-authored-by: Abubakar Abid <islamrealm@gmail.com>
* fix(mcp): address Copilot review findings for OAuth
- exchange: reject redirects on token/refresh/revoke so the code, PKCE verifier,
client secret, and refresh token can't be forwarded cross-origin; bound revoke
with a 10s timeout so a hung AS can't block disconnect.
- start: let a manually-entered OAuth client be corrected on retry instead of
being locked to the first (typo'd) value, while still protecting DCR clients.
- disconnect: distinguish a confirmed removal (revoked/404) from a transport
failure and keep local state on failure so live credentials aren't stranded;
surface a retry hint on the card.
- add an owner-checked /api/mcp/oauth/state endpoint and reconcile connection
state when the manager opens, so a scope challenge recorded mid-chat no longer
leaves a server stuck showing 'Authorized' (and silently dropped).
- add JIT refresh tests: rotation, refresh-token preservation, invalid_grant
clears credentials, transient failure retains them, concurrent single-update.
* fix(mcp): recover OAuth popup flows that close before the result lands
When the popup's postMessage is dropped (most often a PUBLIC_ORIGIN vs
browsing-origin mismatch, where the callback posts to a different origin than
the opener accepts), the popup still closes and the flow reported 'window closed
before completing' even though the token exchange completed server-side.
On popup-closed/timeout, re-check the connection via /api/mcp/oauth/state and, if
it is already authorized, treat the flow as successful instead of failing.
* fix(mcp): address security review findings for OAuth
- urlSafety: strip Authorization/Cookie/Proxy-Authorization when a redirect changes
origin (matching native fetch), so an MCP endpoint can't forward the user's bearer
to another host across 301/302/303/307/308. (+ redirect test)
- clientPool: single-flight cold-miss connects so concurrent requests can't orphan
authenticated clients that are never tracked/closed; don't close a client that has
an in-flight call on an idle-ping failure.
- disconnect: revoke before delete, revoke both tokens, and keep the record (signal
502) when a possible revocation fails so the user can retry instead of stranding a
live credential; 'force' still removes it when deleting the whole server. (+ tests)
- health check now runs OAuth discovery for an existing/base server that returns
401/403 with no connection, and base-server cards get the reauthorize handler, so
pre-existing and env-configured OAuth servers have an Authorize path.
* feat(mcp): encrypt OAuth credentials at rest and expire idle connections
A1 — encrypt the secret fields of an OAuth connection (access/refresh/id tokens,
client secret, PKCE verifier) with AES-256-GCM keyed by MCP_OAUTH_ENCRYPTION_KEY.
Metadata (expiry, scope, client_id, CSRF state) stays plaintext so projections and
lookups are unchanged. Values are tagged 'enc.v1:'; decryption passes through
untagged legacy plaintext, so existing records migrate lazily on rewrite and an
unset key keeps today's plaintext behavior (with a startup warning). Encrypt on
write, decrypt at point-of-use; the redundant tokens.refresh_token match in the
refresh path is dropped in favor of the version guard.
B1 — authenticated connections get a 90-day sliding idle TTL (previously none),
refreshed on each use via the existing deleteAt TTL index, so abandoned grants
don't persist forever.
Adds crypto round-trip tests and documents MCP_OAUTH_ENCRYPTION_KEY in .env.
* test(mcp): expect the sliding TTL on migrated OAuth connections
B1 gives authenticated connections a 90-day idle TTL, so a migrated connection's
deleteAt is no longer undefined. (CI-caught assertion the OAuth subset missed.)
* port mcp oauth to the v2 mcp sdk
main moved to @modelcontextprotocol/client (#2508); the flat v1 error classes are now OAuthError with a code, and the metadata schemas live in @modelcontextprotocol/core.
* fix mcp oauth review findings
- finish the v2 SDK port: pass iss to the code exchange, skip the SDK's exact-string issuer check in favour of ours, drop the last @modelcontextprotocol/sdk imports
- time out token/refresh requests
- guard token writes on the grant (grantVersion) and the refreshed token set instead of the whole-document version, so scope challenges and refreshes can't discard exchanged or rotated tokens
- revoke a grant that was exchanged but couldn't be stored
- restart the pending-connection TTL when a flow starts
- report expired non-refreshable tokens and undecryptable secrets as needing authorization
- persist OAuth state for base servers
- only report a connection missing when it is, not on transient errors
- give full-page flows a way back and surface their errors
- resolve OAuth tokens in parallel on the chat path
- reuse mcpFetchForServer and hasAuthHeader in the health route; drop duplicate connection reads
* keep waiting when a COOP login page severs the oauth popup
A provider page served with Cross-Origin-Opener-Policy makes the popup read as closed while the user is still signing in, and its result message never arrives. Poll the connection's server-side state instead of failing, with Authorize left enabled for a real close. The callback page keeps the close hint alongside the return link when it has no opener.
---------
Co-authored-by: pngwn <hello@pngwn.io>
Co-authored-by: Abubakar Abid <islamrealm@gmail.com>