From 6e802b5b25f3571d7557375e5280966a8874635e Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Mon, 6 Jul 2026 12:48:13 +0000 Subject: [PATCH 1/4] Validate Content-Type by parsed media type instead of substring match The Content-Type checks in the Streamable HTTP server transport and the hono adapter matched the raw header as a substring, accepting values whose media type is not application/json (e.g. 'text/plain; a=application/json') while rejecting valid case variants; the 2026-07-28 entry did not validate Content-Type at all. Parse the header (content-type package, with a media-type fallback for malformed parameter sections) and compare the media type at every entry. Export isJsonContentType for adapter authors, migrate the client's response dispatch to the same comparison, and add an ESLint rule banning the substring pattern. --- .../content-type-media-type-validation.md | 23 +++ common/eslint-config/eslint.config.mjs | 12 ++ docs/migration/upgrade-to-v2.md | 12 ++ examples/json-response/client.ts | 4 +- packages/client/src/client/streamableHttp.ts | 8 +- .../client/test/client/middleware.test.ts | 3 +- packages/core-internal/package.json | 1 + .../core-internal/src/exports/public/index.ts | 3 + packages/core-internal/src/index.ts | 1 + .../core-internal/src/shared/mediaType.ts | 45 +++++ .../test/shared/mediaType.test.ts | 63 ++++++ packages/middleware/hono/src/hono.ts | 11 +- packages/middleware/hono/test/hono.test.ts | 28 +++ packages/server-legacy/package.json | 2 +- .../server/src/server/createMcpHandler.ts | 27 ++- .../server/src/server/perRequestTransport.ts | 4 + packages/server/src/server/streamableHttp.ts | 9 +- .../test/server/contentTypeValidation.test.ts | 186 ++++++++++++++++++ pnpm-lock.yaml | 11 +- pnpm-workspace.yaml | 2 +- 20 files changed, 435 insertions(+), 20 deletions(-) create mode 100644 .changeset/content-type-media-type-validation.md create mode 100644 packages/core-internal/src/shared/mediaType.ts create mode 100644 packages/core-internal/test/shared/mediaType.test.ts create mode 100644 packages/server/test/server/contentTypeValidation.test.ts diff --git a/.changeset/content-type-media-type-validation.md b/.changeset/content-type-media-type-validation.md new file mode 100644 index 0000000000..cdef983ece --- /dev/null +++ b/.changeset/content-type-media-type-validation.md @@ -0,0 +1,23 @@ +--- +'@modelcontextprotocol/server': patch +'@modelcontextprotocol/client': patch +'@modelcontextprotocol/hono': patch +'@modelcontextprotocol/node': patch +--- + +POSTs whose `Content-Type` media type is not `application/json` are now +rejected with `415 Unsupported Media Type`; the header is parsed instead of +substring-matched. Previously any value merely containing the substring +passed the check (for example `text/plain; a=application/json`), case +variants were wrongly rejected, and the 2026-07-28 entry did not inspect +`Content-Type` at all — requests with a missing or non-JSON header that used +to be served on that path now also answer 415. Values with parameters +(`application/json; charset=utf-8`, including malformed parameter sections +like `application/json;`) continue to work. SDK clients always send the +correct header and are unaffected. + +The new `isJsonContentType(header)` helper is exported for transport and +framework-adapter authors — custom entries composing the exported building +blocks (`classifyInboundRequest`, `PerRequestHTTPServerTransport`) must apply +it themselves. The hono adapter's JSON body pre-parse and the client's +response dispatch now use the same parsed-media-type comparison. diff --git a/common/eslint-config/eslint.config.mjs b/common/eslint-config/eslint.config.mjs index 808fe1729d..368ced27f5 100644 --- a/common/eslint-config/eslint.config.mjs +++ b/common/eslint-config/eslint.config.mjs @@ -47,6 +47,18 @@ export default defineConfig( 'unicorn/prevent-abbreviations': 'off', 'unicorn/no-null': 'off', 'unicorn/prefer-add-event-listener': 'off', + 'no-restricted-syntax': [ + 'error', + { + selector: + ":matches(CallExpression[callee.property.name='includes'], CallExpression[callee.property.name='indexOf'], " + + "CallExpression[callee.property.name='startsWith'])[arguments.0.value='application/json']", + message: + "Substring-matching 'application/json' misclassifies Content-Type values whose media type is different " + + "(e.g. 'text/plain; a=application/json') and mishandles parameters and case. " + + 'Parse the media type instead: isJsonContentType() from core-internal.' + } + ], 'unicorn/no-useless-undefined': ['error', { checkArguments: false }], '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], 'n/prefer-node-protocol': 'error', diff --git a/docs/migration/upgrade-to-v2.md b/docs/migration/upgrade-to-v2.md index a149e5b407..c750a457b7 100644 --- a/docs/migration/upgrade-to-v2.md +++ b/docs/migration/upgrade-to-v2.md @@ -815,6 +815,18 @@ same as every released v1.x — only the import path changes. Framework-agnostic (`validateHostHeader`, `localhostAllowedHostnames`, `hostHeaderValidationResponse`) are in `@modelcontextprotocol/server`. +Server entries validate the request `Content-Type` by its **parsed media type**, not a +substring: every POST whose media type is not `application/json` answers +`415 Unsupported Media Type`. Previously any value merely containing the substring +passed (for example `text/plain; a=application/json`), case variants were wrongly +rejected, and the 2026-07-28 entry did not inspect `Content-Type` at all — so +hand-rolled clients that omit the header (or send a non-JSON type) must now set +`Content-Type: application/json`. Parameters (`; charset=utf-8`) and unambiguous +values with malformed parameter sections (`application/json;`) keep working; SDK +clients always sent the correct header and are unaffected. Custom entries that +compose `classifyInboundRequest` / `PerRequestHTTPServerTransport` directly must +apply the same validation themselves — use the exported `isJsonContentType(header)`. + ### Errors The SDK now distinguishes three error kinds: diff --git a/examples/json-response/client.ts b/examples/json-response/client.ts index 72189fe9c2..033f454657 100644 --- a/examples/json-response/client.ts +++ b/examples/json-response/client.ts @@ -8,7 +8,7 @@ * the stateless legacy fallback unaffected. */ import { check, parseExampleArgs } from '@mcp-examples/shared'; -import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { Client, isJsonContentType, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; const { url } = parseExampleArgs(); @@ -39,7 +39,7 @@ const probe = await fetch(url, { } }) }); -check.match(probe.headers.get('content-type') ?? '', /application\/json/); +check.equal(isJsonContentType(probe.headers.get('content-type')), true); check.equal(probe.status, 200); // High-level: the regular Client works unchanged. diff --git a/packages/client/src/client/streamableHttp.ts b/packages/client/src/client/streamableHttp.ts index 04c7ac6f84..0d8eff917b 100644 --- a/packages/client/src/client/streamableHttp.ts +++ b/packages/client/src/client/streamableHttp.ts @@ -10,6 +10,7 @@ import { isJSONRPCResultResponse, isModernProtocolVersion, JSONRPCMessageSchema, + mediaTypeEssence, normalizeHeaders, PROTOCOL_VERSION_META_KEY, SdkError, @@ -1080,11 +1081,12 @@ export class StreamableHTTPClientTransport implements Transport { const hasRequests = messages.some(msg => 'method' in msg && 'id' in msg && msg.id !== undefined); - // Check the response type + // Check the response type (parsed media type — see mediaTypeEssence) const contentType = response.headers.get('content-type'); + const responseMediaType = mediaTypeEssence(contentType); if (hasRequests) { - if (contentType?.includes('text/event-stream')) { + if (responseMediaType === 'text/event-stream') { // Handle SSE stream responses for requests // We use the same handler as standalone streams, which now supports // reconnection with the last event ID @@ -1097,7 +1099,7 @@ export class StreamableHTTPClientTransport implements Transport { }, false ); - } else if (contentType?.includes('application/json')) { + } else if (responseMediaType === 'application/json') { // For non-streaming servers, we might get direct JSON responses const data = await response.json(); const responseMessages = Array.isArray(data) diff --git a/packages/client/test/client/middleware.test.ts b/packages/client/test/client/middleware.test.ts index c0c0886952..70922f7d01 100644 --- a/packages/client/test/client/middleware.test.ts +++ b/packages/client/test/client/middleware.test.ts @@ -1,4 +1,5 @@ import type { FetchLike } from '@modelcontextprotocol/core-internal'; +import { isJsonContentType } from '@modelcontextprotocol/core-internal'; import type { Mocked, MockedFunction, MockInstance } from 'vitest'; import type { OAuthClientProvider } from '../../src/client/auth'; @@ -1016,7 +1017,7 @@ describe('createMiddleware', () => { const transformMiddleware = createMiddleware(async (next, input, init) => { const response = await next(input, init); - if (response.headers.get('content-type')?.includes('application/json')) { + if (isJsonContentType(response.headers.get('content-type'))) { const data = (await response.json()) as Record; const transformed = { ...data, timestamp: 123_456_789 }; diff --git a/packages/core-internal/package.json b/packages/core-internal/package.json index 0514d36112..d38e0088d1 100644 --- a/packages/core-internal/package.json +++ b/packages/core-internal/package.json @@ -51,6 +51,7 @@ "test:watch": "vitest" }, "dependencies": { + "content-type": "catalog:runtimeShared", "json-schema-typed": "catalog:runtimeShared", "zod": "catalog:runtimeShared" }, diff --git a/packages/core-internal/src/exports/public/index.ts b/packages/core-internal/src/exports/public/index.ts index 3fd909d69d..93399ade12 100644 --- a/packages/core-internal/src/exports/public/index.ts +++ b/packages/core-internal/src/exports/public/index.ts @@ -39,6 +39,9 @@ export type { // Auth utilities export { checkResourceAllowed, resourceUrlFromServerUrl } from '../../shared/authUtils'; +// Media-type utilities (for transport and framework-adapter authors) +export { isJsonContentType } from '../../shared/mediaType'; + // Metadata utilities export { getDisplayName } from '../../shared/metadataUtils'; diff --git a/packages/core-internal/src/index.ts b/packages/core-internal/src/index.ts index ab66a038b9..6ffa13d1f4 100644 --- a/packages/core-internal/src/index.ts +++ b/packages/core-internal/src/index.ts @@ -9,6 +9,7 @@ export * from './shared/inputRequired'; export * from './shared/inputRequiredDriver'; export * from './shared/inputRequiredEngine'; export * from './shared/mcpParamHeaders'; +export * from './shared/mediaType'; export * from './shared/metadataUtils'; export * from './shared/protocol'; export * from './shared/protocolEras'; diff --git a/packages/core-internal/src/shared/mediaType.ts b/packages/core-internal/src/shared/mediaType.ts new file mode 100644 index 0000000000..f08294c2c8 --- /dev/null +++ b/packages/core-internal/src/shared/mediaType.ts @@ -0,0 +1,45 @@ +import contentType from 'content-type'; + +/** + * Extracts the media type (the lowercased `type/subtype` pair, without + * parameters) from a raw `Content-Type` header value, or `undefined` when the + * header is missing or empty. + * + * Content-Type comparisons must use the parsed media type, never a substring + * search of the raw header: a value like `text/plain; a=application/json` + * contains the substring `application/json` but its media type is + * `text/plain`, and case variants or parameters make naive string comparison + * wrong in both directions. + * + * Parsing is RFC 9110 (`content-type` package) first. When the parameter + * section is malformed (`application/json;`, `application/json; charset=`), + * browsers and most HTTP stacks still derive the media type from the segment + * before the first `;` — the fallback matches that widely-implemented + * behavior, so a header whose media type is unambiguous is not rejected for + * a sloppy parameter section. + */ +export function mediaTypeEssence(header: string | null | undefined): string | undefined { + if (!header) { + return undefined; + } + try { + return contentType.parse(header).type; + } catch { + const essence = (header.split(';', 1)[0] ?? '').trim().toLowerCase(); + return essence === '' ? undefined : essence; + } +} + +/** + * Whether a raw `Content-Type` header value denotes `application/json`. + * Parameters (for example `charset=utf-8`) are allowed and ignored; malformed + * parameter sections do not reject a header whose media type is unambiguously + * `application/json` (see {@linkcode mediaTypeEssence} for the exact grammar). + */ +export function isJsonContentType(header: string | null | undefined): boolean { + // Fast path: the exact literal is what SDK clients send on every POST. + if (header === 'application/json') { + return true; + } + return mediaTypeEssence(header) === 'application/json'; +} diff --git a/packages/core-internal/test/shared/mediaType.test.ts b/packages/core-internal/test/shared/mediaType.test.ts new file mode 100644 index 0000000000..2c4a5f45ce --- /dev/null +++ b/packages/core-internal/test/shared/mediaType.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest'; + +import { isJsonContentType, mediaTypeEssence } from '../../src/shared/mediaType'; + +describe('mediaTypeEssence', () => { + it('parses well-formed headers', () => { + expect(mediaTypeEssence('application/json')).toBe('application/json'); + expect(mediaTypeEssence('text/event-stream; charset=utf-8')).toBe('text/event-stream'); + expect(mediaTypeEssence('Application/JSON; charset=utf-8')).toBe('application/json'); + }); + + it('falls back to the pre-parameter segment for malformed parameter sections', () => { + expect(mediaTypeEssence('application/json;')).toBe('application/json'); + expect(mediaTypeEssence('application/json; charset=')).toBe('application/json'); + expect(mediaTypeEssence('text/plain;')).toBe('text/plain'); + }); + + it('returns undefined for missing or empty headers', () => { + expect(mediaTypeEssence(null)).toBeUndefined(); + expect(mediaTypeEssence(undefined)).toBeUndefined(); + expect(mediaTypeEssence('')).toBeUndefined(); + expect(mediaTypeEssence(' ')).toBeUndefined(); + expect(mediaTypeEssence(';charset=utf-8')).toBeUndefined(); + }); + + it('yields a no-match essence for a bare joined duplicate header', () => { + // Headers.get() joins repeated headers with ', '; the comparison sees + // the joined string, which does not equal 'application/json'. + expect(mediaTypeEssence('application/json, application/json')).toBe('application/json, application/json'); + }); +}); + +describe('isJsonContentType', () => { + it('accepts application/json with or without parameters', () => { + expect(isJsonContentType('application/json')).toBe(true); + expect(isJsonContentType('application/json; charset=utf-8')).toBe(true); + expect(isJsonContentType('Application/JSON')).toBe(true); + expect(isJsonContentType(' application/json ; charset=utf-8')).toBe(true); + }); + + it('accepts unambiguous media types with malformed parameter sections', () => { + expect(isJsonContentType('application/json;')).toBe(true); + expect(isJsonContentType('application/json; charset=')).toBe(true); + expect(isJsonContentType('application/json; charset=utf-8; charset=x')).toBe(true); + }); + + it('never matches on substrings: parameters and sibling types are not application/json', () => { + expect(isJsonContentType('text/plain; a=application/json')).toBe(false); + expect(isJsonContentType('text/plain;')).toBe(false); + expect(isJsonContentType('text/plain, application/json')).toBe(false); + expect(isJsonContentType('application/json, application/json')).toBe(false); + expect(isJsonContentType('application/json-patch+json')).toBe(false); + expect(isJsonContentType('application/jsonp')).toBe(false); + }); + + it('rejects missing, empty, and non-JSON types', () => { + expect(isJsonContentType(null)).toBe(false); + expect(isJsonContentType(undefined)).toBe(false); + expect(isJsonContentType('')).toBe(false); + expect(isJsonContentType('text/plain')).toBe(false); + expect(isJsonContentType('multipart/form-data')).toBe(false); + }); +}); diff --git a/packages/middleware/hono/src/hono.ts b/packages/middleware/hono/src/hono.ts index 80237f7d4e..8a822fe3be 100644 --- a/packages/middleware/hono/src/hono.ts +++ b/packages/middleware/hono/src/hono.ts @@ -1,3 +1,4 @@ +import { isJsonContentType } from '@modelcontextprotocol/server'; import type { Context } from 'hono'; import { Hono } from 'hono'; @@ -45,8 +46,8 @@ export interface CreateMcpHonoAppOptions { * DNS rebinding attacks on localhost servers. * * This also installs a small JSON body parsing middleware (similar to `express.json()`) - * that stashes the parsed body into `c.set('parsedBody', ...)` when `Content-Type` includes - * `application/json`. + * that stashes the parsed body into `c.set('parsedBody', ...)` when the `Content-Type` + * media type is `application/json`. * * @param options - Configuration options * @returns A configured Hono application @@ -63,8 +64,10 @@ export function createMcpHonoApp(options: CreateMcpHonoAppOptions = {}): Hono { return await next(); } - const ct = c.req.header('content-type') ?? ''; - if (!ct.includes('application/json')) { + // Parsed media type, never a substring match — see isJsonContentType. + // A body left unparsed here is answered 415 by the transport's own + // Content-Type check downstream. + if (!isJsonContentType(c.req.header('content-type'))) { return await next(); } diff --git a/packages/middleware/hono/test/hono.test.ts b/packages/middleware/hono/test/hono.test.ts index 1c743277b0..ea924cbba4 100644 --- a/packages/middleware/hono/test/hono.test.ts +++ b/packages/middleware/hono/test/hono.test.ts @@ -90,6 +90,34 @@ describe('@modelcontextprotocol/hono', () => { expect(await res.text()).toBe('Invalid JSON'); }); + test('createMcpHonoApp does not parse a non-JSON media type whose parameters contain application/json', async () => { + const app = createMcpHonoApp(); + app.post('/echo', (c: Context) => c.json({ parsed: c.get('parsedBody') ?? null })); + + // `text/plain; a=application/json` contains the substring but its media + // type is text/plain — it must never be treated as a JSON body. + const res = await app.request('http://localhost/echo', { + method: 'POST', + headers: { Host: 'localhost:3000', 'content-type': 'text/plain; a=application/json' }, + body: JSON.stringify({ a: 1 }) + }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ parsed: null }); + }); + + test('createMcpHonoApp parses application/json with parameters', async () => { + const app = createMcpHonoApp(); + app.post('/echo', (c: Context) => c.json(c.get('parsedBody'))); + + const res = await app.request('http://localhost/echo', { + method: 'POST', + headers: { Host: 'localhost:3000', 'content-type': 'application/json; charset=utf-8' }, + body: JSON.stringify({ a: 1 }) + }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ a: 1 }); + }); + test('createMcpHonoApp does not override parsedBody if upstream middleware set it', async () => { const app = createMcpHonoApp(); app.use('/echo', async (c: Context, next) => { diff --git a/packages/server-legacy/package.json b/packages/server-legacy/package.json index f571a3af95..acc880f6f6 100644 --- a/packages/server-legacy/package.json +++ b/packages/server-legacy/package.json @@ -85,7 +85,7 @@ "dependencies": { "zod": "catalog:runtimeShared", "raw-body": "catalog:runtimeServerOnly", - "content-type": "catalog:runtimeServerOnly", + "content-type": "catalog:runtimeShared", "cors": "catalog:runtimeServerOnly", "express-rate-limit": "^8.2.1", "pkce-challenge": "catalog:runtimeShared" diff --git a/packages/server/src/server/createMcpHandler.ts b/packages/server/src/server/createMcpHandler.ts index 3f7632d570..708bb818ff 100644 --- a/packages/server/src/server/createMcpHandler.ts +++ b/packages/server/src/server/createMcpHandler.ts @@ -41,6 +41,8 @@ import { CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, httpStatusForErrorCode, + isJsonContentType, + mediaTypeEssence, missingClientCapabilities, MissingRequiredClientCapabilityError, modernOnlyStrictRejection, @@ -330,7 +332,7 @@ export function legacyStatelessFallback(factory: McpServerFactory, onerror?: (er ...(options?.authInfo !== undefined && { authInfo: options.authInfo }), ...(options?.parsedBody !== undefined && { parsedBody: options.parsedBody }) }); - if (response.body === null || !(response.headers.get('content-type') ?? '').includes('text/event-stream')) { + if (response.body === null || mediaTypeEssence(response.headers.get('content-type')) !== 'text/event-stream') { // Non-streaming exchange (a buffered JSON body or a body-less // ack): the response is complete, release the pair now. teardown(); @@ -483,7 +485,11 @@ async function classifyEntryRequest(request: Request, providedParsedBody?: unkno * This is the entry's own classification step exported as a predicate — it * runs exactly the code `createMcpHandler` runs to make the routing decision, * not a re-implementation — so a hand-wired composition that branches on it - * can never disagree with the entry. Use it to keep an existing legacy + * can never disagree with the entry. It is classification only: hand-wired + * compositions must validate Content-Type themselves (415 for POSTs whose + * media type is not `application/json`, via {@linkcode isJsonContentType}) + * before dispatching either leg — routing the legacy leg into the SDK + * transports gets their built-in check, but a custom modern leg has none. Use it to keep an existing legacy * deployment (for example a sessionful streamable HTTP wiring) serving 2025 * traffic next to a strict modern endpoint, now that the entry has no * handler-valued `legacy` option: @@ -574,7 +580,10 @@ export async function isLegacyRequest(request: Request, parsedBody?: unknown): P * pattern. Power users composing transport-neutral routing can also use the * exported building blocks directly: {@linkcode classifyInboundRequest} for * the era decision and `PerRequestHTTPServerTransport` for single-exchange - * serving. + * serving — such compositions must reject POSTs whose Content-Type media type + * is not `application/json` (415) before parsing the body, using + * {@linkcode isJsonContentType}; neither building block performs this + * validation itself. * * The entry performs no token verification: `authInfo` given to `fetch` is * passed through to handlers and the factory as-is and is never derived from @@ -817,6 +826,18 @@ export function createMcpHandler(factory: McpServerFactory, options: CreateMcpHa async function handle(request: Request, requestOptions?: McpHandlerRequestOptions): Promise { const authInfo = requestOptions?.authInfo; + + // Content-Type check, answered before the body is read (parsed media + // type — see isJsonContentType). Load-bearing for the modern leg, + // whose ladder does not inspect Content-Type; the legacy transport + // keeps its own check for hand-wired use, so via this entry a + // doubly-invalid request answers 415 here before the transport's 406 + // Accept check would. + if (request.method.toUpperCase() === 'POST' && !isJsonContentType(request.headers.get('content-type'))) { + reportError(new Error('Unsupported Media Type: Content-Type must be application/json')); + return jsonRpcErrorResponse(415, -32_000, 'Unsupported Media Type: Content-Type must be application/json'); + } + const classified = await classifyEntryRequest(request, requestOptions?.parsedBody); if (classified.step === 'unreadable-body') { diff --git a/packages/server/src/server/perRequestTransport.ts b/packages/server/src/server/perRequestTransport.ts index 4d96bc71c7..5003946404 100644 --- a/packages/server/src/server/perRequestTransport.ts +++ b/packages/server/src/server/perRequestTransport.ts @@ -32,6 +32,10 @@ * `createMcpHandler`) inherit the spec's per-error HTTP mandate with it: a * terminal `MissingRequiredClientCapabilityError` (-32021) MUST answer 400, * which this transport applies whenever the response is still uncommitted. + * A custom entry must also reject POSTs whose Content-Type media type is not + * `application/json` (415) before parsing the body — use `isJsonContentType` + * from the package root; this transport never sees the HTTP headers, so it + * cannot perform that validation itself. */ import type { AuthInfo, diff --git a/packages/server/src/server/streamableHttp.ts b/packages/server/src/server/streamableHttp.ts index 1f2cf94b00..7da5fb853c 100644 --- a/packages/server/src/server/streamableHttp.ts +++ b/packages/server/src/server/streamableHttp.ts @@ -11,6 +11,7 @@ import type { AuthInfo, JSONRPCMessage, MessageExtraInfo, RequestId, Transport } import { DEFAULT_NEGOTIATED_PROTOCOL_VERSION, isInitializeRequest, + isJsonContentType, isJSONRPCErrorResponse, isJSONRPCRequest, isJSONRPCResultResponse, @@ -680,6 +681,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')) { this.onerror?.(new Error('Not Acceptable: Client must accept both application/json and text/event-stream')); return this.createJsonErrorResponse( @@ -689,8 +692,12 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { ); } + // Parsed media type, never a substring match — see + // isJsonContentType. This check stays here for hand-wired + // transports; via createMcpHandler the entry's own check answers + // first. const ct = req.headers.get('content-type'); - if (!ct || !ct.includes('application/json')) { + if (!isJsonContentType(ct)) { this.onerror?.(new Error('Unsupported Media Type: Content-Type must be application/json')); return this.createJsonErrorResponse(415, -32_000, 'Unsupported Media Type: Content-Type must be application/json'); } diff --git a/packages/server/test/server/contentTypeValidation.test.ts b/packages/server/test/server/contentTypeValidation.test.ts new file mode 100644 index 0000000000..4c699e8868 --- /dev/null +++ b/packages/server/test/server/contentTypeValidation.test.ts @@ -0,0 +1,186 @@ +/** + * Content-Type validation at the HTTP entries — the parsed media type decides, + * never a substring search of the raw header. + * + * The shape pinned here: `Content-Type: text/plain; a=application/json` + * contains the substring `application/json`, but its media type is + * `text/plain` — every entry must answer it (and any other non-JSON media + * type) with 415 before the body is dispatched, while values whose media type + * is `application/json` keep working regardless of parameters or case. + */ +import { CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, PROTOCOL_VERSION_META_KEY } from '@modelcontextprotocol/core-internal'; +import { beforeEach, describe, expect, it } from 'vitest'; +import * as z from 'zod/v4'; + +import { createMcpHandler } from '../../src/server/createMcpHandler'; +import { McpServer } from '../../src/server/mcp'; +import { WebStandardStreamableHTTPServerTransport } from '../../src/server/streamableHttp'; + +const MODERN_REVISION = '2026-07-28'; + +const executed: string[] = []; + +function factory(): McpServer { + const mcpServer = new McpServer({ name: 'ct-fixture', version: '1.0.0' }); + mcpServer.registerTool('run', { description: 'records each dispatch', inputSchema: { cmd: z.string() } }, async ({ cmd }) => { + executed.push(cmd); + return { content: [{ type: 'text', text: `ran: ${cmd}` }] }; + }); + return mcpServer; +} + +const LEGACY_CALL = { + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name: 'run', arguments: { cmd: 'hello' } } +}; + +const MODERN_CALL = { + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { + name: 'run', + arguments: { cmd: 'hello' }, + _meta: { + [PROTOCOL_VERSION_META_KEY]: MODERN_REVISION, + [CLIENT_INFO_META_KEY]: { name: 'ct-client', version: '1.0.0' }, + [CLIENT_CAPABILITIES_META_KEY]: {} + } + } +}; + +function postRequest(body: unknown, headers: Record): Request { + return new Request('http://127.0.0.1/mcp', { + method: 'POST', + headers: { + Accept: 'application/json, text/event-stream', + ...headers + }, + body: JSON.stringify(body) + }); +} + +beforeEach(() => { + executed.length = 0; +}); + +describe('createMcpHandler Content-Type validation (default options, legacy stateless fallback active)', () => { + it('serves an application/json POST (control)', async () => { + const handler = createMcpHandler(factory); + const response = await handler.fetch(postRequest(LEGACY_CALL, { 'Content-Type': 'application/json' })); + expect(response.status).toBe(200); + expect(executed).toEqual(['hello']); + }); + + it('accepts application/json with parameters', async () => { + const handler = createMcpHandler(factory); + const response = await handler.fetch(postRequest(LEGACY_CALL, { 'Content-Type': 'application/json; charset=utf-8' })); + expect(response.status).toBe(200); + expect(executed).toEqual(['hello']); + }); + + it('rejects text/plain with 415', async () => { + const handler = createMcpHandler(factory); + const response = await handler.fetch(postRequest(LEGACY_CALL, { 'Content-Type': 'text/plain' })); + expect(response.status).toBe(415); + expect(executed).toEqual([]); + }); + + it('rejects a non-JSON media type whose parameters contain `application/json` and does not dispatch', async () => { + const handler = createMcpHandler(factory); + const response = await handler.fetch(postRequest(LEGACY_CALL, { 'Content-Type': 'text/plain; a=application/json' })); + expect(response.status).toBe(415); + const body = (await response.json()) as { error: { code: number; message: string } }; + expect(body.error.code).toBe(-32_000); + expect(body.error.message).toContain('Content-Type must be application/json'); + expect(executed).toEqual([]); + }); + + it('rejects a POST with no Content-Type header with 415', async () => { + const handler = createMcpHandler(factory); + // A string body makes Request auto-attach `text/plain;charset=UTF-8`; + // delete it so this actually exercises the absent-header branch. + const request = postRequest(LEGACY_CALL, {}); + request.headers.delete('content-type'); + expect(request.headers.get('content-type')).toBeNull(); + const response = await handler.fetch(request); + expect(response.status).toBe(415); + expect(executed).toEqual([]); + }); + + it('accepts an unambiguous media type with a malformed parameter section (trailing semicolon)', async () => { + const handler = createMcpHandler(factory); + const response = await handler.fetch(postRequest(LEGACY_CALL, { 'Content-Type': 'application/json;' })); + expect(response.status).toBe(200); + expect(executed).toEqual(['hello']); + }); + + it('rejects joined duplicate Content-Type headers', async () => { + const handler = createMcpHandler(factory); + const response = await handler.fetch(postRequest(LEGACY_CALL, { 'Content-Type': 'application/json, application/json' })); + expect(response.status).toBe(415); + expect(executed).toEqual([]); + }); + + it('validates the modern leg too: enveloped request with a non-JSON media type is answered 415 before dispatch', async () => { + const handler = createMcpHandler(factory); + const response = await handler.fetch( + postRequest(MODERN_CALL, { + 'Content-Type': 'text/plain; a=application/json', + 'mcp-protocol-version': MODERN_REVISION, + 'Mcp-Method': 'tools/call', + 'Mcp-Name': 'run' + }) + ); + expect(response.status).toBe(415); + expect(executed).toEqual([]); + }); + + it('still serves the modern leg for application/json (control)', async () => { + const handler = createMcpHandler(factory); + const response = await handler.fetch( + postRequest(MODERN_CALL, { + 'Content-Type': 'application/json', + 'mcp-protocol-version': MODERN_REVISION, + 'Mcp-Method': 'tools/call', + 'Mcp-Name': 'run' + }) + ); + expect(response.status).toBe(200); + expect(executed).toEqual(['hello']); + }); + + it('does not apply the POST check to bodyless methods', async () => { + const handler = createMcpHandler(factory); + const response = await handler.fetch(new Request('http://127.0.0.1/mcp', { method: 'DELETE' })); + expect(response.status).not.toBe(415); + }); +}); + +describe('WebStandardStreamableHTTPServerTransport Content-Type validation (direct wiring)', () => { + async function postToTransport(headers: Record): Promise { + const server = factory(); + const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + enableJsonResponse: true + }); + await server.connect(transport); + const response = await transport.handleRequest(postRequest(LEGACY_CALL, headers)); + await server.close(); + return response; + } + + it('rejects a non-JSON media type whose parameters contain `application/json` and does not dispatch', async () => { + const response = await postToTransport({ 'Content-Type': 'text/plain; a=application/json' }); + expect(response.status).toBe(415); + expect(executed).toEqual([]); + }); + + it('serves application/json (control)', async () => { + const response = await postToTransport({ 'Content-Type': 'application/json' }); + expect(response.status).toBe(200); + expect(executed).toEqual(['hello']); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eb4442a9f4..9f7aee4c2a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -98,9 +98,6 @@ catalogs: '@hono/node-server': specifier: ^1.19.9 version: 1.19.11 - content-type: - specifier: ^1.0.5 - version: 1.0.5 cors: specifier: ^2.8.5 version: 2.8.6 @@ -126,6 +123,9 @@ catalogs: ajv-formats: specifier: ^3.0.1 version: 3.0.1 + content-type: + specifier: ^1.0.5 + version: 1.0.5 json-schema-typed: specifier: ^8.0.2 version: 8.0.2 @@ -1403,6 +1403,9 @@ importers: packages/core-internal: dependencies: + content-type: + specifier: catalog:runtimeShared + version: 1.0.5 json-schema-typed: specifier: catalog:runtimeShared version: 8.0.2 @@ -1767,7 +1770,7 @@ importers: packages/server-legacy: dependencies: content-type: - specifier: catalog:runtimeServerOnly + specifier: catalog:runtimeShared version: 1.0.5 cors: specifier: catalog:runtimeServerOnly diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 3923d58ef0..3303ad0607 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -41,7 +41,6 @@ catalogs: jose: ^6.1.3 runtimeServerOnly: '@hono/node-server': ^1.19.9 - content-type: ^1.0.5 cors: ^2.8.5 express: ^5.2.1 fastify: ^5.2.0 @@ -51,6 +50,7 @@ catalogs: '@cfworker/json-schema': ^4.1.1 ajv: ^8.17.1 ajv-formats: ^3.0.1 + content-type: ^1.0.5 json-schema-typed: ^8.0.2 pkce-challenge: ^5.0.0 zod: ^4.2.0 From 81936926ed29236c5dc94f4a7ebbee2590e1866f Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Mon, 6 Jul 2026 18:13:40 +0000 Subject: [PATCH 2/4] Fix import ordering in client auth --- packages/client/src/client/auth.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index 25bed6d2f5..9ebc6fd251 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -15,7 +15,6 @@ import type { import { brandedHasInstance, checkResourceAllowed, - stampErrorBrands, LATEST_PROTOCOL_VERSION, OAuthClientInformationFullSchema, OAuthError, @@ -25,7 +24,8 @@ import { OAuthProtectedResourceMetadataSchema, OAuthTokensSchema, OpenIdProviderDiscoveryMetadataSchema, - resourceUrlFromServerUrl + resourceUrlFromServerUrl, + stampErrorBrands } from '@modelcontextprotocol/core-internal'; import pkceChallenge from 'pkce-challenge'; From 80a1f8a250d4c28fca5642e83cfff77942908f7c Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Mon, 6 Jul 2026 18:22:22 +0000 Subject: [PATCH 3/4] Avoid unresolved docs link in isJsonContentType comment --- packages/core-internal/src/shared/mediaType.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core-internal/src/shared/mediaType.ts b/packages/core-internal/src/shared/mediaType.ts index f08294c2c8..223c31c257 100644 --- a/packages/core-internal/src/shared/mediaType.ts +++ b/packages/core-internal/src/shared/mediaType.ts @@ -34,7 +34,7 @@ export function mediaTypeEssence(header: string | null | undefined): string | un * Whether a raw `Content-Type` header value denotes `application/json`. * Parameters (for example `charset=utf-8`) are allowed and ignored; malformed * parameter sections do not reject a header whose media type is unambiguously - * `application/json` (see {@linkcode mediaTypeEssence} for the exact grammar). + * `application/json` (see `mediaTypeEssence` for the exact grammar). */ export function isJsonContentType(header: string | null | undefined): boolean { // Fast path: the exact literal is what SDK clients send on every POST. From c3e32b847b61154f07f3a949f107210814668d20 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Mon, 6 Jul 2026 18:31:16 +0000 Subject: [PATCH 4/4] Reject joined duplicate Content-Type headers uniformly The essence fallback took everything before the first ';', so a joined duplicate header was rejected when the first copy had no parameters but accepted when it did. Bail out of the fallback when the parameter tail of an unparseable value contains a comma, and cite the WHATWG essence definition in the module docs. --- packages/core-internal/src/shared/mediaType.ts | 14 +++++++++++++- .../core-internal/test/shared/mediaType.test.ts | 11 ++++++++--- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/packages/core-internal/src/shared/mediaType.ts b/packages/core-internal/src/shared/mediaType.ts index 223c31c257..aaa7a3533f 100644 --- a/packages/core-internal/src/shared/mediaType.ts +++ b/packages/core-internal/src/shared/mediaType.ts @@ -11,6 +11,11 @@ import contentType from 'content-type'; * `text/plain`, and case variants or parameters make naive string comparison * wrong in both directions. * + * "Essence" is the WHATWG MIME Sniffing standard's term for the bare + * `type/subtype` pair (https://mimesniff.spec.whatwg.org/#mime-type-essence); + * the Fetch standard's request classification is defined against it + * (https://fetch.spec.whatwg.org/#cors-safelisted-request-header). + * * Parsing is RFC 9110 (`content-type` package) first. When the parameter * section is malformed (`application/json;`, `application/json; charset=`), * browsers and most HTTP stacks still derive the media type from the segment @@ -26,7 +31,14 @@ export function mediaTypeEssence(header: string | null | undefined): string | un return contentType.parse(header).type; } catch { const essence = (header.split(';', 1)[0] ?? '').trim().toLowerCase(); - return essence === '' ? undefined : essence; + // A comma in the parameter tail of an unparseable value indicates + // joined duplicate headers — ambiguous, so no essence at all (keeps + // duplicate-header handling uniform whether or not the first copy + // carries parameters). + if (essence === '' || header.slice(essence.length).includes(',')) { + return undefined; + } + return essence; } } diff --git a/packages/core-internal/test/shared/mediaType.test.ts b/packages/core-internal/test/shared/mediaType.test.ts index 2c4a5f45ce..98e65e5b9b 100644 --- a/packages/core-internal/test/shared/mediaType.test.ts +++ b/packages/core-internal/test/shared/mediaType.test.ts @@ -23,10 +23,13 @@ describe('mediaTypeEssence', () => { expect(mediaTypeEssence(';charset=utf-8')).toBeUndefined(); }); - it('yields a no-match essence for a bare joined duplicate header', () => { - // Headers.get() joins repeated headers with ', '; the comparison sees - // the joined string, which does not equal 'application/json'. + it('yields no essence for joined duplicate headers, with or without parameters', () => { + // Headers.get() joins repeated headers with ', '. Without parameters + // the comma lands in the first segment; with parameters it hides in + // the tail — both must behave the same. expect(mediaTypeEssence('application/json, application/json')).toBe('application/json, application/json'); + expect(mediaTypeEssence('application/json; charset=utf-8, text/plain')).toBeUndefined(); + expect(mediaTypeEssence('application/json; charset=utf-8, application/json')).toBeUndefined(); }); }); @@ -49,6 +52,8 @@ describe('isJsonContentType', () => { expect(isJsonContentType('text/plain;')).toBe(false); expect(isJsonContentType('text/plain, application/json')).toBe(false); expect(isJsonContentType('application/json, application/json')).toBe(false); + expect(isJsonContentType('application/json; charset=utf-8, text/plain')).toBe(false); + expect(isJsonContentType('text/plain; charset=utf-8, application/json')).toBe(false); expect(isJsonContentType('application/json-patch+json')).toBe(false); expect(isJsonContentType('application/jsonp')).toBe(false); });