fix(server): validate Content-Type by parsed media type instead of substring match (#2441)
This commit is contained in:
parent
7015d2129b
commit
561c6d8345
20 changed files with 452 additions and 20 deletions
23
.changeset/content-type-media-type-validation.md
Normal file
23
.changeset/content-type-media-type-validation.md
Normal file
|
|
@ -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.
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>;
|
||||
const transformed = { ...data, timestamp: 123_456_789 };
|
||||
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@
|
|||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"content-type": "catalog:runtimeShared",
|
||||
"json-schema-typed": "catalog:runtimeShared",
|
||||
"zod": "catalog:runtimeShared"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
57
packages/core-internal/src/shared/mediaType.ts
Normal file
57
packages/core-internal/src/shared/mediaType.ts
Normal file
|
|
@ -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 {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
packages/core-internal/test/shared/mediaType.test.ts
Normal file
68
packages/core-internal/test/shared/mediaType.test.ts
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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<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') {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
186
packages/server/test/server/contentTypeValidation.test.ts
Normal file
186
packages/server/test/server/contentTypeValidation.test.ts
Normal file
|
|
@ -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<string, string>): 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<string, string>): Promise<Response> {
|
||||
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']);
|
||||
});
|
||||
});
|
||||
11
pnpm-lock.yaml
generated
11
pnpm-lock.yaml
generated
|
|
@ -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
|
||||
|
|
@ -1425,6 +1425,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
|
||||
|
|
@ -1789,7 +1792,7 @@ importers:
|
|||
packages/server-legacy:
|
||||
dependencies:
|
||||
content-type:
|
||||
specifier: catalog:runtimeServerOnly
|
||||
specifier: catalog:runtimeShared
|
||||
version: 1.0.5
|
||||
cors:
|
||||
specifier: catalog:runtimeServerOnly
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue