Skip to content

Commit 65b5099

Browse files
refactor: adapt OAuthClientProvider at transport boundary (non-breaking)
Alternative to the breaking 'extends AuthProvider' approach. Instead of requiring OAuthClientProvider implementations to add token() + onUnauthorized(), the transport constructor classifies the authProvider option once and adapts OAuth providers via adaptOAuthProvider(). - OAuthClientProvider interface is unchanged from v1 - Transport option: authProvider?: AuthProvider | OAuthClientProvider - Constructor: if OAuth, store both original (for finishAuth/403) and adapted (for _commonHeaders/401) — classification happens once, no runtime type guards in the hot path - 4 built-in providers no longer need token()/onUnauthorized() - migration.md/migration-SKILL.md entries removed — nothing to migrate - Changeset downgraded to minor Net -142 lines vs the breaking approach. Same transport simplification, zero migration burden. Duck-typing via isOAuthClientProvider() ('tokens' + 'clientMetadata' in provider) at construction only.
1 parent 2961101 commit 65b5099

13 files changed

Lines changed: 78 additions & 220 deletions

File tree

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,16 @@
11
---
2-
'@modelcontextprotocol/client': major
2+
'@modelcontextprotocol/client': minor
33
---
44

5-
Unify client auth around a minimal `AuthProvider` interface
6-
7-
**Breaking:** Transport `authProvider` option now accepts the new minimal `AuthProvider` interface instead of being typed as `OAuthClientProvider`. `OAuthClientProvider` now extends `AuthProvider`, so most existing code continues to work — but custom implementations must add a `token()` method.
5+
Add `AuthProvider` for composable bearer-token auth; transports adapt `OAuthClientProvider` automatically
86

97
- New `AuthProvider` interface: `{ token(): Promise<string | undefined>; onUnauthorized?(ctx): Promise<void> }`. Transports call `token()` before every request and `onUnauthorized()` on 401 (then retry once).
10-
- `OAuthClientProvider` extends `AuthProvider`. Custom implementations must add `token()` (typically `return (await this.tokens())?.access_token`) and optionally `onUnauthorized()` (typically `return handleOAuthUnauthorized(this, ctx)`).
11-
- Built-in providers (`ClientCredentialsProvider`, `PrivateKeyJwtProvider`, `StaticPrivateKeyJwtProvider`, `CrossAppAccessProvider`) implement both methods — existing user code is unchanged.
12-
- New `handleOAuthUnauthorized(provider, ctx)` helper runs the standard OAuth flow from `onUnauthorized`.
13-
- New `isOAuthClientProvider()` type guard for gating OAuth-specific transport features like `finishAuth()`.
14-
- Transports no longer inline OAuth orchestration — ~50 lines of `auth()` calls, WWW-Authenticate parsing, and circuit-breaker state moved into `onUnauthorized()` implementations.
8+
- Transport `authProvider` option now accepts `AuthProvider | OAuthClientProvider`. OAuth providers are adapted internally via `adaptOAuthProvider()` — no changes needed to existing `OAuthClientProvider` implementations.
9+
- For simple bearer tokens (API keys, gateway-managed tokens, service accounts): `{ authProvider: { token: async () => myKey } }` — one-line object literal, no class.
10+
- New `adaptOAuthProvider(provider)` export for explicit adaptation.
11+
- New `handleOAuthUnauthorized(provider, ctx)` helper — the standard OAuth `onUnauthorized` behavior.
12+
- New `isOAuthClientProvider()` type guard.
13+
- New `UnauthorizedContext` type.
1514
- Exported previously-internal auth helpers for building custom flows: `applyBasicAuth`, `applyPostAuth`, `applyPublicAuth`, `executeTokenRequest`.
1615

17-
See `docs/migration.md` for before/after examples.
16+
Transports are simplified internally — ~50 lines of inline OAuth orchestration (auth() calls, WWW-Authenticate parsing, circuit-breaker state) moved into the adapter's `onUnauthorized()` implementation. `OAuthClientProvider` itself is unchanged.

docs/migration-SKILL.md

Lines changed: 0 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -203,39 +203,6 @@ import { OAuthError, OAuthErrorCode } from '@modelcontextprotocol/core';
203203
if (error instanceof OAuthError && error.code === OAuthErrorCode.InvalidClient) { ... }
204204
```
205205

206-
### Client `OAuthClientProvider` now extends `AuthProvider`
207-
208-
Transport `authProvider` options now accept the minimal `AuthProvider` interface. `OAuthClientProvider` extends it, so built-in providers work unchanged — custom implementations must add `token()`.
209-
210-
| v1 pattern | v2 equivalent |
211-
| ----------------------------------------------------- | --------------------------------------------------------------------------- |
212-
| `authProvider?: OAuthClientProvider` (option type) | `authProvider?: AuthProvider` (accepts `OAuthClientProvider` via extension) |
213-
| Transport reads `authProvider.tokens()?.access_token` | Transport calls `authProvider.token()` |
214-
| Transport inlines `auth()` on 401 | Transport calls `authProvider.onUnauthorized()` then retries once |
215-
| `_hasCompletedAuthFlow` circuit breaker | `_authRetryInFlight` circuit breaker |
216-
| N/A | `handleOAuthUnauthorized(provider, ctx)` — standard `onUnauthorized` impl |
217-
| N/A | `isOAuthClientProvider(provider)` — type guard |
218-
| N/A | `UnauthorizedContext``{ response, serverUrl, fetchFn }` |
219-
220-
**For custom `OAuthClientProvider` implementations**, add both methods (both required — TypeScript enforces this):
221-
222-
```typescript
223-
async token(): Promise<string | undefined> {
224-
return (await this.tokens())?.access_token;
225-
}
226-
227-
async onUnauthorized(ctx: UnauthorizedContext): Promise<void> {
228-
await handleOAuthUnauthorized(this, ctx);
229-
}
230-
```
231-
232-
**For simple bearer tokens** (previously required stubbing 8 `OAuthClientProvider` members):
233-
234-
```typescript
235-
// v2: one-liner
236-
const authProvider: AuthProvider = { token: async () => process.env.API_KEY };
237-
```
238-
239206
**Unchanged APIs** (only import paths changed): `Client` constructor and most methods, `McpServer` constructor, `server.connect()`, `server.close()`, all client transports (`StreamableHTTPClientTransport`, `SSEClientTransport`, `StdioClientTransport`), `StdioServerTransport`, all
240207
Zod schemas, all callback return types. Note: `callTool()` and `request()` signatures changed (schema parameter removed, see section 11).
241208

docs/migration.md

Lines changed: 0 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -648,44 +648,6 @@ The new design:
648648
- `ProtocolError` with `ProtocolErrorCode`: For errors that are serialized and sent as JSON-RPC error responses
649649
- `SdkError` with `SdkErrorCode`: For local errors that are thrown/rejected locally and never leave the SDK
650650

651-
### Client `authProvider` unified around `AuthProvider`
652-
653-
Transport `authProvider` options now accept the minimal `AuthProvider` interface rather than being typed as `OAuthClientProvider`. `OAuthClientProvider` extends `AuthProvider`, so built-in providers and most existing code continue to work unchanged — but custom
654-
`OAuthClientProvider` implementations must add a `token()` method.
655-
656-
**What changed:** transports now call `authProvider.token()` before every request (instead of `authProvider.tokens()?.access_token`), and call `authProvider.onUnauthorized()` on 401 (instead of inlining OAuth orchestration). One code path handles both simple bearer tokens and
657-
full OAuth.
658-
659-
**If you implement `OAuthClientProvider` directly** (the interactive browser-redirect pattern), add:
660-
661-
```ts
662-
class MyProvider implements OAuthClientProvider {
663-
// ...existing 8 required members...
664-
665-
// Required: return the current access token
666-
async token(): Promise<string | undefined> {
667-
return (await this.tokens())?.access_token;
668-
}
669-
670-
// Required: runs the OAuth flow on 401 — without this, 401 throws with no recovery
671-
async onUnauthorized(ctx: UnauthorizedContext): Promise<void> {
672-
await handleOAuthUnauthorized(this, ctx);
673-
}
674-
}
675-
```
676-
677-
**If you use `ClientCredentialsProvider`, `PrivateKeyJwtProvider`, `StaticPrivateKeyJwtProvider`, or `CrossAppAccessProvider`** — no change. These already implement both methods.
678-
679-
**If you have simple bearer tokens** (API keys, gateway tokens, externally-managed tokens), you can now skip `OAuthClientProvider` entirely:
680-
681-
```ts
682-
// Before: had to implement 8 OAuthClientProvider members with no-op stubs
683-
// After:
684-
const transport = new StreamableHTTPClientTransport(url, {
685-
authProvider: { token: async () => process.env.API_KEY }
686-
});
687-
```
688-
689651
### OAuth error refactoring
690652

691653
The OAuth error classes have been consolidated into a single `OAuthError` class with an `OAuthErrorCode` enum.

examples/client/src/simpleOAuthClientProvider.ts

Lines changed: 1 addition & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,4 @@
1-
import type {
2-
OAuthClientInformationMixed,
3-
OAuthClientMetadata,
4-
OAuthClientProvider,
5-
OAuthTokens,
6-
UnauthorizedContext
7-
} from '@modelcontextprotocol/client';
8-
import { handleOAuthUnauthorized } from '@modelcontextprotocol/client';
1+
import type { OAuthClientInformationMixed, OAuthClientMetadata, OAuthClientProvider, OAuthTokens } from '@modelcontextprotocol/client';
92

103
/**
114
* In-memory OAuth client provider for demonstration purposes
@@ -31,14 +24,6 @@ export class InMemoryOAuthClientProvider implements OAuthClientProvider {
3124

3225
private _onRedirect: (url: URL) => void;
3326

34-
async token(): Promise<string | undefined> {
35-
return this._tokens?.access_token;
36-
}
37-
38-
async onUnauthorized(ctx: UnauthorizedContext): Promise<void> {
39-
await handleOAuthUnauthorized(this, ctx);
40-
}
41-
4227
get redirectUrl(): string | URL {
4328
return this._redirectUrl;
4429
}

packages/client/src/client/auth.examples.ts

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@
99

1010
import type { AuthorizationServerMetadata } from '@modelcontextprotocol/core';
1111

12-
import type { OAuthClientProvider, UnauthorizedContext } from './auth.js';
13-
import { fetchToken, handleOAuthUnauthorized } from './auth.js';
12+
import type { OAuthClientProvider } from './auth.js';
13+
import { fetchToken } from './auth.js';
1414

1515
/**
1616
* Base class providing no-op implementations of required OAuthClientProvider methods.
@@ -29,12 +29,6 @@ abstract class MyProviderBase implements OAuthClientProvider {
2929
tokens(): undefined {
3030
return;
3131
}
32-
async token(): Promise<string | undefined> {
33-
return undefined;
34-
}
35-
async onUnauthorized(ctx: UnauthorizedContext): Promise<void> {
36-
await handleOAuthUnauthorized(this, ctx);
37-
}
3832
saveTokens() {
3933
return Promise.resolve();
4034
}

packages/client/src/client/auth.ts

Lines changed: 27 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,8 @@ export interface UnauthorizedContext {
6060
* const authProvider: AuthProvider = { token: async () => process.env.API_KEY };
6161
* ```
6262
*
63-
* For OAuth flows, use {@linkcode OAuthClientProvider} which extends this interface,
64-
* or one of the built-in providers ({@linkcode index.ClientCredentialsProvider | ClientCredentialsProvider} etc.).
63+
* For OAuth flows, pass an {@linkcode OAuthClientProvider} directly — transports
64+
* accept either shape and adapt OAuth providers automatically via {@linkcode adaptOAuthProvider}.
6565
*/
6666
export interface AuthProvider {
6767
/**
@@ -82,19 +82,17 @@ export interface AuthProvider {
8282
}
8383

8484
/**
85-
* Type guard: checks whether an `AuthProvider` is a full `OAuthClientProvider`.
86-
* Use this to gate OAuth-specific transport features like `finishAuth()` and
87-
* 403 scope upscoping.
85+
* Type guard distinguishing `OAuthClientProvider` from a minimal `AuthProvider`.
86+
* Transports use this at construction time to classify the `authProvider` option.
8887
*/
89-
export function isOAuthClientProvider(provider: AuthProvider | undefined): provider is OAuthClientProvider {
88+
export function isOAuthClientProvider(provider: AuthProvider | OAuthClientProvider | undefined): provider is OAuthClientProvider {
9089
return provider !== undefined && 'tokens' in provider && 'clientMetadata' in provider;
9190
}
9291

9392
/**
94-
* Default `onUnauthorized` implementation for OAuth providers: extracts
93+
* Standard `onUnauthorized` behavior for OAuth providers: extracts
9594
* `WWW-Authenticate` parameters from the 401 response and runs {@linkcode auth}.
96-
* Built-in providers ({@linkcode index.ClientCredentialsProvider | ClientCredentialsProvider} etc.)
97-
* delegate to this. Custom `OAuthClientProvider` implementations can do the same.
95+
* Used by {@linkcode adaptOAuthProvider} to bridge `OAuthClientProvider` to `AuthProvider`.
9896
*/
9997
export async function handleOAuthUnauthorized(provider: OAuthClientProvider, ctx: UnauthorizedContext): Promise<void> {
10098
const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(ctx.response);
@@ -109,28 +107,34 @@ export async function handleOAuthUnauthorized(provider: OAuthClientProvider, ctx
109107
}
110108
}
111109

110+
/**
111+
* Adapts an `OAuthClientProvider` to the minimal `AuthProvider` interface that
112+
* transports consume. Called once at transport construction — the transport stores
113+
* the adapted provider for `_commonHeaders()` and 401 handling, while keeping the
114+
* original `OAuthClientProvider` for OAuth-specific paths (`finishAuth()`, 403 upscoping).
115+
*/
116+
export function adaptOAuthProvider(provider: OAuthClientProvider): AuthProvider {
117+
return {
118+
token: async () => {
119+
const tokens = await provider.tokens();
120+
return tokens?.access_token;
121+
},
122+
onUnauthorized: async ctx => handleOAuthUnauthorized(provider, ctx)
123+
};
124+
}
125+
112126
/**
113127
* Implements an end-to-end OAuth client to be used with one MCP server.
114128
*
115129
* This client relies upon a concept of an authorized "session," the exact
116130
* meaning of which is application-defined. Tokens, authorization codes, and
117131
* code verifiers should not cross different sessions.
118132
*
119-
* Extends {@linkcode AuthProvider} — implementations must provide `token()`
120-
* (typically `return (await this.tokens())?.access_token`) and `onUnauthorized()`
121-
* (typically `return handleOAuthUnauthorized(this, ctx)`). Without `onUnauthorized()`,
122-
* 401 responses throw immediately with no token refresh or reauth.
133+
* Transports accept `OAuthClientProvider` directly via the `authProvider` option —
134+
* they adapt it to {@linkcode AuthProvider} internally via {@linkcode adaptOAuthProvider}.
135+
* No changes are needed to existing implementations.
123136
*/
124-
export interface OAuthClientProvider extends AuthProvider {
125-
/**
126-
* Runs the OAuth re-authentication flow on 401. Required on `OAuthClientProvider`
127-
* (optional on the base `AuthProvider`) because OAuth providers that omit this lose
128-
* all 401 recovery — no token refresh, no redirect to authorization.
129-
*
130-
* Most implementations should delegate: `return handleOAuthUnauthorized(this, ctx)`.
131-
*/
132-
onUnauthorized(ctx: UnauthorizedContext): Promise<void>;
133-
137+
export interface OAuthClientProvider {
134138
/**
135139
* The URL to redirect the user agent to after authorization.
136140
* Return `undefined` for non-interactive flows that don't require user interaction

packages/client/src/client/authExtensions.ts

Lines changed: 1 addition & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,7 @@
88
import type { FetchLike, OAuthClientInformation, OAuthClientMetadata, OAuthTokens } from '@modelcontextprotocol/core';
99
import type { CryptoKey, JWK } from 'jose';
1010

11-
import type { AddClientAuthentication, OAuthClientProvider, UnauthorizedContext } from './auth.js';
12-
import { handleOAuthUnauthorized } from './auth.js';
11+
import type { AddClientAuthentication, OAuthClientProvider } from './auth.js';
1312

1413
/**
1514
* Helper to produce a `private_key_jwt` client authentication function.
@@ -151,14 +150,6 @@ export class ClientCredentialsProvider implements OAuthClientProvider {
151150
};
152151
}
153152

154-
async token(): Promise<string | undefined> {
155-
return this._tokens?.access_token;
156-
}
157-
158-
async onUnauthorized(ctx: UnauthorizedContext): Promise<void> {
159-
await handleOAuthUnauthorized(this, ctx);
160-
}
161-
162153
get redirectUrl(): undefined {
163154
return undefined;
164155
}
@@ -278,14 +269,6 @@ export class PrivateKeyJwtProvider implements OAuthClientProvider {
278269
});
279270
}
280271

281-
async token(): Promise<string | undefined> {
282-
return this._tokens?.access_token;
283-
}
284-
285-
async onUnauthorized(ctx: UnauthorizedContext): Promise<void> {
286-
await handleOAuthUnauthorized(this, ctx);
287-
}
288-
289272
get redirectUrl(): undefined {
290273
return undefined;
291274
}
@@ -383,14 +366,6 @@ export class StaticPrivateKeyJwtProvider implements OAuthClientProvider {
383366
};
384367
}
385368

386-
async token(): Promise<string | undefined> {
387-
return this._tokens?.access_token;
388-
}
389-
390-
async onUnauthorized(ctx: UnauthorizedContext): Promise<void> {
391-
await handleOAuthUnauthorized(this, ctx);
392-
}
393-
394369
get redirectUrl(): undefined {
395370
return undefined;
396371
}
@@ -589,14 +564,6 @@ export class CrossAppAccessProvider implements OAuthClientProvider {
589564
this._fetchFn = options.fetchFn ?? fetch;
590565
}
591566

592-
async token(): Promise<string | undefined> {
593-
return this._tokens?.access_token;
594-
}
595-
596-
async onUnauthorized(ctx: UnauthorizedContext): Promise<void> {
597-
await handleOAuthUnauthorized(this, ctx);
598-
}
599-
600567
get redirectUrl(): undefined {
601568
return undefined;
602569
}

packages/client/src/client/sse.ts

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ import { createFetchWithInit, JSONRPCMessageSchema, normalizeHeaders, SdkError,
33
import type { ErrorEvent, EventSourceInit } from 'eventsource';
44
import { EventSource } from 'eventsource';
55

6-
import type { AuthProvider } from './auth.js';
7-
import { auth, extractWWWAuthenticateParams, isOAuthClientProvider, UnauthorizedError } from './auth.js';
6+
import type { AuthProvider, OAuthClientProvider } from './auth.js';
7+
import { adaptOAuthProvider, auth, extractWWWAuthenticateParams, isOAuthClientProvider, UnauthorizedError } from './auth.js';
88

99
export class SseError extends Error {
1010
constructor(
@@ -35,7 +35,7 @@ export type SSEClientTransportOptions = {
3535
* Interactive flows: after {@linkcode UnauthorizedError}, redirect the user, then call
3636
* {@linkcode SSEClientTransport.finishAuth | finishAuth} with the authorization code before reconnecting.
3737
*/
38-
authProvider?: AuthProvider;
38+
authProvider?: AuthProvider | OAuthClientProvider;
3939

4040
/**
4141
* Customizes the initial SSE request to the server (the request that begins the stream).
@@ -73,6 +73,7 @@ export class SSEClientTransport implements Transport {
7373
private _eventSourceInit?: EventSourceInit;
7474
private _requestInit?: RequestInit;
7575
private _authProvider?: AuthProvider;
76+
private _oauthProvider?: OAuthClientProvider;
7677
private _fetch?: FetchLike;
7778
private _fetchWithInit: FetchLike;
7879
private _protocolVersion?: string;
@@ -87,7 +88,12 @@ export class SSEClientTransport implements Transport {
8788
this._scope = undefined;
8889
this._eventSourceInit = opts?.eventSourceInit;
8990
this._requestInit = opts?.requestInit;
90-
this._authProvider = opts?.authProvider;
91+
if (isOAuthClientProvider(opts?.authProvider)) {
92+
this._oauthProvider = opts.authProvider;
93+
this._authProvider = adaptOAuthProvider(opts.authProvider);
94+
} else {
95+
this._authProvider = opts?.authProvider;
96+
}
9197
this._fetch = opts?.fetch;
9298
this._fetchWithInit = createFetchWithInit(opts?.fetch, opts?.requestInit);
9399
}
@@ -215,11 +221,11 @@ export class SSEClientTransport implements Transport {
215221
* Call this method after the user has finished authorizing via their user agent and is redirected back to the MCP client application. This will exchange the authorization code for an access token, enabling the next connection attempt to successfully auth.
216222
*/
217223
async finishAuth(authorizationCode: string): Promise<void> {
218-
if (!isOAuthClientProvider(this._authProvider)) {
224+
if (!this._oauthProvider) {
219225
throw new UnauthorizedError('finishAuth requires an OAuthClientProvider');
220226
}
221227

222-
const result = await auth(this._authProvider, {
228+
const result = await auth(this._oauthProvider, {
223229
serverUrl: this._url,
224230
authorizationCode,
225231
resourceMetadataUrl: this._resourceMetadataUrl,

0 commit comments

Comments
 (0)