Skip to content
Merged
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
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 @@ -817,6 +817,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 @@ -10,6 +10,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
57 changes: 57 additions & 0 deletions packages/core-internal/src/shared/mediaType.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
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.
*
* "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
* 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();
// 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;
}
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 `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';
}
68 changes: 68 additions & 0 deletions packages/core-internal/test/shared/mediaType.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
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 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();
});
});

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; 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);
});

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
Loading
Loading