Skip to content

Commit 78c714c

Browse files
Do not apply transport request headers to OAuth requests
The client transports built one requestInit-merging fetch and used it for both MCP traffic and the requests issued by the OAuth authorization flow, so headers configured for the MCP connection rode along on every protected-resource metadata, authorization-server metadata, token, and client registration request — including ones targeting a different origin than the MCP server. Connection-level requestInit headers now apply only to MCP requests. The authorization flow uses a fetch built from the custom fetch option and a new oauthRequestInit transport option, which configures those requests explicitly.
1 parent cc70c5e commit 78c714c

6 files changed

Lines changed: 348 additions & 15 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@modelcontextprotocol/client': patch
3+
---
4+
5+
Transport `requestInit` headers now apply only to MCP requests, not to the OAuth requests issued by the transport's authorization flow (protected-resource metadata, authorization-server metadata, token, and client registration requests) — those may target a different origin than the MCP server, so connection-level headers do not carry over. A new `oauthRequestInit` transport option configures those requests explicitly.

docs/migration/upgrade-to-v2.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1112,6 +1112,14 @@ OAuth `onUnauthorized` behavior, for composing your own adapter).
11121112
- **Metadata discovery falls through on 502.** `discoverAuthorizationServerMetadata()`
11131113
treats `502 Bad Gateway` like 4xx — fall through to the next candidate URL instead of
11141114
throwing (fixes path-aware discovery behind reverse proxies). Other 5xx still throw.
1115+
- **Transport `requestInit` headers stay off OAuth requests.** Headers configured via
1116+
the `requestInit` option on `StreamableHTTPClientTransport` / `SSEClientTransport`
1117+
apply only to MCP requests; the OAuth requests the transport's authorization flow
1118+
issues (protected-resource metadata, authorization-server metadata, token, and client
1119+
registration requests) are sent without them — those may target a different origin
1120+
than the MCP server, so connection-level headers do not carry over. A deployment that
1121+
needs extra headers on those requests (e.g. gateway headers on well-known endpoints)
1122+
sets the new `oauthRequestInit` transport option.
11151123
- **Scoped credential invalidation on `invalid_client` / `unauthorized_client`.** The
11161124
`auth()` retry for these errors now issues two scoped calls —
11171125
`invalidateCredentials('client')` then `invalidateCredentials('tokens')` — instead of

packages/client/src/client/sse.ts

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -102,10 +102,14 @@ export type SSEClientTransportOptions = {
102102
eventSourceInit?: EventSourceInit;
103103

104104
/**
105-
* Customizes recurring `POST` requests to the server.
105+
* Customizes recurring `POST` requests to the server. Not applied to OAuth requests —
106+
* use {@linkcode SSEClientTransportOptions.oauthRequestInit | oauthRequestInit} for those.
106107
*/
107108
requestInit?: RequestInit;
108109

110+
/** Customizes the OAuth requests issued by the transport's authorization flow. */
111+
oauthRequestInit?: RequestInit;
112+
109113
/**
110114
* Custom fetch implementation used for all network requests.
111115
*/
@@ -130,7 +134,8 @@ export class SSEClientTransport implements Transport {
130134
private _oauthProvider?: OAuthClientProvider;
131135
private _skipIssuerMetadataValidation?: boolean;
132136
private _fetch?: FetchLike;
133-
private _fetchWithInit: FetchLike;
137+
/** Fetch for OAuth requests — deliberately merges `oauthRequestInit`, never `requestInit`. */
138+
private _authFetch: FetchLike;
134139
private _protocolVersion?: string;
135140

136141
onclose?: () => void;
@@ -153,7 +158,7 @@ export class SSEClientTransport implements Transport {
153158
this._authProvider = opts?.authProvider;
154159
}
155160
this._fetch = opts?.fetch;
156-
this._fetchWithInit = createFetchWithInit(opts?.fetch, opts?.requestInit);
161+
this._authFetch = createFetchWithInit(opts?.fetch, opts?.oauthRequestInit);
157162
}
158163

159164
private _last401Response?: Response;
@@ -209,7 +214,7 @@ export class SSEClientTransport implements Transport {
209214
const response = this._last401Response;
210215
this._last401Response = undefined;
211216
this._eventSource?.close();
212-
this._authProvider.onUnauthorized({ response, serverUrl: this._url, fetchFn: this._fetchWithInit }).then(
217+
this._authProvider.onUnauthorized({ response, serverUrl: this._url, fetchFn: this._authFetch }).then(
213218
// onUnauthorized succeeded → retry fresh. Its onerror handles its own onerror?.() + reject.
214219
() => this._startOrAuth().then(resolve, reject),
215220
// onUnauthorized failed → not yet reported.
@@ -309,7 +314,7 @@ export class SSEClientTransport implements Transport {
309314
iss,
310315
this._oauthProvider,
311316
this._url,
312-
{ fetchFn: this._fetchWithInit, resourceMetadataUrl: this._resourceMetadataUrl }
317+
{ fetchFn: this._authFetch, resourceMetadataUrl: this._resourceMetadataUrl }
313318
);
314319

315320
const result = await auth(this._oauthProvider, {
@@ -318,7 +323,7 @@ export class SSEClientTransport implements Transport {
318323
iss: issParam,
319324
resourceMetadataUrl: this._resourceMetadataUrl,
320325
scope: this._scope,
321-
fetchFn: this._fetchWithInit,
326+
fetchFn: this._authFetch,
322327
skipIssuerMetadataValidation: this._skipIssuerMetadataValidation
323328
});
324329
if (result !== 'AUTHORIZED') {
@@ -365,7 +370,7 @@ export class SSEClientTransport implements Transport {
365370
await this._authProvider.onUnauthorized({
366371
response,
367372
serverUrl: this._url,
368-
fetchFn: this._fetchWithInit
373+
fetchFn: this._authFetch
369374
});
370375
await response.text?.().catch(() => {});
371376
// Purposely _not_ awaited, so we don't call onerror twice

packages/client/src/client/streamableHttp.ts

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -175,10 +175,14 @@ export type StreamableHTTPClientTransportOptions = {
175175
skipIssuerMetadataValidation?: boolean;
176176

177177
/**
178-
* Customizes HTTP requests to the server.
178+
* Customizes HTTP requests to the MCP server. Not applied to OAuth requests —
179+
* use {@linkcode StreamableHTTPClientTransportOptions.oauthRequestInit | oauthRequestInit} for those.
179180
*/
180181
requestInit?: RequestInit;
181182

183+
/** Customizes the OAuth requests issued by the transport's authorization flow. */
184+
oauthRequestInit?: RequestInit;
185+
182186
/**
183187
* Custom fetch implementation used for all network requests.
184188
*/
@@ -313,7 +317,8 @@ export class StreamableHTTPClientTransport implements Transport {
313317
private _oauthProvider?: OAuthClientProvider;
314318
private _skipIssuerMetadataValidation?: boolean;
315319
private _fetch?: FetchLike;
316-
private _fetchWithInit: FetchLike;
320+
/** Fetch for OAuth requests — deliberately merges `oauthRequestInit`, never `requestInit`. */
321+
private _authFetch: FetchLike;
317322
private _sessionId?: string;
318323
private _reconnectionOptions: StreamableHTTPReconnectionOptions;
319324
private _protocolVersion?: string;
@@ -350,7 +355,7 @@ export class StreamableHTTPClientTransport implements Transport {
350355
this._authProvider = opts?.authProvider;
351356
}
352357
this._fetch = opts?.fetch;
353-
this._fetchWithInit = createFetchWithInit(opts?.fetch, opts?.requestInit);
358+
this._authFetch = createFetchWithInit(opts?.fetch, opts?.oauthRequestInit);
354359
this._sessionId = opts?.sessionId;
355360
this._protocolVersion = opts?.protocolVersion;
356361
this._reconnectionOptions = opts?.reconnectionOptions ?? DEFAULT_STREAMABLE_HTTP_RECONNECTION_OPTIONS;
@@ -414,7 +419,7 @@ export class StreamableHTTPClientTransport implements Transport {
414419
resourceMetadataUrl: this._resourceMetadataUrl,
415420
scope: unionScope,
416421
forceReauthorization,
417-
fetchFn: this._fetchWithInit,
422+
fetchFn: this._authFetch,
418423
skipIssuerMetadataValidation: this._skipIssuerMetadataValidation
419424
});
420425
}
@@ -545,7 +550,7 @@ export class StreamableHTTPClientTransport implements Transport {
545550
await this._authProvider.onUnauthorized({
546551
response,
547552
serverUrl: this._url,
548-
fetchFn: this._fetchWithInit
553+
fetchFn: this._authFetch
549554
});
550555
await response.text?.().catch(() => {});
551556
// Purposely _not_ awaited, so we don't call onerror twice
@@ -863,7 +868,7 @@ export class StreamableHTTPClientTransport implements Transport {
863868
iss,
864869
this._oauthProvider,
865870
this._url,
866-
{ fetchFn: this._fetchWithInit, resourceMetadataUrl: this._resourceMetadataUrl }
871+
{ fetchFn: this._authFetch, resourceMetadataUrl: this._resourceMetadataUrl }
867872
);
868873

869874
const result = await auth(this._oauthProvider, {
@@ -872,7 +877,7 @@ export class StreamableHTTPClientTransport implements Transport {
872877
iss: issParam,
873878
resourceMetadataUrl: this._resourceMetadataUrl,
874879
scope: this._scope,
875-
fetchFn: this._fetchWithInit,
880+
fetchFn: this._authFetch,
876881
skipIssuerMetadataValidation: this._skipIssuerMetadataValidation
877882
});
878883
if (result !== 'AUTHORIZED') {
@@ -994,7 +999,7 @@ export class StreamableHTTPClientTransport implements Transport {
994999
await this._authProvider.onUnauthorized({
9951000
response,
9961001
serverUrl: this._url,
997-
fetchFn: this._fetchWithInit
1002+
fetchFn: this._authFetch
9981003
});
9991004
await response.text?.().catch(() => {});
10001005
// Purposely _not_ awaited, so we don't call onerror twice

packages/client/test/client/sse.test.ts

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1548,6 +1548,128 @@ describe('SSEClientTransport', () => {
15481548
});
15491549
});
15501550

1551+
describe('authorization request header isolation', () => {
1552+
type RecordedCall = { url: string; init?: RequestInit };
1553+
1554+
const headerOnCall = (call: RecordedCall, name: string): string | null => new Headers(call.init?.headers).get(name);
1555+
1556+
const isAuthCall = (call: RecordedCall): boolean =>
1557+
call.url.includes('/.well-known/') || call.url.endsWith('/register') || call.url.endsWith('/token');
1558+
1559+
const createIsolationAuthProvider = (): Mocked<OAuthClientProvider> => ({
1560+
get redirectUrl() {
1561+
return 'http://localhost/callback';
1562+
},
1563+
get clientMetadata() {
1564+
return { redirect_uris: ['http://localhost/callback'] };
1565+
},
1566+
clientInformation: vi.fn(() => ({ client_id: 'test-client-id', client_secret: 'test-client-secret' })),
1567+
tokens: vi.fn(),
1568+
saveTokens: vi.fn(),
1569+
redirectToAuthorization: vi.fn(),
1570+
saveCodeVerifier: vi.fn(),
1571+
codeVerifier: vi.fn(),
1572+
invalidateCredentials: vi.fn()
1573+
});
1574+
1575+
/**
1576+
* Serves protected-resource metadata, authorization-server metadata and token
1577+
* issuance from one fake origin and accepts data-plane POSTs, recording every
1578+
* request so header propagation can be asserted per leg.
1579+
*/
1580+
const createDispatcherFetch = (calls: RecordedCall[]): Mock =>
1581+
vi.fn(async (input: string | URL, init?: RequestInit): Promise<Response> => {
1582+
const url = input.toString();
1583+
calls.push({ url, init });
1584+
if (url.includes('/.well-known/oauth-protected-resource')) {
1585+
return Response.json({
1586+
resource: 'http://localhost:1234/mcp',
1587+
authorization_servers: ['http://localhost:1234']
1588+
});
1589+
}
1590+
if (url.includes('/.well-known/oauth-authorization-server')) {
1591+
return Response.json({
1592+
issuer: 'http://localhost:1234',
1593+
authorization_endpoint: 'http://localhost:1234/authorize',
1594+
token_endpoint: 'http://localhost:1234/token',
1595+
response_types_supported: ['code'],
1596+
code_challenge_methods_supported: ['S256']
1597+
});
1598+
}
1599+
if (url === 'http://localhost:1234/token') {
1600+
return Response.json({
1601+
access_token: 'new-access-token',
1602+
token_type: 'Bearer',
1603+
expires_in: 3600
1604+
});
1605+
}
1606+
// Data-plane POST to the message endpoint.
1607+
return new Response(null, { status: 200 });
1608+
});
1609+
1610+
/** Exchanges a code (PRM + AS metadata + token) and then sends a data-plane POST. */
1611+
const runAuthAndSend = async (transport: SSEClientTransport): Promise<void> => {
1612+
await transport.finishAuth('test-auth-code');
1613+
(transport as unknown as { _endpoint?: URL })._endpoint = new URL('http://localhost:1234/messages');
1614+
await transport.send({ jsonrpc: '2.0', method: 'test', params: {}, id: 'req-1' });
1615+
};
1616+
1617+
it('does not apply requestInit headers to authorization requests', async () => {
1618+
const calls: RecordedCall[] = [];
1619+
transport = new SSEClientTransport(new URL('http://localhost:1234/mcp'), {
1620+
authProvider: createIsolationAuthProvider(),
1621+
fetch: createDispatcherFetch(calls),
1622+
requestInit: { headers: { 'X-Api-Key': 'transport-secret' } }
1623+
});
1624+
1625+
await runAuthAndSend(transport);
1626+
1627+
const authCalls = calls.filter(isAuthCall);
1628+
expect(authCalls.map(c => c.url)).toEqual(
1629+
expect.arrayContaining([
1630+
expect.stringContaining('/.well-known/oauth-protected-resource'),
1631+
expect.stringContaining('/.well-known/oauth-authorization-server'),
1632+
'http://localhost:1234/token'
1633+
])
1634+
);
1635+
for (const call of authCalls) {
1636+
expect(headerOnCall(call, 'X-Api-Key')).toBeNull();
1637+
}
1638+
1639+
const dataPlaneCalls = calls.filter(c => !isAuthCall(c));
1640+
expect(dataPlaneCalls.length).toBeGreaterThan(0);
1641+
for (const call of dataPlaneCalls) {
1642+
expect(headerOnCall(call, 'X-Api-Key')).toBe('transport-secret');
1643+
}
1644+
});
1645+
1646+
it('applies oauthRequestInit headers to authorization requests only', async () => {
1647+
const calls: RecordedCall[] = [];
1648+
transport = new SSEClientTransport(new URL('http://localhost:1234/mcp'), {
1649+
authProvider: createIsolationAuthProvider(),
1650+
fetch: createDispatcherFetch(calls),
1651+
requestInit: { headers: { 'X-Api-Key': 'transport-secret' } },
1652+
oauthRequestInit: { headers: { 'X-Auth-Gateway': 'gateway-value' } }
1653+
});
1654+
1655+
await runAuthAndSend(transport);
1656+
1657+
const authCalls = calls.filter(isAuthCall);
1658+
expect(authCalls.length).toBeGreaterThan(0);
1659+
for (const call of authCalls) {
1660+
expect(headerOnCall(call, 'X-Auth-Gateway')).toBe('gateway-value');
1661+
expect(headerOnCall(call, 'X-Api-Key')).toBeNull();
1662+
}
1663+
1664+
const dataPlaneCalls = calls.filter(c => !isAuthCall(c));
1665+
expect(dataPlaneCalls.length).toBeGreaterThan(0);
1666+
for (const call of dataPlaneCalls) {
1667+
expect(headerOnCall(call, 'X-Api-Key')).toBe('transport-secret');
1668+
expect(headerOnCall(call, 'X-Auth-Gateway')).toBeNull();
1669+
}
1670+
});
1671+
});
1672+
15511673
describe('minimal AuthProvider (non-OAuth)', () => {
15521674
let postResponses: number[];
15531675
let postCount: number;

0 commit comments

Comments
 (0)