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:
parent
3924de99df
commit
7b781ed4e2
2 changed files with 651 additions and 53 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue