|
| 1 | +# ADR-002: Immediate Token Exchange in redirectToAuthorization() |
| 2 | + |
| 3 | +**Status:** Accepted |
| 4 | +**Date:** 2025-01-25 |
| 5 | +**Tags:** oauth, mcp, sdk-integration |
| 6 | + |
| 7 | +## Problem |
| 8 | + |
| 9 | +The MCP SDK's `auth()` flow works as follows: |
| 10 | + |
| 11 | +1. Check `provider.tokens()` — if valid tokens exist, return `'AUTHORIZED'` |
| 12 | +2. If no tokens, start authorization: call `redirectToAuthorization(url)` |
| 13 | +3. **Immediately** return `'REDIRECT'` (without re-checking tokens) |
| 14 | + |
| 15 | +For web-based OAuth, this makes sense: `redirectToAuthorization()` triggers a page redirect and control never returns synchronously. The SDK expects authentication to complete in a subsequent request. |
| 16 | + |
| 17 | +For CLI/desktop apps using `browserAuth()`, control **does** return synchronously—we capture the callback in-process via a local HTTP server. We exchange tokens inside `redirectToAuthorization()`, but the SDK has already decided to return `'REDIRECT'`, causing `UnauthorizedError`. |
| 18 | + |
| 19 | +## Decision |
| 20 | + |
| 21 | +Exchange tokens **inside** `redirectToAuthorization()` and document the retry pattern as the expected usage: |
| 22 | + |
| 23 | +```typescript |
| 24 | +// First connect triggers OAuth flow and saves tokens, but SDK returns |
| 25 | +// 'REDIRECT' before checking. Second connect finds valid tokens. |
| 26 | +async function connectWithOAuthRetry(client, serverUrl, authProvider) { |
| 27 | + const transport = new StreamableHTTPClientTransport(serverUrl, { |
| 28 | + authProvider, |
| 29 | + }); |
| 30 | + try { |
| 31 | + await client.connect(transport); |
| 32 | + } catch (error) { |
| 33 | + if (error.message === "Unauthorized") { |
| 34 | + await client.connect( |
| 35 | + new StreamableHTTPClientTransport(serverUrl, { authProvider }), |
| 36 | + ); |
| 37 | + } else throw error; |
| 38 | + } |
| 39 | +} |
| 40 | +``` |
| 41 | + |
| 42 | +**Why a new transport on retry?** The transport caches connection state internally. A fresh transport ensures clean reconnection. |
| 43 | + |
| 44 | +## Rationale |
| 45 | + |
| 46 | +- **SDK constraint**: No hook exists between redirect completion and the `'REDIRECT'` return. The SDK interface (`Promise<void>`) cannot signal "auth completed." |
| 47 | +- **In-process capture**: CLI apps don't have page redirects that would trigger a fresh auth check cycle. |
| 48 | +- **Correctness over elegance**: The retry is unusual but reliable—tokens are always saved before the error. |
| 49 | + |
| 50 | +## Alternatives Considered |
| 51 | + |
| 52 | +| Alternative | Why Rejected | |
| 53 | +| ---------------------------------------------- | ------------------------------------------------------------- | |
| 54 | +| `transport.finishAuth(callbackUrl)` | Breaks provider encapsulation; doesn't fit in-process capture | |
| 55 | +| Return tokens from `redirectToAuthorization()` | SDK interface expects `Promise<void>` | |
| 56 | +| Upstream SDK change | Not viable for library consumers | |
| 57 | + |
| 58 | +## Impact |
| 59 | + |
| 60 | +- **Positive**: Self-contained auth flow; no external coordination needed |
| 61 | +- **Negative**: First connection always throws `UnauthorizedError` after OAuth—must be documented clearly |
| 62 | + |
| 63 | +## Links |
| 64 | + |
| 65 | +- Code: `src/auth/browser-auth.ts` lines 254-368 |
| 66 | +- MCP SDK auth interface: `@modelcontextprotocol/sdk/client/auth.js` |
| 67 | +- Related: ADR-001 (no refresh tokens) |
0 commit comments