Skip to content

Commit 1eb3123

Browse files
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>
1 parent 866c08d commit 1eb3123

File tree

3 files changed

+106
-2
lines changed

3 files changed

+106
-2
lines changed

.changeset/odd-forks-enjoy.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@modelcontextprotocol/client": patch
3+
---
4+
5+
fix(client): append custom Accept headers to spec-required defaults in StreamableHTTPClientTransport
6+
7+
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.

packages/client/src/client/streamableHttp.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -237,7 +237,9 @@ export class StreamableHTTPClientTransport implements Transport {
237237
// Try to open an initial SSE stream with GET to listen for server messages
238238
// This is optional according to the spec - server may not support it
239239
const headers = await this._commonHeaders();
240-
headers.set('Accept', 'text/event-stream');
240+
const userAccept = headers.get('accept');
241+
const types = [...(userAccept?.split(',').map(s => s.trim().toLowerCase()) ?? []), 'text/event-stream'];
242+
headers.set('accept', [...new Set(types)].join(', '));
241243

242244
// Include Last-Event-ID header for resumable streams if provided
243245
if (resumptionToken) {
@@ -538,7 +540,9 @@ export class StreamableHTTPClientTransport implements Transport {
538540

539541
const headers = await this._commonHeaders();
540542
headers.set('content-type', 'application/json');
541-
headers.set('accept', 'application/json, text/event-stream');
543+
const userAccept = headers.get('accept');
544+
const types = [...(userAccept?.split(',').map(s => s.trim().toLowerCase()) ?? []), 'application/json', 'text/event-stream'];
545+
headers.set('accept', [...new Set(types)].join(', '));
542546

543547
const init = {
544548
...this._requestInit,

packages/client/test/client/streamableHttp.test.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -628,6 +628,99 @@ describe('StreamableHTTPClientTransport', () => {
628628
expect((actualReqInit.headers as Headers).get('x-custom-header')).toBe('CustomValue');
629629
});
630630

631+
it('should append custom Accept header to required types on POST requests', async () => {
632+
transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), {
633+
requestInit: {
634+
headers: {
635+
Accept: 'application/vnd.example.v1+json'
636+
}
637+
}
638+
});
639+
640+
let actualReqInit: RequestInit = {};
641+
642+
(globalThis.fetch as Mock).mockImplementation(async (_url, reqInit) => {
643+
actualReqInit = reqInit;
644+
return new Response(JSON.stringify({ jsonrpc: '2.0', result: {} }), {
645+
status: 200,
646+
headers: { 'content-type': 'application/json' }
647+
});
648+
});
649+
650+
await transport.start();
651+
652+
await transport.send({ jsonrpc: '2.0', method: 'test', params: {} } as JSONRPCMessage);
653+
expect((actualReqInit.headers as Headers).get('accept')).toBe(
654+
'application/vnd.example.v1+json, application/json, text/event-stream'
655+
);
656+
});
657+
658+
it('should append custom Accept header to required types on GET SSE requests', async () => {
659+
transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), {
660+
requestInit: {
661+
headers: {
662+
Accept: 'application/json'
663+
}
664+
}
665+
});
666+
667+
let actualReqInit: RequestInit = {};
668+
669+
(globalThis.fetch as Mock).mockImplementation(async (_url, reqInit) => {
670+
actualReqInit = reqInit;
671+
return new Response(null, { status: 200, headers: { 'content-type': 'text/event-stream' } });
672+
});
673+
674+
await transport.start();
675+
676+
await transport['_startOrAuthSse']({});
677+
expect((actualReqInit.headers as Headers).get('accept')).toBe('application/json, text/event-stream');
678+
});
679+
680+
it('should set default Accept header when none provided', async () => {
681+
transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'));
682+
683+
let actualReqInit: RequestInit = {};
684+
685+
(globalThis.fetch as Mock).mockImplementation(async (_url, reqInit) => {
686+
actualReqInit = reqInit;
687+
return new Response(JSON.stringify({ jsonrpc: '2.0', result: {} }), {
688+
status: 200,
689+
headers: { 'content-type': 'application/json' }
690+
});
691+
});
692+
693+
await transport.start();
694+
695+
await transport.send({ jsonrpc: '2.0', method: 'test', params: {} } as JSONRPCMessage);
696+
expect((actualReqInit.headers as Headers).get('accept')).toBe('application/json, text/event-stream');
697+
});
698+
699+
it('should not duplicate Accept media types when user-provided value overlaps required types', async () => {
700+
transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), {
701+
requestInit: {
702+
headers: {
703+
Accept: 'application/json'
704+
}
705+
}
706+
});
707+
708+
let actualReqInit: RequestInit = {};
709+
710+
(globalThis.fetch as Mock).mockImplementation(async (_url, reqInit) => {
711+
actualReqInit = reqInit;
712+
return new Response(JSON.stringify({ jsonrpc: '2.0', result: {} }), {
713+
status: 200,
714+
headers: { 'content-type': 'application/json' }
715+
});
716+
});
717+
718+
await transport.start();
719+
720+
await transport.send({ jsonrpc: '2.0', method: 'test', params: {} } as JSONRPCMessage);
721+
expect((actualReqInit.headers as Headers).get('accept')).toBe('application/json, text/event-stream');
722+
});
723+
631724
it('should have exponential backoff with configurable maxRetries', () => {
632725
// This test verifies the maxRetries and backoff calculation directly
633726

0 commit comments

Comments
 (0)