Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
23 changes: 23 additions & 0 deletions .changeset/content-type-media-type-validation.md
Original file line number Diff line number Diff line change
@@ -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.
12 changes: 12 additions & 0 deletions common/eslint-config/eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
12 changes: 12 additions & 0 deletions docs/migration/upgrade-to-v2.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions examples/json-response/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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.
Expand Down
8 changes: 5 additions & 3 deletions packages/client/src/client/streamableHttp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
isJSONRPCResultResponse,
isModernProtocolVersion,
JSONRPCMessageSchema,
mediaTypeEssence,
normalizeHeaders,
PROTOCOL_VERSION_META_KEY,
SdkError,
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion packages/client/test/client/middleware.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<string, unknown>;
const transformed = { ...data, timestamp: 123_456_789 };

Expand Down
1 change: 1 addition & 0 deletions packages/core-internal/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
"test:watch": "vitest"
},
"dependencies": {
"content-type": "catalog:runtimeShared",
"json-schema-typed": "catalog:runtimeShared",
"zod": "catalog:runtimeShared"
},
Expand Down
3 changes: 3 additions & 0 deletions packages/core-internal/src/exports/public/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
1 change: 1 addition & 0 deletions packages/core-internal/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
45 changes: 45 additions & 0 deletions packages/core-internal/src/shared/mediaType.ts
Original file line number Diff line number Diff line change
@@ -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 {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if (!header) {
return undefined;
}
try {
return contentType.parse(header).type;
} catch {
const essence = (header.split(';', 1)[0] ?? '').trim().toLowerCase();
return essence === '' ? undefined : essence;
}

Check warning on line 30 in packages/core-internal/src/shared/mediaType.ts

View check run for this annotation

Claude / Claude Code Review

mediaTypeEssence fallback accepts joined duplicate Content-Type when the first copy has parameters

The malformed-parameter fallback in mediaTypeEssence() takes everything before the first ';', so a joined duplicate Content-Type header is only rejected when the first copy has no parameters: 'application/json; charset=utf-8, text/plain' parses to 'application/json' and is accepted, while the parameterless 'application/json, text/plain' answers 415 as pinned by the new tests. Consider having the fallback bail (return the raw string) when the remainder after the first ';' contains a top-level com
Comment thread
claude[bot] marked this conversation as resolved.
}

/**
* 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';
}
63 changes: 63 additions & 0 deletions packages/core-internal/test/shared/mediaType.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
11 changes: 7 additions & 4 deletions packages/middleware/hono/src/hono.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { isJsonContentType } from '@modelcontextprotocol/server';
import type { Context } from 'hono';
import { Hono } from 'hono';

Expand Down Expand Up @@ -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
Expand All @@ -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();
}

Expand Down
28 changes: 28 additions & 0 deletions packages/middleware/hono/test/hono.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
2 changes: 1 addition & 1 deletion packages/server-legacy/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
27 changes: 24 additions & 3 deletions packages/server/src/server/createMcpHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ import {
CLIENT_CAPABILITIES_META_KEY,
CLIENT_INFO_META_KEY,
httpStatusForErrorCode,
isJsonContentType,
mediaTypeEssence,
missingClientCapabilities,
MissingRequiredClientCapabilityError,
modernOnlyStrictRejection,
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -817,6 +826,18 @@ export function createMcpHandler(factory: McpServerFactory, options: CreateMcpHa

async function handle(request: Request, requestOptions?: McpHandlerRequestOptions): Promise<Response> {
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') {
Expand Down
Loading
Loading