From dfc3c7358d0796ea64058cba82e5a35db6cf7e33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Theodor=20N=2E=20Eng=C3=B8y?= Date: Sat, 7 Feb 2026 19:06:42 +0100 Subject: [PATCH 1/2] server: enforce maxBodyBytes when parsing JSON --- .changeset/streamable-http-max-body-bytes.md | 6 ++ packages/server/src/server/streamableHttp.ts | 90 ++++++++++++++++++- .../server/test/server/streamableHttp.test.ts | 27 ++++++ 3 files changed, 121 insertions(+), 2 deletions(-) create mode 100644 .changeset/streamable-http-max-body-bytes.md diff --git a/.changeset/streamable-http-max-body-bytes.md b/.changeset/streamable-http-max-body-bytes.md new file mode 100644 index 000000000..5be86d7dc --- /dev/null +++ b/.changeset/streamable-http-max-body-bytes.md @@ -0,0 +1,6 @@ +--- +'@modelcontextprotocol/server': patch +--- + +Add a `maxBodyBytes` option to `WebStandardStreamableHTTPServerTransport` and enforce it while parsing incoming JSON request bodies. + diff --git a/packages/server/src/server/streamableHttp.ts b/packages/server/src/server/streamableHttp.ts index 252455846..c824ad311 100644 --- a/packages/server/src/server/streamableHttp.ts +++ b/packages/server/src/server/streamableHttp.ts @@ -21,6 +21,18 @@ import { export type StreamId = string; export type EventId = string; +const DEFAULT_MAX_BODY_BYTES = 1_000_000; + +class PayloadTooLargeError extends Error { + readonly maxBodyBytes: number; + + constructor(maxBodyBytes: number) { + super('Payload too large'); + this.name = 'PayloadTooLargeError'; + this.maxBodyBytes = maxBodyBytes; + } +} + /** * Interface for resumability support via event storage */ @@ -107,6 +119,16 @@ export interface WebStandardStreamableHTTPServerTransportOptions { */ enableJsonResponse?: boolean; + /** + * Maximum size in bytes that this transport will read when parsing an `application/json` request body. + * This is a basic DoS guard for servers that call `transport.handleRequest(req)` without an upstream body-size limit. + * + * Set to `0` (or any non-finite value like `Infinity`) to disable the limit (not recommended). + * + * @default 1_000_000 + */ + maxBodyBytes?: number; + /** * Event store for resumability support * If provided, resumability will be enabled, allowing clients to reconnect and resume messages @@ -222,6 +244,7 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { private _requestResponseMap: Map = new Map(); private _initialized: boolean = false; private _enableJsonResponse: boolean = false; + private _maxBodyBytes?: number; private _standaloneSseStreamId: string = '_GET_stream'; private _eventStore?: EventStore; private _onsessioninitialized?: (sessionId: string) => void | Promise; @@ -240,6 +263,8 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { constructor(options: WebStandardStreamableHTTPServerTransportOptions = {}) { this.sessionIdGenerator = options.sessionIdGenerator; this._enableJsonResponse = options.enableJsonResponse ?? false; + const maxBodyBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES; + this._maxBodyBytes = Number.isFinite(maxBodyBytes) && maxBodyBytes > 0 ? maxBodyBytes : undefined; this._eventStore = options.eventStore; this._onsessioninitialized = options.onsessioninitialized; this._onsessionclosed = options.onsessionclosed; @@ -298,6 +323,64 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { ); } + private async parseJsonRequestBody(req: Request): Promise { + if (this._maxBodyBytes === undefined) { + return req.json(); + } + + const maxBodyBytes = this._maxBodyBytes; + + // Quick reject when content-length is present and exceeds the limit. + const contentLengthHeader = req.headers.get('content-length'); + if (contentLengthHeader) { + const contentLength = Number(contentLengthHeader); + if (Number.isFinite(contentLength) && contentLength > maxBodyBytes) { + throw new PayloadTooLargeError(maxBodyBytes); + } + } + + const reader = req.body?.getReader(); + if (!reader) { + // Fall back to the platform JSON parsing if the body stream is unavailable. + return req.json(); + } + + const chunks: Uint8Array[] = []; + let totalBytes = 0; + + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + if (!value) { + continue; + } + + totalBytes += value.byteLength; + if (totalBytes > maxBodyBytes) { + try { + await reader.cancel(); + } catch { + // Best-effort. + } + throw new PayloadTooLargeError(maxBodyBytes); + } + + chunks.push(value); + } + + const bodyBytes = new Uint8Array(totalBytes); + let offset = 0; + for (const chunk of chunks) { + bodyBytes.set(chunk, offset); + offset += chunk.byteLength; + } + + const bodyText = new TextDecoder().decode(bodyBytes); + return JSON.parse(bodyText) as unknown; + } + /** * Validates request headers for DNS rebinding protection. * @returns Error response if validation fails, undefined if validation passes. @@ -626,8 +709,11 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { let rawMessage; if (options?.parsedBody === undefined) { try { - rawMessage = await req.json(); - } catch { + rawMessage = await this.parseJsonRequestBody(req); + } catch (error) { + if (error instanceof PayloadTooLargeError) { + return this.createJsonErrorResponse(413, -32_000, 'Payload too large'); + } return this.createJsonErrorResponse(400, -32_700, 'Parse error: Invalid JSON'); } } else { diff --git a/packages/server/test/server/streamableHttp.test.ts b/packages/server/test/server/streamableHttp.test.ts index ab6f22342..f9a8d5433 100644 --- a/packages/server/test/server/streamableHttp.test.ts +++ b/packages/server/test/server/streamableHttp.test.ts @@ -333,6 +333,33 @@ describe('Zod v4', () => { expectErrorResponse(errorData, -32_700, /Parse error.*Invalid JSON/); }); + it('should reject JSON bodies larger than maxBodyBytes', async () => { + const limitedTransport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + maxBodyBytes: 256 + }); + + const bigInit: JSONRPCMessage = { + ...TEST_MESSAGES.initialize, + params: { + ...(TEST_MESSAGES.initialize as any).params, + clientInfo: { + ...(TEST_MESSAGES.initialize as any).params.clientInfo, + name: 'a'.repeat(1024) + } + } + }; + + const request = createRequest('POST', bigInit); + const response = await limitedTransport.handleRequest(request); + + expect(response.status).toBe(413); + const errorData = await response.json(); + expectErrorResponse(errorData, -32_000, /Payload too large/); + + await limitedTransport.close(); + }); + it('should accept notifications without session and return 202', async () => { sessionId = await initializeServer(); From 637b932776a6bc768675c1a07d2b7d682d8f2629 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Theodor=20N=2E=20Eng=C3=B8y?= Date: Sun, 8 Feb 2026 00:32:24 +0100 Subject: [PATCH 2/2] server: document parsedBody + release stream reader lock --- packages/server/src/server/streamableHttp.ts | 45 ++++++++++++-------- 1 file changed, 28 insertions(+), 17 deletions(-) diff --git a/packages/server/src/server/streamableHttp.ts b/packages/server/src/server/streamableHttp.ts index c824ad311..e3d315811 100644 --- a/packages/server/src/server/streamableHttp.ts +++ b/packages/server/src/server/streamableHttp.ts @@ -125,6 +125,9 @@ export interface WebStandardStreamableHTTPServerTransportOptions { * * Set to `0` (or any non-finite value like `Infinity`) to disable the limit (not recommended). * + * Note: if you pass `parsedBody` to `handleRequest`, this limit is not applied + * (your framework/body parser must enforce its own limit). + * * @default 1_000_000 */ maxBodyBytes?: number; @@ -348,26 +351,34 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { const chunks: Uint8Array[] = []; let totalBytes = 0; - while (true) { - const { done, value } = await reader.read(); - if (done) { - break; - } - if (!value) { - continue; - } + try { + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + if (!value) { + continue; + } - totalBytes += value.byteLength; - if (totalBytes > maxBodyBytes) { - try { - await reader.cancel(); - } catch { - // Best-effort. + totalBytes += value.byteLength; + if (totalBytes > maxBodyBytes) { + try { + await reader.cancel(); + } catch { + // Best-effort. + } + throw new PayloadTooLargeError(maxBodyBytes); } - throw new PayloadTooLargeError(maxBodyBytes); - } - chunks.push(value); + chunks.push(value); + } + } finally { + try { + reader.releaseLock(); + } catch { + // Ignore. + } } const bodyBytes = new Uint8Array(totalBytes);