From c053754b652e35990caa0ff76e9a405b98a9fc2d Mon Sep 17 00:00:00 2001 From: meraklbz Date: Sun, 16 Aug 2026 22:34:35 +0800 Subject: [PATCH 1/2] fix(server): validate Accept header by parsed media ranges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The streamable HTTP transport checked the Accept header with a substring search: it passed when the header merely contained the substrings 'application/json' and 'text/event-stream', and rejected legal wildcard ranges such as '*/*' or 'application/*'. A substring check lets forged media types through (e.g. 'application/jsonx') and rejects values every conforming client legitimately sends. Parse the Accept header into media ranges per RFC 9110 §12.5.1 and match with wildcard semantics instead, for both the POST and GET paths. --- .../core-internal/src/shared/mediaType.ts | 34 ++++++++++++ packages/server/src/server/streamableHttp.ts | 8 +-- .../server/test/server/streamableHttp.test.ts | 53 +++++++++++++++++++ 3 files changed, 91 insertions(+), 4 deletions(-) diff --git a/packages/core-internal/src/shared/mediaType.ts b/packages/core-internal/src/shared/mediaType.ts index aaa7a3533f..6b99a6944f 100644 --- a/packages/core-internal/src/shared/mediaType.ts +++ b/packages/core-internal/src/shared/mediaType.ts @@ -55,3 +55,37 @@ export function isJsonContentType(header: string | null | undefined): boolean { } return mediaTypeEssence(header) === 'application/json'; } + +/** + * Parses an `Accept` header value into a set of media ranges (RFC 9110 §12.5.1). + * + * Each comma-separated range is lowercased and stripped of its parameters + * (e.g. `;q=0.9`), so a value like `application/json; q=0.9` yields the range + * `application/json`. Wildcards (the `*` forms) are kept as-is so callers + * can honor the media-range matching rules of RFC 9110 §12.5.1 instead of + * doing a substring search of the raw header — a substring search both lets + * forged types through (`application/jsonx`) and rejects legal wildcards. + */ +export function parseAcceptRanges(header: string | null | undefined): Set { + const ranges = new Set(); + if (!header) { + return ranges; + } + for (const part of header.split(',')) { + const range = part.split(';', 1)[0]?.trim().toLowerCase(); + if (range) { + ranges.add(range); + } + } + return ranges; +} + +/** + * Whether an `Accept` header value includes the given media type, following + * the media-range matching rules of RFC 9110 §12.5.1: an exact `type/subtype` + * range matches, and so do the `type/*` and `*` wildcard forms. + */ +export function acceptIncludes(header: string | null | undefined, type: string, subtype: string): boolean { + const ranges = parseAcceptRanges(header); + return ranges.has(`${type}/${subtype}`) || ranges.has(`${type}/*`) || ranges.has('*/*'); +} diff --git a/packages/server/src/server/streamableHttp.ts b/packages/server/src/server/streamableHttp.ts index c0f48560a2..0f0a638d7c 100644 --- a/packages/server/src/server/streamableHttp.ts +++ b/packages/server/src/server/streamableHttp.ts @@ -10,6 +10,7 @@ import type { AuthInfo, JSONRPCMessage, MessageExtraInfo, RequestId, Transport } from '@modelcontextprotocol/core-internal'; import { DEFAULT_NEGOTIATED_PROTOCOL_VERSION, + acceptIncludes, isInitializeRequest, isJsonContentType, isJSONRPCErrorResponse, @@ -458,7 +459,7 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { private async handleGetRequest(req: Request): Promise { // The client MUST include an Accept header, listing text/event-stream as a supported content type. const acceptHeader = req.headers.get('accept'); - if (!acceptHeader?.includes('text/event-stream')) { + if (!acceptIncludes(acceptHeader, 'text', 'event-stream')) { this.onerror?.(new Error('Not Acceptable: Client must accept text/event-stream')); return this.createJsonErrorResponse(406, -32_000, 'Not Acceptable: Client must accept text/event-stream'); } @@ -737,9 +738,8 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { // 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. - // Accept is a comma-separated list, so a substring check is the intended semantics here (unlike Content-Type below). - // eslint-disable-next-line no-restricted-syntax - if (!acceptHeader?.includes('application/json') || !acceptHeader.includes('text/event-stream')) { + // Parsed per RFC 9110 media ranges (wildcards included), never a substring match. + if (!acceptIncludes(acceptHeader, 'application', 'json') || !acceptIncludes(acceptHeader, 'text', 'event-stream')) { this.onerror?.(new Error('Not Acceptable: Client must accept both application/json and text/event-stream')); return this.createJsonErrorResponse( 406, diff --git a/packages/server/test/server/streamableHttp.test.ts b/packages/server/test/server/streamableHttp.test.ts index 9ec6baf46c..253ffe47df 100644 --- a/packages/server/test/server/streamableHttp.test.ts +++ b/packages/server/test/server/streamableHttp.test.ts @@ -301,6 +301,50 @@ describe('Zod v4', () => { expectErrorResponse(errorData, -32_000, /Not Acceptable/); }); + it('should accept POST with wildcard Accept header', async () => { + sessionId = await initializeServer(); + + const request = createRequest('POST', TEST_MESSAGES.toolsList, { sessionId, accept: '*/*' }); + const response = await transport.handleRequest(request); + + expect(response.status).toBe(200); + }); + + it('should accept POST with type wildcards in Accept header', async () => { + sessionId = await initializeServer(); + + const request = createRequest('POST', TEST_MESSAGES.toolsList, { sessionId, accept: 'application/*, text/*' }); + const response = await transport.handleRequest(request); + + expect(response.status).toBe(200); + }); + + it('should accept POST with Accept parameters', async () => { + sessionId = await initializeServer(); + + const request = createRequest('POST', TEST_MESSAGES.toolsList, { + sessionId, + accept: 'application/json; q=1.0, text/event-stream; q=0.9' + }); + const response = await transport.handleRequest(request); + + expect(response.status).toBe(200); + }); + + it('should reject forged media types in Accept header', async () => { + sessionId = await initializeServer(); + + const request = createRequest('POST', TEST_MESSAGES.toolsList, { + sessionId, + accept: 'application/jsonx, text/event-streamx' + }); + const response = await transport.handleRequest(request); + + expect(response.status).toBe(406); + const errorData = await response.json(); + expectErrorResponse(errorData, -32_000, /Not Acceptable/); + }); + it('should reject request with wrong Content-Type header', async () => { const request = new Request('http://localhost/mcp', { method: 'POST', @@ -371,6 +415,15 @@ describe('Zod v4', () => { expectErrorResponse(errorData, -32_000, /Not Acceptable/); }); + it('should accept GET with wildcard Accept header', async () => { + sessionId = await initializeServer(); + + const request = createRequest('GET', undefined, { sessionId, accept: '*/*' }); + const response = await transport.handleRequest(request); + + expect(response.status).toBe(200); + }); + it('should reject second standalone SSE stream', async () => { sessionId = await initializeServer(); From 769755fad3a9e034b78dfb5c7e477d9b51549cf4 Mon Sep 17 00:00:00 2001 From: meraklbz Date: Sun, 16 Aug 2026 23:25:17 +0800 Subject: [PATCH 2/2] chore: add changeset for Accept header parsing --- .changeset/accept-header-ranges.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/accept-header-ranges.md diff --git a/.changeset/accept-header-ranges.md b/.changeset/accept-header-ranges.md new file mode 100644 index 0000000000..dc43d95b46 --- /dev/null +++ b/.changeset/accept-header-ranges.md @@ -0,0 +1,6 @@ +--- +'@modelcontextprotocol/server': patch +'@modelcontextprotocol/core-internal': patch +--- + +Validate the streamable HTTP Accept header by parsed media ranges (RFC 9110) instead of substring matching: legal wildcards like `*/*` and `application/*` are now accepted, and forged media types such as `application/jsonx` are rejected.