fix(client): preserve custom Accept headers in StreamableHTTPClientTransport (#1655)

Co-authored-by: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com>
Co-authored-by: Felix Weinberger <fweinberger@anthropic.com>
This commit is contained in:
Niels Kaspers 2026-04-02 15:52:53 +03:00 • committed by GitHub
commit 1eb31236e7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 106 additions and 2 deletions

View file

@ -0,0 +1,7 @@
---
"@modelcontextprotocol/client": patch
---
fix(client): append custom Accept headers to spec-required defaults in StreamableHTTPClientTransport
Custom Accept headers provided via `requestInit.headers` are now appended to the spec-mandated Accept types instead of being overwritten. This ensures the required media types (`application/json, text/event-stream` for POST; `text/event-stream` for GET SSE) are always present while allowing users to include additional types for proxy/gateway routing.

View file

@ -237,7 +237,9 @@ export class StreamableHTTPClientTransport implements Transport {
// Try to open an initial SSE stream with GET to listen for server messages
// This is optional according to the spec - server may not support it
const headers = await this._commonHeaders();
headers.set('Accept', 'text/event-stream');
const userAccept = headers.get('accept');
const types = [...(userAccept?.split(',').map(s => s.trim().toLowerCase()) ?? []), 'text/event-stream'];
headers.set('accept', [...new Set(types)].join(', '));
// Include Last-Event-ID header for resumable streams if provided
if (resumptionToken) {
@ -538,7 +540,9 @@ export class StreamableHTTPClientTransport implements Transport {
const headers = await this._commonHeaders();
headers.set('content-type', 'application/json');
headers.set('accept', 'application/json, text/event-stream');
const userAccept = headers.get('accept');
const types = [...(userAccept?.split(',').map(s => s.trim().toLowerCase()) ?? []), 'application/json', 'text/event-stream'];
headers.set('accept', [...new Set(types)].join(', '));
const init = {
...this._requestInit,

View file

@ -628,6 +628,99 @@ describe('StreamableHTTPClientTransport', () => {
expect((actualReqInit.headers as Headers).get('x-custom-header')).toBe('CustomValue');
});
it('should append custom Accept header to required types on POST requests', async () => {
transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), {
requestInit: {
headers: {
Accept: 'application/vnd.example.v1+json'
}
}
});
let actualReqInit: RequestInit = {};
(globalThis.fetch as Mock).mockImplementation(async (_url, reqInit) => {
actualReqInit = reqInit;
return new Response(JSON.stringify({ jsonrpc: '2.0', result: {} }), {
status: 200,
headers: { 'content-type': 'application/json' }
});
});
await transport.start();
await transport.send({ jsonrpc: '2.0', method: 'test', params: {} } as JSONRPCMessage);
expect((actualReqInit.headers as Headers).get('accept')).toBe(
'application/vnd.example.v1+json, application/json, text/event-stream'
);
});
it('should append custom Accept header to required types on GET SSE requests', async () => {
transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), {
requestInit: {
headers: {
Accept: 'application/json'
}
}
});
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('accept')).toBe('application/json, text/event-stream');
});
it('should set default Accept header when none provided', async () => {
transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'));
let actualReqInit: RequestInit = {};
(globalThis.fetch as Mock).mockImplementation(async (_url, reqInit) => {
actualReqInit = reqInit;
return new Response(JSON.stringify({ jsonrpc: '2.0', result: {} }), {
status: 200,
headers: { 'content-type': 'application/json' }
});
});
await transport.start();
await transport.send({ jsonrpc: '2.0', method: 'test', params: {} } as JSONRPCMessage);
expect((actualReqInit.headers as Headers).get('accept')).toBe('application/json, text/event-stream');
});
it('should not duplicate Accept media types when user-provided value overlaps required types', async () => {
transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), {
requestInit: {
headers: {
Accept: 'application/json'
}
}
});
let actualReqInit: RequestInit = {};
(globalThis.fetch as Mock).mockImplementation(async (_url, reqInit) => {
actualReqInit = reqInit;
return new Response(JSON.stringify({ jsonrpc: '2.0', result: {} }), {
status: 200,
headers: { 'content-type': 'application/json' }
});
});
await transport.start();
await transport.send({ jsonrpc: '2.0', method: 'test', params: {} } as JSONRPCMessage);
expect((actualReqInit.headers as Headers).get('accept')).toBe('application/json, text/event-stream');
});
it('should have exponential backoff with configurable maxRetries', () => {
// This test verifies the maxRetries and backoff calculation directly