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:

// 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

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 →