DoS Prevention · Request Limits · Rate Limiting

MCP server payload size DoS security

MCP tool handlers that accept string arguments — file content, document text, SQL queries, template bodies — rarely enforce an upper bound on the argument size. An LLM agent given a task that involves large documents, or an attacker who can influence tool call arguments, can send megabyte-scale payloads that exhaust the Node.js heap, saturate the CPU with JSON parsing, or trigger gigabyte-scale memory expansion through a zip bomb. All three classes of payload attack are preventable with limits applied before the argument reaches any tool handler code.

Attack class 1: raw size exhaustion

The simplest attack sends a single tool call with an oversized string argument. JSON.stringify and JSON.parse are synchronous — a 100 MB JSON body blocks the Node.js event loop for several seconds while parsing. A 1 GB body can exhaust the V8 heap and crash the process:

// No size limit — the handler will JSON.parse the entire body before validation
server.tool('process_document', async (req) => {
  const { content } = req.params;  // content could be 500 MB
  return analyzeDocument(content);
});

// The fix: enforce size at the transport layer before JSON.parse
// For HTTP-based MCP servers (Express/Fastify):
app.use(express.json({ limit: '1mb' }));

// For WebSocket-based MCP servers:
const MAX_MESSAGE_SIZE = 1 * 1024 * 1024;  // 1 MB
wss.on('connection', (ws) => {
  ws.on('message', (data) => {
    if (data.length > MAX_MESSAGE_SIZE) {
      ws.send(JSON.stringify({ error: 'Message too large' }));
      ws.close(1009, 'Message too large');
      return;
    }
    handleMessage(JSON.parse(data.toString()));
  });
});

Attack class 2: deeply nested JSON (JSON bomb)

A deeply nested JSON object can cause O(n²) or exponential memory usage during parsing, even if the serialized byte size is small. The classic "JSON bomb" is a deeply nested array that expands to a much larger in-memory structure. Node.js JSON.parse does not have a recursion depth limit:

// JSON bomb — small serialized size, exponential memory expansion during parse
// {"a":{"a":{"a":{"a": ... (10,000 levels deep) ...}}}}
// V8's stack limit will eventually throw, but only after significant CPU use

// Defense: check nesting depth before or during parsing
// Option A: reject strings with excessive depth markers before parse
function hasExcessiveNesting(rawJson, maxDepth = 20) {
  let depth = 0;
  let maxSeen = 0;
  for (const ch of rawJson) {
    if (ch === '{' || ch === '[') {
      depth++;
      if (depth > maxSeen) maxSeen = depth;
      if (maxSeen > maxDepth) return true;
    } else if (ch === '}' || ch === ']') {
      depth--;
    }
  }
  return false;
}

function safeParse(rawJson, maxDepth = 20) {
  if (hasExcessiveNesting(rawJson, maxDepth)) {
    throw new ToolError('INVALID_INPUT', 'JSON nesting too deep');
  }
  return JSON.parse(rawJson);
}

Attack class 3: archive expansion (zip bomb)

MCP servers with file upload or archive-processing tools (ZIP, tar, gzip) are vulnerable to zip bombs — archives that are small when compressed but expand to terabytes when extracted. A 42 KB zip bomb (42.zip) expands to 4.5 petabytes through recursive zip nesting; a simpler 1 MB compressed file can expand to several gigabytes if the handler extracts without checking the expansion ratio:

// VULNERABLE — no expansion size check
async function handleExtractArchive(req) {
  const { archiveContent } = req.params;
  const buffer = Buffer.from(archiveContent, 'base64');
  const extracted = await unzip(buffer);  // could expand to GB/TB
  return { files: extracted };
}

// SAFE — check expansion ratio and total extracted size during extraction
import { unzipSync, Unzip } from 'fflate';

const MAX_COMPRESSED_SIZE = 10 * 1024 * 1024;   // 10 MB max input
const MAX_EXTRACTED_SIZE  = 50 * 1024 * 1024;   // 50 MB max total extracted
const MAX_EXPANSION_RATIO = 100;                  // max 100:1 compression ratio

async function safeExtract(archiveBase64) {
  const buffer = Buffer.from(archiveBase64, 'base64');

  if (buffer.length > MAX_COMPRESSED_SIZE) {
    throw new ToolError('INVALID_INPUT', 'Archive too large');
  }

  let totalExtracted = 0;
  const files = {};

  // Stream extraction with size tracking
  await new Promise((resolve, reject) => {
    const unzip = new Unzip((stream) => {
      stream.ondata = (err, chunk, final) => {
        if (err) return reject(err);
        totalExtracted += chunk.length;

        if (totalExtracted > MAX_EXTRACTED_SIZE) {
          reject(new ToolError('INVALID_INPUT', 'Archive expansion exceeds limit'));
          return;
        }
        if (totalExtracted / buffer.length > MAX_EXPANSION_RATIO) {
          reject(new ToolError('INVALID_INPUT', 'Suspicious compression ratio'));
          return;
        }
        if (final) files[stream.name] = Buffer.concat([files[stream.name] ?? Buffer.alloc(0), chunk]);
        else files[stream.name] = Buffer.concat([files[stream.name] ?? Buffer.alloc(0), chunk]);
      };
      stream.start();
    });
    unzip.push(buffer, true);
    resolve(files);
  });

  return files;
}

Schema-level size limits with Zod

Transport-level size limits protect against raw byte floods. Schema-level limits protect against logical oversize inputs that pass the transport limit but would still exhaust resources during processing:

import { z } from 'zod';

const DocumentSchema = z.object({
  content: z.string()
    .max(100_000)        // 100 KB max content string
    .min(1),
  format: z.enum(['text', 'markdown', 'json']),
  metadata: z.record(z.string().max(512))
    .optional()
    .refine(m => !m || Object.keys(m).length <= 20, 'Too many metadata keys'),
});

const BatchSchema = z.object({
  items: z.array(
    z.object({ id: z.string().max(128), value: z.string().max(1024) })
  ).max(100),   // max 100 items per batch call
});

// Both together: transport limit (1 MB) + schema limit (100 KB content, 100 batch items)
// Even a valid 1 MB JSON body with 1000 items will fail the batch schema limit

SkillAudit findings for payload size

HIGH No transport-level request size limit. MCP server accepts arbitrarily large request bodies. Any string argument can be used to exhaust the Node.js heap. Grade impact: −12.
MEDIUM File/archive tool with no expansion size check. Tool that processes ZIP, tar, or gzip archives has no check on the extracted size or expansion ratio. Grade impact: −8.
MEDIUM String fields in tool schemas with no max() bound. Zod or other schema validation is present but string fields lack upper bound limits — allows logical overflow of content fields even within the transport size limit. Grade impact: −5.

Related: Rate limiting deep dive · Input validation patterns · Resilience and fail-secure design