Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/accept-header-media-types.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@modelcontextprotocol/server': patch
'@modelcontextprotocol/core-internal': patch
---

Validate Streamable HTTP `Accept` values by parsed media type instead of substring matching, so invalid tokens such as `application/jsonx` and `text/event-stream-bogus` no longer satisfy the required response types.
36 changes: 36 additions & 0 deletions packages/core-internal/src/shared/mediaType.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,39 @@ export function isJsonContentType(header: string | null | undefined): boolean {
}
return mediaTypeEssence(header) === 'application/json';
}

function splitHttpList(header: string): string[] {
const values: string[] = [];
let start = 0;
let quoted = false;
let escaped = false;

for (let index = 0; index < header.length; index += 1) {
const char = header[index];
if (escaped) {
escaped = false;
} else if (quoted && char === '\\') {
escaped = true;
} else if (char === '"') {
quoted = !quoted;
} else if (char === ',' && !quoted) {
values.push(header.slice(start, index));
start = index + 1;
}
}

values.push(header.slice(start));
return values;
}

/**
* Whether an `Accept` header lists a concrete media type. Parameters and
* quality values are ignored; wildcards intentionally do not match because MCP
* transport requirements name exact response media types.
*/
export function listsMediaType(header: string | null | undefined, mediaType: string): boolean {
const expected = mediaType.toLowerCase();
return splitHttpList(header ?? '')
.map(part => mediaTypeEssence(part))
.includes(expected);
}
24 changes: 23 additions & 1 deletion packages/core-internal/test/shared/mediaType.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';

import { isJsonContentType, mediaTypeEssence } from '../../src/shared/mediaType';
import { isJsonContentType, listsMediaType, mediaTypeEssence } from '../../src/shared/mediaType';

describe('mediaTypeEssence', () => {
it('parses well-formed headers', () => {
Expand Down Expand Up @@ -66,3 +66,25 @@ describe('isJsonContentType', () => {
expect(isJsonContentType('multipart/form-data')).toBe(false);
});
});

describe('listsMediaType', () => {
it('matches comma-separated Accept values by parsed media type', () => {
expect(listsMediaType('application/json, text/event-stream', 'application/json')).toBe(true);
expect(listsMediaType('application/json, text/event-stream', 'text/event-stream')).toBe(true);
expect(listsMediaType('Application/JSON; q=0.9, text/event-stream; charset=utf-8', 'application/json')).toBe(true);
expect(listsMediaType('text/plain; note="a,b", application/json', 'application/json')).toBe(true);
});

it('never matches required media types by substring or wildcard', () => {
expect(listsMediaType('application/jsonx, text/event-stream-bogus', 'application/json')).toBe(false);
expect(listsMediaType('application/jsonx, text/event-stream-bogus', 'text/event-stream')).toBe(false);
expect(listsMediaType('text/plain; note=application/json', 'application/json')).toBe(false);
expect(listsMediaType('*/*, application/*', 'application/json')).toBe(false);
});

it('rejects missing or empty Accept values', () => {
expect(listsMediaType(null, 'application/json')).toBe(false);
expect(listsMediaType(undefined, 'application/json')).toBe(false);
expect(listsMediaType('', 'application/json')).toBe(false);
});
});
10 changes: 6 additions & 4 deletions packages/server/src/server/streamableHttp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
isJSONRPCRequest,
isJSONRPCResultResponse,
JSONRPCMessageSchema,
listsMediaType,
SUPPORTED_PROTOCOL_VERSIONS
} from '@modelcontextprotocol/core-internal';

Expand Down Expand Up @@ -428,7 +429,7 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
private async handleGetRequest(req: Request): Promise<Response> {
// 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 (!listsMediaType(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');
}
Expand Down Expand Up @@ -681,9 +682,10 @@ 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')) {
// Accept is a comma-separated list of media ranges. Match parsed
// media types rather than substrings so values like
// `application/jsonx` or `text/event-stream-bogus` are not accepted.
if (!listsMediaType(acceptHeader, 'application/json') || !listsMediaType(acceptHeader, 'text/event-stream')) {
this.onerror?.(new Error('Not Acceptable: Client must accept both application/json and text/event-stream'));
return this.createJsonErrorResponse(
406,
Expand Down
31 changes: 31 additions & 0 deletions packages/server/test/server/streamableHttp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,26 @@ describe('Zod v4', () => {
expectErrorResponse(errorData, -32_000, /Not Acceptable/);
});

it('should reject Accept headers whose media types only contain the required values as substrings', async () => {
const request = createRequest('POST', TEST_MESSAGES.initialize, {
accept: 'application/jsonx, text/event-stream-bogus'
});
const response = await transport.handleRequest(request);

expect(response.status).toBe(406);
const errorData = await response.json();
expectErrorResponse(errorData, -32_000, /Not Acceptable/);
});

it('should accept media types with parameters in the Accept header', async () => {
const request = createRequest('POST', TEST_MESSAGES.initialize, {
accept: 'Application/JSON; q=0.9, text/event-stream; charset=utf-8'
});
const response = await transport.handleRequest(request);

expect(response.status).toBe(200);
});

it('should reject request with wrong Content-Type header', async () => {
const request = new Request('http://localhost/mcp', {
method: 'POST',
Expand Down Expand Up @@ -371,6 +391,17 @@ describe('Zod v4', () => {
expectErrorResponse(errorData, -32_000, /Not Acceptable/);
});

it('should reject GET when text/event-stream appears only as a substring', async () => {
sessionId = await initializeServer();

const request = createRequest('GET', undefined, { sessionId, accept: 'text/event-stream-bogus' });
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 second standalone SSE stream', async () => {
sessionId = await initializeServer();

Expand Down
Loading