fix(client): let OAuth-derived Authorization override caller-supplied header (#2475)

Signed-off-by: sanjibani <18418553+sanjibani@users.noreply.github.com>
Co-authored-by: sanjibani <18418553+sanjibani@users.noreply.github.com>
Co-authored-by: Konstantin Konstantinov <KKonstantinov@users.noreply.github.com>
Co-authored-by: Konstantin Konstantinov <konstantin@mach5technology.com>
This commit is contained in:
sanjibani 2026-09-11 13:05:43 +05:30 • committed by GitHub
commit b65426158e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 438 additions and 28 deletions

View file

@ -0,0 +1,5 @@
---
'@modelcontextprotocol/client': patch
---
`StreamableHTTPClientTransport` and `SSEClientTransport` now give their transport-managed headers precedence over same-named entries in `requestInit.headers`: `Authorization` when `authProvider` yields a token, `mcp-protocol-version`, and (Streamable HTTP) `mcp-session-id`. Header names compare case-insensitively and every `HeadersInit` form is covered (plain object, tuple array, `Headers` instance). Previously the caller-supplied value won, so a static `Authorization` placeholder (e.g. an env-var API key) kept overriding the OAuth token even after the provider obtained one and the fallback-to-OAuth flow never completed; a `Headers` instance or lowercase key produced a combined `Bearer <fresh>, Bearer <stale>` value instead. A configured `Authorization` is still sent while the provider has no token, and other configured headers pass through unchanged. Closes #2208.

View file

@ -808,6 +808,13 @@ value to the spec-required `application/json, text/event-stream` (v1 let it repl
them). The required media types are always present; additional types are kept for
proxy/gateway routing.
Transport-managed headers now take precedence over same-named entries in
`requestInit.headers`: `Authorization` when `authProvider` yields a token,
`mcp-protocol-version`, and (Streamable HTTP) `mcp-session-id`. v1 let the configured
header win, so a static `Authorization` placeholder kept overriding the OAuth token even
after the provider obtained one. A configured `Authorization` value is still sent while
the provider has no token, which is what lets a static API key fall back to OAuth.
`hostHeaderValidation()` and `localhostHostValidation()` moved to
`@modelcontextprotocol/express`. The `(allowedHostnames: string[])` signature is the
same as every released v1.x — only the import path changes. Framework-agnostic helpers

View file

@ -3,7 +3,6 @@ import {
brandedHasInstance,
createFetchWithInit,
JSONRPCMessageSchema,
normalizeHeaders,
SdkError,
SdkErrorCode,
SdkHttpError,
@ -97,15 +96,23 @@ export type SSEClientTransportOptions = {
/**
* Customizes the initial SSE request to the server (the request that begins the stream).
*
* NOTE: Setting this property will prevent an `Authorization` header from
* being automatically attached to the SSE request, if an {@linkcode SSEClientTransportOptions.authProvider | authProvider} is
* also given. This can be worked around by setting the `Authorization` header
* manually.
* A custom `fetch` supplied here is still wrapped by the transport: the
* transport-managed headers, including the `Authorization` header derived from
* {@linkcode SSEClientTransportOptions.authProvider | authProvider}, are attached to the
* SSE request and take precedence over a same-named entry in `requestInit.headers`
* (see {@linkcode SSEClientTransportOptions.requestInit | requestInit}).
*/
eventSourceInit?: EventSourceInit;
/**
* Customizes recurring `POST` requests to the server.
*
* The transport-managed headers take precedence over a same-named entry in
* `headers`: `Authorization` when
* {@linkcode SSEClientTransportOptions.authProvider | authProvider} yields a token, and
* `mcp-protocol-version`. A caller-supplied `Authorization` value is therefore only sent
* while the provider has no token, which lets a static API key fall back to OAuth once
* the provider obtains one.
*/
requestInit?: RequestInit;
@ -170,7 +177,19 @@ export class SSEClientTransport implements Transport {
private _last401Response?: Response;
private async _commonHeaders(): Promise<Headers> {
const headers: RequestInit['headers'] & Record<string, string> = {};
// Start from the caller-supplied `requestInit.headers` and `set()` the
// transport-managed headers on top. `Headers.set` compares names
// case-insensitively, so Authorization / mcp-protocol-version replace a
// same-named caller entry whatever its spelling. (A plain-object spread would
// keep `authorization` and `Authorization` side by side, and the Fetch `Headers`
// constructor would then combine them into one two-token value.) This lets
// a stale static `Authorization` placeholder (e.g. an env-var API key) fall back
// to the OAuth token once the provider has one, and keeps this transport in step
// with StreamableHTTPClientTransport. See #2208.
// `|| undefined` keeps the old tolerance for a falsy `headers` value (e.g. `null`
// from a JS caller or a JSON config forwarded verbatim): the Fetch `Headers`
// constructor accepts `undefined` but throws on `null`.
const headers = new Headers(this._requestInit?.headers || undefined);
let token: string | undefined;
try {
token = await this._authProvider?.token();
@ -180,18 +199,12 @@ export class SSEClientTransport implements Transport {
throw markAuthSeamEscape(error);
}
if (token) {
headers['Authorization'] = `Bearer ${token}`;
headers.set('Authorization', `Bearer ${token}`);
}
if (this._protocolVersion) {
headers['mcp-protocol-version'] = this._protocolVersion;
headers.set('mcp-protocol-version', this._protocolVersion);
}
const extraHeaders = normalizeHeaders(this._requestInit?.headers);
return new Headers({
...headers,
...extraHeaders
});
return headers;
}
private _startOrAuth(): Promise<void> {

View file

@ -13,7 +13,6 @@ import {
JSONRPCMessageSchema,
mcpNameSource,
mediaTypeEssence,
normalizeHeaders,
PROTOCOL_VERSION_META_KEY,
SdkError,
SdkErrorCode,
@ -183,6 +182,13 @@ export type StreamableHTTPClientTransportOptions = {
/**
* Customizes HTTP requests to the server.
*
* `headers` are sent on every request, but the transport-managed headers take
* precedence over a same-named entry here: `Authorization` when
* {@linkcode StreamableHTTPClientTransportOptions.authProvider | authProvider} yields a
* token, `mcp-session-id`, and `mcp-protocol-version`. A caller-supplied `Authorization`
* value is therefore only sent while the provider has no token, which lets a static API
* key fall back to OAuth once the provider obtains one.
*/
requestInit?: RequestInit;
@ -443,7 +449,19 @@ export class StreamableHTTPClientTransport implements Transport {
}
private async _commonHeaders(): Promise<Headers> {
const headers: RequestInit['headers'] & Record<string, string> = {};
// Start from the caller-supplied `requestInit.headers` and `set()` the
// transport-managed headers on top. `Headers.set` compares names
// case-insensitively, so Authorization / mcp-session-id / mcp-protocol-version
// replace a same-named caller entry whatever its spelling. (A plain-object
// spread would keep `authorization` and `Authorization` side by side, and the
// Fetch `Headers` constructor would then combine them into one two-token
// value.) This lets a stale static `Authorization` placeholder (e.g. an env-var
// API key) fall back to the OAuth token once the provider has one, and mirrors
// the per-request `RESERVED_REQUEST_HEADER_NAMES` guard in send(). See #2208.
// `|| undefined` keeps the old tolerance for a falsy `headers` value (e.g. `null`
// from a JS caller or a JSON config forwarded verbatim): the Fetch `Headers`
// constructor accepts `undefined` but throws on `null`.
const headers = new Headers(this._requestInit?.headers || undefined);
let token: string | undefined;
try {
token = await this._authProvider?.token();
@ -453,22 +471,15 @@ export class StreamableHTTPClientTransport implements Transport {
throw markAuthSeamEscape(error);
}
if (token) {
headers['Authorization'] = `Bearer ${token}`;
headers.set('Authorization', `Bearer ${token}`);
}
if (this._sessionId) {
headers['mcp-session-id'] = this._sessionId;
headers.set('mcp-session-id', this._sessionId);
}
if (this._protocolVersion) {
headers['mcp-protocol-version'] = this._protocolVersion;
headers.set('mcp-protocol-version', this._protocolVersion);
}
const extraHeaders = normalizeHeaders(this._requestInit?.headers);
return new Headers({
...headers,
...extraHeaders
});
return headers;
}
/**

View file

@ -303,6 +303,26 @@ describe('SSEClientTransport', () => {
expect(lastServerRequest.headers.authorization).toBe(authToken);
});
it('tolerates requestInit.headers set to null by a JavaScript caller', async () => {
// The TS type excludes null, but a JS caller or a JSON config forwarded verbatim can
// pass it. `new Headers(null)` throws, so the transport must map falsy to undefined.
transport = new SSEClientTransport(resourceBaseUrl, {
requestInit: { headers: null as unknown as RequestInit['headers'] }
});
await transport.start();
expect(lastServerRequest.headers.accept).toBe('text/event-stream');
const message: JSONRPCMessage = {
jsonrpc: '2.0',
id: '1',
method: 'test',
params: {}
};
await transport.send(message);
expect(lastServerRequest.headers['content-type']).toBe('application/json');
});
it('passes custom headers to fetch requests', async () => {
const customHeaders = {
Authorization: 'Bearer test-token',
@ -660,6 +680,123 @@ describe('SSEClientTransport', () => {
expect(lastServerRequest.headers['x-custom-header']).toBe('custom-value');
});
it('lets the auth provider token override a stale caller-supplied Authorization header', async () => {
// Regression test for #2208: transport-managed headers are merged on top of
// requestInit.headers, so a static Authorization placeholder (e.g. an env-var
// API key) gives way to the provider's token on both the SSE GET and POSTs.
mockAuthProvider.tokens.mockResolvedValue({
access_token: 'fresh-token',
token_type: 'Bearer'
});
transport = new SSEClientTransport(resourceBaseUrl, {
authProvider: mockAuthProvider,
requestInit: {
headers: {
Authorization: 'Bearer stale-placeholder',
'X-Custom-Header': 'custom-value'
}
}
});
await transport.start();
// SSE GET
expect(lastServerRequest.headers.authorization).toBe('Bearer fresh-token');
expect(lastServerRequest.headers['x-custom-header']).toBe('custom-value');
const message: JSONRPCMessage = {
jsonrpc: '2.0',
id: '1',
method: 'test',
params: {}
};
await transport.send(message);
// POST
expect(lastServerRequest.headers.authorization).toBe('Bearer fresh-token');
expect(lastServerRequest.headers['x-custom-header']).toBe('custom-value');
});
it('replaces a caller-supplied Authorization header regardless of name casing (Headers instance)', async () => {
// #2208 follow-up: `Headers` normalizes names to lowercase; the transport must
// still send exactly its own token, not a combined two-token value.
mockAuthProvider.tokens.mockResolvedValue({
access_token: 'fresh-token',
token_type: 'Bearer'
});
transport = new SSEClientTransport(resourceBaseUrl, {
authProvider: mockAuthProvider,
requestInit: {
headers: new Headers({
authorization: 'Bearer stale-placeholder',
'x-custom-header': 'custom-value'
})
}
});
await transport.start();
expect(lastServerRequest.headers.authorization).toBe('Bearer fresh-token');
expect(lastServerRequest.headers['x-custom-header']).toBe('custom-value');
const message: JSONRPCMessage = {
jsonrpc: '2.0',
id: '1',
method: 'test',
params: {}
};
await transport.send(message);
expect(lastServerRequest.headers.authorization).toBe('Bearer fresh-token');
expect(lastServerRequest.headers['x-custom-header']).toBe('custom-value');
});
it('keeps Fetch Headers combine semantics for a repeated name in a tuple array', async () => {
// A repeated name in a tuple array is the one `HeadersInit` form that can express a
// multi-valued header, and `fetch(url, { headers: [['x', 'a'], ['x', 'b']] })` sends
// "x: a, b". The transport builds its headers with the same `Headers` constructor, so
// a caller-supplied repeated name is combined exactly as a direct `fetch` would, while
// a repeated *transport-managed* name is still replaced outright by the transport's
// own value rather than combined with it.
mockAuthProvider.tokens.mockResolvedValue({
access_token: 'fresh-token',
token_type: 'Bearer'
});
transport = new SSEClientTransport(resourceBaseUrl, {
authProvider: mockAuthProvider,
requestInit: {
headers: [
['Authorization', 'Bearer stale-1'],
['Authorization', 'Bearer stale-2'],
['x-multi', 'a'],
['x-multi', 'b']
]
}
});
await transport.start();
expect(lastServerRequest.headers.authorization).toBe('Bearer fresh-token');
expect(lastServerRequest.headers['x-multi']).toBe('a, b');
const message: JSONRPCMessage = {
jsonrpc: '2.0',
id: '1',
method: 'test',
params: {}
};
await transport.send(message);
expect(lastServerRequest.headers.authorization).toBe('Bearer fresh-token');
expect(lastServerRequest.headers['x-multi']).toBe('a, b');
});
it('refreshes expired token during SSE connection', async () => {
// Mock tokens() to return expired token until saveTokens is called
let currentTokens: OAuthTokens = {

View file

@ -702,6 +702,169 @@ describe('StreamableHTTPClientTransport', () => {
expect(globalThis.fetch).toHaveBeenCalledTimes(2);
});
it('OAuth-derived Authorization overrides a stale caller-supplied Authorization', async () => {
// Regression test for #2208: the SDK-derived common headers must be
// merged on top of caller-supplied headers, so OAuth-derived tokens
// (or any SDK-computed value) win over a stale placeholder the
// caller might have set in `requestInit.headers` (e.g. an env-var
// API key that's no longer valid).
const tokens: OAuthTokens = {
access_token: 'oauth-access-token',
token_type: 'Bearer'
};
mockAuthProvider.tokens.mockResolvedValue(tokens);
const requestInit = {
headers: {
Authorization: 'Bearer stale-placeholder',
'mcp-protocol-version': 'caller-supplied',
'X-Caller-Header': 'preserved'
}
};
transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), {
requestInit,
authProvider: mockAuthProvider
});
transport.setProtocolVersion('2025-03-26');
let actualReqInit: RequestInit = {};
(globalThis.fetch as Mock).mockImplementation(async (_url, reqInit) => {
actualReqInit = reqInit;
return new Response(null, { status: 200, headers: { 'content-type': 'text/event-stream' } });
});
await transport.start();
await transport['_startOrAuthSse']({});
// On the SSE GET: the OAuth-derived token wins over the stale placeholder, and so
// does the transport-managed protocol version.
expect((actualReqInit.headers as Headers).get('authorization')).toBe('Bearer oauth-access-token');
expect((actualReqInit.headers as Headers).get('mcp-protocol-version')).toBe('2025-03-26');
// Caller-supplied non-auth headers still pass through.
expect((actualReqInit.headers as Headers).get('x-caller-header')).toBe('preserved');
// Same precedence on POST.
await transport.send({ jsonrpc: '2.0', method: 'test', params: {} } as JSONRPCMessage);
expect((actualReqInit.headers as Headers).get('authorization')).toBe('Bearer oauth-access-token');
expect((actualReqInit.headers as Headers).get('mcp-protocol-version')).toBe('2025-03-26');
expect((actualReqInit.headers as Headers).get('x-caller-header')).toBe('preserved');
expect(globalThis.fetch).toHaveBeenCalledTimes(2);
});
it.each([
[
'a Headers instance',
(): NonNullable<RequestInit['headers']> =>
new Headers({
authorization: 'Bearer stale-placeholder',
'MCP-Protocol-Version': 'caller-supplied',
'x-caller-header': 'preserved'
})
],
[
'a lowercase plain object',
(): NonNullable<RequestInit['headers']> => ({
authorization: 'Bearer stale-placeholder',
'MCP-Protocol-Version': 'caller-supplied',
'x-caller-header': 'preserved'
})
],
[
'a tuple array',
(): NonNullable<RequestInit['headers']> => [
['authorization', 'Bearer stale-placeholder'],
['MCP-Protocol-Version', 'caller-supplied'],
['x-caller-header', 'preserved']
]
]
])('transport-managed headers replace caller-supplied ones regardless of name casing (%s)', async (_label, makeHeaders) => {
// #2208 follow-up: header names compare case-insensitively. A `Headers` instance
// normalizes names to lowercase and a caller may spell them any way; a key-exact
// object merge would keep both spellings and the Fetch `Headers` constructor would
// then combine them into one two-token value. The transport must send exactly its
// own value.
mockAuthProvider.tokens.mockResolvedValue({ access_token: 'oauth-access-token', token_type: 'Bearer' });
transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), {
requestInit: { headers: makeHeaders() },
authProvider: mockAuthProvider
});
transport.setProtocolVersion('2025-03-26');
let actualReqInit: RequestInit = {};
(globalThis.fetch as Mock).mockImplementation(async (_url, reqInit) => {
actualReqInit = reqInit;
return new Response(null, { status: 200, headers: { 'content-type': 'text/event-stream' } });
});
await transport.start();
await transport['_startOrAuthSse']({});
expect((actualReqInit.headers as Headers).get('authorization')).toBe('Bearer oauth-access-token');
expect((actualReqInit.headers as Headers).get('mcp-protocol-version')).toBe('2025-03-26');
expect((actualReqInit.headers as Headers).get('x-caller-header')).toBe('preserved');
await transport.send({ jsonrpc: '2.0', method: 'test', params: {} } as JSONRPCMessage);
expect((actualReqInit.headers as Headers).get('authorization')).toBe('Bearer oauth-access-token');
expect((actualReqInit.headers as Headers).get('mcp-protocol-version')).toBe('2025-03-26');
expect((actualReqInit.headers as Headers).get('x-caller-header')).toBe('preserved');
});
it('keeps Fetch Headers combine semantics for a repeated name in a tuple array', async () => {
// A repeated name in a tuple array is the one `HeadersInit` form that can express a
// multi-valued header, and `fetch(url, { headers: [['x', 'a'], ['x', 'b']] })` sends
// "x: a, b". The transport builds its headers with the same `Headers` constructor, so
// a caller-supplied repeated name is combined exactly as a direct `fetch` would, while
// a repeated *transport-managed* name is still replaced outright by the transport's
// own value rather than combined with it.
mockAuthProvider.tokens.mockResolvedValue({ access_token: 'oauth-access-token', token_type: 'Bearer' });
transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), {
requestInit: {
headers: [
['Authorization', 'Bearer stale-1'],
['Authorization', 'Bearer stale-2'],
['x-multi', 'a'],
['x-multi', 'b']
]
},
authProvider: mockAuthProvider
});
let actualReqInit: RequestInit = {};
(globalThis.fetch as Mock).mockImplementation(async (_url, reqInit) => {
actualReqInit = reqInit;
return new Response(null, { status: 200, headers: { 'content-type': 'text/event-stream' } });
});
await transport.start();
await transport['_startOrAuthSse']({});
expect((actualReqInit.headers as Headers).get('authorization')).toBe('Bearer oauth-access-token');
expect((actualReqInit.headers as Headers).get('x-multi')).toBe('a, b');
await transport.send({ jsonrpc: '2.0', method: 'test', params: {} } as JSONRPCMessage);
expect((actualReqInit.headers as Headers).get('authorization')).toBe('Bearer oauth-access-token');
expect((actualReqInit.headers as Headers).get('x-multi')).toBe('a, b');
});
it('tolerates requestInit.headers set to null by a JavaScript caller', async () => {
// The TS type excludes null, but a JS caller or a JSON config forwarded verbatim can
// pass it. `new Headers(null)` throws, so the transport must map falsy to undefined.
transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), {
requestInit: { headers: null as unknown as RequestInit['headers'] }
});
let actualReqInit: RequestInit = {};
(globalThis.fetch as Mock).mockImplementation(async (_url, reqInit) => {
actualReqInit = reqInit;
return new Response(null, { status: 200, headers: { 'content-type': 'text/event-stream' } });
});
await transport.start();
await transport['_startOrAuthSse']({});
expect(actualReqInit.headers).toBeInstanceOf(Headers);
expect((actualReqInit.headers as Headers).get('accept')).toContain('text/event-stream');
await transport.send({ jsonrpc: '2.0', method: 'test', params: {} } as JSONRPCMessage);
expect(actualReqInit.headers).toBeInstanceOf(Headers);
expect(globalThis.fetch).toHaveBeenCalledTimes(2);
});
it('should always send specified custom headers (Headers class)', async () => {
const requestInit = {
headers: new Headers({

View file

@ -2917,6 +2917,13 @@ export const REQUIREMENTS: Record<string, Requirement> = {
transports: ['streamableHttp'],
note: "This exercises the HTTP client transport's auth hook; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs."
},
'client-auth:authprovider:token-overrides-requestinit': {
source: 'sdk',
behavior:
'When an AuthProvider yields a token, the Authorization header the transport derives from it takes precedence over a same-named header configured in requestInit.headers on every HTTP request, so a stale static credential falls back to the provider token; other configured headers still pass through.',
transports: ['streamableHttp'],
note: "This exercises the HTTP client transport's header merge order (#2208); the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs."
},
'client-auth:authprovider:onunauthorized-retry': {
source: 'sdk',
behavior:

View file

@ -2066,6 +2066,73 @@ verifies('client-auth:authprovider:token-attached', async (_args: TestArgs) => {
}
});
verifies('client-auth:authprovider:token-overrides-requestinit', async (_args: TestArgs) => {
const TOKEN = 'provider-bearer-token';
const STALE = 'stale-configured-token';
// Minimal AuthProvider: token() only. Mirrors a config that pins a static API key in
// requestInit.headers but must defer to the provider once it has a token (#2208).
const authProvider: AuthProvider = {
token: async () => TOKEN
};
const seenByServer: Array<{ authorization: string | null; custom: string | null }> = [];
const mcpHost = hostPerSession(() => {
const s = new McpServer({ name: 's', version: '0' });
s.registerTool('probe', { inputSchema: z.object({}) }, (_a, ctx) => {
seenByServer.push({
authorization: ctx.http?.req?.headers.get('authorization') ?? null,
custom: ctx.http?.req?.headers.get('x-caller-header') ?? null
});
return { content: [{ type: 'text', text: 'ok' }] };
});
return s;
});
const requests: Array<{ method: string; authorization: string | null; custom: string | null }> = [];
const recordingFetch = async (url: URL | string, init?: RequestInit) => {
const headers = new Headers(init?.headers);
requests.push({
method: init?.method ?? 'GET',
authorization: headers.get('authorization'),
custom: headers.get('x-caller-header')
});
return mcpHost.handleRequest(new Request(url, init));
};
const client = new Client({ name: 'c', version: '0' });
const transport = new StreamableHTTPClientTransport(new URL(MCP_URL), {
authProvider,
fetch: recordingFetch,
requestInit: { headers: { Authorization: `Bearer ${STALE}`, 'X-Caller-Header': 'preserved' } }
});
try {
await client.connect(transport);
const result = await client.callTool({ name: 'probe', arguments: {} });
expect(result.content).toEqual([{ type: 'text', text: 'ok' }]);
// The standalone SSE GET is opened fire-and-forget after initialize; wait for it so it is checked too.
await vi.waitFor(() => expect(requests.some(r => r.method === 'GET')).toBe(true));
// Exactly three POSTs (initialize, notifications/initialized, tools/call) plus the standalone SSE GET,
// every one carrying the provider token rather than the configured placeholder, with the other
// configured header passing through untouched.
expect(requests.filter(r => r.method === 'POST')).toHaveLength(3);
expect(requests.filter(r => r.method === 'GET')).toHaveLength(1);
for (const req of requests) {
expect(req.authorization).toBe(`Bearer ${TOKEN}`);
expect(req.custom).toBe('preserved');
}
// The provider token, not the placeholder, is what reached the server on tools/call.
expect(seenByServer).toEqual([{ authorization: `Bearer ${TOKEN}`, custom: 'preserved' }]);
} finally {
await client.close();
await mcpHost.close();
}
});
verifies('client-auth:authprovider:onunauthorized-retry', async (_args: TestArgs) => {
const STALE = 'stale-bearer-token';
const FRESH = 'fresh-bearer-token';