Topic: mcp server memory leak security

MCP server memory leak security — DoS via unbounded accumulation

Memory leaks in MCP servers have two security consequences that go beyond crashes: they can cause denial of service through memory exhaustion (OOM kill by the OS), and they can cause cross-session data leakage when retained state from a previous session remains accessible to a subsequent one. The four most common patterns in the corpus are: unbounded response caches that grow with each unique LLM-supplied argument, event listener accumulation from tool handlers that add listeners without removing them, closure leaks in async tool handlers that retain large objects, and uncleaned session state that persists after session end.

Attack 1: Unbounded response cache

MCP servers that cache tool results for performance often key the cache on LLM-supplied arguments. If the LLM (or a prompt-injected attacker) supplies a large number of unique arguments, the cache grows without bound — each new unique argument adds a new entry that is never evicted. With large response payloads (e.g., API responses, file contents), this causes OOM exhaustion quickly.

// WRONG: plain Map as cache — unbounded growth
const responseCache = new Map()

async function fetchResource(url: string): Promise {
  const cacheKey = url  // LLM-controlled — attacker supplies unique URLs

  if (responseCache.has(cacheKey)) {
    return responseCache.get(cacheKey)!
  }

  const result = await callExternalApi(url)
  // Each unique URL adds a new entry — never evicted
  responseCache.set(cacheKey, result)
  return result
}

// CORRECT: LRU cache with fixed max size
import { LRUCache } from 'lru-cache'

const responseCache = new LRUCache({
  max: 500,           // At most 500 entries
  maxSize: 50_000_000, // At most 50 MB total (sum of value sizes)
  sizeCalculation: (value) => value.length,
  ttl: 1000 * 60 * 5, // 5-minute TTL — stale entries evicted automatically
})

async function fetchResource(url: string): Promise {
  // Validate URL is in allowlist BEFORE using as cache key
  if (!ALLOWED_DOMAINS.has(new URL(url).hostname)) {
    throw new Error('URL not in allowed domain list')
  }

  const cached = responseCache.get(url)
  if (cached !== undefined) return cached

  const result = await callExternalApi(url)
  responseCache.set(url, result)
  return result
}

Attack 2: Event listener accumulation

Tool handlers that create event emitters or subscribe to streams inside the handler function — without storing the listener reference for later removal — accumulate listeners on every tool call. Node.js warns when an emitter has more than 10 listeners (MaxListenersExceededWarning), but this warning is often suppressed or ignored. The accumulated listeners hold references to their closure scope, preventing garbage collection of all captured objects.

import { EventEmitter } from 'events'

const dataStream = new EventEmitter()  // shared emitter, lives for server lifetime

// WRONG: new listener added on every tool call, never removed
async function subscribeToData(sessionId: string, filter: string): Promise {
  return new Promise((resolve) => {
    // This listener is added every time the tool is called
    // It's never removed — the emitter accumulates listeners
    dataStream.on('data', (event) => {
      if (event.filter === filter) {
        resolve(event.data)
        // Promise resolves, but the listener stays attached forever
      }
    })
  })
}

// CORRECT: store listener reference, remove after use; or use { once }
async function subscribeToData(sessionId: string, filter: string): Promise {
  return new Promise((resolve, reject) => {
    const timeout = setTimeout(() => {
      dataStream.removeListener('data', listener)
      reject(new Error('Subscription timeout after 10s'))
    }, 10_000)

    function listener(event: { filter: string; data: string }) {
      if (event.filter === filter) {
        clearTimeout(timeout)
        dataStream.removeListener('data', listener)  // Always remove after use
        resolve(event.data)
      }
    }

    dataStream.on('data', listener)
  })
}

// For single-fire listeners, use the { once: true } option
// which auto-removes the listener after first invocation:
// dataStream.once('data', handler)
// Or: dataStream.on('data', handler, { once: true })

Attack 3: Closure leak in async tool handler

Long-lived async operations (polling loops, setTimeout chains, pending Promises) capture their creation-time scope in a closure. If that scope contains a large object — an API response buffer, a session context, a parsed document — the large object is held alive for as long as the closure exists. A tool that starts a polling loop but provides no cancellation mechanism will leak the closure (and everything it captures) for the server's lifetime.

// WRONG: polling loop captures large initialData object, never terminates
async function startMonitoring(sessionId: string, query: string): Promise {
  // Large object fetched at start — captures in closure below
  const initialData = await fetchLargeDataset(query)  // 5MB response

  // Polling loop closure captures initialData — held for server lifetime
  const interval = setInterval(() => {
    const delta = computeDelta(initialData, currentState)
    emitToSession(sessionId, delta)
    // No termination condition — runs until process restart
    // initialData (5MB) never garbage collected while this interval runs
  }, 5_000)

  return 'Monitoring started'
  // The interval and its 5MB closure are now leaked
}

// CORRECT: capture only what you need; register cleanup; set a max lifetime
const activeMonitors = new Map()

async function startMonitoring(sessionId: string, query: string): Promise {
  // Stop any existing monitor for this session
  if (activeMonitors.has(sessionId)) {
    clearInterval(activeMonitors.get(sessionId)!)
    activeMonitors.delete(sessionId)
  }

  // Fetch dataset; extract only the primitive values needed for delta computation
  const initialData = await fetchLargeDataset(query)
  const baseline = extractBaseline(initialData)  // Small summary, not the full 5MB
  // initialData goes out of scope here — eligible for GC

  let callCount = 0
  const MAX_CALLS = 100  // Hard limit: max 500 seconds of monitoring

  const interval = setInterval(() => {
    callCount++
    if (callCount > MAX_CALLS) {
      clearInterval(interval)
      activeMonitors.delete(sessionId)
      return
    }
    const delta = computeDelta(baseline, currentState)  // baseline is small
    emitToSession(sessionId, delta)
  }, 5_000)

  activeMonitors.set(sessionId, interval)
  return 'Monitoring started (max 500s)'
}

// Session cleanup: called when MCP session ends
function onSessionEnd(sessionId: string) {
  const interval = activeMonitors.get(sessionId)
  if (interval) {
    clearInterval(interval)
    activeMonitors.delete(sessionId)
  }
}

Attack 4: Uncleaned session state

Per-session Maps that are populated on session start but never cleaned up on session end grow indefinitely with each new session. Beyond the memory leak, the retained state creates a cross-session data leakage risk: if session IDs are reused (common in test environments, or in servers that generate IDs from predictable seeds), a new session can inherit state from a previous one.

// WRONG: session state never cleaned up
const sessionContexts = new Map()
const sessionHistory = new Map()

function onSessionStart(sessionId: string, userId: string) {
  sessionContexts.set(sessionId, { userId, startedAt: Date.now() })
  sessionHistory.set(sessionId, [])
}

// Sessions end — but Maps grow indefinitely
// After 10,000 sessions: 10,000 entries in both Maps

// CORRECT: register cleanup on session end
// Most MCP server frameworks expose a session lifecycle hook

import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'

const server = new McpServer({ name: 'my-server', version: '1.0.0' })

const sessionContexts = new Map()
const sessionHistory = new Map()

// Register cleanup when the transport closes
server.server.on('close', () => {
  // When the server closes, clear everything
  sessionContexts.clear()
  sessionHistory.clear()
})

// For per-session cleanup with the StreamableHTTP transport,
// hook into request lifecycle:
function handleSessionEnd(sessionId: string) {
  sessionContexts.delete(sessionId)
  sessionHistory.delete(sessionId)
}

// For objects too large to keep in memory at all,
// use WeakRef + FinalizationRegistry to allow GC when memory pressure is high:
const registry = new FinalizationRegistry((sessionId) => {
  // Called by GC when the WeakRef target is collected
  sessionHistory.delete(sessionId)
})

function storeSessionHistory(sessionId: string, history: string[]) {
  const ref = new WeakRef(history)
  registry.register(history, sessionId)
  // Store only the WeakRef — GC can collect history under memory pressure
  weakSessionHistory.set(sessionId, ref)
}

What SkillAudit checks

See also

Check your MCP server for unbounded caches, listener leaks, and uncleaned session state.

Run a free audit → How grading works →