Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .changeset/request-body-size-limit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
'@modelcontextprotocol/server': patch
'@modelcontextprotocol/node': patch
'@modelcontextprotocol/hono': 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 (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, 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. `createMcpHonoApp` also runs its Host/Origin validation before the JSON
pre-parse, so a request from a disallowed Host with an invalid JSON body is now answered
`403` rather than `400`.

Hosts that need to accept larger JSON-RPC bodies can parse the body themselves and pass it
as `parsedBody`, in which case the SDK reads nothing and applies no size limit; the batch
bound applies either way.
102 changes: 75 additions & 27 deletions packages/middleware/hono/src/hono.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,42 @@
allowedOrigins?: string[];
}

/** Same limit the Streamable HTTP transport applies to request bodies. */
const MAX_REQUEST_BODY_SIZE = 4 * 1024 * 1024;

/**
* Reads a request body as text, up to MAX_REQUEST_BODY_SIZE — the same bounded read the Streamable
* HTTP transport performs. A declared Content-Length over the limit is refused without reading.
*/
async function readRequestBody(request: Request): Promise<{ tooLarge: true } | { tooLarge: false; text: string }> {
if (Number(request.headers.get('content-length')) > MAX_REQUEST_BODY_SIZE) {
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 > MAX_REQUEST_BODY_SIZE) {
return { tooLarge: true };
}
text += decoder.decode(value, { stream: true });
}
} finally {
reader.releaseLock();
}
return { tooLarge: false, text: text + decoder.decode() };
}

/**
* Creates a Hono application pre-configured for MCP servers.
*
Expand All @@ -47,7 +83,8 @@
*
* 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 4 MiB are answered `413` before being parsed.
*
* @param options - Configuration options
* @returns A configured Hono application
Expand All @@ -57,32 +94,6 @@

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));
Expand Down Expand Up @@ -111,5 +122,42 @@
app.use('*', localhostOriginValidation());
}

// Similar to `express.json()`: parse JSON bodies (up to MAX_REQUEST_BODY_SIZE; larger ones are
// answered 413) and make them available to MCP adapters via `parsedBody`.
app.use('*', async (c: Context, next) => {

Check notice on line 127 in packages/middleware/hono/src/hono.ts

View check run for this annotation

Claude / Claude Code Review

Pre-existing leftover of the pattern this PR fixes: the sibling Express adapter still installs its JSON body parser before Host/Origin validation. The PR moves createMcpHonoApp's JSON pre-parse to run after hostHeaderValidation/originValidation (so a disa

Pre-existing leftover of the pattern this PR fixes: the sibling Express adapter still installs its JSON body parser before Host/Origin validation. The PR moves createMcpHonoApp's JSON pre-parse to run after hostHeaderValidation/originValidation (so a disallowed Host is answered 403 without the body being read or parsed), but createMcpExpressApp (packages/middleware/express/src/express.ts:79) still registers `app.use(express.json(...))` ahead of hostHeaderValidation/localhostHostValidation/origin
Comment thread
claude[bot] marked this conversation as resolved.
Outdated
// 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 body = await readRequestBody(c.req.raw.clone());
if (body.tooLarge) {
return c.json(
{
jsonrpc: '2.0',
error: { code: -32_000, message: `Payload Too Large: Request body must not exceed ${MAX_REQUEST_BODY_SIZE} bytes` },
id: null
},
413
);
}
c.set('parsedBody', JSON.parse(body.text));
} catch {
// Mirror express.json() behavior loosely: reject invalid JSON.
return c.text('Invalid JSON', 400);
}

return await next();
});

return app;
}
61 changes: 61 additions & 0 deletions packages/middleware/hono/test/hono.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,4 +134,65 @@ 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);
});
});
46 changes: 37 additions & 9 deletions packages/middleware/node/src/toNodeHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,15 @@
*/
import type { AuthInfo, McpHandlerRequestOptions } from '@modelcontextprotocol/server';

/** Same limit the Streamable HTTP transport applies to request bodies. */
const MAX_REQUEST_BODY_SIZE = 4 * 1024 * 1024;

class RequestBodyTooLargeError extends Error {

Check warning on line 34 in packages/middleware/node/src/toNodeHandler.ts

View check run for this annotation

Claude / Claude Code Review

nit: public `toWebRequest` now rejects mid-read with `RequestBodyTooLargeError`, but the class is not exported from @ modelcontextprotocol/node, so hand-wired callers (whom the changeset explicitly tells to 'handle the rejection') cannot discriminate the

nit: public `toWebRequest` now rejects mid-read with `RequestBodyTooLargeError`, but the class is not exported from @ modelcontextprotocol/node, so hand-wired callers (whom the changeset explicitly tells to 'handle the rejection') cannot discriminate the over-limit case from other adapter failures except by fragile message-string matching
Comment thread
claude[bot] marked this conversation as resolved.
constructor() {
super(`Payload Too Large: Request body must not exceed ${MAX_REQUEST_BODY_SIZE} bytes`);
}
}

/**
* Minimal duck-typed shape of a Node.js `IncomingMessage` accepted by
* {@linkcode toNodeHandler}. Kept structural so the adapter stays free of
Expand Down Expand Up @@ -121,12 +130,22 @@
...(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> = {};
Expand Down Expand Up @@ -203,10 +222,11 @@
* 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 4 MiB — a
* longer body rejects) — 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 method = (req.method ?? 'GET').toUpperCase();
Expand Down Expand Up @@ -237,9 +257,17 @@
let body: string | undefined;
if (method !== 'GET' && method !== 'HEAD') {
if (parsedBody === undefined) {
if (Number(singleHeaderValue(req.headers['content-length'])) > MAX_REQUEST_BODY_SIZE) {
throw new RequestBodyTooLargeError();
}
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 > MAX_REQUEST_BODY_SIZE) {
throw new RequestBodyTooLargeError();
}

Check failure on line 270 in packages/middleware/node/src/toNodeHandler.ts

View check run for this annotation

Claude / Claude Code Review

toWebRequest throws RequestBodyTooLargeError from inside `for await (const chunk of req)`; early exit from a for-await over a Node Readable invokes the iterator's return(), which destroys the stream, and destroying an http.IncomingMessage whose body is no

toWebRequest throws RequestBodyTooLargeError from inside `for await (const chunk of req)`; early exit from a for-await over a Node Readable invokes the iterator's return(), which destroys the stream, and destroying an http.IncomingMessage whose body is not fully read destroys the underlying socket. The 413 + connection:close response that toNodeHandler's catch (lines 133-140) builds for exactly this error is then written to a dead socket and never reaches the client. The new test masks this by f
Comment on lines 300 to +304

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 toWebRequest throws RequestBodyTooLargeError from inside for await (const chunk of req); early exit from a for-await over a Node Readable invokes the iterator's return(), which destroys the stream, and destroying an http.IncomingMessage whose body is not fully read destroys the underlying socket. The 413 + connection:close response that toNodeHandler's catch (lines 133-140) builds for exactly this error is then written to a dead socket and never reaches the client. The new test masks this by feeding Readable.from(chunks) and a mock res instead of a real HTTP request/response pair. Fix: stop reading without destroying (e.g. collect via 'data' events and pause/unpipe on limit, as raw-body does) so the 413 can be written before the connection closes.

Extended reasoning...

A client sends a >4 MiB POST with Transfer-Encoding: chunked (no Content-Length — e.g. a streaming HTTP client or an intermediary that strips Content-Length) to the documented Node mounting app.all('/mcp', toNodeHandler(handler)) or plain node:http. Once 4 MiB+1 bytes arrive, the throw at line 269 aborts the for-await, Node destroys req and its socket (IncomingMessage._destroy destroys the socket for an incomplete request), and the res.writeHead(413)/res.end() in the catch writes into the destroyed socket. The client observes ECONNRESET / empty reply instead of the documented 413 JSON-RPC error ({code:-32000, 'Payload Too Large...'}), so it cannot distinguish payload-too-large from a server crash; the changeset's and PR's promised 413 behavior is never delivered on the chunked path (the Content-Length pre-check path at line 260 works because it throws before any read starts).

Verification: normal — the mid-stream throw destroys the real request socket before the 413 is written, so the response the catch block builds never reaches the client on the chunked path. Mechanics, from /home/claude/typescript-sdk/packages/middleware/node/src/toNodeHandler.ts: - Lines 266-270: `for await (const chunk of req) { ... if (received > MAX_REQUEST_BODY_SIZE) { throw new RequestBodyTooLargeError(); }

collected += typeof chunk === 'string' ? chunk : decoder.decode(chunk as Uint8Array, { stream: true });
}
collected += decoder.decode();
Expand Down
31 changes: 31 additions & 0 deletions packages/middleware/node/test/toNodeHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,37 @@ 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();
});
});

/* ------------------------------------------------------------------------ *
Expand Down
15 changes: 13 additions & 2 deletions packages/server/src/server/createMcpHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 { readRequestBody, REQUEST_BODY_TOO_LARGE_MESSAGE } from './requestBody';
import type { Server } from './server';
import { installModernOnlyHandlers, seedClientIdentityFromEnvelope, serverIdentityOf } from './server';
import type { ServerEventBus, ServerNotifier } from './serverEventBus';
Expand Down Expand Up @@ -430,6 +431,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. */
Expand Down Expand Up @@ -464,7 +467,11 @@ async function classifyEntryRequest(request: Request, providedParsedBody?: unkno
}
let bodyText: string;
try {
bodyText = await request.text();
const read = await readRequestBody(request);
if (read.tooLarge) {
return { step: 'body-too-large' };
}
bodyText = read.text;
} catch {
return { step: 'unreadable-body' };
}
Expand Down Expand Up @@ -553,7 +560,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
Expand Down Expand Up @@ -871,6 +879,9 @@ export function createMcpHandler(factory: McpServerFactory, options: CreateMcpHa
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, REQUEST_BODY_TOO_LARGE_MESSAGE);
}
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
Expand Down
Loading
Loading