fix(server): read HTTP request bodies with a size limit and bound JSON-RPC batch length (#2698)
Co-authored-by: Felix Weinberger <fweinberger@anthropic.com>
This commit is contained in:
parent
3924de99df
commit
7b781ed4e2
4 changed files with 651 additions and 53 deletions
|
|
@ -10,7 +10,9 @@ export type { CompletableSchema, CompleteCallback } from './server/completable';
|
|||
export { completable, isCompletable } from './server/completable';
|
||||
export type {
|
||||
CreateMcpHandlerOptions,
|
||||
IsLegacyRequestOptions,
|
||||
LegacyHttpHandler,
|
||||
LegacyStatelessFallbackOptions,
|
||||
McpHandlerRequestOptions,
|
||||
McpHttpHandler,
|
||||
McpRequestContext,
|
||||
|
|
@ -71,6 +73,9 @@ export type {
|
|||
WebStandardStreamableHTTPServerTransportOptions
|
||||
} from './server/streamableHttp';
|
||||
export { WebStandardStreamableHTTPServerTransport } from './server/streamableHttp';
|
||||
// Request-body bound shared by the HTTP entry points; the reader is exported for
|
||||
// adapter authors that pre-parse bodies (the way isJsonContentType is).
|
||||
export { DEFAULT_MAX_REQUEST_BODY_SIZE, readRequestBody } from './server/requestBody';
|
||||
|
||||
// runtime-aware wrapper (shadows core/public's fromJsonSchema with optional validator)
|
||||
export { fromJsonSchema } from './fromJsonSchema';
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ import { invoke } from './invoke';
|
|||
import { createListenRouter, DEFAULT_MAX_SUBSCRIPTIONS } from './listenRouter';
|
||||
import { McpServer } from './mcp';
|
||||
import type { PerRequestResponseMode } from './perRequestTransport';
|
||||
import { DEFAULT_MAX_REQUEST_BODY_SIZE, readRequestBody, requestBodyTooLargeMessage, resolveMaxRequestBodySize } from './requestBody';
|
||||
import type { Server } from './server';
|
||||
import { installModernOnlyHandlers, seedClientIdentityFromEnvelope, serverIdentityOf } from './server';
|
||||
import type { ServerEventBus, ServerNotifier } from './serverEventBus';
|
||||
|
|
@ -201,6 +202,16 @@ export interface CreateMcpHandlerOptions {
|
|||
* @default 15000
|
||||
*/
|
||||
keepAliveMs?: number;
|
||||
/**
|
||||
* Upper bound, in bytes, on a POST body the handler reads itself (the
|
||||
* classification step and the stateless legacy leg). A body over the bound
|
||||
* is answered `413` before anything is parsed or any server instance is
|
||||
* created. Not applied to a body supplied as `parsedBody`. Must be a
|
||||
* positive number. Hand-wired compositions routing on
|
||||
* {@linkcode isLegacyRequest} pass the same value to the predicate.
|
||||
* @default 4194304 (4 MiB)
|
||||
*/
|
||||
maxRequestBodySize?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -311,7 +322,8 @@ function internalServerErrorResponse(id: RequestId | null = null): Response {
|
|||
function createLegacyStatelessFallback(
|
||||
factory: McpServerFactory,
|
||||
onerror?: (error: Error) => void,
|
||||
keepAliveMs?: number
|
||||
keepAliveMs?: number,
|
||||
maxRequestBodySize?: number
|
||||
): LegacyHttpHandler {
|
||||
return async (request, options) => {
|
||||
if (request.method.toUpperCase() !== 'POST') {
|
||||
|
|
@ -325,7 +337,8 @@ function createLegacyStatelessFallback(
|
|||
});
|
||||
const transport = new WebStandardStreamableHTTPServerTransport({
|
||||
sessionIdGenerator: undefined,
|
||||
...(keepAliveMs !== undefined && { keepAliveMs })
|
||||
...(keepAliveMs !== undefined && { keepAliveMs }),
|
||||
...(maxRequestBodySize !== undefined && { maxRequestBodySize })
|
||||
});
|
||||
await product.connect(transport);
|
||||
|
||||
|
|
@ -399,8 +412,22 @@ function createLegacyStatelessFallback(
|
|||
};
|
||||
}
|
||||
|
||||
export function legacyStatelessFallback(factory: McpServerFactory, onerror?: (error: Error) => void): LegacyHttpHandler {
|
||||
return createLegacyStatelessFallback(factory, onerror);
|
||||
/** Options for {@linkcode legacyStatelessFallback}. */
|
||||
export interface LegacyStatelessFallbackOptions {
|
||||
/**
|
||||
* Upper bound, in bytes, on a POST body the leg reads itself (not applied
|
||||
* to a body supplied as `parsedBody`). Must be a positive number.
|
||||
* @default 4194304 (4 MiB)
|
||||
*/
|
||||
maxRequestBodySize?: number;
|
||||
}
|
||||
|
||||
export function legacyStatelessFallback(
|
||||
factory: McpServerFactory,
|
||||
onerror?: (error: Error) => void,
|
||||
options?: LegacyStatelessFallbackOptions
|
||||
): LegacyHttpHandler {
|
||||
return createLegacyStatelessFallback(factory, onerror, undefined, resolveMaxRequestBodySize(options?.maxRequestBodySize));
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------ *
|
||||
|
|
@ -430,6 +457,8 @@ function standardHeadersOf(request: Request): Omit<InboundHttpRequest, 'httpMeth
|
|||
type EntryClassification =
|
||||
/** The body bytes could not be read at all (a failing stream, not malformed JSON). */
|
||||
| { step: 'unreadable-body' }
|
||||
/** A POST body over the size limit; nothing past the limit was read. */
|
||||
| { step: 'body-too-large' }
|
||||
/** A POST with an empty or non-JSON body: nothing to classify, so there is no envelope claim. */
|
||||
| { step: 'no-json-body'; forwardRequest: Request }
|
||||
/** A classifiable request, with the classifier's routing outcome. */
|
||||
|
|
@ -446,7 +475,12 @@ type EntryClassification =
|
|||
* the body-preserving clone is then skipped and `forwardRequest` is the
|
||||
* (consumed) input request.
|
||||
*/
|
||||
async function classifyEntryRequest(request: Request, providedParsedBody?: unknown, needsForward = true): Promise<EntryClassification> {
|
||||
async function classifyEntryRequest(
|
||||
request: Request,
|
||||
providedParsedBody?: unknown,
|
||||
needsForward = true,
|
||||
maxRequestBodySize: number = DEFAULT_MAX_REQUEST_BODY_SIZE
|
||||
): Promise<EntryClassification> {
|
||||
const httpMethod = request.method.toUpperCase();
|
||||
|
||||
let body: unknown;
|
||||
|
|
@ -464,7 +498,11 @@ async function classifyEntryRequest(request: Request, providedParsedBody?: unkno
|
|||
}
|
||||
let bodyText: string;
|
||||
try {
|
||||
bodyText = await request.text();
|
||||
const read = await readRequestBody(request, maxRequestBodySize);
|
||||
if (read.tooLarge) {
|
||||
return { step: 'body-too-large' };
|
||||
}
|
||||
bodyText = read.text;
|
||||
} catch {
|
||||
return { step: 'unreadable-body' };
|
||||
}
|
||||
|
|
@ -493,6 +531,17 @@ async function classifyEntryRequest(request: Request, providedParsedBody?: unkno
|
|||
return { step: 'classified', outcome, body, parsedBody, forwardRequest };
|
||||
}
|
||||
|
||||
/** Options for {@linkcode isLegacyRequest}. */
|
||||
export interface IsLegacyRequestOptions {
|
||||
/**
|
||||
* The same `maxRequestBodySize` the handler the predicate routes for was
|
||||
* created with, so the two read the body under one bound (a body over it
|
||||
* classifies `false`; the modern handler answers `413`).
|
||||
* @default 4194304 (4 MiB)
|
||||
*/
|
||||
maxRequestBodySize?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether {@linkcode createMcpHandler} would route this request to its legacy
|
||||
* (2025-era) serving rather than the modern (2026-07-28) path.
|
||||
|
|
@ -553,7 +602,8 @@ async function classifyEntryRequest(request: Request, providedParsedBody?: unkno
|
|||
* answers it with the unsupported-protocol-version error), a malformed
|
||||
* envelope behind a present claim (answered `-32602`), a request whose
|
||||
* `MCP-Protocol-Version` header names a modern revision but that lacks the
|
||||
* envelope (`-32602`), and header/body mismatches (`-32020`). Consumers
|
||||
* envelope (`-32602`), header/body mismatches (`-32020`), and a POST body
|
||||
* over the size limit (413). Consumers
|
||||
* routing on the predicate must send `false` traffic to the modern handler,
|
||||
* never to a legacy handler — the modern path owns those error answers.
|
||||
* - `server/discover` probes sent by negotiating clients always carry the
|
||||
|
|
@ -561,13 +611,14 @@ async function classifyEntryRequest(request: Request, providedParsedBody?: unkno
|
|||
* a method named `server/discover` has no claim and classifies legacy,
|
||||
* exactly as the entry itself routes it.
|
||||
*/
|
||||
export async function isLegacyRequest(request: Request, parsedBody?: unknown): Promise<boolean> {
|
||||
export async function isLegacyRequest(request: Request, parsedBody?: unknown, options?: IsLegacyRequestOptions): Promise<boolean> {
|
||||
const maxRequestBodySize = resolveMaxRequestBodySize(options?.maxRequestBodySize);
|
||||
// Classify a clone so the caller's request body stays readable; with a
|
||||
// pre-parsed body (or a body-less method) nothing is read and no clone is
|
||||
// needed. The predicate never reads forwardRequest, so the classification
|
||||
// step's own forwarding clone is skipped.
|
||||
const probe = parsedBody === undefined && request.method.toUpperCase() === 'POST' ? request.clone() : request;
|
||||
const classified = await classifyEntryRequest(probe, parsedBody, false);
|
||||
const classified = await classifyEntryRequest(probe, parsedBody, false, maxRequestBodySize);
|
||||
return classified.step === 'no-json-body' || (classified.step === 'classified' && classified.outcome.kind === 'legacy');
|
||||
}
|
||||
|
||||
|
|
@ -621,6 +672,7 @@ export async function isLegacyRequest(request: Request, parsedBody?: unknown): P
|
|||
*/
|
||||
export function createMcpHandler(factory: McpServerFactory, options: CreateMcpHandlerOptions = {}): McpHttpHandler {
|
||||
const { legacy, onerror, responseMode } = options;
|
||||
const maxRequestBodySize = resolveMaxRequestBodySize(options.maxRequestBodySize);
|
||||
|
||||
// Construction-time guard for JavaScript callers passing a handler as the
|
||||
// legacy value: the option only selects a posture ('stateless' | 'reject').
|
||||
|
|
@ -663,7 +715,7 @@ export function createMcpHandler(factory: McpServerFactory, options: CreateMcpHa
|
|||
// The default posture is the stateless fallback; 'reject' is the only way
|
||||
// to turn legacy serving off (modern-only strict).
|
||||
const legacyHandler: LegacyHttpHandler | undefined =
|
||||
legacy === 'reject' ? undefined : createLegacyStatelessFallback(factory, reportError, options.keepAliveMs);
|
||||
legacy === 'reject' ? undefined : createLegacyStatelessFallback(factory, reportError, options.keepAliveMs, maxRequestBodySize);
|
||||
|
||||
async function serveModern(route: InboundModernRoute, request: Request, authInfo: AuthInfo | undefined): Promise<Response> {
|
||||
const claimedRevision = route.classification.revision;
|
||||
|
|
@ -866,11 +918,14 @@ export function createMcpHandler(factory: McpServerFactory, options: CreateMcpHa
|
|||
return jsonRpcErrorResponse(415, -32_000, 'Unsupported Media Type: Content-Type must be application/json');
|
||||
}
|
||||
|
||||
const classified = await classifyEntryRequest(request, requestOptions?.parsedBody);
|
||||
const classified = await classifyEntryRequest(request, requestOptions?.parsedBody, true, maxRequestBodySize);
|
||||
|
||||
if (classified.step === 'unreadable-body') {
|
||||
return jsonRpcErrorResponse(400, -32_700, 'Parse error: the request body could not be read');
|
||||
}
|
||||
if (classified.step === 'body-too-large') {
|
||||
return jsonRpcErrorResponse(413, -32_000, requestBodyTooLargeMessage(maxRequestBodySize));
|
||||
}
|
||||
if (classified.step === 'no-json-body') {
|
||||
// No JSON body to classify: there is no envelope claim, so this is
|
||||
// legacy traffic when legacy serving is configured (the legacy leg
|
||||
|
|
|
|||
63
packages/server/src/server/requestBody.ts
Normal file
63
packages/server/src/server/requestBody.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
/** Default upper bound, in bytes, on a request body read by the HTTP entry points (4 MiB). */
|
||||
export const DEFAULT_MAX_REQUEST_BODY_SIZE = 4 * 1024 * 1024;
|
||||
|
||||
/** Upper bound on the number of messages accepted in one JSON-RPC batch array. */
|
||||
export const MAX_BATCH_SIZE = 100;
|
||||
|
||||
/** The message answered with 413 for a request body over `maxBytes`. */
|
||||
export function requestBodyTooLargeMessage(maxBytes: number): string {
|
||||
return `Payload Too Large: Request body must not exceed ${maxBytes} bytes`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a `maxRequestBodySize` option to the bound to apply: the default when
|
||||
* omitted, otherwise the value itself, which must be a positive finite number of
|
||||
* bytes (a `RangeError` is thrown at configuration time for anything else).
|
||||
*/
|
||||
export function resolveMaxRequestBodySize(value: number | undefined): number {
|
||||
if (value === undefined) {
|
||||
return DEFAULT_MAX_REQUEST_BODY_SIZE;
|
||||
}
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
|
||||
throw new RangeError(`maxRequestBodySize must be a positive number of bytes, got ${String(value)}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a request body as text, up to `maxBytes` (default
|
||||
* {@linkcode DEFAULT_MAX_REQUEST_BODY_SIZE}). A declared `Content-Length` over the
|
||||
* limit is refused without reading anything; otherwise the read stops as soon as
|
||||
* more than the limit has arrived. Stream failures propagate.
|
||||
*/
|
||||
export async function readRequestBody(
|
||||
request: Request,
|
||||
maxBytes: number = DEFAULT_MAX_REQUEST_BODY_SIZE
|
||||
): Promise<{ tooLarge: true } | { tooLarge: false; text: string }> {
|
||||
if (Number(request.headers.get('content-length')) > maxBytes) {
|
||||
return { tooLarge: true };
|
||||
}
|
||||
if (request.body === null) {
|
||||
return { tooLarge: false, text: '' };
|
||||
}
|
||||
const reader = request.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let received = 0;
|
||||
let text = '';
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
received += value.byteLength;
|
||||
if (received > maxBytes) {
|
||||
return { tooLarge: true };
|
||||
}
|
||||
text += decoder.decode(value, { stream: true });
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
return { tooLarge: false, text: text + decoder.decode() };
|
||||
}
|
||||
|
|
@ -19,6 +19,7 @@ import {
|
|||
SUPPORTED_PROTOCOL_VERSIONS
|
||||
} from '@modelcontextprotocol/core-internal';
|
||||
|
||||
import { MAX_BATCH_SIZE, readRequestBody, requestBodyTooLargeMessage, resolveMaxRequestBodySize } from './requestBody';
|
||||
import { armSseKeepAlive, DEFAULT_SSE_KEEP_ALIVE_MS } from './sseKeepAlive';
|
||||
|
||||
export type StreamId = string;
|
||||
|
|
@ -156,6 +157,15 @@ export interface WebStandardStreamableHTTPServerTransportOptions {
|
|||
*/
|
||||
keepAliveMs?: number;
|
||||
|
||||
/**
|
||||
* Upper bound, in bytes, on a POST body the transport reads itself. A body
|
||||
* over the bound (declared `Content-Length`, or observed while streaming)
|
||||
* is answered `413` before anything is parsed. Not applied when the caller
|
||||
* supplies `parsedBody`. Must be a positive number.
|
||||
* @default 4194304 (4 MiB)
|
||||
*/
|
||||
maxRequestBodySize?: number;
|
||||
|
||||
/**
|
||||
* List of protocol versions that this transport will accept.
|
||||
* Used to validate the `mcp-protocol-version` header in incoming requests.
|
||||
|
|
@ -174,7 +184,7 @@ export interface WebStandardStreamableHTTPServerTransportOptions {
|
|||
*/
|
||||
export interface HandleRequestOptions {
|
||||
/**
|
||||
* Pre-parsed request body. If provided, the transport will use this instead of parsing `req.json()`.
|
||||
* Pre-parsed request body. If provided, the transport will use this instead of reading and parsing the request body.
|
||||
* Useful when using body-parser middleware that has already parsed the body.
|
||||
*/
|
||||
parsedBody?: unknown;
|
||||
|
|
@ -256,6 +266,7 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
|
|||
private _retryInterval?: number;
|
||||
private _supportedProtocolVersions: string[];
|
||||
private _keepAliveMs: number;
|
||||
private _maxRequestBodySize: number;
|
||||
|
||||
sessionId?: string;
|
||||
onclose?: () => void;
|
||||
|
|
@ -274,6 +285,7 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
|
|||
this._retryInterval = options.retryInterval;
|
||||
this._supportedProtocolVersions = options.supportedProtocolVersions ?? SUPPORTED_PROTOCOL_VERSIONS;
|
||||
this._keepAliveMs = options.keepAliveMs ?? DEFAULT_SSE_KEEP_ALIVE_MS;
|
||||
this._maxRequestBodySize = resolveMaxRequestBodySize(options.maxRequestBodySize);
|
||||
}
|
||||
|
||||
private startKeepAlive(
|
||||
|
|
@ -763,7 +775,13 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
|
|||
let rawMessage;
|
||||
if (options?.parsedBody === undefined) {
|
||||
try {
|
||||
rawMessage = await req.json();
|
||||
const body = await readRequestBody(req, this._maxRequestBodySize);
|
||||
if (body.tooLarge) {
|
||||
const message = requestBodyTooLargeMessage(this._maxRequestBodySize);
|
||||
this.onerror?.(new Error(message));
|
||||
return this.createJsonErrorResponse(413, -32_000, message);
|
||||
}
|
||||
rawMessage = JSON.parse(body.text);
|
||||
} catch (error) {
|
||||
this.onerror?.(error as Error);
|
||||
return this.createJsonErrorResponse(400, -32_700, 'Parse error: Invalid JSON');
|
||||
|
|
@ -771,6 +789,10 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
|
|||
} else {
|
||||
rawMessage = options.parsedBody;
|
||||
}
|
||||
if (Array.isArray(rawMessage) && rawMessage.length > MAX_BATCH_SIZE) {
|
||||
this.onerror?.(new Error(`Invalid Request: Batch must not exceed ${MAX_BATCH_SIZE} messages`));
|
||||
return this.createJsonErrorResponse(400, -32_600, `Invalid Request: Batch must not exceed ${MAX_BATCH_SIZE} messages`);
|
||||
}
|
||||
|
||||
let messages: JSONRPCMessage[];
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue