Topic: resource exhaustion security

MCP server resource exhaustion security — file descriptor leaks, connection pool starvation, event loop blocking

MCP servers are called by LLMs, not humans. A human might invoke a file-reading tool five times in a session; an LLM navigating a large codebase might call it fifty times in two minutes — and under adversarial prompt injection, it might loop indefinitely. This changes the resource exhaustion calculus: patterns that are harmless under human usage rates become availability attacks at LLM call rates. A file descriptor that leaks on each call exhausts the OS limit after a few hundred calls. A database connection that is never returned to the pool starves subsequent calls. Synchronous file I/O in an async handler blocks the entire Node.js event loop for every concurrent request. This page covers five resource exhaustion defense patterns: file descriptor lifecycle enforcement, connection pool sizing and timeout, event loop blocking detection and prevention, worker thread pool saturation prevention, and per-session resource quotas.

1. File descriptor lifecycle enforcement

Every fs.open() call allocates a file descriptor from the OS pool. The default Linux limit is 1024 open FDs per process. An MCP server called by an LLM that invokes a file-reading tool 20 times concurrently — each opening a file but encountering an error before the close — will exhaust its FD budget in seconds. After exhaustion, every subsequent fs.open() call returns EMFILE ("too many open files"), crashing the handler for every session.

import { open } from 'node:fs/promises'

// DANGEROUS: file descriptor leak — early return before close
async function readFileDangerous(path: string): Promise<string> {
  const fd = await open(path, 'r')
  const stat = await fd.stat()
  if (stat.size > 10 * 1024 * 1024) {
    return 'File too large' // BUG: fd is never closed — leaks one FD per oversized file call
  }
  const content = await fd.readFile('utf-8')
  await fd.close()
  return content
  // BUG: if readFile throws, fd is also never closed
}

// SAFE: file descriptor always closed in finally block
async function readFileSafe(path: string): Promise<string> {
  let fd: Awaited<ReturnType<typeof open>> | undefined
  try {
    fd = await open(path, 'r')
    const stat = await fd.stat()
    if (stat.size > 10 * 1024 * 1024) {
      return 'File too large' // fd will be closed in finally
    }
    return await fd.readFile('utf-8')
  } finally {
    await fd?.close() // always executes — even on early return or thrown error
  }
}

// SAFER: track open FDs globally to detect leaks in development
let openFdCount = 0
async function readFileTracked(path: string): Promise<string> {
  let fd: Awaited<ReturnType<typeof open>> | undefined
  openFdCount++
  try {
    fd = await open(path, 'r')
    if (openFdCount > 50) {
      // Alert: more than 50 FDs open simultaneously — likely a leak
      console.error({ event: 'fd_high_water_mark', count: openFdCount, path })
    }
    return await fd.readFile('utf-8')
  } finally {
    await fd?.close()
    openFdCount--
  }
}

2. Database connection pool starvation

A connection pool with a maximum of 10 connections serves 10 concurrent requests. If a tool handler acquires a connection and then awaits an unrelated I/O operation before releasing it, the connection is held idle during the wait — counting against the pool limit but doing no useful work. An LLM that triggers 15 concurrent tool calls, each holding a connection during a 2-second upstream API call, will exhaust the pool and block all subsequent calls for the duration.

import { Pool } from 'pg'

const pool = new Pool({
  max: 10,
  idleTimeoutMillis: 30_000,    // release idle connections after 30s
  connectionTimeoutMillis: 5_000, // fail fast rather than queue indefinitely
})

// DANGEROUS: connection held across unrelated I/O — starves the pool
async function getDocumentDangerous(docId: string): Promise<object> {
  const client = await pool.connect()
  const doc = await client.query('SELECT * FROM documents WHERE id = $1', [docId])
  // BUG: connection held while calling an upstream API (may take 2-10 seconds)
  const enriched = await fetch(`https://api.example.com/enrich/${docId}`)
  const data = await enriched.json()
  client.release() // released only after the upstream API call — wasteful
  return { ...doc.rows[0], enrichment: data }
}

// SAFE: release connection immediately after the DB query, before other I/O
async function getDocumentSafe(docId: string): Promise<object> {
  let docRow: object | null = null

  // Scope the connection strictly to the DB operation
  {
    const client = await pool.connect()
    try {
      const result = await client.query('SELECT * FROM documents WHERE id = $1', [docId])
      docRow = result.rows[0] ?? null
    } finally {
      client.release() // released before any other I/O
    }
  }

  if (!docRow) return {}

  // Now do the upstream API call without holding the DB connection
  const enriched = await fetch(`https://api.example.com/enrich/${docId}`)
  const data = await enriched.json()
  return { ...docRow, enrichment: data }
}

// Monitor pool health — emit metrics to detect creeping starvation
setInterval(() => {
  const { totalCount, idleCount, waitingCount } = pool
  if (waitingCount > 3) {
    console.warn({ event: 'pool_starvation_warning', totalCount, idleCount, waitingCount })
  }
}, 10_000)

3. Event loop blocking detection and prevention

Node.js runs JavaScript on a single event loop thread. Any synchronous operation that takes more than a few milliseconds — fs.readFileSync, JSON.parse on a 10 MB payload, crypto.createHash on a large buffer, a tight computation loop — blocks every other pending I/O operation for its entire duration. In an MCP server, an LLM that triggers a tool call with a large payload causes the event loop block to affect all concurrent sessions, not just the one that triggered it. This is an amplified availability impact: one adversarially crafted large argument blocks service for all users.

import { createReadStream } from 'node:fs'
import { createHash } from 'node:crypto'
import { Worker } from 'node:worker_threads'

// DANGEROUS: synchronous I/O blocks the event loop
function hashFileDangerous(path: string): string {
  const content = require('fs').readFileSync(path) // blocks for the full read duration
  return createHash('sha256').update(content).digest('hex') // may block on large files
}

// SAFE: stream-based hashing — event loop is never blocked
async function hashFileSafe(path: string): Promise<string> {
  return new Promise((resolve, reject) => {
    const hash = createHash('sha256')
    const stream = createReadStream(path, { highWaterMark: 64 * 1024 }) // 64 KB chunks
    stream.on('data', chunk => hash.update(chunk))
    stream.on('end', () => resolve(hash.digest('hex')))
    stream.on('error', reject)
  })
}

// For CPU-intensive operations: offload to a worker thread
// worker-hash.ts: runs in a separate thread, does not block the main event loop
function hashInWorker(path: string): Promise<string> {
  return new Promise((resolve, reject) => {
    const worker = new Worker(`
      const { workerData, parentPort } = require('worker_threads')
      const { createHash } = require('crypto')
      const { readFileSync } = require('fs')
      const hash = createHash('sha256')
      hash.update(readFileSync(workerData.path))
      parentPort.postMessage(hash.digest('hex'))
    `, { eval: true, workerData: { path } })
    worker.on('message', resolve)
    worker.on('error', reject)
    worker.on('exit', code => { if (code !== 0) reject(new Error(`Worker exited with code ${code}`)) })
  })
}

// Detect event loop lag: if lag exceeds 100ms, log a warning
let lastCheck = Date.now()
setInterval(() => {
  const now = Date.now()
  const lag = now - lastCheck - 100 // 100ms = the interval duration
  if (lag > 50) { // more than 50ms of unexpected delay
    console.warn({ event: 'event_loop_lag', lagMs: lag })
  }
  lastCheck = now
}, 100)

4. Worker thread pool saturation

Node.js delegates certain operations — dns.lookup, fs operations, crypto functions like pbkdf2 and scrypt — to libuv's thread pool. The default pool size is 4 threads. If an MCP server handles 5 concurrent crypto.scrypt calls (e.g., hashing uploaded content before storage), the 5th call queues behind the 4 running operations. Each subsequent call queues further. An LLM that triggers 20 concurrent hash operations saturates the thread pool for every operation in every session that needs libuv — including DNS resolution and file reads.

// Increase thread pool size at startup (must be set before any libuv operations)
// In your server entry point:
process.env.UV_THREADPOOL_SIZE = '16' // increase from default 4

// For concurrent crypto operations: use a semaphore to bound parallelism
class ConcurrencySemaphore {
  private running = 0
  private queue: Array<() => void> = []

  constructor(private readonly maxConcurrent: number) {}

  async withPermit<T>(fn: () => Promise<T>): Promise<T> {
    await this.acquire()
    try {
      return await fn()
    } finally {
      this.release()
    }
  }

  private acquire(): Promise<void> {
    if (this.running < this.maxConcurrent) {
      this.running++
      return Promise.resolve()
    }
    return new Promise(resolve => this.queue.push(resolve))
  }

  private release(): void {
    const next = this.queue.shift()
    if (next) {
      next()
    } else {
      this.running--
    }
  }
}

// At most 4 concurrent scrypt operations — leaves headroom for DNS and file I/O
const cryptoSemaphore = new ConcurrencySemaphore(4)

import { scrypt, randomBytes } from 'node:crypto'
import { promisify } from 'node:util'
const scryptAsync = promisify(scrypt)

async function hashPassword(password: string): Promise<string> {
  return cryptoSemaphore.withPermit(async () => {
    const salt = randomBytes(32)
    const hash = await scryptAsync(password, salt, 64) as Buffer
    return `${salt.toString('hex')}:${hash.toString('hex')}`
  })
}

5. Per-session resource quotas

When a single LLM session behaves anomalously — due to adversarial prompt injection or a runaway agent loop — it should not be able to exhaust resources for other sessions. Per-session resource quotas track cumulative resource consumption (FDs opened, DB connections acquired, bytes read, CPU time) per session and reject new tool calls when a session exceeds its allocation. This limits the blast radius of both bugs and attacks to a single session.

interface SessionQuota {
  toolCallCount: number
  bytesRead: number
  dbQueriesRun: number
  openFdCount: number
  startedAt: number
}

const SESSION_LIMITS = {
  maxToolCalls: 100,
  maxBytesRead: 50 * 1024 * 1024, // 50 MB total reads per session
  maxDbQueries: 200,
  maxOpenFds: 10,                  // concurrent open FDs for this session
  maxSessionAgeMs: 30 * 60 * 1000, // 30 minutes
}

class SessionResourceTracker {
  private sessions = new Map<string, SessionQuota>()

  getOrCreate(sessionId: string): SessionQuota {
    if (!this.sessions.has(sessionId)) {
      this.sessions.set(sessionId, {
        toolCallCount: 0, bytesRead: 0, dbQueriesRun: 0,
        openFdCount: 0, startedAt: Date.now(),
      })
    }
    return this.sessions.get(sessionId)!
  }

  checkAndIncrement(sessionId: string, field: keyof SessionQuota, amount = 1): void {
    const quota = this.getOrCreate(sessionId)
    const limit = SESSION_LIMITS[`max${field.charAt(0).toUpperCase()}${field.slice(1)}` as keyof typeof SESSION_LIMITS]

    if (typeof limit === 'number' && typeof quota[field] === 'number') {
      if ((quota[field] as number) + amount > limit) {
        throw new Error(`Session resource quota exceeded: ${field} limit is ${limit}`)
      }
      ;(quota[field] as number) += amount
    }

    // Also check session age
    if (Date.now() - quota.startedAt > SESSION_LIMITS.maxSessionAgeMs) {
      this.sessions.delete(sessionId)
      throw new Error('Session expired due to age limit')
    }
  }

  release(sessionId: string, field: keyof SessionQuota, amount = 1): void {
    const quota = this.sessions.get(sessionId)
    if (quota && typeof quota[field] === 'number') {
      ;(quota[field] as number) = Math.max(0, (quota[field] as number) - amount)
    }
  }
}

const sessionTracker = new SessionResourceTracker()

// Usage in a tool handler
async function readFileWithQuota(sessionId: string, path: string): Promise<string> {
  sessionTracker.checkAndIncrement(sessionId, 'toolCallCount')
  sessionTracker.checkAndIncrement(sessionId, 'openFdCount')

  let fd: Awaited<ReturnType<typeof open>> | undefined
  try {
    fd = await open(path, 'r')
    const stat = await fd.stat()
    sessionTracker.checkAndIncrement(sessionId, 'bytesRead', stat.size)
    return await fd.readFile('utf-8')
  } finally {
    await fd?.close()
    sessionTracker.release(sessionId, 'openFdCount')
  }
}

What SkillAudit checks

SkillAudit's static analysis and LLM-assisted review looks for these resource exhaustion patterns:

Run a free SkillAudit scan to check your MCP server's resource exhaustion exposure. The Security sub-score covers availability attacks including resource exhaustion from LLM-driven tool call loops.