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
13 changed files with 651 additions and 53 deletions
31
.changeset/request-body-size-limit.md
Normal file
31
.changeset/request-body-size-limit.md
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
---
|
||||
'@modelcontextprotocol/server': patch
|
||||
'@modelcontextprotocol/node': patch
|
||||
'@modelcontextprotocol/hono': patch
|
||||
'@modelcontextprotocol/express': patch
|
||||
---
|
||||
|
||||
Read Streamable HTTP request bodies with a size limit. Every SDK-owned body read —
|
||||
`WebStandardStreamableHTTPServerTransport` (and the Node transport built on it),
|
||||
`createMcpHandler`, `toNodeHandler`, and `createMcpHonoApp`'s JSON pre-parse — now stops at
|
||||
4 MiB by default (the limit the legacy SSE transport already uses; the Express adapter and stdio
|
||||
bound their reads too) and answers `413 Payload Too Large` before anything is parsed.
|
||||
`toWebRequest` (when it reads the Node stream itself) now rejects once the body exceeds the
|
||||
limit with an error whose `name` is `'RequestBodyTooLargeError'` and `status` is `413`, and
|
||||
`toNodeHandler` answers that with `413`; hand-wired callers of `toWebRequest` should handle the
|
||||
rejection or pass a pre-parsed body, and `isLegacyRequest` reports such a request as non-legacy
|
||||
so the modern handler answers it. JSON-RPC batch arrays are limited to 100 messages; a longer
|
||||
batch is answered `400` / `-32600` and none of it is dispatched.
|
||||
|
||||
The limit is configurable with a new `maxRequestBodySize` option (bytes, default
|
||||
`DEFAULT_MAX_REQUEST_BODY_SIZE` = 4 MiB, exported from `@modelcontextprotocol/server`) on
|
||||
`WebStandardStreamableHTTPServerTransportOptions`, `CreateMcpHandlerOptions` (forwarded to its
|
||||
stateless legacy leg; `isLegacyRequest` and `legacyStatelessFallback` take the same option),
|
||||
`CreateMcpHonoAppOptions`, and `ToNodeHandlerOptions` / `ToWebRequestOptions` (the adapter's
|
||||
bound applies before the handler's, so raise both). The bounded reader is exported as
|
||||
`readRequestBody` for adapter authors. Hosts that pre-parse the body and pass it as
|
||||
`parsedBody` skip the SDK's read and its size limit entirely; the batch bound applies either way.
|
||||
|
||||
`createMcpHonoApp` and `createMcpExpressApp` now run their Host/Origin validation before the
|
||||
JSON body parser, so a request from a disallowed Host or Origin with an invalid JSON body is
|
||||
answered `403` rather than `400`, and its body is not read.
|
||||
|
|
@ -76,7 +76,6 @@ export function createMcpExpressApp(options: CreateMcpExpressAppOptions = {}): E
|
|||
const { host = '127.0.0.1', allowedHosts, allowedOrigins, jsonLimit } = options;
|
||||
|
||||
const app = express();
|
||||
app.use(express.json(jsonLimit ? { limit: jsonLimit } : undefined));
|
||||
|
||||
// If allowedHosts is explicitly provided, use that for validation
|
||||
if (allowedHosts) {
|
||||
|
|
@ -106,5 +105,9 @@ export function createMcpExpressApp(options: CreateMcpExpressAppOptions = {}): E
|
|||
app.use(localhostOriginValidation());
|
||||
}
|
||||
|
||||
// The JSON body parser runs after the Host/Origin validation, so a request
|
||||
// from a disallowed origin is answered 403 without its body being read.
|
||||
app.use(express.json(jsonLimit ? { limit: jsonLimit } : undefined));
|
||||
|
||||
return app;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { NextFunction, Request, Response } from 'express';
|
||||
import supertest from 'supertest';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
import { createMcpExpressApp } from '../src/express';
|
||||
|
|
@ -107,6 +108,43 @@ describe('@modelcontextprotocol/express', () => {
|
|||
});
|
||||
|
||||
describe('createMcpExpressApp', () => {
|
||||
test('validates Host/Origin before the JSON body parser runs', async () => {
|
||||
const app = createMcpExpressApp();
|
||||
app.post('/mcp', (req: Request, res: Response) => {
|
||||
res.json({ parsed: req.body });
|
||||
});
|
||||
|
||||
const disallowedHost = await supertest(app)
|
||||
.post('/mcp')
|
||||
.set('Host', 'evil.example.com')
|
||||
.set('Content-Type', 'application/json')
|
||||
.send('{not json');
|
||||
expect(disallowedHost.status).toBe(403);
|
||||
|
||||
const disallowedOrigin = await supertest(app)
|
||||
.post('/mcp')
|
||||
.set('Host', 'localhost:3000')
|
||||
.set('Origin', 'http://evil.example.com')
|
||||
.set('Content-Type', 'application/json')
|
||||
.send('{not json');
|
||||
expect(disallowedOrigin.status).toBe(403);
|
||||
|
||||
const invalidJson = await supertest(app)
|
||||
.post('/mcp')
|
||||
.set('Host', 'localhost:3000')
|
||||
.set('Content-Type', 'application/json')
|
||||
.send('{not json');
|
||||
expect(invalidJson.status).toBe(400);
|
||||
|
||||
const allowed = await supertest(app)
|
||||
.post('/mcp')
|
||||
.set('Host', 'localhost:3000')
|
||||
.set('Content-Type', 'application/json')
|
||||
.send({ ok: true });
|
||||
expect(allowed.status).toBe(200);
|
||||
expect(allowed.body).toEqual({ parsed: { ok: true } });
|
||||
});
|
||||
|
||||
test('should enable localhost DNS rebinding protection by default', () => {
|
||||
const app = createMcpExpressApp();
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { isJsonContentType } from '@modelcontextprotocol/server';
|
||||
import { DEFAULT_MAX_REQUEST_BODY_SIZE, isJsonContentType, readRequestBody } from '@modelcontextprotocol/server';
|
||||
import type { Context } from 'hono';
|
||||
import { Hono } from 'hono';
|
||||
|
||||
|
|
@ -36,6 +36,28 @@ export interface CreateMcpHonoAppOptions {
|
|||
* is rejected with `403`.
|
||||
*/
|
||||
allowedOrigins?: string[];
|
||||
|
||||
/**
|
||||
* Upper bound, in bytes, on a JSON request body the app's body-parsing
|
||||
* middleware reads. A larger body is answered `413` before being parsed.
|
||||
* Must be a positive number. The counterpart of `createMcpExpressApp`'s
|
||||
* `jsonLimit`.
|
||||
* @default 4194304 (4 MiB)
|
||||
*/
|
||||
maxRequestBodySize?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads (bounded, via the transport's own reader) and parses a JSON body from a
|
||||
* clone of the request, so the original stream stays readable. Returning only the
|
||||
* parsed value keeps the decoded text from outliving the parse.
|
||||
*/
|
||||
async function parseJsonBody(request: Request, maxBytes: number): Promise<{ tooLarge: true } | { tooLarge: false; value: unknown }> {
|
||||
const body = await readRequestBody(request, maxBytes);
|
||||
if (body.tooLarge) {
|
||||
return { tooLarge: true };
|
||||
}
|
||||
return { tooLarge: false, value: JSON.parse(body.text) };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -47,42 +69,21 @@ export interface CreateMcpHonoAppOptions {
|
|||
*
|
||||
* This also installs a small JSON body parsing middleware (similar to `express.json()`)
|
||||
* that stashes the parsed body into `c.set('parsedBody', ...)` when the `Content-Type`
|
||||
* media type is `application/json`.
|
||||
* media type is `application/json`. It runs after the Host/Origin validation, and JSON
|
||||
* bodies over `maxRequestBodySize` (4 MiB by default) are answered `413` before being parsed.
|
||||
*
|
||||
* @param options - Configuration options
|
||||
* @returns A configured Hono application
|
||||
*/
|
||||
export function createMcpHonoApp(options: CreateMcpHonoAppOptions = {}): Hono {
|
||||
const { host = '127.0.0.1', allowedHosts, allowedOrigins } = options;
|
||||
const maxRequestBodySize = options.maxRequestBodySize ?? DEFAULT_MAX_REQUEST_BODY_SIZE;
|
||||
if (typeof maxRequestBodySize !== 'number' || !Number.isFinite(maxRequestBodySize) || maxRequestBodySize <= 0) {
|
||||
throw new RangeError(`maxRequestBodySize must be a positive number of bytes, got ${String(maxRequestBodySize)}`);
|
||||
}
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
// Similar to `express.json()`: parse JSON bodies and make them available to MCP adapters via `parsedBody`.
|
||||
app.use('*', async (c: Context, next) => {
|
||||
// If an upstream middleware already set parsedBody, keep it.
|
||||
if (c.get('parsedBody') !== undefined) {
|
||||
return await next();
|
||||
}
|
||||
|
||||
// Parsed media type, never a substring match — see isJsonContentType.
|
||||
// A body left unparsed here is answered 415 by the transport's own
|
||||
// Content-Type check downstream.
|
||||
if (!isJsonContentType(c.req.header('content-type'))) {
|
||||
return await next();
|
||||
}
|
||||
|
||||
try {
|
||||
// Parse from a clone so we don't consume the original request stream.
|
||||
const parsed = await c.req.raw.clone().json();
|
||||
c.set('parsedBody', parsed);
|
||||
} catch {
|
||||
// Mirror express.json() behavior loosely: reject invalid JSON.
|
||||
return c.text('Invalid JSON', 400);
|
||||
}
|
||||
|
||||
return await next();
|
||||
});
|
||||
|
||||
// If allowedHosts is explicitly provided, use that for validation.
|
||||
if (allowedHosts) {
|
||||
app.use('*', hostHeaderValidation(allowedHosts));
|
||||
|
|
@ -111,5 +112,42 @@ export function createMcpHonoApp(options: CreateMcpHonoAppOptions = {}): Hono {
|
|||
app.use('*', localhostOriginValidation());
|
||||
}
|
||||
|
||||
// Similar to `express.json()`: parse JSON bodies (up to maxRequestBodySize; larger ones are
|
||||
// answered 413) and make them available to MCP adapters via `parsedBody`.
|
||||
app.use('*', async (c: Context, next) => {
|
||||
// If an upstream middleware already set parsedBody, keep it.
|
||||
if (c.get('parsedBody') !== undefined) {
|
||||
return await next();
|
||||
}
|
||||
|
||||
// Parsed media type, never a substring match — see isJsonContentType.
|
||||
// A body left unparsed here is answered 415 by the transport's own
|
||||
// Content-Type check downstream.
|
||||
if (!isJsonContentType(c.req.header('content-type'))) {
|
||||
return await next();
|
||||
}
|
||||
|
||||
try {
|
||||
// Parse from a clone so we don't consume the original request stream.
|
||||
const parsed = await parseJsonBody(c.req.raw.clone(), maxRequestBodySize);
|
||||
if (parsed.tooLarge) {
|
||||
return c.json(
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
error: { code: -32_000, message: `Payload Too Large: Request body must not exceed ${maxRequestBodySize} bytes` },
|
||||
id: null
|
||||
},
|
||||
413
|
||||
);
|
||||
}
|
||||
c.set('parsedBody', parsed.value);
|
||||
} catch {
|
||||
// Mirror express.json() behavior loosely: reject invalid JSON.
|
||||
return c.text('Invalid JSON', 400);
|
||||
}
|
||||
|
||||
return await next();
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -134,4 +134,89 @@ describe('@modelcontextprotocol/hono', () => {
|
|||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toEqual({ preset: true });
|
||||
});
|
||||
|
||||
/** A body that yields up to `chunks` 1 MiB chunks on demand (no Content-Length), counting pulls. */
|
||||
function streamedBody(chunks: number): { body: ReadableStream<Uint8Array>; 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(0x20));
|
||||
}
|
||||
},
|
||||
{ highWaterMark: 0 }
|
||||
);
|
||||
return { body, pulls: () => pulled };
|
||||
}
|
||||
const jsonHeaders = { Host: 'localhost:3000', 'content-type': 'application/json' };
|
||||
|
||||
test('createMcpHonoApp rejects a disallowed Host before reading the body', async () => {
|
||||
const app = createMcpHonoApp();
|
||||
app.post('/echo', (c: Context) => c.text('ok'));
|
||||
|
||||
const { body, pulls } = streamedBody(1);
|
||||
const init = { method: 'POST', headers: { ...jsonHeaders, Host: 'example.com:3000' }, body, duplex: 'half' };
|
||||
const res = await app.request('http://localhost/echo', init as RequestInit);
|
||||
expect(res.status).toBe(403);
|
||||
expect(pulls()).toBe(0);
|
||||
});
|
||||
|
||||
test('createMcpHonoApp answers 413 for a JSON body over the size limit', async () => {
|
||||
const app = createMcpHonoApp();
|
||||
app.post('/echo', (c: Context) => c.text('ok'));
|
||||
|
||||
const declared = streamedBody(5);
|
||||
const declaredOverLimit = { ...jsonHeaders, 'content-length': String(4 * 1024 * 1024 + 1) };
|
||||
const res = await app.request('http://localhost/echo', {
|
||||
method: 'POST',
|
||||
headers: declaredOverLimit,
|
||||
body: declared.body,
|
||||
duplex: 'half'
|
||||
} as RequestInit);
|
||||
expect(res.status).toBe(413);
|
||||
expect(await res.json()).toEqual({
|
||||
jsonrpc: '2.0',
|
||||
error: { code: -32_000, message: expect.stringMatching(/^Payload Too Large/) },
|
||||
id: null
|
||||
});
|
||||
// clone() tees the body, which buffers one chunk up front; nothing past that is read.
|
||||
expect(declared.pulls()).toBeLessThanOrEqual(1);
|
||||
|
||||
const streamed = await app.request('http://localhost/echo', {
|
||||
method: 'POST',
|
||||
headers: jsonHeaders,
|
||||
body: streamedBody(5).body,
|
||||
duplex: 'half'
|
||||
} as RequestInit);
|
||||
expect(streamed.status).toBe(413);
|
||||
expect(((await streamed.json()) as { error: { code: number } }).error.code).toBe(-32_000);
|
||||
});
|
||||
|
||||
test('createMcpHonoApp maxRequestBodySize moves the pre-parse bound and is validated', async () => {
|
||||
const strict = createMcpHonoApp({ maxRequestBodySize: 1024 });
|
||||
strict.post('/echo', (c: Context) => c.text('ok'));
|
||||
const refused = await strict.request('http://localhost/echo', {
|
||||
method: 'POST',
|
||||
headers: jsonHeaders,
|
||||
body: JSON.stringify({ pad: 'x'.repeat(2048) })
|
||||
});
|
||||
expect(refused.status).toBe(413);
|
||||
expect(((await refused.json()) as { error: { message: string } }).error.message).toMatch(/must not exceed 1024 bytes/);
|
||||
|
||||
const roomy = createMcpHonoApp({ maxRequestBodySize: 8 * 1024 * 1024 });
|
||||
roomy.post('/echo', (c: Context) => c.json({ keys: Object.keys(c.get('parsedBody') as object) }));
|
||||
const served = await roomy.request('http://localhost/echo', {
|
||||
method: 'POST',
|
||||
headers: jsonHeaders,
|
||||
body: JSON.stringify({ pad: 'x'.repeat(5 * 1024 * 1024) })
|
||||
});
|
||||
expect(served.status).toBe(200);
|
||||
expect(await served.json()).toEqual({ keys: ['pad'] });
|
||||
|
||||
expect(() => createMcpHonoApp({ maxRequestBodySize: 0 })).toThrow(RangeError);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -27,6 +27,29 @@
|
|||
* it as the handler's pass-through `authInfo`.
|
||||
*/
|
||||
import type { AuthInfo, McpHandlerRequestOptions } from '@modelcontextprotocol/server';
|
||||
import { DEFAULT_MAX_REQUEST_BODY_SIZE } from '@modelcontextprotocol/server';
|
||||
|
||||
/**
|
||||
* The rejection {@linkcode toWebRequest} produces for a body over the size
|
||||
* limit, recognisable by `name` and `status` without matching message text.
|
||||
*/
|
||||
class RequestBodyTooLargeError extends Error {
|
||||
readonly status = 413;
|
||||
constructor(maxBytes: number) {
|
||||
super(`Payload Too Large: Request body must not exceed ${maxBytes} bytes`);
|
||||
this.name = 'RequestBodyTooLargeError';
|
||||
}
|
||||
}
|
||||
|
||||
function resolveMaxRequestBodySize(value: number | undefined): number {
|
||||
if (value === undefined) {
|
||||
return DEFAULT_MAX_REQUEST_BODY_SIZE;
|
||||
}
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
|
||||
throw new RangeError(`maxRequestBodySize must be a positive number of bytes, got ${String(value)}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal duck-typed shape of a Node.js `IncomingMessage` accepted by
|
||||
|
|
@ -79,6 +102,15 @@ export interface ToNodeHandlerOptions {
|
|||
* `handler.fetch` and surface via the entry's `onerror` option as before.
|
||||
*/
|
||||
onerror?: (error: Error) => void;
|
||||
/**
|
||||
* Upper bound, in bytes, on a request body the adapter buffers from the
|
||||
* Node stream (when no `parsedBody` is supplied). A larger body is answered
|
||||
* `413` without calling `handler.fetch`. Raise it together with the
|
||||
* handler's own `maxRequestBodySize`; the adapter's bound applies first.
|
||||
* Must be a positive number.
|
||||
* @default 4194304 (4 MiB)
|
||||
*/
|
||||
maxRequestBodySize?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -95,6 +127,7 @@ export interface ToNodeHandlerOptions {
|
|||
* conversion / `handler.fetch` throw) before the `500` response is written.
|
||||
*/
|
||||
export function toNodeHandler(handler: FetchLikeMcpHandler, opts?: ToNodeHandlerOptions): NodeMcpRequestHandler {
|
||||
const maxRequestBodySize = resolveMaxRequestBodySize(opts?.maxRequestBodySize);
|
||||
return async (req, res, parsedBody) => {
|
||||
// Express passes (req, res, next) when the handler is mounted as a
|
||||
// middleware function; a function third argument is `next`, not a body.
|
||||
|
|
@ -115,18 +148,28 @@ export function toNodeHandler(handler: FetchLikeMcpHandler, opts?: ToNodeHandler
|
|||
|
||||
let response: Response;
|
||||
try {
|
||||
const request = await toWebRequest(req, parsedBody, { signal: abort.signal });
|
||||
const request = await toWebRequest(req, parsedBody, { signal: abort.signal, maxRequestBodySize });
|
||||
response = await handler.fetch(request, {
|
||||
...(req.auth !== undefined && { authInfo: req.auth }),
|
||||
...(parsedBody !== undefined && { parsedBody })
|
||||
});
|
||||
} catch (error) {
|
||||
try {
|
||||
opts?.onerror?.(error instanceof Error ? error : new Error(String(error)));
|
||||
} catch {
|
||||
// Reporting must never alter the response.
|
||||
if (error instanceof RequestBodyTooLargeError) {
|
||||
// A normal answer, not an adapter failure. `connection: close` has
|
||||
// an HTTP/1.1 server end the socket after answering instead of
|
||||
// idling a connection whose request stream was left partly unread.
|
||||
response = Response.json(
|
||||
{ jsonrpc: '2.0', error: { code: -32_000, message: error.message }, id: null },
|
||||
{ status: 413, headers: { connection: 'close' } }
|
||||
);
|
||||
} else {
|
||||
try {
|
||||
opts?.onerror?.(error instanceof Error ? error : new Error(String(error)));
|
||||
} catch {
|
||||
// Reporting must never alter the response.
|
||||
}
|
||||
response = internalServerErrorResponse(echoableRequestId(parsedBody));
|
||||
}
|
||||
response = internalServerErrorResponse(echoableRequestId(parsedBody));
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
|
|
@ -190,6 +233,14 @@ function singleHeaderValue(value: string | string[] | undefined): string | undef
|
|||
export interface ToWebRequestOptions {
|
||||
/** An `AbortSignal` to attach to the constructed `Request` (`request.signal`). */
|
||||
signal?: AbortSignal;
|
||||
/**
|
||||
* Upper bound, in bytes, on the body read from the Node stream (when no
|
||||
* `parsedBody` is supplied); the returned promise rejects past it with an
|
||||
* `Error` whose `name` is `'RequestBodyTooLargeError'` and `status` is `413`.
|
||||
* Must be a positive number.
|
||||
* @default 4194304 (4 MiB)
|
||||
*/
|
||||
maxRequestBodySize?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -203,12 +254,15 @@ export interface ToWebRequestOptions {
|
|||
* await ((await isLegacyRequest(probe)) ? legacy(req, res) : modern(req, res, req.body));
|
||||
* ```
|
||||
*
|
||||
* With no `parsedBody` the Node stream is read to completion — read the body
|
||||
* from the returned `Request` afterwards, not from `req`. When a body parser
|
||||
* already consumed the stream (`express.json()`), pass the parsed value as
|
||||
* `parsedBody` and nothing is read from `req`.
|
||||
* With no `parsedBody` the Node stream is read to completion (up to
|
||||
* `maxRequestBodySize`, 4 MiB by default — a longer body rejects with an error
|
||||
* carrying `name: 'RequestBodyTooLargeError'` and `status: 413`) — read the
|
||||
* body from the returned `Request` afterwards, not from `req`. When a body
|
||||
* parser already consumed the stream (`express.json()`), pass the parsed value
|
||||
* as `parsedBody` and nothing is read from `req`.
|
||||
*/
|
||||
export async function toWebRequest(req: NodeIncomingMessageLike, parsedBody?: unknown, options?: ToWebRequestOptions): Promise<Request> {
|
||||
const maxRequestBodySize = resolveMaxRequestBodySize(options?.maxRequestBodySize);
|
||||
const method = (req.method ?? 'GET').toUpperCase();
|
||||
// HTTP/2 requests carry their authority as the `:authority` pseudo-header,
|
||||
// usually with no `host` entry at all (mirrors Node's `request.authority`).
|
||||
|
|
@ -237,9 +291,17 @@ export async function toWebRequest(req: NodeIncomingMessageLike, parsedBody?: un
|
|||
let body: string | undefined;
|
||||
if (method !== 'GET' && method !== 'HEAD') {
|
||||
if (parsedBody === undefined) {
|
||||
if (Number(singleHeaderValue(req.headers['content-length'])) > maxRequestBodySize) {
|
||||
throw new RequestBodyTooLargeError(maxRequestBodySize);
|
||||
}
|
||||
const decoder = new TextDecoder();
|
||||
let collected = '';
|
||||
let received = 0;
|
||||
for await (const chunk of req) {
|
||||
received += typeof chunk === 'string' ? new TextEncoder().encode(chunk).byteLength : (chunk as Uint8Array).byteLength;
|
||||
if (received > maxRequestBodySize) {
|
||||
throw new RequestBodyTooLargeError(maxRequestBodySize);
|
||||
}
|
||||
collected += typeof chunk === 'string' ? chunk : decoder.decode(chunk as Uint8Array, { stream: true });
|
||||
}
|
||||
collected += decoder.decode();
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import { describe, expect, it, vi } from 'vitest';
|
|||
import * as z from 'zod/v4';
|
||||
|
||||
import type { NodeServerResponseLike } from '../src/toNodeHandler';
|
||||
import { toNodeHandler } from '../src/toNodeHandler';
|
||||
import { toNodeHandler, toWebRequest } from '../src/toNodeHandler';
|
||||
|
||||
const MODERN_REVISION = '2026-07-28';
|
||||
|
||||
|
|
@ -344,6 +344,75 @@ describe('toNodeHandler', () => {
|
|||
expect(payload.error.code).toBe(-32_603);
|
||||
expect(payload.id).toBe(1);
|
||||
});
|
||||
|
||||
it('answers 413 and never calls handler.fetch when the request stream exceeds the body size limit', async () => {
|
||||
const fetch = vi.fn(async () => new Response(null, { status: 200 }));
|
||||
const onerror = vi.fn();
|
||||
const node = toNodeHandler({ fetch }, { onerror });
|
||||
|
||||
const { req, res, body } = nodeRequestResponse(undefined);
|
||||
const chunks = Array.from({ length: 8 }, () => new Uint8Array(1024 * 1024));
|
||||
await node(Object.assign(Readable.from(chunks), { method: req.method, url: req.url, headers: req.headers }), res);
|
||||
|
||||
expect(res.statusCode).toBe(413);
|
||||
expect(res.headers?.['connection']).toBe('close');
|
||||
expect(JSON.parse(await body())).toEqual({
|
||||
jsonrpc: '2.0',
|
||||
error: { code: -32_000, message: expect.stringMatching(/^Payload Too Large/) },
|
||||
id: null
|
||||
});
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
expect(onerror).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('answers 413 without reading the stream when Content-Length exceeds the body size limit', async () => {
|
||||
const fetch = vi.fn(async () => new Response(null, { status: 200 }));
|
||||
const { req, res } = nodeRequestResponse(undefined);
|
||||
const next = vi.fn(async () => ({ done: false, value: new Uint8Array(1024) }));
|
||||
const headers = { ...req.headers, 'content-length': String(4 * 1024 * 1024 + 1) };
|
||||
await toNodeHandler({ fetch })({ [Symbol.asyncIterator]: () => ({ next }), method: 'POST', url: req.url, headers }, res);
|
||||
expect(res.statusCode).toBe(413);
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('maxRequestBodySize moves the adapter bound, and the toWebRequest rejection is recognisable by name and status', async () => {
|
||||
const fetch = vi.fn(async () => new Response(null, { status: 200 }));
|
||||
const { req, res } = nodeRequestResponse(undefined);
|
||||
const twoKiB = Object.assign(Readable.from([new Uint8Array(2048)]), { method: req.method, url: req.url, headers: req.headers });
|
||||
await toNodeHandler({ fetch }, { maxRequestBodySize: 1024 })(twoKiB, res);
|
||||
expect(res.statusCode).toBe(413);
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
|
||||
const roomy = nodeRequestResponse(undefined);
|
||||
const fiveMiB = Object.assign(Readable.from(Array.from({ length: 5 }, () => new Uint8Array(1024 * 1024).fill(0x20))), {
|
||||
method: 'POST',
|
||||
url: roomy.req.url,
|
||||
headers: roomy.req.headers
|
||||
});
|
||||
await toNodeHandler({ fetch }, { maxRequestBodySize: 8 * 1024 * 1024 })(fiveMiB, roomy.res);
|
||||
expect(roomy.res.statusCode).toBe(200);
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
|
||||
const rejection = await toWebRequest(
|
||||
{
|
||||
[Symbol.asyncIterator]: () => ({ next: async () => ({ done: false, value: new Uint8Array(2048) }) }),
|
||||
method: 'POST',
|
||||
url: '/mcp',
|
||||
headers: {}
|
||||
},
|
||||
undefined,
|
||||
{ maxRequestBodySize: 1024 }
|
||||
).catch((error: unknown) => error);
|
||||
expect(rejection).toBeInstanceOf(Error);
|
||||
expect(rejection).toMatchObject({
|
||||
name: 'RequestBodyTooLargeError',
|
||||
status: 413,
|
||||
message: expect.stringMatching(/must not exceed 1024 bytes/)
|
||||
});
|
||||
|
||||
expect(() => toNodeHandler({ fetch }, { maxRequestBodySize: 0 })).toThrow(RangeError);
|
||||
});
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------------ *
|
||||
|
|
|
|||
|
|
@ -10,7 +10,9 @@ export type { CompletableSchema, CompleteCallback } from './server/completable';
|
|||
export { completable, isCompletable } from './server/completable';
|
||||
export type {
|
||||
CreateMcpHandlerOptions,
|
||||
IsLegacyRequestOptions,
|
||||
LegacyHttpHandler,
|
||||
LegacyStatelessFallbackOptions,
|
||||
McpHandlerRequestOptions,
|
||||
McpHttpHandler,
|
||||
McpRequestContext,
|
||||
|
|
@ -71,6 +73,9 @@ export type {
|
|||
WebStandardStreamableHTTPServerTransportOptions
|
||||
} from './server/streamableHttp';
|
||||
export { WebStandardStreamableHTTPServerTransport } from './server/streamableHttp';
|
||||
// Request-body bound shared by the HTTP entry points; the reader is exported for
|
||||
// adapter authors that pre-parse bodies (the way isJsonContentType is).
|
||||
export { DEFAULT_MAX_REQUEST_BODY_SIZE, readRequestBody } from './server/requestBody';
|
||||
|
||||
// runtime-aware wrapper (shadows core/public's fromJsonSchema with optional validator)
|
||||
export { fromJsonSchema } from './fromJsonSchema';
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ import { invoke } from './invoke';
|
|||
import { createListenRouter, DEFAULT_MAX_SUBSCRIPTIONS } from './listenRouter';
|
||||
import { McpServer } from './mcp';
|
||||
import type { PerRequestResponseMode } from './perRequestTransport';
|
||||
import { DEFAULT_MAX_REQUEST_BODY_SIZE, readRequestBody, requestBodyTooLargeMessage, resolveMaxRequestBodySize } from './requestBody';
|
||||
import type { Server } from './server';
|
||||
import { installModernOnlyHandlers, seedClientIdentityFromEnvelope, serverIdentityOf } from './server';
|
||||
import type { ServerEventBus, ServerNotifier } from './serverEventBus';
|
||||
|
|
@ -201,6 +202,16 @@ export interface CreateMcpHandlerOptions {
|
|||
* @default 15000
|
||||
*/
|
||||
keepAliveMs?: number;
|
||||
/**
|
||||
* Upper bound, in bytes, on a POST body the handler reads itself (the
|
||||
* classification step and the stateless legacy leg). A body over the bound
|
||||
* is answered `413` before anything is parsed or any server instance is
|
||||
* created. Not applied to a body supplied as `parsedBody`. Must be a
|
||||
* positive number. Hand-wired compositions routing on
|
||||
* {@linkcode isLegacyRequest} pass the same value to the predicate.
|
||||
* @default 4194304 (4 MiB)
|
||||
*/
|
||||
maxRequestBodySize?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -311,7 +322,8 @@ function internalServerErrorResponse(id: RequestId | null = null): Response {
|
|||
function createLegacyStatelessFallback(
|
||||
factory: McpServerFactory,
|
||||
onerror?: (error: Error) => void,
|
||||
keepAliveMs?: number
|
||||
keepAliveMs?: number,
|
||||
maxRequestBodySize?: number
|
||||
): LegacyHttpHandler {
|
||||
return async (request, options) => {
|
||||
if (request.method.toUpperCase() !== 'POST') {
|
||||
|
|
@ -325,7 +337,8 @@ function createLegacyStatelessFallback(
|
|||
});
|
||||
const transport = new WebStandardStreamableHTTPServerTransport({
|
||||
sessionIdGenerator: undefined,
|
||||
...(keepAliveMs !== undefined && { keepAliveMs })
|
||||
...(keepAliveMs !== undefined && { keepAliveMs }),
|
||||
...(maxRequestBodySize !== undefined && { maxRequestBodySize })
|
||||
});
|
||||
await product.connect(transport);
|
||||
|
||||
|
|
@ -399,8 +412,22 @@ function createLegacyStatelessFallback(
|
|||
};
|
||||
}
|
||||
|
||||
export function legacyStatelessFallback(factory: McpServerFactory, onerror?: (error: Error) => void): LegacyHttpHandler {
|
||||
return createLegacyStatelessFallback(factory, onerror);
|
||||
/** Options for {@linkcode legacyStatelessFallback}. */
|
||||
export interface LegacyStatelessFallbackOptions {
|
||||
/**
|
||||
* Upper bound, in bytes, on a POST body the leg reads itself (not applied
|
||||
* to a body supplied as `parsedBody`). Must be a positive number.
|
||||
* @default 4194304 (4 MiB)
|
||||
*/
|
||||
maxRequestBodySize?: number;
|
||||
}
|
||||
|
||||
export function legacyStatelessFallback(
|
||||
factory: McpServerFactory,
|
||||
onerror?: (error: Error) => void,
|
||||
options?: LegacyStatelessFallbackOptions
|
||||
): LegacyHttpHandler {
|
||||
return createLegacyStatelessFallback(factory, onerror, undefined, resolveMaxRequestBodySize(options?.maxRequestBodySize));
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------ *
|
||||
|
|
@ -430,6 +457,8 @@ function standardHeadersOf(request: Request): Omit<InboundHttpRequest, 'httpMeth
|
|||
type EntryClassification =
|
||||
/** The body bytes could not be read at all (a failing stream, not malformed JSON). */
|
||||
| { step: 'unreadable-body' }
|
||||
/** A POST body over the size limit; nothing past the limit was read. */
|
||||
| { step: 'body-too-large' }
|
||||
/** A POST with an empty or non-JSON body: nothing to classify, so there is no envelope claim. */
|
||||
| { step: 'no-json-body'; forwardRequest: Request }
|
||||
/** A classifiable request, with the classifier's routing outcome. */
|
||||
|
|
@ -446,7 +475,12 @@ type EntryClassification =
|
|||
* the body-preserving clone is then skipped and `forwardRequest` is the
|
||||
* (consumed) input request.
|
||||
*/
|
||||
async function classifyEntryRequest(request: Request, providedParsedBody?: unknown, needsForward = true): Promise<EntryClassification> {
|
||||
async function classifyEntryRequest(
|
||||
request: Request,
|
||||
providedParsedBody?: unknown,
|
||||
needsForward = true,
|
||||
maxRequestBodySize: number = DEFAULT_MAX_REQUEST_BODY_SIZE
|
||||
): Promise<EntryClassification> {
|
||||
const httpMethod = request.method.toUpperCase();
|
||||
|
||||
let body: unknown;
|
||||
|
|
@ -464,7 +498,11 @@ async function classifyEntryRequest(request: Request, providedParsedBody?: unkno
|
|||
}
|
||||
let bodyText: string;
|
||||
try {
|
||||
bodyText = await request.text();
|
||||
const read = await readRequestBody(request, maxRequestBodySize);
|
||||
if (read.tooLarge) {
|
||||
return { step: 'body-too-large' };
|
||||
}
|
||||
bodyText = read.text;
|
||||
} catch {
|
||||
return { step: 'unreadable-body' };
|
||||
}
|
||||
|
|
@ -493,6 +531,17 @@ async function classifyEntryRequest(request: Request, providedParsedBody?: unkno
|
|||
return { step: 'classified', outcome, body, parsedBody, forwardRequest };
|
||||
}
|
||||
|
||||
/** Options for {@linkcode isLegacyRequest}. */
|
||||
export interface IsLegacyRequestOptions {
|
||||
/**
|
||||
* The same `maxRequestBodySize` the handler the predicate routes for was
|
||||
* created with, so the two read the body under one bound (a body over it
|
||||
* classifies `false`; the modern handler answers `413`).
|
||||
* @default 4194304 (4 MiB)
|
||||
*/
|
||||
maxRequestBodySize?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether {@linkcode createMcpHandler} would route this request to its legacy
|
||||
* (2025-era) serving rather than the modern (2026-07-28) path.
|
||||
|
|
@ -553,7 +602,8 @@ async function classifyEntryRequest(request: Request, providedParsedBody?: unkno
|
|||
* answers it with the unsupported-protocol-version error), a malformed
|
||||
* envelope behind a present claim (answered `-32602`), a request whose
|
||||
* `MCP-Protocol-Version` header names a modern revision but that lacks the
|
||||
* envelope (`-32602`), and header/body mismatches (`-32020`). Consumers
|
||||
* envelope (`-32602`), header/body mismatches (`-32020`), and a POST body
|
||||
* over the size limit (413). Consumers
|
||||
* routing on the predicate must send `false` traffic to the modern handler,
|
||||
* never to a legacy handler — the modern path owns those error answers.
|
||||
* - `server/discover` probes sent by negotiating clients always carry the
|
||||
|
|
@ -561,13 +611,14 @@ async function classifyEntryRequest(request: Request, providedParsedBody?: unkno
|
|||
* a method named `server/discover` has no claim and classifies legacy,
|
||||
* exactly as the entry itself routes it.
|
||||
*/
|
||||
export async function isLegacyRequest(request: Request, parsedBody?: unknown): Promise<boolean> {
|
||||
export async function isLegacyRequest(request: Request, parsedBody?: unknown, options?: IsLegacyRequestOptions): Promise<boolean> {
|
||||
const maxRequestBodySize = resolveMaxRequestBodySize(options?.maxRequestBodySize);
|
||||
// Classify a clone so the caller's request body stays readable; with a
|
||||
// pre-parsed body (or a body-less method) nothing is read and no clone is
|
||||
// needed. The predicate never reads forwardRequest, so the classification
|
||||
// step's own forwarding clone is skipped.
|
||||
const probe = parsedBody === undefined && request.method.toUpperCase() === 'POST' ? request.clone() : request;
|
||||
const classified = await classifyEntryRequest(probe, parsedBody, false);
|
||||
const classified = await classifyEntryRequest(probe, parsedBody, false, maxRequestBodySize);
|
||||
return classified.step === 'no-json-body' || (classified.step === 'classified' && classified.outcome.kind === 'legacy');
|
||||
}
|
||||
|
||||
|
|
@ -621,6 +672,7 @@ export async function isLegacyRequest(request: Request, parsedBody?: unknown): P
|
|||
*/
|
||||
export function createMcpHandler(factory: McpServerFactory, options: CreateMcpHandlerOptions = {}): McpHttpHandler {
|
||||
const { legacy, onerror, responseMode } = options;
|
||||
const maxRequestBodySize = resolveMaxRequestBodySize(options.maxRequestBodySize);
|
||||
|
||||
// Construction-time guard for JavaScript callers passing a handler as the
|
||||
// legacy value: the option only selects a posture ('stateless' | 'reject').
|
||||
|
|
@ -663,7 +715,7 @@ export function createMcpHandler(factory: McpServerFactory, options: CreateMcpHa
|
|||
// The default posture is the stateless fallback; 'reject' is the only way
|
||||
// to turn legacy serving off (modern-only strict).
|
||||
const legacyHandler: LegacyHttpHandler | undefined =
|
||||
legacy === 'reject' ? undefined : createLegacyStatelessFallback(factory, reportError, options.keepAliveMs);
|
||||
legacy === 'reject' ? undefined : createLegacyStatelessFallback(factory, reportError, options.keepAliveMs, maxRequestBodySize);
|
||||
|
||||
async function serveModern(route: InboundModernRoute, request: Request, authInfo: AuthInfo | undefined): Promise<Response> {
|
||||
const claimedRevision = route.classification.revision;
|
||||
|
|
@ -866,11 +918,14 @@ export function createMcpHandler(factory: McpServerFactory, options: CreateMcpHa
|
|||
return jsonRpcErrorResponse(415, -32_000, 'Unsupported Media Type: Content-Type must be application/json');
|
||||
}
|
||||
|
||||
const classified = await classifyEntryRequest(request, requestOptions?.parsedBody);
|
||||
const classified = await classifyEntryRequest(request, requestOptions?.parsedBody, true, maxRequestBodySize);
|
||||
|
||||
if (classified.step === 'unreadable-body') {
|
||||
return jsonRpcErrorResponse(400, -32_700, 'Parse error: the request body could not be read');
|
||||
}
|
||||
if (classified.step === 'body-too-large') {
|
||||
return jsonRpcErrorResponse(413, -32_000, requestBodyTooLargeMessage(maxRequestBodySize));
|
||||
}
|
||||
if (classified.step === 'no-json-body') {
|
||||
// No JSON body to classify: there is no envelope claim, so this is
|
||||
// legacy traffic when legacy serving is configured (the legacy leg
|
||||
|
|
|
|||
63
packages/server/src/server/requestBody.ts
Normal file
63
packages/server/src/server/requestBody.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
/** Default upper bound, in bytes, on a request body read by the HTTP entry points (4 MiB). */
|
||||
export const DEFAULT_MAX_REQUEST_BODY_SIZE = 4 * 1024 * 1024;
|
||||
|
||||
/** Upper bound on the number of messages accepted in one JSON-RPC batch array. */
|
||||
export const MAX_BATCH_SIZE = 100;
|
||||
|
||||
/** The message answered with 413 for a request body over `maxBytes`. */
|
||||
export function requestBodyTooLargeMessage(maxBytes: number): string {
|
||||
return `Payload Too Large: Request body must not exceed ${maxBytes} bytes`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a `maxRequestBodySize` option to the bound to apply: the default when
|
||||
* omitted, otherwise the value itself, which must be a positive finite number of
|
||||
* bytes (a `RangeError` is thrown at configuration time for anything else).
|
||||
*/
|
||||
export function resolveMaxRequestBodySize(value: number | undefined): number {
|
||||
if (value === undefined) {
|
||||
return DEFAULT_MAX_REQUEST_BODY_SIZE;
|
||||
}
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
|
||||
throw new RangeError(`maxRequestBodySize must be a positive number of bytes, got ${String(value)}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a request body as text, up to `maxBytes` (default
|
||||
* {@linkcode DEFAULT_MAX_REQUEST_BODY_SIZE}). A declared `Content-Length` over the
|
||||
* limit is refused without reading anything; otherwise the read stops as soon as
|
||||
* more than the limit has arrived. Stream failures propagate.
|
||||
*/
|
||||
export async function readRequestBody(
|
||||
request: Request,
|
||||
maxBytes: number = DEFAULT_MAX_REQUEST_BODY_SIZE
|
||||
): Promise<{ tooLarge: true } | { tooLarge: false; text: string }> {
|
||||
if (Number(request.headers.get('content-length')) > maxBytes) {
|
||||
return { tooLarge: true };
|
||||
}
|
||||
if (request.body === null) {
|
||||
return { tooLarge: false, text: '' };
|
||||
}
|
||||
const reader = request.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let received = 0;
|
||||
let text = '';
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
received += value.byteLength;
|
||||
if (received > maxBytes) {
|
||||
return { tooLarge: true };
|
||||
}
|
||||
text += decoder.decode(value, { stream: true });
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
return { tooLarge: false, text: text + decoder.decode() };
|
||||
}
|
||||
|
|
@ -19,6 +19,7 @@ import {
|
|||
SUPPORTED_PROTOCOL_VERSIONS
|
||||
} from '@modelcontextprotocol/core-internal';
|
||||
|
||||
import { MAX_BATCH_SIZE, readRequestBody, requestBodyTooLargeMessage, resolveMaxRequestBodySize } from './requestBody';
|
||||
import { armSseKeepAlive, DEFAULT_SSE_KEEP_ALIVE_MS } from './sseKeepAlive';
|
||||
|
||||
export type StreamId = string;
|
||||
|
|
@ -156,6 +157,15 @@ export interface WebStandardStreamableHTTPServerTransportOptions {
|
|||
*/
|
||||
keepAliveMs?: number;
|
||||
|
||||
/**
|
||||
* Upper bound, in bytes, on a POST body the transport reads itself. A body
|
||||
* over the bound (declared `Content-Length`, or observed while streaming)
|
||||
* is answered `413` before anything is parsed. Not applied when the caller
|
||||
* supplies `parsedBody`. Must be a positive number.
|
||||
* @default 4194304 (4 MiB)
|
||||
*/
|
||||
maxRequestBodySize?: number;
|
||||
|
||||
/**
|
||||
* List of protocol versions that this transport will accept.
|
||||
* Used to validate the `mcp-protocol-version` header in incoming requests.
|
||||
|
|
@ -174,7 +184,7 @@ export interface WebStandardStreamableHTTPServerTransportOptions {
|
|||
*/
|
||||
export interface HandleRequestOptions {
|
||||
/**
|
||||
* Pre-parsed request body. If provided, the transport will use this instead of parsing `req.json()`.
|
||||
* Pre-parsed request body. If provided, the transport will use this instead of reading and parsing the request body.
|
||||
* Useful when using body-parser middleware that has already parsed the body.
|
||||
*/
|
||||
parsedBody?: unknown;
|
||||
|
|
@ -256,6 +266,7 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
|
|||
private _retryInterval?: number;
|
||||
private _supportedProtocolVersions: string[];
|
||||
private _keepAliveMs: number;
|
||||
private _maxRequestBodySize: number;
|
||||
|
||||
sessionId?: string;
|
||||
onclose?: () => void;
|
||||
|
|
@ -274,6 +285,7 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
|
|||
this._retryInterval = options.retryInterval;
|
||||
this._supportedProtocolVersions = options.supportedProtocolVersions ?? SUPPORTED_PROTOCOL_VERSIONS;
|
||||
this._keepAliveMs = options.keepAliveMs ?? DEFAULT_SSE_KEEP_ALIVE_MS;
|
||||
this._maxRequestBodySize = resolveMaxRequestBodySize(options.maxRequestBodySize);
|
||||
}
|
||||
|
||||
private startKeepAlive(
|
||||
|
|
@ -763,7 +775,13 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
|
|||
let rawMessage;
|
||||
if (options?.parsedBody === undefined) {
|
||||
try {
|
||||
rawMessage = await req.json();
|
||||
const body = await readRequestBody(req, this._maxRequestBodySize);
|
||||
if (body.tooLarge) {
|
||||
const message = requestBodyTooLargeMessage(this._maxRequestBodySize);
|
||||
this.onerror?.(new Error(message));
|
||||
return this.createJsonErrorResponse(413, -32_000, message);
|
||||
}
|
||||
rawMessage = JSON.parse(body.text);
|
||||
} catch (error) {
|
||||
this.onerror?.(error as Error);
|
||||
return this.createJsonErrorResponse(400, -32_700, 'Parse error: Invalid JSON');
|
||||
|
|
@ -771,6 +789,10 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
|
|||
} else {
|
||||
rawMessage = options.parsedBody;
|
||||
}
|
||||
if (Array.isArray(rawMessage) && rawMessage.length > MAX_BATCH_SIZE) {
|
||||
this.onerror?.(new Error(`Invalid Request: Batch must not exceed ${MAX_BATCH_SIZE} messages`));
|
||||
return this.createJsonErrorResponse(400, -32_600, `Invalid Request: Batch must not exceed ${MAX_BATCH_SIZE} messages`);
|
||||
}
|
||||
|
||||
let messages: JSONRPCMessage[];
|
||||
|
||||
|
|
|
|||
|
|
@ -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