Skip to content

OAuth on the MCP SDK v2; any 401 challenge starts authorization - #28

Merged
ethan-huo merged 2 commits into
mainfrom
feat/sdk-oauth
Oct 25, 2026
Merged

ethan-huo merged 2 commits into
mainfrom
feat/sdk-oauth

Conversation

@ethan-huo

@ethan-huo ethan-huo commented Oct 25, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

A server's need for OAuth is now decided by its Authorization Challenge, on any request, not by an @add GET probe. OAuth itself is the official SDK's auth() (@modelcontextprotocol/client 2.4) driven by an mcpx OAuthClientProvider; src/oauth.ts no longer hand-rolls discovery/DCR/PKCE/exchange/refresh.

 runAuthorized(call)
   resolveCredential            # token now flows via transport authProvider.token()
   callTool
-    catch /401|unauthorized/i  → reauth-required (auth "none" never recovers)
+    catch AuthorizationChallenge {status, resource_metadata, scope}
+      authorize(server, challenge)   # joins the shared Authentication Flow (ADR 0004/0008)
+        authorizeOAuth
+          refresh mode:     SDK auth() — never opens the browser
+          interactive mode: loopback callback → SDK auth() → browser → auth(code, iss)
+        grant written through to Credential Store; none → oauth in the same commit
+      retry once
 src/
-├── auth-discovery.ts     # GET probe at @add, frozen as auth kind
 ├── mcp-client.ts         # + challengeAuthProvider, authorizationChallenge()
 ├── oauth.ts              # SDK auth() + McpxOAuthProvider + loopback callback
 ├── runtime-stores.ts     # Credential Store v3: oauth[server] = {client, tokens}; journaled v2→v3 migration
 └── runtime-authentication.ts
  • Manual clients (servers without DCR) still prompt via Caller Input and use http://127.0.0.1:65245/callback. Slack-specific authed_user handling is removed (Slack is used through its own CLI).
  • Invalidation deletes only what the server refused (another flow's rotation survives); a failed sign-in never replaces a working grant.
  • @add reports auth-required when the tool listing is challenged; it still never opens a browser.
  • ADR 0009 added; README, GLOSSARY (Authorization Challenge, Credential Identity), ADR 0008 updated. Bump to 0.14.0.

Evidence

  • Before: mcpx wb.status (whiteboardanimation.ai, registered as auth: none because GET /mcp → 405) → Credentials for wb must be refreshed.
    After (local build, isolated MCPX_HOME copy of real state):
    mcpx wb.status
      mcpx: wb: authorize in your browser (waiting up to 5 minutes): https://whiteboardanimation.ai/oauth/authorize?…scope=mcp:tools+offline_access…
      [approved in Aside]
      workspace: org:… plan: pro credits: 589 …           # registry: wb → { kind: oauth }
    mcpx wb.status                                        # no prompt
    [expiresAt forced to 2000-01-01] mcpx wb.status
      mcpx: wb: refreshing OAuth token                    # token rotated, no browser
    mcpx sentry.find_organizations                        # migrated v2 grant works
    
  • bun run check: 127 pass. New tests/oauth-challenge.test.ts runs the real SDK transport against a mixed-auth fixture: anonymous quote ok → status 401 → browser → retry ok → next call reuses grant. tests/oauth.test.ts covers DCR, refresh, revoked-refresh fallback, compare-and-delete invalidation, manual client, iss mismatch.
  • Live acceptance found a real bug the old fixtures missed (Bun reports port 0 after stop(), so the code exchange sent a different redirect_uri); fixed, and the fixture now enforces redirect_uri equality.
  • prototype:cancellation:evidence re-run on v2: same verdicts; only the error type changed (SdkError REQUEST_TIMEOUT) and v2 no longer sends a stray cancel after completion.

Merge Danger

Door: one-way

The first 0.14.0 Runtime start migrates state-v2/credentials.json to version 3 (journaled). A 0.13.x binary cannot read v3 credentials, so downgrading after running 0.14.0 means re-authenticating OAuth servers.

Blast Radius: authentication

Every OAuth server and every HTTP call path (token now via transport authProvider). Bearer servers use the same path (round-robin unchanged). Servers previously recorded unknown become none and are re-decided by their next challenge. SDK v2 also touches stdio/notifications (covered by existing dogfood tests).


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.

Replace the hand-written OAuth client (metadata discovery, DCR, PKCE, token
exchange, refresh) with the official SDK's auth() and an mcpx
OAuthClientProvider, and move from @modelcontextprotocol/sdk 1.x to
@modelcontextprotocol/client 2.4.

A server's need for OAuth is now decided by its challenge, not by an @add
probe. The HTTP transport carries an authProvider whose onUnauthorized
surfaces the 401 (or 403 insufficient_scope) challenge; the session pool
joins the shared Authentication Flow with it and retries once. Mixed-auth
servers such as whiteboardanimation.ai — anonymous tools beside protected
ones, 405 on GET — previously froze as auth "none" and failed every
protected call with reauth-required.

- Credential Store v3: one OAuth grant per server in the SDK's stored shapes
  (client + tokens, issuer-stamped). A journaled migration converts v2
  tokens; manual clients keep their secret and fixed redirect URI.
- Refresh never opens the browser; a new sign-in never replays stored tokens
  or a client registered for another loopback port, and only replaces the
  grant once it succeeds. Invalidation deletes only what the server refused.
- @add no longer probes with GET; a challenged tool listing records OAuth.
- Manual clients (Slack) keep http://127.0.0.1:65245/callback; Slack's
  authed_user token response is still accepted.
- ADR 0009 records the challenge as the OAuth discovery entry.

Bump to 0.14.0.

Why: src/oauth.ts#OAuthCallback | a stopped Bun server reports port 0, so the code exchange must reuse the redirect URI captured at listen time
Why: src/mcp-client.ts#challengeAuthProvider | the transport must not run OAuth inside a request; the Runtime owns single-flight, Caller Input and registry writes
Slack is reached through its own CLI, not an mcpx server, so the authed_user
token-response shim goes. Manually registered OAuth clients stay generic.
@ethan-huo
ethan-huo merged commit 269ac3c into main Oct 25, 2026
1 check 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