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:
- File descriptor leaks —
fs.open()oropen()calls where theclose()is not in afinallyblock; early return paths that bypass close calls. - Connection pool acquisition without release —
pool.connect()orpool.acquire()calls where the correspondingrelease()is not in afinallyblock, or where the connection is held across non-DB I/O operations. - Synchronous I/O in async handlers —
readFileSync,writeFileSync,execSync, or JSON.parse calls on arguments that are not bounded in size. - No per-session tool call limits — handlers without a counter that blocks a session from making unbounded tool calls, enabling LLM-driven exhaustion attacks.
- Missing pool configuration — database pools created without
max,idleTimeoutMillis, orconnectionTimeoutMillis, leaving the pool unbounded or prone to indefinite queuing.
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.