diff --git a/.changeset/express-max-body-bytes.md b/.changeset/express-max-body-bytes.md new file mode 100644 index 000000000..27b225273 --- /dev/null +++ b/.changeset/express-max-body-bytes.md @@ -0,0 +1,6 @@ +--- +"@modelcontextprotocol/express": patch +--- + +Add `maxBodyBytes` option (default: 100kb) to cap JSON request body parsing and return JSON-RPC errors for invalid JSON / oversized payloads. + diff --git a/packages/middleware/express/README.md b/packages/middleware/express/README.md index 386141d14..c6fe9a5eb 100644 --- a/packages/middleware/express/README.md +++ b/packages/middleware/express/README.md @@ -34,6 +34,14 @@ import { createMcpExpressApp } from '@modelcontextprotocol/express'; const app = createMcpExpressApp(); // default host is 127.0.0.1; protection enabled ``` +`createMcpExpressApp()` also installs `express.json()` so `req.body` is available for MCP transports. The JSON body size limit defaults to 100kb (Express default) and can be configured: + +```ts +import { createMcpExpressApp } from '@modelcontextprotocol/express'; + +const app = createMcpExpressApp({ maxBodyBytes: 1_000_000 }); +``` + ### Streamable HTTP endpoint (Express) ```ts diff --git a/packages/middleware/express/src/express.ts b/packages/middleware/express/src/express.ts index ff23cde85..138a31a04 100644 --- a/packages/middleware/express/src/express.ts +++ b/packages/middleware/express/src/express.ts @@ -1,8 +1,35 @@ -import type { Express } from 'express'; +import type { ErrorRequestHandler, Express } from 'express'; import express from 'express'; import { hostHeaderValidation, localhostHostValidation } from './middleware/hostHeaderValidation.js'; +const DEFAULT_MAX_BODY_BYTES = 100 * 1024; // Express default (100kb), made explicit. + +// Ensure body parsing failures return JSON-RPC-shaped errors (instead of HTML). +const jsonBodyErrorHandler: ErrorRequestHandler = (error, _req, res, next) => { + if (res.headersSent) return next(error); + + const type = typeof (error as { type?: unknown } | null)?.type === 'string' ? String((error as { type: string }).type) : ''; + if (type === 'entity.too.large') { + res.status(413).json({ + jsonrpc: '2.0', + error: { code: -32_000, message: 'Payload too large' }, + id: null + }); + return; + } + if (type === 'entity.parse.failed') { + res.status(400).json({ + jsonrpc: '2.0', + error: { code: -32_700, message: 'Parse error: Invalid JSON' }, + id: null + }); + return; + } + + next(error); +}; + /** * Options for creating an MCP Express application. */ @@ -22,6 +49,13 @@ export interface CreateMcpExpressAppOptions { * to restrict which hostnames are allowed. */ allowedHosts?: string[]; + + /** + * Maximum size (in bytes) for JSON request bodies. + * + * Defaults to 100kb (Express default). Increase this if your tool calls need larger payloads. + */ + maxBodyBytes?: number; } /** @@ -48,10 +82,9 @@ export interface CreateMcpExpressAppOptions { * ``` */ export function createMcpExpressApp(options: CreateMcpExpressAppOptions = {}): Express { - const { host = '127.0.0.1', allowedHosts } = options; + const { host = '127.0.0.1', allowedHosts, maxBodyBytes = DEFAULT_MAX_BODY_BYTES } = options; const app = express(); - app.use(express.json()); // If allowedHosts is explicitly provided, use that for validation if (allowedHosts) { @@ -72,5 +105,10 @@ export function createMcpExpressApp(options: CreateMcpExpressAppOptions = {}): E } } + // Parse JSON request bodies for MCP endpoints (explicit limit to reduce DoS risk). + app.use(express.json({ limit: maxBodyBytes })); + + app.use(jsonBodyErrorHandler); + return app; } diff --git a/packages/middleware/express/test/express.test.ts b/packages/middleware/express/test/express.test.ts index 64cf533bc..844effcfe 100644 --- a/packages/middleware/express/test/express.test.ts +++ b/packages/middleware/express/test/express.test.ts @@ -107,6 +107,21 @@ describe('@modelcontextprotocol/express', () => { }); describe('createMcpExpressApp', () => { + async function withServer(app: ReturnType, fn: (baseUrl: string) => Promise) { + const server = await new Promise(resolve => { + const s = app.listen(0, '127.0.0.1', () => resolve(s)); + }); + try { + const addr = server.address(); + if (!addr || typeof addr === 'string') throw new TypeError('Unexpected server address'); + await fn(`http://127.0.0.1:${addr.port}`); + } finally { + await new Promise((resolve, reject) => { + server.close(err => (err ? reject(err) : resolve())); + }); + } + } + test('should enable localhost DNS rebinding protection by default', () => { const app = createMcpExpressApp(); @@ -178,5 +193,51 @@ describe('@modelcontextprotocol/express', () => { warn.mockRestore(); }); + + test('should return JSON-RPC error for invalid JSON', async () => { + const app = createMcpExpressApp({ maxBodyBytes: 1024 }); + app.post('/mcp', (_req, res) => { + res.json({ ok: true }); + }); + + await withServer(app, async baseUrl => { + const resp = await fetch(`${baseUrl}/mcp`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: '{' + }); + + expect(resp.status).toBe(400); + const data = await resp.json(); + expect(data).toEqual({ + jsonrpc: '2.0', + error: { code: -32_700, message: 'Parse error: Invalid JSON' }, + id: null + }); + }); + }); + + test('should return JSON-RPC error for payload too large', async () => { + const app = createMcpExpressApp({ maxBodyBytes: 64 }); + app.post('/mcp', (_req, res) => { + res.json({ ok: true }); + }); + + await withServer(app, async baseUrl => { + const resp = await fetch(`${baseUrl}/mcp`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ data: 'x'.repeat(2048) }) + }); + + expect(resp.status).toBe(413); + const data = await resp.json(); + expect(data).toEqual({ + jsonrpc: '2.0', + error: { code: -32_000, message: 'Payload too large' }, + id: null + }); + }); + }); }); }); diff --git a/packages/middleware/node/README.md b/packages/middleware/node/README.md index 678e1d452..ae038658a 100644 --- a/packages/middleware/node/README.md +++ b/packages/middleware/node/README.md @@ -27,7 +27,8 @@ import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; import { McpServer } from '@modelcontextprotocol/server'; const server = new McpServer({ name: 'my-server', version: '1.0.0' }); -const app = createMcpExpressApp(); +// Default JSON body limit is 100kb (Express default). Increase if your tool calls need larger payloads. +const app = createMcpExpressApp({ maxBodyBytes: 1_000_000 }); app.post('/mcp', async (req, res) => { const transport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: undefined });