Skip to content

Commit 3b37516

Browse files
committed
fix(server): allow json accept in json response mode
1 parent bf1e022 commit 3b37516

3 files changed

Lines changed: 89 additions & 8 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@modelcontextprotocol/sdk': patch
3+
---
4+
5+
Allow Streamable HTTP JSON response mode to accept requests with `Accept: application/json` without requiring `text/event-stream`.

src/server/webStandardStreamableHttp.ts

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,33 @@ interface StreamMapping {
7272
cleanup: () => void;
7373
}
7474

75+
function acceptsMediaType(acceptHeader: string | null, mediaType: string): boolean {
76+
if (!acceptHeader) {
77+
return false;
78+
}
79+
80+
const [expectedType, expectedSubtype] = mediaType.toLowerCase().split('/');
81+
82+
return acceptHeader.split(',').some(entry => {
83+
const [rawMediaRange, ...params] = entry
84+
.trim()
85+
.toLowerCase()
86+
.split(';')
87+
.map(part => part.trim());
88+
if (!rawMediaRange) {
89+
return false;
90+
}
91+
92+
const qParam = params.find(param => param.startsWith('q='));
93+
if (qParam !== undefined && Number(qParam.slice(2)) === 0) {
94+
return false;
95+
}
96+
97+
const [type, subtype] = rawMediaRange.split('/');
98+
return (type === expectedType || type === '*') && (subtype === expectedSubtype || subtype === '*');
99+
});
100+
}
101+
75102
/**
76103
* Configuration options for WebStandardStreamableHTTPServerTransport
77104
*/
@@ -598,14 +625,15 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
598625
try {
599626
// Validate the Accept header
600627
const acceptHeader = req.headers.get('accept');
601-
// The client MUST include an Accept header, listing both application/json and text/event-stream as supported content types.
602-
if (!acceptHeader?.includes('application/json') || !acceptHeader.includes('text/event-stream')) {
603-
this.onerror?.(new Error('Not Acceptable: Client must accept both application/json and text/event-stream'));
604-
return this.createJsonErrorResponse(
605-
406,
606-
-32000,
607-
'Not Acceptable: Client must accept both application/json and text/event-stream'
608-
);
628+
const acceptsJson = acceptsMediaType(acceptHeader, 'application/json');
629+
const acceptsEventStream = acceptsMediaType(acceptHeader, 'text/event-stream');
630+
631+
if (!acceptsJson || (!this._enableJsonResponse && !acceptsEventStream)) {
632+
const error = this._enableJsonResponse
633+
? 'Not Acceptable: Client must accept application/json'
634+
: 'Not Acceptable: Client must accept both application/json and text/event-stream';
635+
this.onerror?.(new Error(error));
636+
return this.createJsonErrorResponse(406, -32000, error);
609637
}
610638

611639
const ct = req.headers.get('content-type');

test/server/streamableHttp.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1115,6 +1115,29 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => {
11151115
});
11161116
});
11171117

1118+
it('should accept application/json-only Accept headers in JSON response mode', async () => {
1119+
const response = await sendPostRequest(baseUrl, TEST_MESSAGES.toolsList, sessionId, { Accept: 'application/json' });
1120+
1121+
expect(response.status).toBe(200);
1122+
expect(response.headers.get('content-type')).toBe('application/json');
1123+
1124+
const result = await response.json();
1125+
expect(result).toMatchObject({
1126+
jsonrpc: '2.0',
1127+
result: expect.objectContaining({
1128+
tools: expect.arrayContaining([expect.objectContaining({ name: 'greet' })])
1129+
}),
1130+
id: 'tools-1'
1131+
});
1132+
});
1133+
1134+
it('should accept wildcard Accept headers in JSON response mode', async () => {
1135+
const response = await sendPostRequest(baseUrl, TEST_MESSAGES.toolsList, sessionId, { Accept: '*/*' });
1136+
1137+
expect(response.status).toBe(200);
1138+
expect(response.headers.get('content-type')).toBe('application/json');
1139+
});
1140+
11181141
it('should return JSON response for batch requests', async () => {
11191142
const batchMessages: JSONRPCMessage[] = [
11201143
{ jsonrpc: '2.0', method: 'tools/list', params: {}, id: 'batch-1' },
@@ -3180,6 +3203,31 @@ describe('WebStandardStreamableHTTPServerTransport - onerror callback', () => {
31803203
expect(onerrorSpy.mock.calls[0]![0]!.message).toMatch(/Not Acceptable/);
31813204
});
31823205

3206+
it('should allow application/json-only Accept headers in JSON response mode', async () => {
3207+
const jsonServer = new McpServer({ name: 'json-test-server', version: '1.0.0' });
3208+
const jsonTransport = new WebStandardStreamableHTTPServerTransport({
3209+
sessionIdGenerator: () => randomUUID(),
3210+
enableJsonResponse: true
3211+
});
3212+
const jsonOnError = vi.fn<(error: Error) => void>();
3213+
jsonTransport.onerror = jsonOnError;
3214+
await jsonServer.connect(jsonTransport);
3215+
3216+
try {
3217+
const response = await jsonTransport.handleRequest(
3218+
req('POST', { body: TEST_MESSAGES.initialize, headers: { Accept: 'application/json', 'Content-Type': 'application/json' } })
3219+
);
3220+
3221+
expect(response.status).toBe(200);
3222+
expect(response.headers.get('content-type')).toBe('application/json');
3223+
expect(await response.json()).toMatchObject({ jsonrpc: '2.0', id: 'init-1' });
3224+
expect(jsonOnError).not.toHaveBeenCalled();
3225+
} finally {
3226+
await jsonTransport.close();
3227+
await jsonServer.close();
3228+
}
3229+
});
3230+
31833231
it('should call onerror for unsupported Content-Type', async () => {
31843232
await transport.handleRequest(
31853233
req('POST', {

0 commit comments

Comments
 (0)