Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/oauth-header-spread-order.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 8 additions & 2 deletions packages/client/src/client/sse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
});
}

Expand Down
10 changes: 8 additions & 2 deletions packages/client/src/client/streamableHttp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
});
}

Expand Down
36 changes: 36 additions & 0 deletions packages/client/test/client/streamableHttp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Loading