Security reference · DoS · Memory Safety

MCP server memory exhaustion security

A single tool call that triggers JSON.parse on an unbounded response body can exhaust the Node.js heap and crash the entire MCP server process — denying service to all concurrent sessions. Memory exhaustion attacks are easy to execute (one large response is enough), hard to diagnose (the process dies without a helpful error), and completely preventable with size limits, streaming parsers, and heap pressure gates. This reference covers each vector and its mitigation.

Attack vector 1 — unbounded JSON.parse on HTTP response

The most common memory exhaustion path in MCP tool handlers: a tool fetches from an external URL and parses the response body without a size check.

// VULNERABLE — no size limit before parsing
async function fetchDataTool(args) {
  const resp = await fetch(args.url);
  const text = await resp.text();   // buffers entire body in memory
  return JSON.parse(text);          // may throw RangeError or OOM if text is huge
}

An attacker who controls the target URL (or who can influence it via prompt injection) can serve a 500MB JSON response. resp.text() buffers the entire body before returning. JSON.parse on a 500MB string allocates roughly 3–5× the string size in object graph memory. On a Node.js process with the default 1.5GB heap, this kills the process.

// SAFE — size-limited body reading before parse
const MAX_BODY_BYTES = 4 * 1024 * 1024; // 4 MB

async function fetchDataTool(args) {
  const resp = await fetch(args.url, { signal: AbortSignal.timeout(10_000) });

  if (!resp.ok) throw new Error(`HTTP ${resp.status}`);

  // Read with a byte limit
  const reader = resp.body.getReader();
  const chunks = [];
  let totalBytes = 0;

  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    totalBytes += value.byteLength;
    if (totalBytes > MAX_BODY_BYTES) {
      reader.cancel();
      throw new Error(`Response too large: ${totalBytes} bytes exceeds ${MAX_BODY_BYTES} limit`);
    }
    chunks.push(value);
  }

  const text = new TextDecoder().decode(
    chunks.reduce((a, b) => { const c = new Uint8Array(a.length + b.length); c.set(a); c.set(b, a.length); return c; }, new Uint8Array(0))
  );
  return JSON.parse(text);
}

Attack vector 2 — deeply nested JSON (stack overflow)

Even a small JSON document can crash the parser if it has extreme nesting depth. A JSON array nested 10,000 levels deep takes only ~100KB of text but causes JSON.parse to overflow the V8 call stack (default depth limit ~9,000–15,000 depending on Node.js version), throwing a RangeError: Maximum call stack size exceeded that propagates up and may terminate the process if uncaught.

// Generate a crash payload (for testing only)
const payload = '['.repeat(10000) + 'null' + ']'.repeat(10000);
// ~20KB text, but crashes JSON.parse via stack overflow

// Mitigation: limit nesting depth with a safe JSON parser
import { parse as safeJsonParse } from 'safe-json-parser'; // npm: secure-json-parse

const MAX_DEPTH = 32;
function parseSafe(text) {
  return safeJsonParse(text, { maxDepth: MAX_DEPTH });
}
// secure-json-parse throws on __proto__ keys AND enforces depth limits

Use secure-json-parse instead of native JSON.parse for any JSON that originates from untrusted sources (external APIs, user uploads, tool arguments). It blocks prototype pollution via __proto__ and constructor keys and supports a max-depth option. See also: prototype chain manipulation.

Attack vector 3 — heap exhaustion via large array accumulation

Tool handlers that accumulate results across multiple paginated API calls without a record count limit can be driven to heap exhaustion by an attacker who controls the API or can influence the query parameters.

// VULNERABLE — unbounded accumulation
async function searchTool(args) {
  const results = [];
  let cursor = null;

  do {
    const page = await fetchPage(args.query, cursor);
    results.push(...page.items);  // heap grows without bound
    cursor = page.nextCursor;
  } while (cursor);

  return results; // may be millions of items
}

// SAFE — record limit + early exit
const MAX_RESULTS = 500;

async function searchTool(args) {
  const results = [];
  let cursor = null;

  do {
    const page = await fetchPage(args.query, cursor);
    results.push(...page.items);
    cursor = page.nextCursor;

    if (results.length >= MAX_RESULTS) {
      return {
        results: results.slice(0, MAX_RESULTS),
        truncated: true,
        message: `Showing first ${MAX_RESULTS} of more results`,
      };
    }
  } while (cursor);

  return { results, truncated: false };
}

Heap size configuration

Node.js defaults to a heap limit of ~1.5GB on 64-bit systems. For MCP servers running in containers, the default may be set based on total system memory (via --max-old-space-size percent heuristics in newer Node.js versions), which can be misleading on containers with memory limits.

# Set explicit heap limit — don't rely on defaults
node --max-old-space-size=512 server.js   # 512 MB max heap

# In Dockerfile / container entry point:
CMD ["node", "--max-old-space-size=512", "server.js"]

# Verify the effective limit at startup:
# node -e "console.log(v8.getHeapStatistics().heap_size_limit / 1e6, 'MB')"

Choose a heap limit that leaves headroom: if your container is limited to 1GB RAM, set --max-old-space-size=700. The remaining 300MB covers native stack, Buffers (off-heap), and OS overhead. Running at 95% of container RAM causes OOM kills from the OS, not graceful Node.js errors.

Heap pressure gate

A heap pressure gate rejects expensive tool calls when the process is already under memory pressure, preventing the cascade where one large call leaves the heap too fragmented for subsequent calls to succeed.

import v8 from 'v8';

const HEAP_PRESSURE_THRESHOLD = 0.80; // 80% of heap_size_limit

function isHeapUnderPressure() {
  const stats = v8.getHeapStatistics();
  const ratio = stats.used_heap_size / stats.heap_size_limit;
  return ratio > HEAP_PRESSURE_THRESHOLD;
}

// Use in tool handlers
async function largeDataTool(args) {
  if (isHeapUnderPressure()) {
    throw new Error('Server temporarily unavailable: memory pressure. Retry in a few seconds.');
  }
  // ... proceed with tool logic
}

Monitoring: Emit v8.getHeapStatistics().used_heap_size as a metric every 30 seconds. Alert when used_heap_size / heap_size_limit > 0.85. Spike patterns (rapid rise to 90%+, then sudden drop as GC runs) indicate a tool handler that's allocating and releasing large objects — a candidate for streaming refactor. A monotonic rise without GC relief indicates a heap leak.

Streaming JSON for large payloads

For tool handlers that legitimately need to process large responses (file indexers, log analyzers, data pipelines), replace buffered parse with a streaming JSON parser that emits values without holding the full document in memory.

import { parser } from 'stream-json';
import { streamArray } from 'stream-json/streamers/StreamArray.js';
import { pipeline } from 'stream/promises';

async function indexFilesTool(args) {
  const MAX_ITEMS = 10_000;
  const results = [];

  await pipeline(
    (await fetch(args.url)).body,          // Response body as ReadableStream
    parser(),                               // Streaming JSON tokenizer
    streamArray(),                          // Emit each top-level array item
    async function* (source) {
      for await (const { value } of source) {
        results.push(value);
        if (results.length >= MAX_ITEMS) break; // enforce limit mid-stream
      }
    }
  );

  return { items: results, count: results.length };
}

The stream-json npm package (well-maintained, TypeScript types available) processes arbitrarily large JSON without loading the full document into heap. Peak memory use is bounded by the size of a single item in the array, not the total document size.

SkillAudit findings for memory exhaustion risks

CRITICAL −20 Unbounded fetch().text() or fetch().json() on external URLs without size limit — a single crafted response can exhaust heap and crash the entire MCP server process.
HIGH −16 Native JSON.parse on untrusted input without depth protection — deeply nested JSON triggers stack overflow via parser recursion; process crashes with RangeError.
HIGH −14 Unbounded pagination accumulation — an attacker who influences query parameters or API responses can drive unlimited heap growth across paginated results.
MEDIUM −10 No explicit --max-old-space-size in container entry point — effective heap limit is unpredictable on memory-constrained containers; OOM kills bypass graceful error handling.
MEDIUM −8 No heap pressure gate or memory monitoring — the server has no mechanism to reject requests when already under memory pressure, making cascade failures more likely.

Run a full memory safety and DoS resilience audit on your MCP server at SkillAudit. The audit includes static analysis for unbounded JSON.parse, fetch without size limits, and missing heap configuration, alongside the full security report card.

Related references: file descriptor leak security · prototype chain manipulation · behavioral intrusion detection