From 96b4af9f1c222cd1749a2abd3f09ccc1f6922456 Mon Sep 17 00:00:00 2001 From: sanjibani <18418553+sanjibani@users.noreply.github.com> Date: Fri, 10 Jul 2026 11:00:29 +0530 Subject: [PATCH 1/7] fix(client): let OAuth-derived Authorization override caller-supplied header Closes #2208. StreamableHTTPClientTransport and SSEClientTransport both build the outgoing request's headers as: return new Headers({ ...headers, // common: Authorization (OAuth), mcp-session-id, mcp-protocol-version ...extraHeaders // caller-supplied via requestInit.headers }); This meant a caller-supplied Authorization placeholder (e.g. an env-var API key passed through the MCP config) was placed AFTER the SDK-derived common headers, which silently overrode OAuth-computed tokens and broke the auth-refresh flow once the placeholder went stale. Several MCP servers (Atlassian Rovo and others) let both API tokens and OAuth share the same Authorization header and rely on it being valid. Swap the spread order to so SDK-computed common headers (including the freshest OAuth token) take precedence, matching the merge order used elsewhere in the SDK. Caller-supplied non-auth headers still pass through unchanged. Test: a regression test in streamableHttp.test.ts seeds a stale Authorization placeholder in requestInit.headers alongside a working authProvider and asserts the request goes out with the OAuth-derived token. 722 client tests pass; lint + format clean. --- packages/client/src/client/sse.ts | 10 ++++-- packages/client/src/client/streamableHttp.ts | 10 ++++-- .../client/test/client/streamableHttp.test.ts | 36 +++++++++++++++++++ 3 files changed, 52 insertions(+), 4 deletions(-) diff --git a/packages/client/src/client/sse.ts b/packages/client/src/client/sse.ts index 1c81928f10..723510407b 100644 --- a/packages/client/src/client/sse.ts +++ b/packages/client/src/client/sse.ts @@ -168,11 +168,17 @@ export class SSEClientTransport implements Transport { headers['mcp-protocol-version'] = this._protocolVersion; } + // Order matters: caller-supplied headers (e.g. an `Authorization` placeholder + // for an env-var API key) are merged first, then the SDK-derived common + // headers spread on top. This lets OAuth-derived tokens override any + // stale user-supplied header (matching the order used in streamableHttp + // and the rest of the SDK) without requiring the caller to know which + // common headers the client will compute at request time. See #2208. const extraHeaders = normalizeHeaders(this._requestInit?.headers); return new Headers({ - ...headers, - ...extraHeaders + ...extraHeaders, + ...headers }); } diff --git a/packages/client/src/client/streamableHttp.ts b/packages/client/src/client/streamableHttp.ts index 9067fd1ec4..190b5a9cd1 100644 --- a/packages/client/src/client/streamableHttp.ts +++ b/packages/client/src/client/streamableHttp.ts @@ -434,11 +434,17 @@ export class StreamableHTTPClientTransport implements Transport { headers['mcp-protocol-version'] = this._protocolVersion; } + // Order matters: caller-supplied headers (e.g. an `Authorization` placeholder + // for an env-var API key) are merged first, then the SDK-derived common + // headers spread on top. This lets OAuth-derived tokens override any + // stale user-supplied header (matching the order used in the rest of + // the SDK) without requiring the caller to know which common headers + // the client will compute at request time. See #2208. const extraHeaders = normalizeHeaders(this._requestInit?.headers); return new Headers({ - ...headers, - ...extraHeaders + ...extraHeaders, + ...headers }); } diff --git a/packages/client/test/client/streamableHttp.test.ts b/packages/client/test/client/streamableHttp.test.ts index a36bbc0ad3..fcee3b8ab9 100644 --- a/packages/client/test/client/streamableHttp.test.ts +++ b/packages/client/test/client/streamableHttp.test.ts @@ -702,6 +702,42 @@ 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', + 'X-Caller-Header': 'preserved' + } + }; + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { + requestInit, + 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']({}); + // OAuth-derived token wins over the stale placeholder. + expect((actualReqInit.headers as Headers).get('authorization')).toBe('Bearer oauth-access-token'); + // Caller-supplied non-auth headers still pass through. + expect((actualReqInit.headers as Headers).get('x-caller-header')).toBe('preserved'); + }); + it('should always send specified custom headers (Headers class)', async () => { const requestInit = { headers: new Headers({ From 0003f276b8f401235024b799bf0941ddb7fee83b Mon Sep 17 00:00:00 2001 From: sanjibani <18418553+sanjibani@users.noreply.github.com> Date: Fri, 10 Jul 2026 21:04:07 +0530 Subject: [PATCH 2/7] fix(client): add changeset for OAuth header spread order fix (#2475) Signed-off-by: sanjibani <18418553+sanjibani@users.noreply.github.com> --- .changeset/oauth-header-spread-order.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/oauth-header-spread-order.md diff --git a/.changeset/oauth-header-spread-order.md b/.changeset/oauth-header-spread-order.md new file mode 100644 index 0000000000..777bcf0447 --- /dev/null +++ b/.changeset/oauth-header-spread-order.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/client': patch +--- + +Fix the spread order in `StreamableHTTPClientTransport._commonHeaders()` and `SSEClientTransport._commonHeaders()` so SDK-derived common headers (including fresh OAuth tokens from `authProvider`) win over caller-supplied headers in `requestInit.headers`. Previously a caller-supplied `Authorization` placeholder (e.g. an env-var API key) was merged after the SDK-computed value, silently overriding OAuth-refreshed tokens and breaking the auth-refresh flow once the placeholder went stale. Closes #2208. From f8d98144eb15b84cc92a1865007eb8d7fde8f29d Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Thu, 10 Sep 2026 00:03:55 +0300 Subject: [PATCH 3/7] test(client): cover SSE, POST and e2e for auth-header precedence; document requestInit precedence - add SSEClientTransport regression test for #2208 (GET + POST) - extend the Streamable HTTP test to the POST path and mcp-protocol-version - add e2e requirement client-auth:authprovider:token-overrides-requestinit and scenario - document transport-managed header precedence on requestInit (JSDoc + migration guide) - tighten the merge-site comments --- docs/migration/upgrade-to-v2.md | 7 ++ packages/client/src/client/sse.ts | 19 ++++-- packages/client/src/client/streamableHttp.ts | 19 ++++-- packages/client/test/client/sse.test.ts | 39 +++++++++++ .../client/test/client/streamableHttp.test.ts | 13 +++- test/e2e/requirements.ts | 7 ++ test/e2e/scenarios/client-auth.test.ts | 67 +++++++++++++++++++ 7 files changed, 158 insertions(+), 13 deletions(-) diff --git a/docs/migration/upgrade-to-v2.md b/docs/migration/upgrade-to-v2.md index 802c2ec264..3353fa5cb7 100644 --- a/docs/migration/upgrade-to-v2.md +++ b/docs/migration/upgrade-to-v2.md @@ -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 diff --git a/packages/client/src/client/sse.ts b/packages/client/src/client/sse.ts index b02196248a..9a157c65a2 100644 --- a/packages/client/src/client/sse.ts +++ b/packages/client/src/client/sse.ts @@ -106,6 +106,13 @@ export type SSEClientTransportOptions = { /** * 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; @@ -186,12 +193,12 @@ export class SSEClientTransport implements Transport { headers['mcp-protocol-version'] = this._protocolVersion; } - // Order matters: caller-supplied headers (e.g. an `Authorization` placeholder - // for an env-var API key) are merged first, then the SDK-derived common - // headers spread on top. This lets OAuth-derived tokens override any - // stale user-supplied header (matching the order used in streamableHttp - // and the rest of the SDK) without requiring the caller to know which - // common headers the client will compute at request time. See #2208. + // Order matters: caller-supplied `requestInit.headers` are spread first and the + // transport-managed headers (Authorization from the auth provider, + // mcp-protocol-version) on top, so they win over a same-named caller entry. 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. const extraHeaders = normalizeHeaders(this._requestInit?.headers); return new Headers({ diff --git a/packages/client/src/client/streamableHttp.ts b/packages/client/src/client/streamableHttp.ts index d954841414..3a931caa39 100644 --- a/packages/client/src/client/streamableHttp.ts +++ b/packages/client/src/client/streamableHttp.ts @@ -183,6 +183,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; @@ -463,12 +470,12 @@ export class StreamableHTTPClientTransport implements Transport { headers['mcp-protocol-version'] = this._protocolVersion; } - // Order matters: caller-supplied headers (e.g. an `Authorization` placeholder - // for an env-var API key) are merged first, then the SDK-derived common - // headers spread on top. This lets OAuth-derived tokens override any - // stale user-supplied header (matching the order used in the rest of - // the SDK) without requiring the caller to know which common headers - // the client will compute at request time. See #2208. + // Order matters: caller-supplied `requestInit.headers` are spread first and the + // transport-managed headers (Authorization from the auth provider, mcp-session-id, + // mcp-protocol-version) on top, so they win over a same-named caller entry. 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. const extraHeaders = normalizeHeaders(this._requestInit?.headers); return new Headers({ diff --git a/packages/client/test/client/sse.test.ts b/packages/client/test/client/sse.test.ts index a0d4e7b6f9..587dfd7b33 100644 --- a/packages/client/test/client/sse.test.ts +++ b/packages/client/test/client/sse.test.ts @@ -660,6 +660,45 @@ 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('refreshes expired token during SSE connection', async () => { // Mock tokens() to return expired token until saveTokens is called let currentTokens: OAuthTokens = { diff --git a/packages/client/test/client/streamableHttp.test.ts b/packages/client/test/client/streamableHttp.test.ts index fcee3b8ab9..60b131d068 100644 --- a/packages/client/test/client/streamableHttp.test.ts +++ b/packages/client/test/client/streamableHttp.test.ts @@ -716,6 +716,7 @@ describe('StreamableHTTPClientTransport', () => { const requestInit = { headers: { Authorization: 'Bearer stale-placeholder', + 'mcp-protocol-version': 'caller-supplied', 'X-Caller-Header': 'preserved' } }; @@ -723,6 +724,7 @@ describe('StreamableHTTPClientTransport', () => { requestInit, authProvider: mockAuthProvider }); + transport.setProtocolVersion('2025-03-26'); let actualReqInit: RequestInit = {}; (globalThis.fetch as Mock).mockImplementation(async (_url, reqInit) => { @@ -732,10 +734,19 @@ describe('StreamableHTTPClientTransport', () => { await transport.start(); await transport['_startOrAuthSse']({}); - // OAuth-derived token wins over the stale placeholder. + // 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('should always send specified custom headers (Headers class)', async () => { diff --git a/test/e2e/requirements.ts b/test/e2e/requirements.ts index 75311be0e3..3f3fe4b39b 100644 --- a/test/e2e/requirements.ts +++ b/test/e2e/requirements.ts @@ -2917,6 +2917,13 @@ export const REQUIREMENTS: Record = { 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: diff --git a/test/e2e/scenarios/client-auth.test.ts b/test/e2e/scenarios/client-auth.test.ts index caa1d74fe4..6441abd593 100644 --- a/test/e2e/scenarios/client-auth.test.ts +++ b/test/e2e/scenarios/client-auth.test.ts @@ -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'; From 52491ff1d78c4f5bd0cacb8c9ab6b52a57fbcad7 Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Thu, 10 Sep 2026 19:29:32 +0300 Subject: [PATCH 4/7] fix(client): make transport-managed header precedence case-insensitive; fix stale eventSourceInit note - build _commonHeaders() on `new Headers(requestInit.headers)` and `set()` the transport-managed values in both transports, so Authorization / mcp-session-id / mcp-protocol-version replace a same-named caller entry whatever its spelling. The plain-object spread kept `authorization` and `Authorization` side by side and the Headers constructor combined them into "Bearer , Bearer " for callers passing a Headers instance or a lowercase key - drop the now-unused normalizeHeaders import from both transports - add regression tests for a Headers instance and a lowercase plain object - rewrite the SSEClientTransportOptions.eventSourceInit note: a custom fetch is always wrapped, so the provider token is attached and takes precedence over requestInit - reword the changeset to describe the behavior rather than the old spread order --- .changeset/oauth-header-spread-order.md | 2 +- packages/client/src/client/sse.ts | 39 +++++++-------- packages/client/src/client/streamableHttp.ts | 33 ++++++------- packages/client/test/client/sse.test.ts | 36 ++++++++++++++ .../client/test/client/streamableHttp.test.ts | 49 +++++++++++++++++++ 5 files changed, 118 insertions(+), 41 deletions(-) diff --git a/.changeset/oauth-header-spread-order.md b/.changeset/oauth-header-spread-order.md index 777bcf0447..be81fc9d39 100644 --- a/.changeset/oauth-header-spread-order.md +++ b/.changeset/oauth-header-spread-order.md @@ -2,4 +2,4 @@ '@modelcontextprotocol/client': patch --- -Fix the spread order in `StreamableHTTPClientTransport._commonHeaders()` and `SSEClientTransport._commonHeaders()` so SDK-derived common headers (including fresh OAuth tokens from `authProvider`) win over caller-supplied headers in `requestInit.headers`. Previously a caller-supplied `Authorization` placeholder (e.g. an env-var API key) was merged after the SDK-computed value, silently overriding OAuth-refreshed tokens and breaking the auth-refresh flow once the placeholder went stale. Closes #2208. +`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 , Bearer ` value instead. A configured `Authorization` is still sent while the provider has no token, and other configured headers pass through unchanged. Closes #2208. diff --git a/packages/client/src/client/sse.ts b/packages/client/src/client/sse.ts index 9a157c65a2..f55177204d 100644 --- a/packages/client/src/client/sse.ts +++ b/packages/client/src/client/sse.ts @@ -3,7 +3,6 @@ import { brandedHasInstance, createFetchWithInit, JSONRPCMessageSchema, - normalizeHeaders, SdkError, SdkErrorCode, SdkHttpError, @@ -97,10 +96,11 @@ 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; @@ -177,7 +177,16 @@ export class SSEClientTransport implements Transport { private _last401Response?: Response; private async _commonHeaders(): Promise { - const headers: RequestInit['headers'] & Record = {}; + // 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 "stale, fresh" 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. + const headers = new Headers(this._requestInit?.headers); let token: string | undefined; try { token = await this._authProvider?.token(); @@ -187,24 +196,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); } - - // Order matters: caller-supplied `requestInit.headers` are spread first and the - // transport-managed headers (Authorization from the auth provider, - // mcp-protocol-version) on top, so they win over a same-named caller entry. 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. - const extraHeaders = normalizeHeaders(this._requestInit?.headers); - - return new Headers({ - ...extraHeaders, - ...headers - }); + return headers; } private _startOrAuth(): Promise { diff --git a/packages/client/src/client/streamableHttp.ts b/packages/client/src/client/streamableHttp.ts index 3a931caa39..6ce8ad7202 100644 --- a/packages/client/src/client/streamableHttp.ts +++ b/packages/client/src/client/streamableHttp.ts @@ -13,7 +13,6 @@ import { JSONRPCMessageSchema, mcpNameSource, mediaTypeEssence, - normalizeHeaders, PROTOCOL_VERSION_META_KEY, SdkError, SdkErrorCode, @@ -450,7 +449,16 @@ export class StreamableHTTPClientTransport implements Transport { } private async _commonHeaders(): Promise { - const headers: RequestInit['headers'] & Record = {}; + // 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 "stale, fresh" + // 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. + const headers = new Headers(this._requestInit?.headers); let token: string | undefined; try { token = await this._authProvider?.token(); @@ -460,28 +468,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); } - - // Order matters: caller-supplied `requestInit.headers` are spread first and the - // transport-managed headers (Authorization from the auth provider, mcp-session-id, - // mcp-protocol-version) on top, so they win over a same-named caller entry. 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. - const extraHeaders = normalizeHeaders(this._requestInit?.headers); - - return new Headers({ - ...extraHeaders, - ...headers - }); + return headers; } /** diff --git a/packages/client/test/client/sse.test.ts b/packages/client/test/client/sse.test.ts index 587dfd7b33..2b0807ed96 100644 --- a/packages/client/test/client/sse.test.ts +++ b/packages/client/test/client/sse.test.ts @@ -699,6 +699,42 @@ describe('SSEClientTransport', () => { 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 "Bearer stale, Bearer fresh". + 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('refreshes expired token during SSE connection', async () => { // Mock tokens() to return expired token until saveTokens is called let currentTokens: OAuthTokens = { diff --git a/packages/client/test/client/streamableHttp.test.ts b/packages/client/test/client/streamableHttp.test.ts index 60b131d068..6a0cf25af1 100644 --- a/packages/client/test/client/streamableHttp.test.ts +++ b/packages/client/test/client/streamableHttp.test.ts @@ -749,6 +749,55 @@ describe('StreamableHTTPClientTransport', () => { expect(globalThis.fetch).toHaveBeenCalledTimes(2); }); + it.each([ + [ + 'a Headers instance', + (): NonNullable => + new Headers({ + authorization: 'Bearer stale-placeholder', + 'MCP-Protocol-Version': 'caller-supplied', + 'x-caller-header': 'preserved' + }) + ], + [ + 'a lowercase plain object', + (): NonNullable => ({ + 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 "Bearer stale, Bearer fresh". 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('should always send specified custom headers (Headers class)', async () => { const requestInit = { headers: new Headers({ From db9a66c9d8bc29ba5cf28ac9277dff0aa29e6257 Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Thu, 10 Sep 2026 20:17:37 +0300 Subject: [PATCH 5/7] fix(client): keep tolerating a null requestInit.headers in _commonHeaders() `new Headers(null)` throws where the replaced normalizeHeaders() returned an empty record, so a JS caller (or a JSON config forwarded verbatim) passing `requestInit: { headers: null }` failed on every request. Map falsy to undefined in both transports and add a regression test for each. --- packages/client/src/client/sse.ts | 5 +++- packages/client/src/client/streamableHttp.ts | 5 +++- packages/client/test/client/sse.test.ts | 20 ++++++++++++++++ .../client/test/client/streamableHttp.test.ts | 23 +++++++++++++++++++ 4 files changed, 51 insertions(+), 2 deletions(-) diff --git a/packages/client/src/client/sse.ts b/packages/client/src/client/sse.ts index f55177204d..0050ff22fd 100644 --- a/packages/client/src/client/sse.ts +++ b/packages/client/src/client/sse.ts @@ -186,7 +186,10 @@ export class SSEClientTransport implements Transport { // 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. - const headers = new Headers(this._requestInit?.headers); + // `|| 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(); diff --git a/packages/client/src/client/streamableHttp.ts b/packages/client/src/client/streamableHttp.ts index 6ce8ad7202..1ab9278c0e 100644 --- a/packages/client/src/client/streamableHttp.ts +++ b/packages/client/src/client/streamableHttp.ts @@ -458,7 +458,10 @@ export class StreamableHTTPClientTransport implements Transport { // 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. - const headers = new Headers(this._requestInit?.headers); + // `|| 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(); diff --git a/packages/client/test/client/sse.test.ts b/packages/client/test/client/sse.test.ts index 2b0807ed96..f7a7715c11 100644 --- a/packages/client/test/client/sse.test.ts +++ b/packages/client/test/client/sse.test.ts @@ -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', diff --git a/packages/client/test/client/streamableHttp.test.ts b/packages/client/test/client/streamableHttp.test.ts index 6a0cf25af1..3422ca8885 100644 --- a/packages/client/test/client/streamableHttp.test.ts +++ b/packages/client/test/client/streamableHttp.test.ts @@ -798,6 +798,29 @@ describe('StreamableHTTPClientTransport', () => { expect((actualReqInit.headers as Headers).get('x-caller-header')).toBe('preserved'); }); + 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({ From dcc7928780a90abceb24a8d5ca2201be3e133bba Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Thu, 10 Sep 2026 23:59:50 +0300 Subject: [PATCH 6/7] test(client): cover tuple-array requestInit.headers and pin repeated-name combine semantics A repeated name in a tuple array is the one HeadersInit form that can express a multi-valued header. The transports build their headers with the Fetch Headers constructor, so caller duplicates combine exactly as a direct fetch() would, while a repeated transport-managed name (e.g. Authorization) is still replaced outright by the transport's own value. Cover the tuple-array form in the case-insensitivity matrix and pin the combine behaviour on GET and POST for both StreamableHTTPClientTransport and SSEClientTransport. --- packages/client/test/client/sse.test.ts | 42 ++++++++++++++++++ .../client/test/client/streamableHttp.test.ts | 44 +++++++++++++++++++ 2 files changed, 86 insertions(+) diff --git a/packages/client/test/client/sse.test.ts b/packages/client/test/client/sse.test.ts index f7a7715c11..c1516e3014 100644 --- a/packages/client/test/client/sse.test.ts +++ b/packages/client/test/client/sse.test.ts @@ -755,6 +755,48 @@ describe('SSEClientTransport', () => { 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 = { diff --git a/packages/client/test/client/streamableHttp.test.ts b/packages/client/test/client/streamableHttp.test.ts index 3422ca8885..c7f80c724f 100644 --- a/packages/client/test/client/streamableHttp.test.ts +++ b/packages/client/test/client/streamableHttp.test.ts @@ -766,6 +766,14 @@ describe('StreamableHTTPClientTransport', () => { 'MCP-Protocol-Version': 'caller-supplied', 'x-caller-header': 'preserved' }) + ], + [ + 'a tuple array', + (): NonNullable => [ + ['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 @@ -798,6 +806,42 @@ describe('StreamableHTTPClientTransport', () => { 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. From 86beff1d7f7df3dcf48b49109851a55146b902ce Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Fri, 11 Sep 2026 00:28:41 +0300 Subject: [PATCH 7/7] docs(client): correct the pre-fix combined Authorization order in changeset and comments The released base spread the transport-managed headers first, so a Headers instance or lowercase caller key produced 'Bearer , Bearer ', not the reversed order 52491ff1 described (that order only held for the branch's intermediate caller-first swap). State the real base output in the changeset and drop the ordering claim from the inline and test comments, which describe a hypothetical spread whose order is not fixed. --- .changeset/oauth-header-spread-order.md | 2 +- packages/client/src/client/sse.ts | 2 +- packages/client/src/client/streamableHttp.ts | 2 +- packages/client/test/client/sse.test.ts | 2 +- packages/client/test/client/streamableHttp.test.ts | 4 ++-- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.changeset/oauth-header-spread-order.md b/.changeset/oauth-header-spread-order.md index be81fc9d39..45b58775b0 100644 --- a/.changeset/oauth-header-spread-order.md +++ b/.changeset/oauth-header-spread-order.md @@ -2,4 +2,4 @@ '@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 , Bearer ` value instead. A configured `Authorization` is still sent while the provider has no token, and other configured headers pass through unchanged. Closes #2208. +`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 , Bearer ` value instead. A configured `Authorization` is still sent while the provider has no token, and other configured headers pass through unchanged. Closes #2208. diff --git a/packages/client/src/client/sse.ts b/packages/client/src/client/sse.ts index 0050ff22fd..e0b320b107 100644 --- a/packages/client/src/client/sse.ts +++ b/packages/client/src/client/sse.ts @@ -182,7 +182,7 @@ export class SSEClientTransport implements Transport { // 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 "stale, fresh" value.) This lets + // 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. diff --git a/packages/client/src/client/streamableHttp.ts b/packages/client/src/client/streamableHttp.ts index 1ab9278c0e..c91c6db008 100644 --- a/packages/client/src/client/streamableHttp.ts +++ b/packages/client/src/client/streamableHttp.ts @@ -454,7 +454,7 @@ export class StreamableHTTPClientTransport implements Transport { // 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 "stale, fresh" + // 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. diff --git a/packages/client/test/client/sse.test.ts b/packages/client/test/client/sse.test.ts index c1516e3014..6952af8f76 100644 --- a/packages/client/test/client/sse.test.ts +++ b/packages/client/test/client/sse.test.ts @@ -721,7 +721,7 @@ describe('SSEClientTransport', () => { 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 "Bearer stale, Bearer fresh". + // still send exactly its own token, not a combined two-token value. mockAuthProvider.tokens.mockResolvedValue({ access_token: 'fresh-token', token_type: 'Bearer' diff --git a/packages/client/test/client/streamableHttp.test.ts b/packages/client/test/client/streamableHttp.test.ts index c7f80c724f..17fd2df276 100644 --- a/packages/client/test/client/streamableHttp.test.ts +++ b/packages/client/test/client/streamableHttp.test.ts @@ -779,8 +779,8 @@ describe('StreamableHTTPClientTransport', () => { // #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 "Bearer stale, Bearer fresh". The transport must send - // exactly its own value. + // 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() },