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

Co-authored-by: Felix Weinberger <fweinberger@anthropic.com>
This commit is contained in:
Max 2026-08-25 14:23:40 +01:00 • committed by GitHub
commit 7b781ed4e2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 651 additions and 53 deletions

View file

@ -538,6 +538,44 @@ describe('createMcpHandler — stateless legacy fallback (the default)', () => {
expect(body.error.code).toBe(-32_700);
});
it('answers 413 for a request body over the size limit before creating a server', async () => {
const { factory, state } = testFactory();
const handler = createMcpHandler(factory);
const streamed = postRequest('x'.repeat(4 * 1024 * 1024 + 1));
const declared = postRequest('{}', { 'Content-Length': String(4 * 1024 * 1024 + 1) });
for (const request of [streamed, declared]) {
const response = await handler.fetch(request);
expect(response.status).toBe(413);
expect(((await response.json()) as JSONRPCErrorBody).error.code).toBe(-32_000);
}
expect(state.contexts).toHaveLength(0);
});
it('maxRequestBodySize moves the bound for the entry, its stateless legacy leg, and isLegacyRequest', async () => {
const { factory, state } = testFactory();
const paddedPing = { jsonrpc: '2.0', id: 1, method: 'ping', params: { pad: 'x'.repeat(5 * 1024 * 1024) } };
const roomy = createMcpHandler(factory, { maxRequestBodySize: 8 * 1024 * 1024 });
const served = await roomy.fetch(postRequest(paddedPing));
expect(served.status).toBe(200);
expect(await served.text()).toContain('"result":{}');
expect(state.contexts).toHaveLength(1);
const strict = createMcpHandler(factory, { maxRequestBodySize: 1024 });
const refused = await strict.fetch(postRequest('x'.repeat(1025)));
expect(refused.status).toBe(413);
expect(((await refused.json()) as JSONRPCErrorBody).error.message).toMatch(/must not exceed 1024 bytes/);
expect(state.contexts).toHaveLength(1);
expect(await isLegacyRequest(postRequest(paddedPing))).toBe(false);
expect(await isLegacyRequest(postRequest(paddedPing), undefined, { maxRequestBodySize: 8 * 1024 * 1024 })).toBe(true);
for (const invalid of [0, -1, Number.NaN]) {
expect(() => createMcpHandler(factory, { maxRequestBodySize: invalid })).toThrow(RangeError);
}
});
it('still serves the modern path on the same endpoint (one factory, both legs)', async () => {
const { factory, state } = testFactory();
const handler = createMcpHandler(factory);

View file

@ -1547,3 +1547,92 @@ describe('WebStandardStreamableHTTPServerTransport SSE keep-alive', () => {
expect(vi.getTimerCount()).toBe(0);
});
});
describe('WebStandardStreamableHTTPServerTransport request body limits', () => {
let transport: WebStandardStreamableHTTPServerTransport;
beforeEach(() => {
transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined });
});
/** A POST whose body yields up to `chunks` 1 MiB chunks on demand, counting pulls. */
function streamedPost(chunks: number, headers: Record<string, string> = {}): { request: Request; 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(32));
}
},
{ highWaterMark: 0 }
);
const request = new Request('http://localhost/mcp', {
method: 'POST',
headers: { Accept: 'application/json, text/event-stream', 'Content-Type': 'application/json', ...headers },
body,
duplex: 'half'
});
return { request, pulls: () => pulled };
}
it('answers 413 when Content-Length exceeds the body size limit without reading the body', async () => {
const { request, pulls } = streamedPost(1, { 'Content-Length': String(4 * 1024 * 1024 + 1) });
const response = await transport.handleRequest(request);
expect(response.status).toBe(413);
expectErrorResponse(await response.json(), -32_000, /Payload Too Large/);
expect(pulls()).toBe(0);
});
it('answers 413 once a streamed body without Content-Length exceeds the limit', async () => {
const { request, pulls } = streamedPost(8);
const response = await transport.handleRequest(request);
expect(response.status).toBe(413);
expect(pulls()).toBeLessThan(8);
});
it('maxRequestBodySize sets the bound on both read paths and is validated at construction', async () => {
const strict = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined, maxRequestBodySize: 1024 });
const declared = await strict.handleRequest(streamedPost(1, { 'Content-Length': '1025' }).request);
expect(declared.status).toBe(413);
expectErrorResponse(await declared.json(), -32_000, /must not exceed 1024 bytes/);
const streamed = streamedPost(2);
const overLimit = await strict.handleRequest(streamed.request);
expect(overLimit.status).toBe(413);
expect(streamed.pulls()).toBe(1);
const roomy = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined, maxRequestBodySize: 8 * 1024 * 1024 });
const onmessage = vi.fn();
roomy.onmessage = onmessage;
const padded: JSONRPCMessage = {
jsonrpc: '2.0',
method: 'notifications/initialized',
params: { pad: 'x'.repeat(5 * 1024 * 1024) }
};
const accepted = await roomy.handleRequest(createRequest('POST', padded));
expect(accepted.status).toBe(202);
expect(onmessage).toHaveBeenCalledTimes(1);
for (const invalid of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) {
expect(
() => new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined, maxRequestBodySize: invalid })
).toThrow(RangeError);
}
});
it('answers 400 for a JSON-RPC batch longer than 100 messages and dispatches none of it', async () => {
const batch = Array.from({ length: 101 }, (_, i): JSONRPCMessage => ({ jsonrpc: '2.0', method: 'ping', id: i }));
const onmessage = vi.fn();
transport.onmessage = onmessage;
const read = await transport.handleRequest(createRequest('POST', batch));
const preParsed = await transport.handleRequest(createRequest('POST', batch), { parsedBody: batch });
for (const response of [read, preParsed]) {
expect(response.status).toBe(400);
expectErrorResponse(await response.json(), -32_600, /Batch must not exceed 100 messages/);
}
expect(onmessage).not.toHaveBeenCalled();
});
});