DoS Prevention · Node.js · Memory Management
MCP server V8 memory limit security
Node.js V8 has a default old-generation heap limit of approximately 1.4 GB on 64-bit systems (exact value depends on Node.js version and available system memory). MCP tool handlers that buffer large datasets, parse large JSON payloads, or process file uploads in memory can drive the heap to this limit. When V8 cannot allocate more heap space, the process crashes with JavaScript heap out of memory — killing all active agent sessions simultaneously. This is a server-wide DoS from a single tool call.
Understanding the V8 heap limit
V8 manages memory in two generations: the "new space" (young generation, typically 16–32 MB) for short-lived allocations, and the "old space" (old generation, subject to the heap limit) for objects that survive multiple GC cycles. Large buffers (file contents, JSON-parsed objects, database query results) go directly into old space.
The default old-space limit varies by Node.js version and available RAM. On a container with 512 MB RAM, the limit may be as low as 256 MB. On a bare-metal server with 64 GB RAM, V8 may set it to 4 GB by default. Check your actual limit:
// Check the current V8 heap statistics
const v8 = require('v8');
const stats = v8.getHeapStatistics();
console.log({
heapSizeLimit: `${(stats.heap_size_limit / 1024 / 1024).toFixed(0)} MB`,
usedHeapSize: `${(stats.used_heap_size / 1024 / 1024).toFixed(0)} MB`,
totalHeapSize: `${(stats.total_heap_size / 1024 / 1024).toFixed(0)} MB`,
});
// Set an explicit limit — do NOT rely on the default in production
// node --max-old-space-size=512 server.js (512 MB old-space limit)
// In Dockerfile:
// CMD ["node", "--max-old-space-size=512", "server.js"]
// Set to ~70% of container memory limit to leave room for native code and OS
Container memory ≠ heap limit: a container with a 1 GB memory limit has total memory including OS overhead, Node.js buffers, and native addons. Setting --max-old-space-size=1024 on a 1 GB container will OOM the container before V8 reaches its heap limit, resulting in a SIGKILL from the container runtime instead of a clean Node.js error.
Detecting heap pressure before OOM
V8 exposes process.memoryUsage() and the v8 module for heap inspection. Monitor heap usage periodically and reject new tool calls when the heap is under pressure — a controlled error to one caller is better than an OOM that kills all callers:
import v8 from 'v8';
const HEAP_PRESSURE_THRESHOLD = 0.85; // alert at 85% of heap limit
function isHeapUnderPressure(): boolean {
const stats = v8.getHeapStatistics();
return stats.used_heap_size / stats.heap_size_limit > HEAP_PRESSURE_THRESHOLD;
}
// Check before accepting large-payload tool calls
server.tool('process_document', async (req) => {
if (isHeapUnderPressure()) {
throw new ToolError('RESOURCE_LIMIT', 'Server under memory pressure — retry in a moment');
}
const { content } = req.params; // could be large
return processDocument(content);
});
// Periodic monitoring
setInterval(() => {
const stats = v8.getHeapStatistics();
const usagePct = (stats.used_heap_size / stats.heap_size_limit * 100).toFixed(1);
logger.info({ event: 'heap_stats', usagePct: parseFloat(usagePct), heapMB: Math.round(stats.used_heap_size / 1024 / 1024) });
if (parseFloat(usagePct) > 90) {
logger.error({ event: 'heap_critical', usagePct, action: 'rejecting_new_requests' });
// Force GC if exposed (requires --expose-gc flag — only use in emergencies)
// if (global.gc) global.gc();
}
}, 30_000);
Stream-based alternatives to in-memory buffering
The most impactful memory optimization for MCP servers is replacing in-memory accumulation patterns with streaming. Common patterns that buffer unnecessarily and their stream-based replacements:
// PROBLEMATIC: reads entire file into memory before returning
server.tool('read_file', async (req) => {
const { path } = req.params;
const content = await fs.readFile(path); // entire file into Buffer
return { content: content.toString('base64') }; // doubled: Buffer + string copy
});
// BETTER: for large files, stream and chunk the response
server.tool('read_file_chunked', async (req) => {
const { path, offset = 0, length = 65536 } = req.params; // 64KB chunks
const fd = await fs.open(path, 'r');
try {
const buffer = Buffer.allocUnsafe(length);
const { bytesRead } = await fd.read(buffer, 0, length, offset);
return {
content: buffer.subarray(0, bytesRead).toString('base64'),
bytesRead,
offset,
};
} finally {
await fd.close();
}
});
// PROBLEMATIC: fetches entire URL response into memory
server.tool('fetch_url', async (req) => {
const response = await fetch(req.params.url);
const text = await response.text(); // entire response body into string
return { content: text }; // potentially MB of content
});
// BETTER: truncate at a safe size before returning
server.tool('fetch_url', async (req) => {
const MAX_RESPONSE_BYTES = 512 * 1024; // 512 KB max
const response = await fetch(req.params.url);
const reader = response.body.getReader();
const chunks = [];
let totalBytes = 0;
let truncated = false;
while (true) {
const { done, value } = await reader.read();
if (done) break;
totalBytes += value.length;
if (totalBytes > MAX_RESPONSE_BYTES) {
chunks.push(value.subarray(0, MAX_RESPONSE_BYTES - (totalBytes - value.length)));
truncated = true;
reader.cancel();
break;
}
chunks.push(value);
}
return {
content: Buffer.concat(chunks).toString('utf-8'),
truncated,
totalBytes: truncated ? `>${MAX_RESPONSE_BYTES}` : totalBytes,
};
});
SkillAudit findings for V8 memory limits
Related: Payload size DoS security · Connection pool exhaustion security · Resilience and fail-secure design