From 3b375161c0b3f75cc735bebd3251ca69f7f12e36 Mon Sep 17 00:00:00 2001 From: Genmin Date: Thu, 30 Apr 2026 08:37:40 -0700 Subject: [PATCH] fix(server): allow json accept in json response mode --- .changeset/json-accept-json-response.md | 5 +++ src/server/webStandardStreamableHttp.ts | 44 ++++++++++++++++++----- test/server/streamableHttp.test.ts | 48 +++++++++++++++++++++++++ 3 files changed, 89 insertions(+), 8 deletions(-) create mode 100644 .changeset/json-accept-json-response.md diff --git a/.changeset/json-accept-json-response.md b/.changeset/json-accept-json-response.md new file mode 100644 index 0000000000..f35f38d965 --- /dev/null +++ b/.changeset/json-accept-json-response.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/sdk': patch +--- + +Allow Streamable HTTP JSON response mode to accept requests with `Accept: application/json` without requiring `text/event-stream`. diff --git a/src/server/webStandardStreamableHttp.ts b/src/server/webStandardStreamableHttp.ts index 1f528427c8..27e1c2cb4e 100644 --- a/src/server/webStandardStreamableHttp.ts +++ b/src/server/webStandardStreamableHttp.ts @@ -72,6 +72,33 @@ interface StreamMapping { cleanup: () => void; } +function acceptsMediaType(acceptHeader: string | null, mediaType: string): boolean { + if (!acceptHeader) { + return false; + } + + const [expectedType, expectedSubtype] = mediaType.toLowerCase().split('/'); + + return acceptHeader.split(',').some(entry => { + const [rawMediaRange, ...params] = entry + .trim() + .toLowerCase() + .split(';') + .map(part => part.trim()); + if (!rawMediaRange) { + return false; + } + + const qParam = params.find(param => param.startsWith('q=')); + if (qParam !== undefined && Number(qParam.slice(2)) === 0) { + return false; + } + + const [type, subtype] = rawMediaRange.split('/'); + return (type === expectedType || type === '*') && (subtype === expectedSubtype || subtype === '*'); + }); +} + /** * Configuration options for WebStandardStreamableHTTPServerTransport */ @@ -598,14 +625,15 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { try { // Validate the Accept header const acceptHeader = req.headers.get('accept'); - // The client MUST include an Accept header, listing both application/json and text/event-stream as supported content types. - if (!acceptHeader?.includes('application/json') || !acceptHeader.includes('text/event-stream')) { - this.onerror?.(new Error('Not Acceptable: Client must accept both application/json and text/event-stream')); - return this.createJsonErrorResponse( - 406, - -32000, - 'Not Acceptable: Client must accept both application/json and text/event-stream' - ); + const acceptsJson = acceptsMediaType(acceptHeader, 'application/json'); + const acceptsEventStream = acceptsMediaType(acceptHeader, 'text/event-stream'); + + if (!acceptsJson || (!this._enableJsonResponse && !acceptsEventStream)) { + const error = this._enableJsonResponse + ? 'Not Acceptable: Client must accept application/json' + : 'Not Acceptable: Client must accept both application/json and text/event-stream'; + this.onerror?.(new Error(error)); + return this.createJsonErrorResponse(406, -32000, error); } const ct = req.headers.get('content-type'); diff --git a/test/server/streamableHttp.test.ts b/test/server/streamableHttp.test.ts index 4a4f7d8248..e8a52415ff 100644 --- a/test/server/streamableHttp.test.ts +++ b/test/server/streamableHttp.test.ts @@ -1115,6 +1115,29 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { }); }); + it('should accept application/json-only Accept headers in JSON response mode', async () => { + const response = await sendPostRequest(baseUrl, TEST_MESSAGES.toolsList, sessionId, { Accept: 'application/json' }); + + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toBe('application/json'); + + const result = await response.json(); + expect(result).toMatchObject({ + jsonrpc: '2.0', + result: expect.objectContaining({ + tools: expect.arrayContaining([expect.objectContaining({ name: 'greet' })]) + }), + id: 'tools-1' + }); + }); + + it('should accept wildcard Accept headers in JSON response mode', async () => { + const response = await sendPostRequest(baseUrl, TEST_MESSAGES.toolsList, sessionId, { Accept: '*/*' }); + + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toBe('application/json'); + }); + it('should return JSON response for batch requests', async () => { const batchMessages: JSONRPCMessage[] = [ { jsonrpc: '2.0', method: 'tools/list', params: {}, id: 'batch-1' }, @@ -3180,6 +3203,31 @@ describe('WebStandardStreamableHTTPServerTransport - onerror callback', () => { expect(onerrorSpy.mock.calls[0]![0]!.message).toMatch(/Not Acceptable/); }); + it('should allow application/json-only Accept headers in JSON response mode', async () => { + const jsonServer = new McpServer({ name: 'json-test-server', version: '1.0.0' }); + const jsonTransport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + enableJsonResponse: true + }); + const jsonOnError = vi.fn<(error: Error) => void>(); + jsonTransport.onerror = jsonOnError; + await jsonServer.connect(jsonTransport); + + try { + const response = await jsonTransport.handleRequest( + req('POST', { body: TEST_MESSAGES.initialize, headers: { Accept: 'application/json', 'Content-Type': 'application/json' } }) + ); + + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toBe('application/json'); + expect(await response.json()).toMatchObject({ jsonrpc: '2.0', id: 'init-1' }); + expect(jsonOnError).not.toHaveBeenCalled(); + } finally { + await jsonTransport.close(); + await jsonServer.close(); + } + }); + it('should call onerror for unsupported Content-Type', async () => { await transport.handleRequest( req('POST', {