Skip to content

ADR-006: The MCP SDK Owns OAuth ​

Status: Accepted Date: 2026-09-26 (revised 2026-10-01: CIMD, flows end with their connection) Tags: mcp, oauth, architecture

Problem ​

  • v2 browserAuth() re-implemented parts of OAuth (code exchange inside redirectToAuthorization(), fixed client metadata, no refresh tokens) next to the SDK's own implementation. MCP SDK v2 (@modelcontextprotocol/client) now covers discovery, DCR, PKCE, exchange, refresh, RFC 9207 iss checks, issuer-stamped credentials and client-auth selection.
  • Three SDK behaviors are easy to get wrong: auth() returns REDIRECT right after redirectToAuthorization(); finishAuth(params) must run on the transport that received the 401/403 (it holds _scope and _resourceMetadataUrl); Protocol.connect() replaces a transport without closing it.

Decision ​

  • The adapter owns only the browser, the loopback listener, state, flow ownership and credential persistence. Everything else is the SDK's.
  • browserAuth() returns an OAuthClientProvider plus connect(client) (Streamable HTTP; completes the browser flow and reconnects) and completeAuthorization(transport) (any transport with finishAuth(params)).
  • One interactive authorization per provider at a time. A flow is reserved in state() and lasts until its token exchange settles; overlapping attempts fail fast and never merge. On transports connect() created the overlap is an UnauthorizedError, since connect() knows the originating transport and completes the flow there; caller-created transports share one owner, so their overlap is a plain Error that can't be mistaken for "call completeAuthorization(thisTransport)". The client identity is pinned while a flow is active, and a redirect whose client_id no longer matches the stored client is refused.
  • connect() gives each transport it creates its own provider view, so every flow knows its transport and completes there. It completes any flow pending on one of its transports (initial 401, step-up, or a concurrent request's flow); for a client already connected through one of its transports it then returns without reconnecting, so concurrent step-up retries don't reject each other's requests. It never closes a transport it didn't create. A flow ends with the connection that started it: closing a transport connect() connected ends its pending flow, rejects a connect() completing it rather than reconnecting the closed client, and refuses its late hooks, so an abandoned step-up frees the redirect port without a sign-out. A failed attempt's transport is exempt: it closes, yet its flow may still resume.
  • timeout bounds one flow from state() through the exchange; transports created by connect() get a fetch whose OAuth requests (discovery, DCR, token) abort with it or the connect() signal, so a hung token endpoint can't hold the flow. Requests to serverUrl itself are never touched: the SSE stream and concurrent MCP requests outlive both signals.
  • Credential invalidation ("all", "client", "tokens") and every completed interactive exchange advance a session generation and abort the in-flight OAuth requests (discovery, registration, refresh, exchange) of transports connect() created. On that path no response from before a sign-out can be saved after it, a slow refresh can't overwrite a newer step-up's tokens, and an auth pass that began earlier can't fall back to opening the browser. A sign-out ("all", which the SDK never uses for its own recovery) also ends every connect() call in progress (including one waiting for the browser or its turn): the SDK retries some aborted requests, such as a discovery fallback, so a per-request abort alone could let the call continue. A credential invalidation also ends a connect() or completeAuthorization() wait for the browser in progress: the flow may have been started for the credentials just removed. No refresh starts while an exchange runs: on the exchanging transport its token save can't be told from the exchange's, so connect() transports refuse the refresh request itself, and a token save from any other owner is refused. Superseded work, including work ended by "client"/"tokens" (the SDK's own recovery scopes), fails with UnauthorizedError, so the usual connect()-then-retry converges on newer credentials; only work ended by a sign-out ("all") fails with a plain Error, since retrying must not sign back in. Transports the caller creates share one owner, and the SDK gives provider hooks no attempt identity, so there the guarantee is best-effort: an exchange checks the generation it took when completion started, any other token save is a refresh that may only replace tokens still stored, and registrations and state() check the generation their pass saw in clientInformation(). OAuth work through the provider directly must be serialized; concurrent attempts on caller-created transports are unsupported.
  • Discovery state is snapshotted when a flow is reserved and only the flow's owner reads it; later writes never touch it. Caller-created transports can't be told apart, so their redirected flow stays pending, even failed or expired, until completeAuthorization() consumes it: a stale transport can never complete a newer flow.
  • The adapter sets no grant_types, application_type or token_endpoint_auth_method: the SDK derives DCR defaults and adds offline_access only when the caller declares refresh_token. A registration the authorization server made without the configured redirect URI (RFC 7591 lets it change metadata) is refused before it is saved. clientName (DCR, optionally with clientMetadataUrl) and a static clientInformation are mutually exclusive: BrowserAuthOptions is a union, so TypeScript rejects mixing them; runtime checks repeat it for untyped callers. Wrappers that Omit fields must distribute over the union. A static clientInformation requires issuer, is never re-registered, and the provider omits saveClientInformation so the SDK refuses DCR and foreign issuers itself.
  • clientMetadataUrl (CIMD) is passed through, with clientName as the DCR fallback: MCP 2026-07-28 prefers CIMD over DCR. The SDK picks the client (a stored one, else CIMD where supported, else DCR); the adapter never migrates a stored client to CIMD, which would mean inspecting discovery and remembering how each client was obtained. Switching identity is a sign-out (invalidateCredentials("all")). The URL is the client_id, which CIMD compares as a plain string, so the raw string is validated and passed on verbatim, never normalized through new URL().
  • Other advanced SDK hooks (dpop(), addClientAuthentication, prepareTokenRequest, validateResourceURL) are not mirrored as options; apps needing them implement OAuthClientProvider directly.

Alternatives (brief) ​

  • Keep the v2 in-redirect exchange — a parallel OAuth implementation that misses refresh, iss checks and SDK fixes.
  • Raw waitForCallback() API — leaves the originating-transport and reconnect traps to every caller, and releases flow ownership before the exchange settles.
  • Merge overlapping flows — the second attempt's scope and resource metadata live on a different transport.
  • Reconnect on every connect() — rejects in-flight requests, including concurrent step-up retries; callers wanting a fresh session call client.close() first.

Impact ​

  • Positive: refresh tokens, step-up, iss validation and SDK fixes come for free; one line connects.
  • Negative/Risks: peer dependency on @modelcontextprotocol/client ^2.2 (2.2 treats *.localhost token endpoints as loopback); connect() is Streamable HTTP only (SSE is legacy; use completeAuthorization()).