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 insideredirectToAuthorization(), 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 9207isschecks, issuer-stamped credentials and client-auth selection. - Three SDK behaviors are easy to get wrong:
auth()returnsREDIRECTright afterredirectToAuthorization();finishAuth(params)must run on the transport that received the 401/403 (it holds_scopeand_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 anOAuthClientProviderplusconnect(client)(Streamable HTTP; completes the browser flow and reconnects) andcompleteAuthorization(transport)(any transport withfinishAuth(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 transportsconnect()created the overlap is anUnauthorizedError, sinceconnect()knows the originating transport and completes the flow there; caller-created transports share one owner, so their overlap is a plainErrorthat can't be mistaken for "callcompleteAuthorization(thisTransport)". The client identity is pinned while a flow is active, and a redirect whoseclient_idno 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 transportconnect()connected ends its pending flow, rejects aconnect()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.timeoutbounds one flow fromstate()through the exchange; transports created byconnect()get afetchwhose OAuth requests (discovery, DCR, token) abort with it or theconnect()signal, so a hung token endpoint can't hold the flow. Requests toserverUrlitself 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 transportsconnect()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 everyconnect()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 aconnect()orcompleteAuthorization()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, soconnect()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 withUnauthorizedError, so the usualconnect()-then-retry converges on newer credentials; only work ended by a sign-out ("all") fails with a plainError, 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 andstate()check the generation their pass saw inclientInformation(). 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_typeortoken_endpoint_auth_method: the SDK derives DCR defaults and addsoffline_accessonly when the caller declaresrefresh_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 withclientMetadataUrl) and a staticclientInformationare mutually exclusive:BrowserAuthOptionsis a union, so TypeScript rejects mixing them; runtime checks repeat it for untyped callers. Wrappers thatOmitfields must distribute over the union. A staticclientInformationrequiresissuer, is never re-registered, and the provider omitssaveClientInformationso the SDK refuses DCR and foreign issuers itself. clientMetadataUrl(CIMD) is passed through, withclientNameas 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 theclient_id, which CIMD compares as a plain string, so the raw string is validated and passed on verbatim, never normalized throughnew URL().- Other advanced SDK hooks (
dpop(),addClientAuthentication,prepareTokenRequest,validateResourceURL) are not mirrored as options; apps needing them implementOAuthClientProviderdirectly.
Alternatives (brief)
- Keep the v2 in-redirect exchange — a parallel OAuth implementation that misses refresh,
isschecks 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 callclient.close()first.
Impact
- Positive: refresh tokens, step-up,
issvalidation and SDK fixes come for free; one line connects. - Negative/Risks: peer dependency on
@modelcontextprotocol/client^2.2 (2.2 treats*.localhosttoken endpoints as loopback);connect()is Streamable HTTP only (SSE is legacy; usecompleteAuthorization()).