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
| Finding | Severity | Grade impact |
|---|---|---|
fs.readFileSync / readdirSync in tool handler | High | −10 |
execSync or spawnSync in tool handler | High | −10 |
crypto.scryptSync or pbkdf2Sync in tool handler | Medium | −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.