diff --git a/.changeset/request-body-size-limit.md b/.changeset/request-body-size-limit.md new file mode 100644 index 0000000000..0057ebbf4e --- /dev/null +++ b/.changeset/request-body-size-limit.md @@ -0,0 +1,31 @@ +--- +'@modelcontextprotocol/server': patch +'@modelcontextprotocol/node': patch +'@modelcontextprotocol/hono': patch +'@modelcontextprotocol/express': patch +--- + +Read Streamable HTTP request bodies with a size limit. Every SDK-owned body read — +`WebStandardStreamableHTTPServerTransport` (and the Node transport built on it), +`createMcpHandler`, `toNodeHandler`, and `createMcpHonoApp`'s JSON pre-parse — now stops at +4 MiB by default (the limit the legacy SSE transport already uses; the Express adapter and stdio +bound their reads too) and answers `413 Payload Too Large` before anything is parsed. +`toWebRequest` (when it reads the Node stream itself) now rejects once the body exceeds the +limit with an error whose `name` is `'RequestBodyTooLargeError'` and `status` is `413`, and +`toNodeHandler` answers that with `413`; hand-wired callers of `toWebRequest` should handle the +rejection or pass a pre-parsed body, and `isLegacyRequest` reports such a request as non-legacy +so the modern handler answers it. JSON-RPC batch arrays are limited to 100 messages; a longer +batch is answered `400` / `-32600` and none of it is dispatched. + +The limit is configurable with a new `maxRequestBodySize` option (bytes, default +`DEFAULT_MAX_REQUEST_BODY_SIZE` = 4 MiB, exported from `@modelcontextprotocol/server`) on +`WebStandardStreamableHTTPServerTransportOptions`, `CreateMcpHandlerOptions` (forwarded to its +stateless legacy leg; `isLegacyRequest` and `legacyStatelessFallback` take the same option), +`CreateMcpHonoAppOptions`, and `ToNodeHandlerOptions` / `ToWebRequestOptions` (the adapter's +bound applies before the handler's, so raise both). The bounded reader is exported as +`readRequestBody` for adapter authors. Hosts that pre-parse the body and pass it as +`parsedBody` skip the SDK's read and its size limit entirely; the batch bound applies either way. + +`createMcpHonoApp` and `createMcpExpressApp` now run their Host/Origin validation before the +JSON body parser, so a request from a disallowed Host or Origin with an invalid JSON body is +answered `403` rather than `400`, and its body is not read. diff --git a/packages/middleware/express/src/express.ts b/packages/middleware/express/src/express.ts index dd930f5713..0d3137f549 100644 --- a/packages/middleware/express/src/express.ts +++ b/packages/middleware/express/src/express.ts @@ -76,7 +76,6 @@ export function createMcpExpressApp(options: CreateMcpExpressAppOptions = {}): E const { host = '127.0.0.1', allowedHosts, allowedOrigins, jsonLimit } = options; const app = express(); - app.use(express.json(jsonLimit ? { limit: jsonLimit } : undefined)); // If allowedHosts is explicitly provided, use that for validation if (allowedHosts) { @@ -106,5 +105,9 @@ export function createMcpExpressApp(options: CreateMcpExpressAppOptions = {}): E app.use(localhostOriginValidation()); } + // The JSON body parser runs after the Host/Origin validation, so a request + // from a disallowed origin is answered 403 without its body being read. + app.use(express.json(jsonLimit ? { limit: jsonLimit } : undefined)); + return app; } diff --git a/packages/middleware/express/test/express.test.ts b/packages/middleware/express/test/express.test.ts index 8f44ce0ee3..f805bf5d96 100644 --- a/packages/middleware/express/test/express.test.ts +++ b/packages/middleware/express/test/express.test.ts @@ -1,4 +1,5 @@ import type { NextFunction, Request, Response } from 'express'; +import supertest from 'supertest'; import { vi } from 'vitest'; import { createMcpExpressApp } from '../src/express'; @@ -107,6 +108,43 @@ describe('@modelcontextprotocol/express', () => { }); describe('createMcpExpressApp', () => { + test('validates Host/Origin before the JSON body parser runs', async () => { + const app = createMcpExpressApp(); + app.post('/mcp', (req: Request, res: Response) => { + res.json({ parsed: req.body }); + }); + + const disallowedHost = await supertest(app) + .post('/mcp') + .set('Host', 'evil.example.com') + .set('Content-Type', 'application/json') + .send('{not json'); + expect(disallowedHost.status).toBe(403); + + const disallowedOrigin = await supertest(app) + .post('/mcp') + .set('Host', 'localhost:3000') + .set('Origin', 'http://evil.example.com') + .set('Content-Type', 'application/json') + .send('{not json'); + expect(disallowedOrigin.status).toBe(403); + + const invalidJson = await supertest(app) + .post('/mcp') + .set('Host', 'localhost:3000') + .set('Content-Type', 'application/json') + .send('{not json'); + expect(invalidJson.status).toBe(400); + + const allowed = await supertest(app) + .post('/mcp') + .set('Host', 'localhost:3000') + .set('Content-Type', 'application/json') + .send({ ok: true }); + expect(allowed.status).toBe(200); + expect(allowed.body).toEqual({ parsed: { ok: true } }); + }); + test('should enable localhost DNS rebinding protection by default', () => { const app = createMcpExpressApp(); diff --git a/packages/middleware/hono/src/hono.ts b/packages/middleware/hono/src/hono.ts index 8a822fe3be..a72cd93fa0 100644 --- a/packages/middleware/hono/src/hono.ts +++ b/packages/middleware/hono/src/hono.ts @@ -1,4 +1,4 @@ -import { isJsonContentType } from '@modelcontextprotocol/server'; +import { DEFAULT_MAX_REQUEST_BODY_SIZE, isJsonContentType, readRequestBody } from '@modelcontextprotocol/server'; import type { Context } from 'hono'; import { Hono } from 'hono'; @@ -36,6 +36,28 @@ export interface CreateMcpHonoAppOptions { * is rejected with `403`. */ allowedOrigins?: string[]; + + /** + * Upper bound, in bytes, on a JSON request body the app's body-parsing + * middleware reads. A larger body is answered `413` before being parsed. + * Must be a positive number. The counterpart of `createMcpExpressApp`'s + * `jsonLimit`. + * @default 4194304 (4 MiB) + */ + maxRequestBodySize?: number; +} + +/** + * Reads (bounded, via the transport's own reader) and parses a JSON body from a + * clone of the request, so the original stream stays readable. Returning only the + * parsed value keeps the decoded text from outliving the parse. + */ +async function parseJsonBody(request: Request, maxBytes: number): Promise<{ tooLarge: true } | { tooLarge: false; value: unknown }> { + const body = await readRequestBody(request, maxBytes); + if (body.tooLarge) { + return { tooLarge: true }; + } + return { tooLarge: false, value: JSON.parse(body.text) }; } /** @@ -47,42 +69,21 @@ export interface CreateMcpHonoAppOptions { * * This also installs a small JSON body parsing middleware (similar to `express.json()`) * that stashes the parsed body into `c.set('parsedBody', ...)` when the `Content-Type` - * media type is `application/json`. + * media type is `application/json`. It runs after the Host/Origin validation, and JSON + * bodies over `maxRequestBodySize` (4 MiB by default) are answered `413` before being parsed. * * @param options - Configuration options * @returns A configured Hono application */ export function createMcpHonoApp(options: CreateMcpHonoAppOptions = {}): Hono { const { host = '127.0.0.1', allowedHosts, allowedOrigins } = options; + const maxRequestBodySize = options.maxRequestBodySize ?? DEFAULT_MAX_REQUEST_BODY_SIZE; + if (typeof maxRequestBodySize !== 'number' || !Number.isFinite(maxRequestBodySize) || maxRequestBodySize <= 0) { + throw new RangeError(`maxRequestBodySize must be a positive number of bytes, got ${String(maxRequestBodySize)}`); + } const app = new Hono(); - // Similar to `express.json()`: parse JSON bodies and make them available to MCP adapters via `parsedBody`. - app.use('*', async (c: Context, next) => { - // If an upstream middleware already set parsedBody, keep it. - if (c.get('parsedBody') !== undefined) { - return await next(); - } - - // 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(); - } - - try { - // Parse from a clone so we don't consume the original request stream. - const parsed = await c.req.raw.clone().json(); - c.set('parsedBody', parsed); - } catch { - // Mirror express.json() behavior loosely: reject invalid JSON. - return c.text('Invalid JSON', 400); - } - - return await next(); - }); - // If allowedHosts is explicitly provided, use that for validation. if (allowedHosts) { app.use('*', hostHeaderValidation(allowedHosts)); @@ -111,5 +112,42 @@ export function createMcpHonoApp(options: CreateMcpHonoAppOptions = {}): Hono { app.use('*', localhostOriginValidation()); } + // Similar to `express.json()`: parse JSON bodies (up to maxRequestBodySize; larger ones are + // answered 413) and make them available to MCP adapters via `parsedBody`. + app.use('*', async (c: Context, next) => { + // If an upstream middleware already set parsedBody, keep it. + if (c.get('parsedBody') !== undefined) { + return await next(); + } + + // 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(); + } + + try { + // Parse from a clone so we don't consume the original request stream. + const parsed = await parseJsonBody(c.req.raw.clone(), maxRequestBodySize); + if (parsed.tooLarge) { + return c.json( + { + jsonrpc: '2.0', + error: { code: -32_000, message: `Payload Too Large: Request body must not exceed ${maxRequestBodySize} bytes` }, + id: null + }, + 413 + ); + } + c.set('parsedBody', parsed.value); + } catch { + // Mirror express.json() behavior loosely: reject invalid JSON. + return c.text('Invalid JSON', 400); + } + + return await next(); + }); + return app; } diff --git a/packages/middleware/hono/test/hono.test.ts b/packages/middleware/hono/test/hono.test.ts index ea924cbba4..775d3fe0e7 100644 --- a/packages/middleware/hono/test/hono.test.ts +++ b/packages/middleware/hono/test/hono.test.ts @@ -134,4 +134,89 @@ describe('@modelcontextprotocol/hono', () => { expect(res.status).toBe(200); expect(await res.json()).toEqual({ preset: true }); }); + + /** A body that yields up to `chunks` 1 MiB chunks on demand (no Content-Length), counting pulls. */ + function streamedBody(chunks: number): { body: ReadableStream; pulls: () => number } { + let pulled = 0; + const body = new ReadableStream( + { + pull(controller) { + if (pulled >= chunks) { + return controller.close(); + } + pulled++; + controller.enqueue(new Uint8Array(1024 * 1024).fill(0x20)); + } + }, + { highWaterMark: 0 } + ); + return { body, pulls: () => pulled }; + } + const jsonHeaders = { Host: 'localhost:3000', 'content-type': 'application/json' }; + + test('createMcpHonoApp rejects a disallowed Host before reading the body', async () => { + const app = createMcpHonoApp(); + app.post('/echo', (c: Context) => c.text('ok')); + + const { body, pulls } = streamedBody(1); + const init = { method: 'POST', headers: { ...jsonHeaders, Host: 'example.com:3000' }, body, duplex: 'half' }; + const res = await app.request('http://localhost/echo', init as RequestInit); + expect(res.status).toBe(403); + expect(pulls()).toBe(0); + }); + + test('createMcpHonoApp answers 413 for a JSON body over the size limit', async () => { + const app = createMcpHonoApp(); + app.post('/echo', (c: Context) => c.text('ok')); + + const declared = streamedBody(5); + const declaredOverLimit = { ...jsonHeaders, 'content-length': String(4 * 1024 * 1024 + 1) }; + const res = await app.request('http://localhost/echo', { + method: 'POST', + headers: declaredOverLimit, + body: declared.body, + duplex: 'half' + } as RequestInit); + expect(res.status).toBe(413); + expect(await res.json()).toEqual({ + jsonrpc: '2.0', + error: { code: -32_000, message: expect.stringMatching(/^Payload Too Large/) }, + id: null + }); + // clone() tees the body, which buffers one chunk up front; nothing past that is read. + expect(declared.pulls()).toBeLessThanOrEqual(1); + + const streamed = await app.request('http://localhost/echo', { + method: 'POST', + headers: jsonHeaders, + body: streamedBody(5).body, + duplex: 'half' + } as RequestInit); + expect(streamed.status).toBe(413); + expect(((await streamed.json()) as { error: { code: number } }).error.code).toBe(-32_000); + }); + + test('createMcpHonoApp maxRequestBodySize moves the pre-parse bound and is validated', async () => { + const strict = createMcpHonoApp({ maxRequestBodySize: 1024 }); + strict.post('/echo', (c: Context) => c.text('ok')); + const refused = await strict.request('http://localhost/echo', { + method: 'POST', + headers: jsonHeaders, + body: JSON.stringify({ pad: 'x'.repeat(2048) }) + }); + expect(refused.status).toBe(413); + expect(((await refused.json()) as { error: { message: string } }).error.message).toMatch(/must not exceed 1024 bytes/); + + const roomy = createMcpHonoApp({ maxRequestBodySize: 8 * 1024 * 1024 }); + roomy.post('/echo', (c: Context) => c.json({ keys: Object.keys(c.get('parsedBody') as object) })); + const served = await roomy.request('http://localhost/echo', { + method: 'POST', + headers: jsonHeaders, + body: JSON.stringify({ pad: 'x'.repeat(5 * 1024 * 1024) }) + }); + expect(served.status).toBe(200); + expect(await served.json()).toEqual({ keys: ['pad'] }); + + expect(() => createMcpHonoApp({ maxRequestBodySize: 0 })).toThrow(RangeError); + }); }); diff --git a/packages/middleware/node/src/toNodeHandler.ts b/packages/middleware/node/src/toNodeHandler.ts index 02ad932fc9..5f26b1ad26 100644 --- a/packages/middleware/node/src/toNodeHandler.ts +++ b/packages/middleware/node/src/toNodeHandler.ts @@ -27,6 +27,29 @@ * it as the handler's pass-through `authInfo`. */ import type { AuthInfo, McpHandlerRequestOptions } from '@modelcontextprotocol/server'; +import { DEFAULT_MAX_REQUEST_BODY_SIZE } from '@modelcontextprotocol/server'; + +/** + * The rejection {@linkcode toWebRequest} produces for a body over the size + * limit, recognisable by `name` and `status` without matching message text. + */ +class RequestBodyTooLargeError extends Error { + readonly status = 413; + constructor(maxBytes: number) { + super(`Payload Too Large: Request body must not exceed ${maxBytes} bytes`); + this.name = 'RequestBodyTooLargeError'; + } +} + +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; +} /** * Minimal duck-typed shape of a Node.js `IncomingMessage` accepted by @@ -79,6 +102,15 @@ export interface ToNodeHandlerOptions { * `handler.fetch` and surface via the entry's `onerror` option as before. */ onerror?: (error: Error) => void; + /** + * Upper bound, in bytes, on a request body the adapter buffers from the + * Node stream (when no `parsedBody` is supplied). A larger body is answered + * `413` without calling `handler.fetch`. Raise it together with the + * handler's own `maxRequestBodySize`; the adapter's bound applies first. + * Must be a positive number. + * @default 4194304 (4 MiB) + */ + maxRequestBodySize?: number; } /** @@ -95,6 +127,7 @@ export interface ToNodeHandlerOptions { * conversion / `handler.fetch` throw) before the `500` response is written. */ export function toNodeHandler(handler: FetchLikeMcpHandler, opts?: ToNodeHandlerOptions): NodeMcpRequestHandler { + const maxRequestBodySize = resolveMaxRequestBodySize(opts?.maxRequestBodySize); return async (req, res, parsedBody) => { // Express passes (req, res, next) when the handler is mounted as a // middleware function; a function third argument is `next`, not a body. @@ -115,18 +148,28 @@ export function toNodeHandler(handler: FetchLikeMcpHandler, opts?: ToNodeHandler let response: Response; try { - const request = await toWebRequest(req, parsedBody, { signal: abort.signal }); + const request = await toWebRequest(req, parsedBody, { signal: abort.signal, maxRequestBodySize }); response = await handler.fetch(request, { ...(req.auth !== undefined && { authInfo: req.auth }), ...(parsedBody !== undefined && { parsedBody }) }); } catch (error) { - try { - opts?.onerror?.(error instanceof Error ? error : new Error(String(error))); - } catch { - // Reporting must never alter the response. + if (error instanceof RequestBodyTooLargeError) { + // A normal answer, not an adapter failure. `connection: close` has + // an HTTP/1.1 server end the socket after answering instead of + // idling a connection whose request stream was left partly unread. + response = Response.json( + { jsonrpc: '2.0', error: { code: -32_000, message: error.message }, id: null }, + { status: 413, headers: { connection: 'close' } } + ); + } else { + try { + opts?.onerror?.(error instanceof Error ? error : new Error(String(error))); + } catch { + // Reporting must never alter the response. + } + response = internalServerErrorResponse(echoableRequestId(parsedBody)); } - response = internalServerErrorResponse(echoableRequestId(parsedBody)); } const headers: Record = {}; @@ -190,6 +233,14 @@ function singleHeaderValue(value: string | string[] | undefined): string | undef export interface ToWebRequestOptions { /** An `AbortSignal` to attach to the constructed `Request` (`request.signal`). */ signal?: AbortSignal; + /** + * Upper bound, in bytes, on the body read from the Node stream (when no + * `parsedBody` is supplied); the returned promise rejects past it with an + * `Error` whose `name` is `'RequestBodyTooLargeError'` and `status` is `413`. + * Must be a positive number. + * @default 4194304 (4 MiB) + */ + maxRequestBodySize?: number; } /** @@ -203,12 +254,15 @@ export interface ToWebRequestOptions { * await ((await isLegacyRequest(probe)) ? legacy(req, res) : modern(req, res, req.body)); * ``` * - * With no `parsedBody` the Node stream is read to completion — read the body - * from the returned `Request` afterwards, not from `req`. When a body parser - * already consumed the stream (`express.json()`), pass the parsed value as - * `parsedBody` and nothing is read from `req`. + * With no `parsedBody` the Node stream is read to completion (up to + * `maxRequestBodySize`, 4 MiB by default — a longer body rejects with an error + * carrying `name: 'RequestBodyTooLargeError'` and `status: 413`) — read the + * body from the returned `Request` afterwards, not from `req`. When a body + * parser already consumed the stream (`express.json()`), pass the parsed value + * as `parsedBody` and nothing is read from `req`. */ export async function toWebRequest(req: NodeIncomingMessageLike, parsedBody?: unknown, options?: ToWebRequestOptions): Promise { + const maxRequestBodySize = resolveMaxRequestBodySize(options?.maxRequestBodySize); const method = (req.method ?? 'GET').toUpperCase(); // HTTP/2 requests carry their authority as the `:authority` pseudo-header, // usually with no `host` entry at all (mirrors Node's `request.authority`). @@ -237,9 +291,17 @@ export async function toWebRequest(req: NodeIncomingMessageLike, parsedBody?: un let body: string | undefined; if (method !== 'GET' && method !== 'HEAD') { if (parsedBody === undefined) { + if (Number(singleHeaderValue(req.headers['content-length'])) > maxRequestBodySize) { + throw new RequestBodyTooLargeError(maxRequestBodySize); + } const decoder = new TextDecoder(); let collected = ''; + let received = 0; for await (const chunk of req) { + received += typeof chunk === 'string' ? new TextEncoder().encode(chunk).byteLength : (chunk as Uint8Array).byteLength; + if (received > maxRequestBodySize) { + throw new RequestBodyTooLargeError(maxRequestBodySize); + } collected += typeof chunk === 'string' ? chunk : decoder.decode(chunk as Uint8Array, { stream: true }); } collected += decoder.decode(); diff --git a/packages/middleware/node/test/toNodeHandler.test.ts b/packages/middleware/node/test/toNodeHandler.test.ts index 3f76c53a3d..b879365f4a 100644 --- a/packages/middleware/node/test/toNodeHandler.test.ts +++ b/packages/middleware/node/test/toNodeHandler.test.ts @@ -17,7 +17,7 @@ import { describe, expect, it, vi } from 'vitest'; import * as z from 'zod/v4'; import type { NodeServerResponseLike } from '../src/toNodeHandler'; -import { toNodeHandler } from '../src/toNodeHandler'; +import { toNodeHandler, toWebRequest } from '../src/toNodeHandler'; const MODERN_REVISION = '2026-07-28'; @@ -344,6 +344,75 @@ describe('toNodeHandler', () => { expect(payload.error.code).toBe(-32_603); expect(payload.id).toBe(1); }); + + it('answers 413 and never calls handler.fetch when the request stream exceeds the body size limit', async () => { + const fetch = vi.fn(async () => new Response(null, { status: 200 })); + const onerror = vi.fn(); + const node = toNodeHandler({ fetch }, { onerror }); + + const { req, res, body } = nodeRequestResponse(undefined); + const chunks = Array.from({ length: 8 }, () => new Uint8Array(1024 * 1024)); + await node(Object.assign(Readable.from(chunks), { method: req.method, url: req.url, headers: req.headers }), res); + + expect(res.statusCode).toBe(413); + expect(res.headers?.['connection']).toBe('close'); + expect(JSON.parse(await body())).toEqual({ + jsonrpc: '2.0', + error: { code: -32_000, message: expect.stringMatching(/^Payload Too Large/) }, + id: null + }); + expect(fetch).not.toHaveBeenCalled(); + expect(onerror).not.toHaveBeenCalled(); + }); + + it('answers 413 without reading the stream when Content-Length exceeds the body size limit', async () => { + const fetch = vi.fn(async () => new Response(null, { status: 200 })); + const { req, res } = nodeRequestResponse(undefined); + const next = vi.fn(async () => ({ done: false, value: new Uint8Array(1024) })); + const headers = { ...req.headers, 'content-length': String(4 * 1024 * 1024 + 1) }; + await toNodeHandler({ fetch })({ [Symbol.asyncIterator]: () => ({ next }), method: 'POST', url: req.url, headers }, res); + expect(res.statusCode).toBe(413); + expect(fetch).not.toHaveBeenCalled(); + expect(next).not.toHaveBeenCalled(); + }); + + it('maxRequestBodySize moves the adapter bound, and the toWebRequest rejection is recognisable by name and status', async () => { + const fetch = vi.fn(async () => new Response(null, { status: 200 })); + const { req, res } = nodeRequestResponse(undefined); + const twoKiB = Object.assign(Readable.from([new Uint8Array(2048)]), { method: req.method, url: req.url, headers: req.headers }); + await toNodeHandler({ fetch }, { maxRequestBodySize: 1024 })(twoKiB, res); + expect(res.statusCode).toBe(413); + expect(fetch).not.toHaveBeenCalled(); + + const roomy = nodeRequestResponse(undefined); + const fiveMiB = Object.assign(Readable.from(Array.from({ length: 5 }, () => new Uint8Array(1024 * 1024).fill(0x20))), { + method: 'POST', + url: roomy.req.url, + headers: roomy.req.headers + }); + await toNodeHandler({ fetch }, { maxRequestBodySize: 8 * 1024 * 1024 })(fiveMiB, roomy.res); + expect(roomy.res.statusCode).toBe(200); + expect(fetch).toHaveBeenCalledTimes(1); + + const rejection = await toWebRequest( + { + [Symbol.asyncIterator]: () => ({ next: async () => ({ done: false, value: new Uint8Array(2048) }) }), + method: 'POST', + url: '/mcp', + headers: {} + }, + undefined, + { maxRequestBodySize: 1024 } + ).catch((error: unknown) => error); + expect(rejection).toBeInstanceOf(Error); + expect(rejection).toMatchObject({ + name: 'RequestBodyTooLargeError', + status: 413, + message: expect.stringMatching(/must not exceed 1024 bytes/) + }); + + expect(() => toNodeHandler({ fetch }, { maxRequestBodySize: 0 })).toThrow(RangeError); + }); }); /* ------------------------------------------------------------------------ * diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index d2263d5829..4bd9a04f3f 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -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'; diff --git a/packages/server/src/server/createMcpHandler.ts b/packages/server/src/server/createMcpHandler.ts index da165a6c7c..9adbe54fb8 100644 --- a/packages/server/src/server/createMcpHandler.ts +++ b/packages/server/src/server/createMcpHandler.ts @@ -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 { +async function classifyEntryRequest( + request: Request, + providedParsedBody?: unknown, + needsForward = true, + maxRequestBodySize: number = DEFAULT_MAX_REQUEST_BODY_SIZE +): Promise { 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 { +export async function isLegacyRequest(request: Request, parsedBody?: unknown, options?: IsLegacyRequestOptions): Promise { + 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 { 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 diff --git a/packages/server/src/server/requestBody.ts b/packages/server/src/server/requestBody.ts new file mode 100644 index 0000000000..a11e8d99ed --- /dev/null +++ b/packages/server/src/server/requestBody.ts @@ -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() }; +} diff --git a/packages/server/src/server/streamableHttp.ts b/packages/server/src/server/streamableHttp.ts index c0f48560a2..ce73cf8a20 100644 --- a/packages/server/src/server/streamableHttp.ts +++ b/packages/server/src/server/streamableHttp.ts @@ -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[]; diff --git a/packages/server/test/server/createMcpHandler.test.ts b/packages/server/test/server/createMcpHandler.test.ts index a51062b977..4dae5c0ad7 100644 --- a/packages/server/test/server/createMcpHandler.test.ts +++ b/packages/server/test/server/createMcpHandler.test.ts @@ -538,6 +538,44 @@ describe('createMcpHandler — stateless legacy fallback (the default)', () => { expect(body.error.code).toBe(-32_700); }); + it('answers 413 for a request body over the size limit before creating a server', async () => { + const { factory, state } = testFactory(); + const handler = createMcpHandler(factory); + + const streamed = postRequest('x'.repeat(4 * 1024 * 1024 + 1)); + const declared = postRequest('{}', { 'Content-Length': String(4 * 1024 * 1024 + 1) }); + for (const request of [streamed, declared]) { + const response = await handler.fetch(request); + expect(response.status).toBe(413); + expect(((await response.json()) as JSONRPCErrorBody).error.code).toBe(-32_000); + } + expect(state.contexts).toHaveLength(0); + }); + + it('maxRequestBodySize moves the bound for the entry, its stateless legacy leg, and isLegacyRequest', async () => { + const { factory, state } = testFactory(); + const paddedPing = { jsonrpc: '2.0', id: 1, method: 'ping', params: { pad: 'x'.repeat(5 * 1024 * 1024) } }; + + const roomy = createMcpHandler(factory, { maxRequestBodySize: 8 * 1024 * 1024 }); + const served = await roomy.fetch(postRequest(paddedPing)); + expect(served.status).toBe(200); + expect(await served.text()).toContain('"result":{}'); + expect(state.contexts).toHaveLength(1); + + const strict = createMcpHandler(factory, { maxRequestBodySize: 1024 }); + const refused = await strict.fetch(postRequest('x'.repeat(1025))); + expect(refused.status).toBe(413); + expect(((await refused.json()) as JSONRPCErrorBody).error.message).toMatch(/must not exceed 1024 bytes/); + expect(state.contexts).toHaveLength(1); + + expect(await isLegacyRequest(postRequest(paddedPing))).toBe(false); + expect(await isLegacyRequest(postRequest(paddedPing), undefined, { maxRequestBodySize: 8 * 1024 * 1024 })).toBe(true); + + for (const invalid of [0, -1, Number.NaN]) { + expect(() => createMcpHandler(factory, { maxRequestBodySize: invalid })).toThrow(RangeError); + } + }); + it('still serves the modern path on the same endpoint (one factory, both legs)', async () => { const { factory, state } = testFactory(); const handler = createMcpHandler(factory); diff --git a/packages/server/test/server/streamableHttp.test.ts b/packages/server/test/server/streamableHttp.test.ts index 9ec6baf46c..a0fa60efc6 100644 --- a/packages/server/test/server/streamableHttp.test.ts +++ b/packages/server/test/server/streamableHttp.test.ts @@ -1547,3 +1547,92 @@ describe('WebStandardStreamableHTTPServerTransport SSE keep-alive', () => { expect(vi.getTimerCount()).toBe(0); }); }); + +describe('WebStandardStreamableHTTPServerTransport request body limits', () => { + let transport: WebStandardStreamableHTTPServerTransport; + + beforeEach(() => { + transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined }); + }); + + /** A POST whose body yields up to `chunks` 1 MiB chunks on demand, counting pulls. */ + function streamedPost(chunks: number, headers: Record = {}): { request: Request; pulls: () => number } { + let pulled = 0; + const body = new ReadableStream( + { + pull(controller) { + if (pulled >= chunks) { + return controller.close(); + } + pulled++; + controller.enqueue(new Uint8Array(1024 * 1024).fill(32)); + } + }, + { highWaterMark: 0 } + ); + const request = new Request('http://localhost/mcp', { + method: 'POST', + headers: { Accept: 'application/json, text/event-stream', 'Content-Type': 'application/json', ...headers }, + body, + duplex: 'half' + }); + return { request, pulls: () => pulled }; + } + + it('answers 413 when Content-Length exceeds the body size limit without reading the body', async () => { + const { request, pulls } = streamedPost(1, { 'Content-Length': String(4 * 1024 * 1024 + 1) }); + const response = await transport.handleRequest(request); + expect(response.status).toBe(413); + expectErrorResponse(await response.json(), -32_000, /Payload Too Large/); + expect(pulls()).toBe(0); + }); + + it('answers 413 once a streamed body without Content-Length exceeds the limit', async () => { + const { request, pulls } = streamedPost(8); + const response = await transport.handleRequest(request); + expect(response.status).toBe(413); + expect(pulls()).toBeLessThan(8); + }); + + it('maxRequestBodySize sets the bound on both read paths and is validated at construction', async () => { + const strict = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined, maxRequestBodySize: 1024 }); + const declared = await strict.handleRequest(streamedPost(1, { 'Content-Length': '1025' }).request); + expect(declared.status).toBe(413); + expectErrorResponse(await declared.json(), -32_000, /must not exceed 1024 bytes/); + const streamed = streamedPost(2); + const overLimit = await strict.handleRequest(streamed.request); + expect(overLimit.status).toBe(413); + expect(streamed.pulls()).toBe(1); + + const roomy = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined, maxRequestBodySize: 8 * 1024 * 1024 }); + const onmessage = vi.fn(); + roomy.onmessage = onmessage; + const padded: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'notifications/initialized', + params: { pad: 'x'.repeat(5 * 1024 * 1024) } + }; + const accepted = await roomy.handleRequest(createRequest('POST', padded)); + expect(accepted.status).toBe(202); + expect(onmessage).toHaveBeenCalledTimes(1); + + for (const invalid of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) { + expect( + () => new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined, maxRequestBodySize: invalid }) + ).toThrow(RangeError); + } + }); + + it('answers 400 for a JSON-RPC batch longer than 100 messages and dispatches none of it', async () => { + const batch = Array.from({ length: 101 }, (_, i): JSONRPCMessage => ({ jsonrpc: '2.0', method: 'ping', id: i })); + const onmessage = vi.fn(); + transport.onmessage = onmessage; + const read = await transport.handleRequest(createRequest('POST', batch)); + const preParsed = await transport.handleRequest(createRequest('POST', batch), { parsedBody: batch }); + for (const response of [read, preParsed]) { + expect(response.status).toBe(400); + expectErrorResponse(await response.json(), -32_600, /Batch must not exceed 100 messages/); + } + expect(onmessage).not.toHaveBeenCalled(); + }); +});