MCP Server Security

Event loop blocking in MCP servers: sync operations and the denial-of-service risk

Node.js is single-threaded. Any synchronous operation that takes more than a few milliseconds blocks every other concurrent session. In an MCP server where multiple LLM sessions share one process, a single tool call using fs.readFileSync on a large file, or a CPU-bound JSON transformation, creates a denial-of-service condition across all sessions.

How the event loop blocking attack works

The attack requires no exploit code — just a legitimate-looking tool call with inputs designed to maximize CPU or I/O time. If your MCP server has a search tool that reads a large directory synchronously, an attacker (or a badly instructed LLM) can call it with a deep directory path, blocking all other sessions for the duration. The attack is amplified in multi-session deployments where one session's blocking operation affects everyone.

Unlike SSRF or path traversal, event loop blocking is a security issue framed as a reliability issue. SkillAudit scores it under the Security axis because it creates a denial-of-service vector against other tenants in multi-session deployments.

Common blocking operation patterns

Synchronous file I/O

// Bad — blocks event loop for file duration
const content = fs.readFileSync(filePath, 'utf-8')
const files = fs.readdirSync(directory)
const stat = fs.statSync(filePath)

// Good — yields to event loop between I/O operations
const content = await fs.promises.readFile(filePath, 'utf-8')
const files = await fs.promises.readdir(directory)
const stat = await fs.promises.stat(filePath)

Synchronous crypto operations

// Bad — crypto.scryptSync blocks for ~100ms on standard parameters
const key = crypto.scryptSync(password, salt, 32)
const hash = crypto.createHash('sha256').update(largeBuffer).digest('hex')

// Good — use async variants
const key = await new Promise<Buffer>((resolve, reject) =>
  crypto.scrypt(password, salt, 32, (err, derivedKey) =>
    err ? reject(err) : resolve(derivedKey)
  )
)

// For large buffer hashing — use crypto.createHash in chunks with streams
import { createHash } from 'node:crypto'
import { pipeline } from 'node:stream/promises'

const hash = createHash('sha256')
await pipeline(fs.createReadStream(filePath), hash)
const digest = hash.digest('hex')

Child process synchronous execution

// Bad — execSync and spawnSync block
const output = execSync('git log --oneline', { encoding: 'utf-8' })

// Good — promisified execFile
import { execFile } from 'node:child_process'
import { promisify } from 'node:util'
const execFileAsync = promisify(execFile)

const { stdout } = await execFileAsync('git', ['log', '--oneline'], {
  timeout: 5000,
  maxBuffer: 1024 * 1024  // 1MB limit to prevent memory exhaustion
})

CPU-bound loops: yielding with setImmediate

For CPU-intensive operations that cannot be offloaded to a worker thread, yield to the event loop periodically using setImmediate. This allows I/O callbacks and other pending microtasks to execute between batches:

async function processLargeArray(items: string[]): Promise<string[]> {
  const results: string[] = []
  const BATCH_SIZE = 100

  for (let i = 0; i < items.length; i += BATCH_SIZE) {
    const batch = items.slice(i, i + BATCH_SIZE)

    for (const item of batch) {
      results.push(expensiveTransform(item))
    }

    // Yield to event loop between batches
    // setImmediate fires after I/O events; setTimeout(fn, 0) is less reliable
    if (i + BATCH_SIZE < items.length) {
      await new Promise<void>(resolve => setImmediate(resolve))
    }
  }

  return results
}

Worker threads for truly blocking work

For CPU-bound work that takes more than 10ms (regex compilation, cryptographic operations on large inputs, image processing, JSON parsing of very large payloads), use Worker threads to keep the main thread free:

// worker.ts — runs in a separate OS thread
import { parentPort, workerData } from 'node:worker_threads'
const { payload } = workerData as { payload: string }
const result = expensiveCpuOperation(payload)
parentPort!.postMessage(result)

// main handler — spawns a worker and awaits the result
import { Worker } from 'node:worker_threads'
import path from 'node:path'

function runInWorker<T>(workerScript: string, data: unknown, timeoutMs = 5000): Promise<T> {
  return new Promise((resolve, reject) => {
    const worker = new Worker(workerScript, { workerData: data })

    const timer = setTimeout(() => {
      worker.terminate()
      reject(new Error('Worker timeout'))
    }, timeoutMs)

    worker.on('message', (result) => { clearTimeout(timer); resolve(result as T) })
    worker.on('error', (err) => { clearTimeout(timer); reject(err) })
  })
}

// In tool handler:
const result = await runInWorker<string>(
  path.join(__dirname, 'worker.js'),
  { payload: args.content },
  3000
)

Detecting blocking calls statically

Add an ESLint rule to prevent synchronous blocking calls in your tool handler files:

// .eslintrc for tool handlers
{
  "rules": {
    "no-restricted-syntax": [
      "error",
      {
        "selector": "CallExpression[callee.object.name='fs'][callee.property.name=/Sync$/]",
        "message": "Use fs.promises.* — sync I/O blocks the event loop"
      },
      {
        "selector": "CallExpression[callee.name='execSync']",
        "message": "Use execFile (promisified) — execSync blocks the event loop"
      },
      {
        "selector": "CallExpression[callee.property.name='spawnSync']",
        "message": "Use spawn with stream handling — spawnSync blocks the event loop"
      }
    ]
  }
}

Timeout boundaries for all async operations

Every awaited operation in a tool handler should have a timeout. An unconstrained await on a slow external service can hold the connection open indefinitely, leaking resources across sessions:

import { setTimeout as sleep } from 'node:timers/promises'

async function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
  const timeout = new Promise<never>((_, reject) =>
    setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms)
  )
  return Promise.race([promise, timeout])
}

// In handler:
const content = await withTimeout(
  fs.promises.readFile(safePath, 'utf-8'),
  2000,
  'readFile'
)

SkillAudit grading for event loop safety

FindingSeverityGrade impact
fs.readFileSync / readdirSync in tool handlerHigh−10
execSync or spawnSync in tool handlerHigh−10
crypto.scryptSync or pbkdf2Sync in tool handlerMedium−6
Unbounded await on external I/O (no timeout)Medium−5
Worker threads used for CPU-bound operations+4
All external I/O wrapped with timeout+3

Scan your MCP server for blocking operations

SkillAudit's static scanner flags synchronous I/O calls, sync child process execution, and unbounded async operations in tool handler files. Paste your GitHub URL and get a full report with line numbers in under 60 seconds.