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:
parent
866c08d364
commit
1eb31236e7
3 changed files with 106 additions and 2 deletions
7
.changeset/odd-forks-enjoy.md
Normal file
7
.changeset/odd-forks-enjoy.md
Normal 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.
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue