Skip to content

Commit 9aea20f

Browse files
feat: add TokenProvider for composable bearer-token auth (non-breaking)
Adds a minimal `() => Promise<string | undefined>` function type as a lightweight alternative to OAuthClientProvider, for scenarios where bearer tokens are managed externally (gateway/proxy patterns, service accounts, API keys). - New TokenProvider type + withBearerAuth(getToken, fetchFn?) helper - New tokenProvider option on StreamableHTTPClientTransport and SSEClientTransport, used as fallback after authProvider in _commonHeaders(). authProvider takes precedence when both set. - On 401 with tokenProvider (no authProvider), transports throw UnauthorizedError — no retry, since tokenProvider() is already called before every request and would likely return the same rejected token. Callers catch UnauthorizedError, invalidate external cache, reconnect. - Exported previously-internal auth helpers for building custom flows: applyBasicAuth, applyPostAuth, applyPublicAuth, executeTokenRequest. - Tests, example, docs, changeset. Zero breakage. Bughunter fleet review: 28 findings submitted, 2 confirmed, both addressed.
1 parent ccb78f2 commit 9aea20f

10 files changed

Lines changed: 478 additions & 52 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@modelcontextprotocol/client': minor
3+
---
4+
5+
Add `TokenProvider` for simple bearer-token authentication and export composable auth primitives
6+
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`.

docs/client.md

Lines changed: 14 additions & 2 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, Tool } from '@modelcontextprotocol/client';
16+
import type { Prompt, Resource, TokenProvider, Tool } from '@modelcontextprotocol/client';
1717
import {
1818
applyMiddlewares,
1919
Client,
@@ -113,7 +113,19 @@ console.log(systemPrompt);
113113

114114
## Authentication
115115

116-
MCP servers can require OAuth 2.0 authentication before accepting client connections (see [Authorization](https://modelcontextprotocol.io/specification/latest/basic/authorization) in the MCP specification). Pass an `authProvider` to {@linkcode @modelcontextprotocol/client!client/streamableHttp.StreamableHTTPClientTransport | StreamableHTTPClientTransport} to enable this — 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). 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.
117+
118+
### Token provider
119+
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:
121+
122+
```ts source="../examples/client/src/clientGuide.examples.ts#auth_tokenProvider"
123+
const tokenProvider: TokenProvider = async () => getStoredToken();
124+
125+
const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp'), { tokenProvider });
126+
```
127+
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.
117129

118130
### Client credentials
119131

examples/client/src/clientGuide.examples.ts

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

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

110+
/** Example: TokenProvider for bearer auth with externally-managed tokens. */
111+
async function auth_tokenProvider(getStoredToken: () => Promise<string>) {
112+
//#region auth_tokenProvider
113+
const tokenProvider: TokenProvider = async () => getStoredToken();
114+
115+
const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp'), { tokenProvider });
116+
//#endregion auth_tokenProvider
117+
return transport;
118+
}
119+
110120
/** Example: Client credentials auth for service-to-service communication. */
111121
async function auth_clientCredentials() {
112122
//#region auth_clientCredentials
@@ -540,6 +550,7 @@ void connect_stdio;
540550
void connect_sseFallback;
541551
void disconnect_streamableHttp;
542552
void serverInstructions_basic;
553+
void auth_tokenProvider;
543554
void auth_clientCredentials;
544555
void auth_privateKeyJwt;
545556
void auth_crossAppAccess;
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
#!/usr/bin/env node
2+
3+
/**
4+
* Example demonstrating TokenProvider for simple bearer token authentication.
5+
*
6+
* TokenProvider is a lightweight alternative to OAuthClientProvider for cases
7+
* where tokens are managed externally — e.g., pre-configured API tokens,
8+
* gateway/proxy patterns, or tokens obtained through a separate auth flow.
9+
*
10+
* Environment variables:
11+
* MCP_SERVER_URL - Server URL (default: http://localhost:3000/mcp)
12+
* MCP_TOKEN - Bearer token to use for authentication (required)
13+
*
14+
* Two approaches are demonstrated:
15+
* 1. Using `tokenProvider` option on the transport (simplest)
16+
* 2. Using `withBearerAuth` to wrap a custom fetch function (more flexible)
17+
*/
18+
19+
import type { TokenProvider } from '@modelcontextprotocol/client';
20+
import { Client, StreamableHTTPClientTransport, withBearerAuth } from '@modelcontextprotocol/client';
21+
22+
const DEFAULT_SERVER_URL = process.env.MCP_SERVER_URL || 'http://localhost:3000/mcp';
23+
24+
async function main() {
25+
const token = process.env.MCP_TOKEN;
26+
if (!token) {
27+
console.error('MCP_TOKEN environment variable is required');
28+
process.exit(1);
29+
}
30+
31+
// A TokenProvider is just an async function that returns a token string.
32+
// It is called before every request, so it can handle refresh logic internally.
33+
const tokenProvider: TokenProvider = async () => token;
34+
35+
const client = new Client({ name: 'token-provider-example', version: '1.0.0' }, { capabilities: {} });
36+
37+
// Approach 1: Pass tokenProvider directly to the transport.
38+
// This is the simplest way to add bearer auth.
39+
const transport = new StreamableHTTPClientTransport(new URL(DEFAULT_SERVER_URL), {
40+
tokenProvider
41+
});
42+
43+
// Approach 2 (alternative): Use withBearerAuth to wrap fetch.
44+
// This is useful when you need more control over the fetch behavior,
45+
// or when composing with other fetch wrappers.
46+
//
47+
// const transport = new StreamableHTTPClientTransport(new URL(DEFAULT_SERVER_URL), {
48+
// fetch: withBearerAuth(tokenProvider),
49+
// });
50+
51+
await client.connect(transport);
52+
console.log('Connected successfully.');
53+
54+
const tools = await client.listTools();
55+
console.log('Available tools:', tools.tools.map(t => t.name).join(', ') || '(none)');
56+
57+
await transport.close();
58+
}
59+
60+
try {
61+
await main();
62+
} catch (error) {
63+
console.error('Error running client:', error);
64+
// eslint-disable-next-line unicorn/no-process-exit
65+
process.exit(1);
66+
}
67+
68+
// Referenced in the commented-out Approach 2 above; kept so uncommenting it type-checks.
69+
void withBearerAuth;

packages/client/src/client/auth.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -381,7 +381,7 @@ export function applyClientAuthentication(
381381
/**
382382
* Applies HTTP Basic authentication (RFC 6749 Section 2.3.1)
383383
*/
384-
function applyBasicAuth(clientId: string, clientSecret: string | undefined, headers: Headers): void {
384+
export function applyBasicAuth(clientId: string, clientSecret: string | undefined, headers: Headers): void {
385385
if (!clientSecret) {
386386
throw new Error('client_secret_basic authentication requires a client_secret');
387387
}
@@ -393,7 +393,7 @@ function applyBasicAuth(clientId: string, clientSecret: string | undefined, head
393393
/**
394394
* Applies POST body authentication (RFC 6749 Section 2.3.1)
395395
*/
396-
function applyPostAuth(clientId: string, clientSecret: string | undefined, params: URLSearchParams): void {
396+
export function applyPostAuth(clientId: string, clientSecret: string | undefined, params: URLSearchParams): void {
397397
params.set('client_id', clientId);
398398
if (clientSecret) {
399399
params.set('client_secret', clientSecret);
@@ -403,7 +403,7 @@ function applyPostAuth(clientId: string, clientSecret: string | undefined, param
403403
/**
404404
* Applies public client authentication (RFC 6749 Section 2.1)
405405
*/
406-
function applyPublicAuth(clientId: string, params: URLSearchParams): void {
406+
export function applyPublicAuth(clientId: string, params: URLSearchParams): void {
407407
params.set('client_id', clientId);
408408
}
409409

@@ -1265,7 +1265,7 @@ export function prepareAuthorizationCodeRequest(
12651265
* Internal helper to execute a token request with the given parameters.
12661266
* Used by {@linkcode exchangeAuthorization}, {@linkcode refreshAuthorization}, and {@linkcode fetchToken}.
12671267
*/
1268-
async function executeTokenRequest(
1268+
export async function executeTokenRequest(
12691269
authorizationServerUrl: string | URL,
12701270
{
12711271
metadata,

packages/client/src/client/sse.ts

Lines changed: 49 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { EventSource } from 'eventsource';
55

66
import type { AuthResult, OAuthClientProvider } from './auth.js';
77
import { auth, extractWWWAuthenticateParams, UnauthorizedError } from './auth.js';
8+
import type { TokenProvider } from './tokenProvider.js';
89

910
export class SseError extends Error {
1011
constructor(
@@ -36,6 +37,16 @@ export type SSEClientTransportOptions = {
3637
*/
3738
authProvider?: OAuthClientProvider;
3839

40+
/**
41+
* A simple token provider for bearer authentication.
42+
*
43+
* Use this instead of `authProvider` when tokens are managed externally
44+
* (e.g., upfront auth, gateway/proxy patterns, service accounts).
45+
*
46+
* If both `authProvider` and `tokenProvider` are set, `authProvider` takes precedence.
47+
*/
48+
tokenProvider?: TokenProvider;
49+
3950
/**
4051
* Customizes the initial SSE request to the server (the request that begins the stream).
4152
*
@@ -72,6 +83,7 @@ export class SSEClientTransport implements Transport {
7283
private _eventSourceInit?: EventSourceInit;
7384
private _requestInit?: RequestInit;
7485
private _authProvider?: OAuthClientProvider;
86+
private _tokenProvider?: TokenProvider;
7587
private _fetch?: FetchLike;
7688
private _fetchWithInit: FetchLike;
7789
private _protocolVersion?: string;
@@ -87,6 +99,7 @@ export class SSEClientTransport implements Transport {
8799
this._eventSourceInit = opts?.eventSourceInit;
88100
this._requestInit = opts?.requestInit;
89101
this._authProvider = opts?.authProvider;
102+
this._tokenProvider = opts?.tokenProvider;
90103
this._fetch = opts?.fetch;
91104
this._fetchWithInit = createFetchWithInit(opts?.fetch, opts?.requestInit);
92105
}
@@ -123,6 +136,11 @@ export class SSEClientTransport implements Transport {
123136
if (tokens) {
124137
headers['Authorization'] = `Bearer ${tokens.access_token}`;
125138
}
139+
} else if (this._tokenProvider) {
140+
const token = await this._tokenProvider();
141+
if (token) {
142+
headers['Authorization'] = `Bearer ${token}`;
143+
}
126144
}
127145
if (this._protocolVersion) {
128146
headers['mcp-protocol-version'] = this._protocolVersion;
@@ -161,9 +179,17 @@ export class SSEClientTransport implements Transport {
161179
this._abortController = new AbortController();
162180

163181
this._eventSource.onerror = event => {
164-
if (event.code === 401 && this._authProvider) {
165-
this._authThenStart().then(resolve, reject);
166-
return;
182+
if (event.code === 401) {
183+
if (this._authProvider) {
184+
this._authThenStart().then(resolve, reject);
185+
return;
186+
}
187+
if (this._tokenProvider) {
188+
const error = new UnauthorizedError('Server returned 401 — token from tokenProvider was rejected');
189+
reject(error);
190+
this.onerror?.(error);
191+
return;
192+
}
167193
}
168194

169195
const error = new SseError(event.code, event.message, event);
@@ -263,23 +289,28 @@ export class SSEClientTransport implements Transport {
263289
if (!response.ok) {
264290
const text = await response.text?.().catch(() => null);
265291

266-
if (response.status === 401 && this._authProvider) {
267-
const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response);
268-
this._resourceMetadataUrl = resourceMetadataUrl;
269-
this._scope = scope;
292+
if (response.status === 401) {
293+
if (this._authProvider) {
294+
const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response);
295+
this._resourceMetadataUrl = resourceMetadataUrl;
296+
this._scope = scope;
270297

271-
const result = await auth(this._authProvider, {
272-
serverUrl: this._url,
273-
resourceMetadataUrl: this._resourceMetadataUrl,
274-
scope: this._scope,
275-
fetchFn: this._fetchWithInit
276-
});
277-
if (result !== 'AUTHORIZED') {
278-
throw new UnauthorizedError();
298+
const result = await auth(this._authProvider, {
299+
serverUrl: this._url,
300+
resourceMetadataUrl: this._resourceMetadataUrl,
301+
scope: this._scope,
302+
fetchFn: this._fetchWithInit
303+
});
304+
if (result !== 'AUTHORIZED') {
305+
throw new UnauthorizedError();
306+
}
307+
308+
// Purposely _not_ awaited, so we don't call onerror twice
309+
return this.send(message);
310+
}
311+
if (this._tokenProvider) {
312+
throw new UnauthorizedError('Server returned 401 — token from tokenProvider was rejected');
279313
}
280-
281-
// Purposely _not_ awaited, so we don't call onerror twice
282-
return this.send(message);
283314
}
284315

285316
throw new Error(`Error POSTing to endpoint (HTTP ${response.status}): ${text}`);

0 commit comments

Comments
 (0)