Skip to content

Commit 9e2716a

Browse files
BREAKING: unify client auth around minimal AuthProvider interface
Transports now accept AuthProvider { token(), onUnauthorized?() } instead of being typed as OAuthClientProvider. OAuthClientProvider extends AuthProvider, so built-in providers work unchanged — but custom implementations must add token(). Key changes: - New AuthProvider interface in auth.ts — transports only need token() + optional onUnauthorized(), not the full 21-member OAuth interface - OAuthClientProvider extends AuthProvider; the 4 built-in providers implement token() + onUnauthorized() (delegating to new handleOAuthUnauthorized helper) - Transports call authProvider.token() in _commonHeaders() — one code path, no precedence rules - Transports call authProvider.onUnauthorized() on 401, retry once — ~50 lines of inline OAuth orchestration removed per transport - finishAuth() and 403 upscoping gated on isOAuthClientProvider() guard - TokenProvider type + tokenProvider option deleted — subsumed by { token: async () => ... } as authProvider See migration.md for before/after. This is an alternative design presented for discussion alongside the additive approach in the earlier commits — the team should pick one shape before merge.
1 parent 47a0eb3 commit 9e2716a

18 files changed

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

5-
Add `TokenProvider` for simple bearer-token authentication and export composable auth primitives
5+
Unify client auth around a minimal `AuthProvider` interface
66

7-
- New `TokenProvider` type — a minimal `() => Promise<string | undefined>` function interface for supplying bearer tokens. Use this instead of `OAuthClientProvider` when tokens are managed externally (gateway/proxy patterns, service accounts, upfront API tokens, or any scenario where the full OAuth redirect flow is not needed).
8-
- New `tokenProvider` option on `StreamableHTTPClientTransport` and `SSEClientTransport`. Called before every request to obtain a fresh token. If both `authProvider` and `tokenProvider` are set, `authProvider` takes precedence.
9-
- New `withBearerAuth(getToken, fetchFn?)` helper that wraps a fetch function to inject `Authorization: Bearer` headers — useful for composing with other fetch middleware.
10-
- Exported previously-internal auth helpers for building custom auth flows: `applyBasicAuth`, `applyPostAuth`, `applyPublicAuth`, `executeTokenRequest`.
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.
8+
9+
- 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.
15+
- Exported previously-internal auth helpers for building custom flows: `applyBasicAuth`, `applyPostAuth`, `applyPublicAuth`, `executeTokenRequest`.
16+
17+
See `docs/migration.md` for before/after examples.

docs/client.md

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ A client connects to a server, discovers what it offers — tools, resources, pr
1313
The examples below use these imports. Adjust based on which features and transport you need:
1414

1515
```ts source="../examples/client/src/clientGuide.examples.ts#imports"
16-
import type { Prompt, Resource, TokenProvider, Tool } from '@modelcontextprotocol/client';
16+
import type { AuthProvider, Prompt, Resource, Tool } from '@modelcontextprotocol/client';
1717
import {
1818
applyMiddlewares,
1919
Client,
@@ -113,19 +113,19 @@ console.log(systemPrompt);
113113

114114
## Authentication
115115

116-
MCP servers can require authentication before accepting client connections (see [Authorization](https://modelcontextprotocol.io/specification/latest/basic/authorization) in the MCP specification). For servers that accept plain bearer tokens, pass a `tokenProvider` function to {@linkcode @modelcontextprotocol/client!client/streamableHttp.StreamableHTTPClientTransport | StreamableHTTPClientTransport}. For servers that require OAuth 2.0, pass an `authProvider` — the SDK provides built-in providers for common machine-to-machine flows, or you can implement the full {@linkcode @modelcontextprotocol/client!client/auth.OAuthClientProvider | OAuthClientProvider} interface for user-facing OAuth.
116+
MCP servers can require authentication before accepting client connections (see [Authorization](https://modelcontextprotocol.io/specification/latest/basic/authorization) in the MCP specification). Pass an {@linkcode @modelcontextprotocol/client!client/auth.AuthProvider | AuthProvider} to {@linkcode @modelcontextprotocol/client!client/streamableHttp.StreamableHTTPClientTransport | StreamableHTTPClientTransport}. The transport calls `token()` before every request and `onUnauthorized()` (if provided) on 401, then retries once.
117117

118-
### Token provider
118+
### Bearer tokens
119119

120-
For servers that accept bearer tokens managed outside the SDK — API keys, tokens from a gateway or proxy, service-account credentials, or tokens obtained through a separate auth flow — pass a {@linkcode @modelcontextprotocol/client!client/tokenProvider.TokenProvider | TokenProvider} function. It is called before every request, so it can handle expiry and refresh internally. If the server rejects the token with 401, the transport throws {@linkcode @modelcontextprotocol/client!client/auth.UnauthorizedError | UnauthorizedError} without retrying — catch it to invalidate any external cache and reconnect:
120+
For servers that accept bearer tokens managed outside the SDK — API keys, tokens from a gateway or proxy, service-account credentials — implement only `token()`. With no `onUnauthorized()`, a 401 throws {@linkcode @modelcontextprotocol/client!client/auth.UnauthorizedError | UnauthorizedError} immediately:
121121

122122
```ts source="../examples/client/src/clientGuide.examples.ts#auth_tokenProvider"
123-
const tokenProvider: TokenProvider = async () => getStoredToken();
123+
const authProvider: AuthProvider = { token: async () => getStoredToken() };
124124

125-
const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp'), { tokenProvider });
125+
const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp'), { authProvider });
126126
```
127127

128-
See [`simpleTokenProvider.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/client/src/simpleTokenProvider.ts) for a complete runnable example. For finer control, {@linkcode @modelcontextprotocol/client!client/tokenProvider.withBearerAuth | withBearerAuth} wraps a fetch function directly.
128+
See [`simpleTokenProvider.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/client/src/simpleTokenProvider.ts) for a complete runnable example.
129129

130130
### Client credentials
131131

docs/migration.md

Lines changed: 94 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,7 @@ server.registerTool('ping', {
252252
```
253253

254254
This applies to:
255+
255256
- `inputSchema` in `registerTool()`
256257
- `outputSchema` in `registerTool()`
257258
- `argsSchema` in `registerPrompt()`
@@ -339,25 +340,21 @@ Common method string replacements:
339340

340341
### `Protocol.request()`, `ctx.mcpReq.send()`, and `Client.callTool()` no longer take a schema parameter
341342

342-
The public `Protocol.request()`, `BaseContext.mcpReq.send()`, and `Client.callTool()` methods no longer accept a Zod result schema argument. The SDK now resolves the correct result schema internally based on the method name. This means you no longer need to import result schemas like `CallToolResultSchema` or `ElicitResultSchema` when making requests.
343+
The public `Protocol.request()`, `BaseContext.mcpReq.send()`, and `Client.callTool()` methods no longer accept a Zod result schema argument. The SDK now resolves the correct result schema internally based on the method name. This means you no longer need to import result schemas
344+
like `CallToolResultSchema` or `ElicitResultSchema` when making requests.
343345

344346
**`client.request()` — Before (v1):**
345347

346348
```typescript
347349
import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js';
348350

349-
const result = await client.request(
350-
{ method: 'tools/call', params: { name: 'my-tool', arguments: {} } },
351-
CallToolResultSchema
352-
);
351+
const result = await client.request({ method: 'tools/call', params: { name: 'my-tool', arguments: {} } }, CallToolResultSchema);
353352
```
354353

355354
**After (v2):**
356355

357356
```typescript
358-
const result = await client.request(
359-
{ method: 'tools/call', params: { name: 'my-tool', arguments: {} } }
360-
);
357+
const result = await client.request({ method: 'tools/call', params: { name: 'my-tool', arguments: {} } });
361358
```
362359

363360
**`ctx.mcpReq.send()` — Before (v1):**
@@ -390,10 +387,7 @@ server.setRequestHandler('tools/call', async (request, ctx) => {
390387
```typescript
391388
import { CompatibilityCallToolResultSchema } from '@modelcontextprotocol/sdk/types.js';
392389

393-
const result = await client.callTool(
394-
{ name: 'my-tool', arguments: {} },
395-
CompatibilityCallToolResultSchema
396-
);
390+
const result = await client.callTool({ name: 'my-tool', arguments: {} }, CompatibilityCallToolResultSchema);
397391
```
398392

399393
**After (v2):**
@@ -452,43 +446,43 @@ import { JSONRPCErrorResponse, ResourceTemplateReference, isJSONRPCErrorResponse
452446

453447
The `RequestHandlerExtra` type has been replaced with a structured context type hierarchy using nested groups:
454448

455-
| v1 | v2 |
456-
|----|-----|
449+
| v1 | v2 |
450+
| ---------------------------------------- | ---------------------------------------------------------------------- |
457451
| `RequestHandlerExtra` (flat, all fields) | `ServerContext` (server handlers) or `ClientContext` (client handlers) |
458-
| `extra` parameter name | `ctx` parameter name |
459-
| `extra.signal` | `ctx.mcpReq.signal` |
460-
| `extra.requestId` | `ctx.mcpReq.id` |
461-
| `extra._meta` | `ctx.mcpReq._meta` |
462-
| `extra.sendRequest(...)` | `ctx.mcpReq.send(...)` |
463-
| `extra.sendNotification(...)` | `ctx.mcpReq.notify(...)` |
464-
| `extra.authInfo` | `ctx.http?.authInfo` |
465-
| `extra.requestInfo` | `ctx.http?.req` (only on `ServerContext`) |
466-
| `extra.closeSSEStream` | `ctx.http?.closeSSE` (only on `ServerContext`) |
467-
| `extra.closeStandaloneSSEStream` | `ctx.http?.closeStandaloneSSE` (only on `ServerContext`) |
468-
| `extra.sessionId` | `ctx.sessionId` |
469-
| `extra.taskStore` | `ctx.task?.store` |
470-
| `extra.taskId` | `ctx.task?.id` |
471-
| `extra.taskRequestedTtl` | `ctx.task?.requestedTtl` |
452+
| `extra` parameter name | `ctx` parameter name |
453+
| `extra.signal` | `ctx.mcpReq.signal` |
454+
| `extra.requestId` | `ctx.mcpReq.id` |
455+
| `extra._meta` | `ctx.mcpReq._meta` |
456+
| `extra.sendRequest(...)` | `ctx.mcpReq.send(...)` |
457+
| `extra.sendNotification(...)` | `ctx.mcpReq.notify(...)` |
458+
| `extra.authInfo` | `ctx.http?.authInfo` |
459+
| `extra.requestInfo` | `ctx.http?.req` (only on `ServerContext`) |
460+
| `extra.closeSSEStream` | `ctx.http?.closeSSE` (only on `ServerContext`) |
461+
| `extra.closeStandaloneSSEStream` | `ctx.http?.closeStandaloneSSE` (only on `ServerContext`) |
462+
| `extra.sessionId` | `ctx.sessionId` |
463+
| `extra.taskStore` | `ctx.task?.store` |
464+
| `extra.taskId` | `ctx.task?.id` |
465+
| `extra.taskRequestedTtl` | `ctx.task?.requestedTtl` |
472466

473467
**Before (v1):**
474468

475469
```typescript
476470
server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
477-
const headers = extra.requestInfo?.headers;
478-
const taskStore = extra.taskStore;
479-
await extra.sendNotification({ method: 'notifications/progress', params: { progressToken: 'abc', progress: 50, total: 100 } });
480-
return { content: [{ type: 'text', text: 'result' }] };
471+
const headers = extra.requestInfo?.headers;
472+
const taskStore = extra.taskStore;
473+
await extra.sendNotification({ method: 'notifications/progress', params: { progressToken: 'abc', progress: 50, total: 100 } });
474+
return { content: [{ type: 'text', text: 'result' }] };
481475
});
482476
```
483477

484478
**After (v2):**
485479

486480
```typescript
487481
server.setRequestHandler('tools/call', async (request, ctx) => {
488-
const headers = ctx.http?.req?.headers;
489-
const taskStore = ctx.task?.store;
490-
await ctx.mcpReq.notify({ method: 'notifications/progress', params: { progressToken: 'abc', progress: 50, total: 100 } });
491-
return { content: [{ type: 'text', text: 'result' }] };
482+
const headers = ctx.http?.req?.headers;
483+
const taskStore = ctx.task?.store;
484+
await ctx.mcpReq.notify({ method: 'notifications/progress', params: { progressToken: 'abc', progress: 50, total: 100 } });
485+
return { content: [{ type: 'text', text: 'result' }] };
492486
});
493487
```
494488

@@ -504,22 +498,22 @@ Context fields are organized into 4 groups:
504498

505499
```typescript
506500
server.setRequestHandler('tools/call', async (request, ctx) => {
507-
// Send a log message (respects client's log level filter)
508-
await ctx.mcpReq.log('info', 'Processing tool call', 'my-logger');
509-
510-
// Request client to sample an LLM
511-
const samplingResult = await ctx.mcpReq.requestSampling({
512-
messages: [{ role: 'user', content: { type: 'text', text: 'Hello' } }],
513-
maxTokens: 100,
514-
});
515-
516-
// Elicit user input via a form
517-
const elicitResult = await ctx.mcpReq.elicitInput({
518-
message: 'Please provide details',
519-
requestedSchema: { type: 'object', properties: { name: { type: 'string' } } },
520-
});
521-
522-
return { content: [{ type: 'text', text: 'done' }] };
501+
// Send a log message (respects client's log level filter)
502+
await ctx.mcpReq.log('info', 'Processing tool call', 'my-logger');
503+
504+
// Request client to sample an LLM
505+
const samplingResult = await ctx.mcpReq.requestSampling({
506+
messages: [{ role: 'user', content: { type: 'text', text: 'Hello' } }],
507+
maxTokens: 100
508+
});
509+
510+
// Elicit user input via a form
511+
const elicitResult = await ctx.mcpReq.elicitInput({
512+
message: 'Please provide details',
513+
requestedSchema: { type: 'object', properties: { name: { type: 'string' } } }
514+
});
515+
516+
return { content: [{ type: 'text', text: 'done' }] };
523517
});
524518
```
525519

@@ -646,13 +640,52 @@ try {
646640

647641
#### Why this change?
648642

649-
Previously, `ErrorCode.RequestTimeout` (-32001) and `ErrorCode.ConnectionClosed` (-32000) were used for local timeout/connection errors. However, these errors never cross the wire as JSON-RPC responses - they are rejected locally. Using protocol error codes for local errors was semantically inconsistent.
643+
Previously, `ErrorCode.RequestTimeout` (-32001) and `ErrorCode.ConnectionClosed` (-32000) were used for local timeout/connection errors. However, these errors never cross the wire as JSON-RPC responses - they are rejected locally. Using protocol error codes for local errors was
644+
semantically inconsistent.
650645

651646
The new design:
652647

653648
- `ProtocolError` with `ProtocolErrorCode`: For errors that are serialized and sent as JSON-RPC error responses
654649
- `SdkError` with `SdkErrorCode`: For local errors that are thrown/rejected locally and never leave the SDK
655650

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+
// Optional but recommended: runs the OAuth flow on 401
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+
656689
### OAuth error refactoring
657690

658691
The OAuth error classes have been consolidated into a single `OAuthError` class with an `OAuthErrorCode` enum.
@@ -743,11 +776,11 @@ This means Cloudflare Workers users no longer need to explicitly pass the valida
743776
import { McpServer, CfWorkerJsonSchemaValidator } from '@modelcontextprotocol/server';
744777

745778
const server = new McpServer(
746-
{ name: 'my-server', version: '1.0.0' },
747-
{
748-
capabilities: { tools: {} },
749-
jsonSchemaValidator: new CfWorkerJsonSchemaValidator() // Required in v1
750-
}
779+
{ name: 'my-server', version: '1.0.0' },
780+
{
781+
capabilities: { tools: {} },
782+
jsonSchemaValidator: new CfWorkerJsonSchemaValidator() // Required in v1
783+
}
751784
);
752785
```
753786

@@ -757,9 +790,9 @@ const server = new McpServer(
757790
import { McpServer } from '@modelcontextprotocol/server';
758791

759792
const server = new McpServer(
760-
{ name: 'my-server', version: '1.0.0' },
761-
{ capabilities: { tools: {} } }
762-
// Validator auto-selected based on runtime
793+
{ name: 'my-server', version: '1.0.0' },
794+
{ capabilities: { tools: {} } }
795+
// Validator auto-selected based on runtime
763796
);
764797
```
765798

examples/client/src/clientGuide.examples.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
*/
99

1010
//#region imports
11-
import type { Prompt, Resource, TokenProvider, Tool } from '@modelcontextprotocol/client';
11+
import type { AuthProvider, Prompt, Resource, Tool } from '@modelcontextprotocol/client';
1212
import {
1313
applyMiddlewares,
1414
Client,
@@ -107,12 +107,12 @@ async function serverInstructions_basic(client: Client) {
107107
// Authentication
108108
// ---------------------------------------------------------------------------
109109

110-
/** Example: TokenProvider for bearer auth with externally-managed tokens. */
110+
/** Example: Minimal AuthProvider for bearer auth with externally-managed tokens. */
111111
async function auth_tokenProvider(getStoredToken: () => Promise<string>) {
112112
//#region auth_tokenProvider
113-
const tokenProvider: TokenProvider = async () => getStoredToken();
113+
const authProvider: AuthProvider = { token: async () => getStoredToken() };
114114

115-
const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp'), { tokenProvider });
115+
const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp'), { authProvider });
116116
//#endregion auth_tokenProvider
117117
return transport;
118118
}

examples/client/src/simpleOAuthClientProvider.ts

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

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

2532
private _onRedirect: (url: URL) => void;
2633

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+
2742
get redirectUrl(): string | URL {
2843
return this._redirectUrl;
2944
}

0 commit comments

Comments
 (0)