diff --git a/.changeset/oauth-header-spread-order.md b/.changeset/oauth-header-spread-order.md new file mode 100644 index 0000000000..45b58775b0 --- /dev/null +++ b/.changeset/oauth-header-spread-order.md @@ -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 , 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/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 965bb91daa..e0b320b107 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,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 { - 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 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 { diff --git a/packages/client/src/client/streamableHttp.ts b/packages/client/src/client/streamableHttp.ts index c6eaef46d8..c91c6db008 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, @@ -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 { - 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 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; } /** diff --git a/packages/client/test/client/sse.test.ts b/packages/client/test/client/sse.test.ts index a0d4e7b6f9..6952af8f76 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', @@ -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 = { diff --git a/packages/client/test/client/streamableHttp.test.ts b/packages/client/test/client/streamableHttp.test.ts index a36bbc0ad3..17fd2df276 100644 --- a/packages/client/test/client/streamableHttp.test.ts +++ b/packages/client/test/client/streamableHttp.test.ts @@ -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 => + 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' + }) + ], + [ + '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 + // 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({ 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';