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:
Max 2026-08-25 14:23:40 +01:00 • committed by GitHub
commit 7b781ed4e2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 651 additions and 53 deletions

View file

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

View file

@ -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();

View file

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

View file

@ -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<Uint8Array>; pulls: () => number } {
let pulled = 0;
const body = new ReadableStream<Uint8Array>(
{
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);
});
});

View file

@ -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<string, string> = {};
@ -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<Request> {
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();

View file

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