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
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue