MCP Server Security
Memory safety for Node.js MCP servers
Memory exhaustion is a denial-of-service vector that is easy to overlook when building MCP servers. Node.js has no built-in limits on Buffer allocation, JSON serialisation depth, or recursive object traversal. An LLM tool call with crafted arguments — or a malicious upstream response — can crash your server or degrade it to unusability. This guide covers the specific patterns that matter.
Buffer allocation limits
MCP servers that read files, fetch URLs, or stream data into Buffers must enforce size limits before allocation. Allocating a Buffer from an untrusted size field — Buffer.alloc(args.size) — can exhaust server memory instantly.
// Vulnerable: allocates whatever size the LLM passes
server.tool('read_chunk', { size: z.number() }, async ({ size }) => {
const buf = Buffer.alloc(size) // 1 GB if size=1073741824
// ...
})
// Safe: cap before allocation
const MAX_BUFFER_BYTES = 10 * 1024 * 1024 // 10 MB
server.tool('read_chunk', { size: z.number().int().positive() }, async ({ size }) => {
if (size > MAX_BUFFER_BYTES) {
return {
content: [{ type: 'text', text: `Size exceeds maximum (${MAX_BUFFER_BYTES} bytes)` }],
isError: true
}
}
const buf = Buffer.alloc(size)
// ...
})
For file reads, use streaming with a size limit rather than reading the entire file into memory:
import { createReadStream } from 'fs'
import { pipeline } from 'stream/promises'
const MAX_FILE_READ = 50 * 1024 * 1024 // 50 MB
let bytesRead = 0
const chunks = []
const readable = createReadStream(filePath)
readable.on('data', chunk => {
bytesRead += chunk.length
if (bytesRead > MAX_FILE_READ) {
readable.destroy(new Error('File exceeds read limit'))
return
}
chunks.push(chunk)
})
await pipeline(readable, async function*(source) { yield* source })
const content = Buffer.concat(chunks)
JSON.stringify circular reference prevention
Serialising tool output with JSON.stringify will throw a TypeError: Converting circular structure to JSON if the object contains circular references. In an MCP handler, an uncaught serialisation error crashes the handler or returns garbage to the LLM. The risk is higher when the data comes from a downstream API you don't fully control.
// Dangerous: uncaught circular reference crashes handler
server.tool('get_data', schema, async (args) => {
const data = await fetchFromApi(args)
return { content: [{ type: 'text', text: JSON.stringify(data) }] }
})
// Safe: use a replacer that tracks seen values
function safeStringify(obj, indent) {
const seen = new WeakSet()
return JSON.stringify(obj, (key, value) => {
if (typeof value === 'object' && value !== null) {
if (seen.has(value)) return '[Circular]'
seen.add(value)
}
return value
}, indent)
}
// Or cap output size before returning to LLM
const MAX_OUTPUT_CHARS = 100_000 // ~100 KB
const serialised = safeStringify(data)
const truncated = serialised.length > MAX_OUTPUT_CHARS
? serialised.slice(0, MAX_OUTPUT_CHARS) + '... [truncated]'
: serialised
Recursive data structure depth limits
Deeply nested objects cause stack overflow in recursive traversal functions. This is a DoS vector when an MCP server processes LLM-supplied or API-returned nested structures without depth limits.
function safeTraverse(obj, depth = 0, maxDepth = 20) {
if (depth > maxDepth) throw new Error('Structure depth exceeds limit')
if (typeof obj !== 'object' || obj === null) return obj
return Object.fromEntries(
Object.entries(obj).map(([k, v]) =>
[k, safeTraverse(v, depth + 1, maxDepth)]
)
)
}
// Schema-level depth validation with Zod
const NestedSchema = z.lazy(() =>
z.object({
value: z.string(),
children: z.array(NestedSchema).max(10).optional()
})
).superRefine((val, ctx) => {
// Zod's lazy doesn't check depth natively — pair with a depth-checking utility
if (getDepth(val) > 10) {
ctx.addIssue({ code: 'custom', message: 'Max nesting depth exceeded' })
}
})
Heap snapshot leak patterns
Long-lived MCP servers accumulate memory leaks that cause OOM crashes after days of operation. Common leak patterns in MCP servers:
- Tool history stored in module-level arrays — each tool call appends; array grows unbounded
- Event listener accumulation — attaching listeners inside request handlers without removing them
- Session objects that survive beyond session lifetime — Maps keyed by session ID that are never evicted
- Caches without TTL or max-size — LRU caches not configured with
maxSize
// Bounded session cache with TTL eviction
import QuickLRU from 'quick-lru'
const sessionCache = new QuickLRU({
maxSize: 1000, // evict LRU when >1000 entries
maxAge: 4 * 60 * 60 * 1000 // evict entries older than 4h
})
// Tool history with ring buffer (fixed memory)
class RingBuffer {
constructor(capacity) {
this.capacity = capacity
this.buf = new Array(capacity)
this.pos = 0
this.size = 0
}
push(item) {
this.buf[this.pos % this.capacity] = item
this.pos++
this.size = Math.min(this.size + 1, this.capacity)
}
toArray() {
return this.buf.slice(0, this.size)
}
}
const toolHistory = new RingBuffer(500) // last 500 tool calls, no unbounded growth
What SkillAudit flags
- Buffer.alloc from untrusted size field — High (memory exhaustion DoS)
- fs.readFile without size limit on user-supplied paths — High (unbounded memory allocation)
- JSON.stringify without circular reference guard — Medium (crash on circular data from APIs)
- Recursive traversal without depth limit — Medium (stack overflow DoS)
- Module-level arrays used as unbounded log/history stores — Low (slow memory leak)
- Maps/Sets keyed by session or request ID with no eviction — Low (slow memory leak)
Audit your MCP server for memory safety issues
SkillAudit checks for unbounded Buffer allocation, circular reference crashes, and common heap leak patterns. Free graded report in 60 seconds.
Run a free audit →