Skip to content

fix(server): read HTTP request bodies with a size limit and bound JSON-RPC batch length - #2698

Open
maxisbey wants to merge 3 commits into
mainfrom
streamable-http-body-limits
Open

fix(server): read HTTP request bodies with a size limit and bound JSON-RPC batch length#2698
maxisbey wants to merge 3 commits into
mainfrom
streamable-http-body-limits

Conversation

@maxisbey

Copy link
Copy Markdown
Contributor

The Streamable HTTP transport, the createMcpHandler entry, the Node adapter's toWebRequest, and the Hono app's JSON pre-parse each read the whole POST body into memory before doing anything with it, and the transport accepts a JSON-RPC batch array of any length. This reads bodies with a 4 MiB limit at all four (answering 413 past it) and caps batch arrays at 100 messages (400).

Motivation and Context

Every other body read in the SDK already has a ceiling: createMcpExpressApp goes through express.json() (100 kb default), the Fastify adapter through Fastify's 1 MiB bodyLimit, the legacy SSE transport reads at '4mb', and stdio framing stops at STDIO_DEFAULT_MAX_BUFFER_SIZE. The web-standard transport and the 2.x entry points had none, so how much a single request could make the server buffer depended on which adapter it arrived through. They now use the SSE transport's 4 MiB.

  • WebStandardStreamableHTTPServerTransport (and the Node transport wrapping it) reads through a small private helper: a declared Content-Length over the limit is refused without reading; otherwise reading stops once 4 MiB is crossed, so chunked bodies are covered too. Unchanged when parsedBody is supplied.
  • The transport rejects a batch array longer than 100 with 400 / -32600 before parsing any element (read path and parsedBody). Batching left the protocol in 2025-06-18; 100 leaves older clients plenty of room.
  • createMcpHandler uses the same helper, so an over-limit body is answered before a server instance is created; isLegacyRequest returns false for it.
  • toWebRequest applies the same limit to what it buffers from the Node stream and rejects past it; toNodeHandler answers that with 413 and Connection: close instead of its generic 500.
  • createMcpHonoApp now validates Host/Origin before its JSON pre-parse, and the pre-parse does the same bounded read (its own copy of the ~20-line reader, since nothing new is exported; hono/body-limit re-wraps c.req.raw, which fails under @hono/node-server with overrideGlobalObjects: false).

Requests under the limits behave exactly as before. A host that needs to accept larger bodies can already parse the body itself and pass parsedBody; the SDK then reads nothing and applies no size limit (the batch bound still applies).

Making the limit configurable (deliberately not in this PR)

The limits are private constants for now. If a knob is wanted, the shape I'd suggest:

new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator, maxRequestBodySize: 16 * 1024 * 1024 });
createMcpHandler(buildServer, { maxRequestBodySize: 16 * 1024 * 1024 });
createMcpHonoApp({ maxRequestBodySize: 16 * 1024 * 1024 });
createMcpExpressApp({ jsonLimit: '16mb' }); // already exists, unchanged
  • One optional maxRequestBodySize?: number (bytes, default 4 MiB, same JSDoc everywhere) on WebStandardStreamableHTTPServerTransportOptions (the Node transport shares it), CreateMcpHandlerOptions (forwarded to its stateless fallback the way keepAliveMs is), and CreateMcpHonoAppOptions (the counterpart of Express's jsonLimit). Plus an exported DEFAULT_MAX_REQUEST_BODY_SIZE, mirroring STDIO_DEFAULT_MAX_BUFFER_SIZE. Name follows stdio's maxBufferSize; reject 0/negative/NaN at construction.
  • Rather than a second knob on ToNodeHandlerOptions that users would have to raise in step, have toWebRequest pass the Node stream through as the Request body instead of buffering it to a string (what @hono/node-server already does for the sibling transport). The adapter then reads nothing and the handler's limit is the only one on that path.
  • Optionally export the reader helper for adapter authors, the way isJsonContentType was in fix(server): validate Content-Type by parsed media type instead of substring match #2441, so the hono package can drop its copy.
  • No option for the batch bound.

Purely additive; roughly +50 lines of src.

How Has This Been Tested?

  • Eight new tests next to the existing ones (streamableHttp.test.ts, createMcpHandler.test.ts, toNodeHandler.test.ts, hono.test.ts), each failing on main first: declared and streamed over-limit bodies get 413 without the stream being pulled further; a 101-message batch gets 400 with nothing dispatched (read path and parsedBody); the 413 arrives before the server factory / handler.fetch is called; the Hono app rejects a disallowed Host without reading the body.
  • Also exercised over loopback sockets on each entry (including Hono via @hono/node-server with and without overrideGlobalObjects): exactly 4194304 bytes served, 4194305 refused, under both Content-Length and chunked framing; batch of 100 served, 101 refused; memory flat while a client keeps streaming after the 413.
  • pnpm check:all, every package's unit suite, test-e2e, and the server/client conformance runs pass locally.

Breaking Changes

No API changes: no new exports, options, or signatures. Behavioural differences, all for requests no SDK client sends:

  • POST bodies over 4 MiB get 413 from the transports (when they read the body themselves), createMcpHandler, toNodeHandler, and createMcpHonoApp's JSON pre-parse; toWebRequest rejects instead of resolving, so hand-wired callers should catch that or pass a pre-parsed body.
  • Batch arrays over 100 entries get 400, including arrays passed as parsedBody (so through the Express and Fastify adapters too).
  • createMcpHonoApp: a disallowed Host/Origin with an invalid JSON body is now 403 rather than 400, since validation runs first.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

AI Disclaimer

…N-RPC batch length

The Streamable HTTP transport, createMcpHandler, the Node adapter's
toWebRequest and the Hono app's JSON pre-parse each read the whole POST
body before doing anything with it. Read it with a 4 MiB limit instead
(the value the legacy SSE transport uses; the Express adapter and stdio
already bound their reads) and answer 413 past that, and cap JSON-RPC
batch arrays at 100 messages. The Node adapter answers its 413 with
`connection: close` so the partly read socket is not kept alive.
createMcpHonoApp now runs its Host/Origin validation before the JSON
pre-parse.
@changeset-bot

changeset-bot Bot commented Aug 21, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 0fbc28c

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 9 packages
Name Type
@modelcontextprotocol/server Patch
@modelcontextprotocol/node Patch
@modelcontextprotocol/hono Patch
@modelcontextprotocol/express Patch
@modelcontextprotocol/core Patch
@modelcontextprotocol/client Patch
@modelcontextprotocol/server-legacy Patch
@modelcontextprotocol/codemod Patch
@modelcontextprotocol/core-internal Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new

pkg-pr-new Bot commented Aug 21, 2026

Copy link
Copy Markdown

Open in StackBlitz

@modelcontextprotocol/client

npm i https://pkg.pr.new/@modelcontextprotocol/client@2698

@modelcontextprotocol/codemod

npm i https://pkg.pr.new/@modelcontextprotocol/codemod@2698

@modelcontextprotocol/core

npm i https://pkg.pr.new/@modelcontextprotocol/core@2698

@modelcontextprotocol/server

npm i https://pkg.pr.new/@modelcontextprotocol/server@2698

@modelcontextprotocol/server-legacy

npm i https://pkg.pr.new/@modelcontextprotocol/server-legacy@2698

@modelcontextprotocol/express

npm i https://pkg.pr.new/@modelcontextprotocol/express@2698

@modelcontextprotocol/fastify

npm i https://pkg.pr.new/@modelcontextprotocol/fastify@2698

@modelcontextprotocol/hono

npm i https://pkg.pr.new/@modelcontextprotocol/hono@2698

@modelcontextprotocol/node

npm i https://pkg.pr.new/@modelcontextprotocol/node@2698

commit: 0fbc28c

@felixweinberger
felixweinberger marked this pull request as ready for review August 21, 2026 13:01
@felixweinberger
felixweinberger requested a review from a team as a code owner August 21, 2026 13:01
Comment thread packages/server/src/server/requestBody.ts Outdated
Comment on lines 266 to +270
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();
}

Copy link
Copy Markdown

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

Comment thread packages/middleware/node/src/toNodeHandler.ts
Comment thread packages/middleware/hono/src/hono.ts Outdated
…tBodySize

Add a maxRequestBodySize option (bytes, default 4 MiB) to
WebStandardStreamableHTTPServerTransport, createMcpHandler (forwarded to its
stateless legacy leg; isLegacyRequest and legacyStatelessFallback take the same
option), createMcpHonoApp, and toNodeHandler/toWebRequest, so hosts that accept
larger JSON-RPC bodies can raise the bound instead of pre-parsing the body
themselves. Invalid values (0, negative, NaN, non-finite) throw a RangeError at
construction. DEFAULT_MAX_REQUEST_BODY_SIZE and the bounded readRequestBody are
exported from @modelcontextprotocol/server; the Hono adapter uses them instead
of its private copy and no longer keeps the decoded body text alive across
next().

The rejection toWebRequest produces for an over-limit body now carries
name 'RequestBodyTooLargeError' and status 413, so hand-wired callers can
recognise it without matching message text.

createMcpExpressApp runs its Host/Origin validation before express.json(), the
ordering createMcpHonoApp already uses, so a request from a disallowed origin is
answered 403 without its body being read.

Changeset bumped to minor for server/node/hono (new option), patch for express.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Beyond the inline findings on this push, two other candidates were examined and ruled out: the 400/-32700 "Parse error" mapping in handlePostRequest's catch around the new bounded body read predates this PR (the rewrite preserves existing behavior rather than introducing the miscoding), and the 100-message batch cap remaining non-configurable while the byte limit gained maxRequestBodySize matches the PR's stated scope (batching left the protocol in 2025-06-18), so it was not flagged.

Extended reasoning...

This is a findings-present run on a previously reviewed PR: three new nits are posted inline (unvalidated maxBytes in the newly public readRequestBody, missing docs/ guide updates for the new 413/400 behavior and maxRequestBodySize option, and the option-validation logic copy-pasted across three packages). The note above records only what was newly examined and ruled out this run — the pre-existing -32700 parse-error mapping in packages/server/src/server/streamableHttp.ts and the deliberate non-configurability of MAX_BATCH_SIZE — without restating the inline findings or the prior review's comments. The new commit making the limit configurable responds to the earlier configurability finding; remaining prior comments (e.g. the throw-from-for-await read path in toWebRequest, which the new commit keeps) were already posted and are not repeated.

Comment on lines +33 to +37
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) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 nit: the newly exported public readRequestBody never validates maxBytes, so a NaN (or Infinity) bound silently disables the size limit instead of failing fast. Both guards — Number(request.headers.get('content-length')) > maxBytes (line 37) and received > maxBytes (line 54) — evaluate false for NaN/Infinity, so the loop reads the entire body unbounded. Every sibling entry point that accepts the same knob (WebStandardStreamableHTTPServerTransport ctor, createMcpHandler, legacyStatelessFallback, isLegacyRequest, createMcpHonoApp, toNodeHandler/toWebRequest) throws RangeError for non-finite values via resolveMaxRequestBodySize, but that resolver is deliberately not applied inside readRequestBody itself, and it is not exported — yet readRequestBody IS exported from…

Extended reasoning...

An adapter author follows the index.ts guidance and calls the public readRequestBody(request, maxBytes) with a config-derived bound, e.g. Number(process.env.MAX_BODY) when the env var is unset or malformed, yielding NaN. Unlike every other maxRequestBodySize entry point in this PR (which throws RangeError at construction for the same input), readRequestBody raises no error and applies no bound: the Content-Length fast path and the streamed received > maxBytes check are both false against NaN, so a multi-gigabyte POST body is buffered into the text string in full — the exact memory-exhaustion DoS this new API exists to prevent — while the author believes a limit is in force.

Verification: nit — the described behavior is real in the code. readRequestBody (/home/claude/typescript-sdk/packages/server/src/server/requestBody.ts:33-63) performs no validation of maxBytes: with maxBytes = NaN, both guards — line 37 if (Number(request.headers.get('content-length')) > maxBytes) and line 54 if (received > maxBytes) — evaluate false (any comparison against NaN is false), so the loop

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 nit: new user-facing behavior (default 4 MiB body cap answering 413, 100-message batch cap answering 400) and the new maxRequestBodySize option on four public surfaces are documented only in this changeset and JSDoc — no docs/ guide page is updated. REVIEW.md's checklist explicitly requires prose documentation (not just JSDoc) for new features and a check that existing docs don't omit new behavior; the serving guides are exactly where the sibling knobs already live: docs/serving/legacy-clients.md:96 tells users to raise jsonLimit to '4mb' because "the SSE transport itself accepts messages up to 4mb", and docs/serving/express.md… [also at: packages/server/src/index.ts:78 - nit: new public feature ships with zero prose documentation under docs/ — the maxRequestBodySize option (added to…]

Extended reasoning...

An operator whose MCP server accepts large tool arguments (e.g. base64 images) follows docs/serving/express.md or docs/serving/http.md to deploy after upgrading; clients start receiving 413 for >4 MiB POSTs. The guides they were told to follow never mention the limit, the maxRequestBodySize option, or that on the toNodeHandler path both the adapter's and the handler's bound must be raised in step, so they either misdiagnose the 413 as a proxy problem or raise only CreateMcpHandlerOptions.maxRequestBodySize and still get 413 from the adapter's default bound. The only discoverable documentation is a CHANGELOG entry and per-symbol JSDoc.

Verification: nit. The omission is real. The diff stat shows no docs/** file is touched (13 changed files, all under .changeset/ and packages/), yet the change introduces user-facing behavior and a new public option on four surfaces: .changeset/request-body-size-limit.md states "now stops at 4 MiB by default ... and answers 413 Payload Too Large", "JSON-RPC batch arrays are limited to 100 messages;

Comment on lines +42 to +51
}

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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 nit: maxRequestBodySize validation logic copy-pasted across three packages instead of exporting the canonical helper sweep:maxRequestBodySize must be a positive number of bytes — packages/server/src/server/requestBody.ts exports resolveMaxRequestBodySize internally, but @ modelcontextprotocol/node re-implements the whole function (toNodeHandler.ts:44-51) and @ modelcontextprotocol/hono inlines the same check (hono.ts:80-83); export resolveMaxRequestBodySize from @ modelcontextprotocol/server (alongside the already-exported readRequestBody/DEFAULT_MAX_REQUEST_BODY_SIZE) and delete the copies.

Extended reasoning...

The three copies drift: e.g. the server later tightens resolveMaxRequestBodySize (say, rejecting non-integers or changing the error type/message that tests and callers match on) while the node and hono copies keep the old behavior, so createMcpHonoApp/toNodeHandler accept a value the paired WebStandardStreamableHTTPServerTransport rejects with RangeError at construction — an app that passes the same option to adapter and transport throws from one layer but not the other, and the adapter's documented validation contract silently diverges from the SDK's.

Verification: nit — the duplication is real and verbatim. The canonical helper lives in /home/claude/typescript-sdk/packages/server/src/server/requestBody.ts:17-25 (export function resolveMaxRequestBodySize(...) throwing RangeError('maxRequestBodySize must be a positive number of bytes, got ...')), but it is deliberately kept off the public surface — packages/server/src/index.ts:78 exports only `DEFAULT_MAX

A minor on @modelcontextprotocol/server would bump every adapter package
that peer-depends on it to a new major under the current changesets
config; additive options have shipped as patch here before (stdio
maxBufferSize, isJsonContentType).
Comment on lines +779 to +783
if (body.tooLarge) {
const message = requestBodyTooLargeMessage(this._maxRequestBodySize);
this.onerror?.(new Error(message));
return this.createJsonErrorResponse(413, -32_000, message);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 nit: the new 413 path in handlePost calls this.onerror inside the same try whose catch hard-codes 400/-32700 'Parse error: Invalid JSON', so a throwing user onerror callback converts the documented 413 Payload Too Large answer into a misleading 400 Parse error; the sibling adapter (toNodeHandler.ts) wraps its onerror call in try/catch with the comment 'Reporting must never alter the response', but this new site does not.

Extended reasoning...

An operator sets transport.onerror to a logger that can throw (e.g. a reporting call that rejects/throws synchronously). A client posts a >4 MiB body: readRequestBody returns tooLarge, onerror(new Error('Payload Too Large...')) throws, the surrounding catch fires, onerror is invoked a second time with the secondary error, and the client receives 400 -32700 'Parse error: Invalid JSON' instead of the 413/-32000 the changeset and JSDoc promise — the client mis-diagnoses its request as malformed JSON rather than oversized.

Verification: nit: Real but edge-case. streamableHttp.ts lines 777-788 (added by this diff): this.onerror?.(new Error(message)); at line 781 sits inside the inner try whose catch (785-787) returns this.createJsonErrorResponse(400, -32_700, 'Parse error: Invalid JSON') — a throwing user onerror callback aborts before the return ...413... at line 782 and the client receives 400/-32700 instead of the documen

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants