Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
6 changes: 6 additions & 0 deletions .changeset/streamable-http-max-body-bytes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@modelcontextprotocol/server': patch
---

Add a `maxBodyBytes` option to `WebStandardStreamableHTTPServerTransport` and enforce it while parsing incoming JSON request bodies.

101 changes: 99 additions & 2 deletions packages/server/src/server/streamableHttp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down Expand Up @@ -107,6 +119,19 @@ 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).
*
* 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;

/**
* Event store for resumability support
* If provided, resumability will be enabled, allowing clients to reconnect and resume messages
Expand Down Expand Up @@ -222,6 +247,7 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
private _requestResponseMap: Map<RequestId, JSONRPCMessage> = 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<void>;
Expand All @@ -240,6 +266,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;
Expand Down Expand Up @@ -298,6 +326,72 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
);
}

private async parseJsonRequestBody(req: Request): Promise<unknown> {
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;

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.
}
throw new PayloadTooLargeError(maxBodyBytes);
}

chunks.push(value);
}
} finally {
try {
reader.releaseLock();
} catch {
// Ignore.
}
}

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.
Expand Down Expand Up @@ -626,8 +720,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 {
Expand Down
27 changes: 27 additions & 0 deletions packages/server/test/server/streamableHttp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
Loading