fix(server): validate Content-Type by parsed media type instead of substring match (#2441)

This commit is contained in:
Felix Weinberger 2026-07-06 20:29:58 +01:00 • committed by GitHub
commit 561c6d8345
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 452 additions and 20 deletions

View file

@ -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') {

View file

@ -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,

View file

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