Skip to content

Commit 7ec9f3e

Browse files
Theodor N. EngøyTheodor N. Engøy
authored andcommitted
server: add maxBodyBytes guard for streamable HTTP
1 parent 65bbcea commit 7ec9f3e

3 files changed

Lines changed: 105 additions & 1 deletion

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@modelcontextprotocol/server': patch
3+
---
4+
5+
Add a default `maxBodyBytes` limit for `WebStandardStreamableHTTPServerTransport` to prevent unbounded JSON request body buffering (413 on oversized payloads).
6+

packages/server/src/server/streamableHttp.ts

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,62 @@ import {
2121
export type StreamId = string;
2222
export type EventId = string;
2323

24+
const DEFAULT_MAX_BODY_BYTES = 1_000_000; // 1MB
25+
26+
class PayloadTooLargeError extends Error {
27+
constructor() {
28+
super('payload_too_large');
29+
this.name = 'PayloadTooLargeError';
30+
}
31+
}
32+
33+
async function readRequestTextWithLimit(req: Request, maxBytes: number): Promise<string> {
34+
const body = req.body;
35+
if (!body) return '';
36+
37+
if (Number.isFinite(maxBytes)) {
38+
const clRaw = req.headers.get('content-length') ?? '';
39+
const cl = Number(clRaw);
40+
if (Number.isFinite(cl) && cl > maxBytes) {
41+
throw new PayloadTooLargeError();
42+
}
43+
}
44+
45+
const reader = body.getReader();
46+
const chunks: Uint8Array[] = [];
47+
let total = 0;
48+
49+
try {
50+
while (true) {
51+
const { value, done } = await reader.read();
52+
if (done) break;
53+
if (!value) continue;
54+
55+
total += value.byteLength;
56+
if (Number.isFinite(maxBytes) && total > maxBytes) {
57+
void reader.cancel().catch(() => {});
58+
throw new PayloadTooLargeError();
59+
}
60+
chunks.push(value);
61+
}
62+
} finally {
63+
try {
64+
reader.releaseLock();
65+
} catch {
66+
// Ignore.
67+
}
68+
}
69+
70+
const out = new Uint8Array(total);
71+
let offset = 0;
72+
for (const c of chunks) {
73+
out.set(c, offset);
74+
offset += c.byteLength;
75+
}
76+
77+
return new TextDecoder().decode(out);
78+
}
79+
2480
/**
2581
* Interface for resumability support via event storage
2682
*/
@@ -152,6 +208,19 @@ export interface WebStandardStreamableHTTPServerTransportOptions {
152208
* @default SUPPORTED_PROTOCOL_VERSIONS
153209
*/
154210
supportedProtocolVersions?: string[];
211+
212+
/**
213+
* Maximum JSON request body size in bytes.
214+
* Used when parsing request bodies to guard against unbounded buffering.
215+
*
216+
* Set to a negative number to disable the limit.
217+
*
218+
* Note: if you pass `parsedBody` to `handleRequest`, this limit is not applied
219+
* (your framework/body parser must enforce its own limit).
220+
*
221+
* @default 1_000_000 (1 MB)
222+
*/
223+
maxBodyBytes?: number;
155224
}
156225

157226
/**
@@ -231,6 +300,7 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
231300
private _enableDnsRebindingProtection: boolean;
232301
private _retryInterval?: number;
233302
private _supportedProtocolVersions: string[];
303+
private _maxBodyBytes: number;
234304

235305
sessionId?: string;
236306
onclose?: () => void;
@@ -248,6 +318,7 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
248318
this._enableDnsRebindingProtection = options.enableDnsRebindingProtection ?? false;
249319
this._retryInterval = options.retryInterval;
250320
this._supportedProtocolVersions = options.supportedProtocolVersions ?? SUPPORTED_PROTOCOL_VERSIONS;
321+
this._maxBodyBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;
251322
}
252323

253324
/**
@@ -625,8 +696,18 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
625696

626697
let rawMessage;
627698
if (options?.parsedBody === undefined) {
699+
const effectiveMaxBodyBytes = this._maxBodyBytes < 0 ? Number.POSITIVE_INFINITY : this._maxBodyBytes;
700+
let text: string;
701+
try {
702+
text = await readRequestTextWithLimit(req, effectiveMaxBodyBytes);
703+
} catch (error) {
704+
if (error instanceof PayloadTooLargeError) {
705+
return this.createJsonErrorResponse(413, -32_000, 'Payload too large');
706+
}
707+
return this.createJsonErrorResponse(400, -32_700, 'Parse error: Invalid JSON');
708+
}
628709
try {
629-
rawMessage = await req.json();
710+
rawMessage = JSON.parse(text);
630711
} catch {
631712
return this.createJsonErrorResponse(400, -32_700, 'Parse error: Invalid JSON');
632713
}

packages/server/test/server/streamableHttp.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,23 @@ describe('Zod v4', () => {
209209
});
210210

211211
describe('POST Requests', () => {
212+
it('should return 413 on oversized JSON request bodies', async () => {
213+
const limitedTransport = new WebStandardStreamableHTTPServerTransport({
214+
sessionIdGenerator: () => randomUUID(),
215+
maxBodyBytes: 10
216+
});
217+
await mcpServer.connect(limitedTransport);
218+
219+
const request = createRequest('POST', TEST_MESSAGES.initialize);
220+
const response = await limitedTransport.handleRequest(request);
221+
222+
expect(response.status).toBe(413);
223+
const errorData = await response.json();
224+
expectErrorResponse(errorData, -32_000, /Payload too large/);
225+
226+
await limitedTransport.close();
227+
});
228+
212229
it('should handle post requests via SSE response correctly', async () => {
213230
sessionId = await initializeServer();
214231

0 commit comments

Comments
 (0)