Topic: mcp server timeout security
MCP server timeout security — preventing DoS via hung tool calls
MCP tool handlers that make outbound HTTP requests, spawn child processes, or query databases without timeouts can be hung indefinitely by a slow or unresponsive external service. In a single-user stdio deployment, a hung tool call blocks the entire session. In a multi-user HTTP deployment, hung handlers accumulate until the process exhausts file descriptors or memory. This page covers three missing-timeout patterns that appear in MCP server security audits, with the AbortSignal.timeout(), execFile timeout option, and database query timeout patterns for each.
Pattern 1: Missing fetch timeout (most common)
The Node.js native fetch() function (and most HTTP client libraries) has no default timeout. If the remote server accepts the TCP connection but never sends a response body, the fetch hangs indefinitely. In an MCP tool handler, this means the tool call never completes, and the LLM session waits forever for a tool result it will never receive.
The attack surface: any LLM-supplied URL or even a hard-coded external API endpoint can be slow or unresponsive — due to a real outage, rate limiting, or deliberate manipulation by an attacker who controls the target server (e.g., via SSRF to an internal service that hangs on connection).
// WRONG: no timeout — hangs indefinitely on slow responses
server.tool('fetch_data', 'Fetch data from API', {
resourceId: z.string().regex(/^[a-zA-Z0-9_-]{1,64}$/),
}, async ({ resourceId }) => {
const res = await fetch(`https://api.example.com/resources/${resourceId}`)
// If api.example.com is slow or hung, this await never resolves
const data = await res.json()
return { content: [{ type: 'text', text: JSON.stringify(data) }] }
})
// CORRECT: AbortSignal.timeout() — available in Node 18+
server.tool('fetch_data', 'Fetch data from API', {
resourceId: z.string().regex(/^[a-zA-Z0-9_-]{1,64}$/),
}, async ({ resourceId }) => {
const res = await fetch(`https://api.example.com/resources/${resourceId}`, {
signal: AbortSignal.timeout(10_000), // 10 seconds; throws AbortError on timeout
})
if (!res.ok) throw new Error(`API error: ${res.status}`)
const data = await res.json()
return { content: [{ type: 'text', text: JSON.stringify(data) }] }
})
// If you need both a timeout AND cancellation, compose signals:
const controller = new AbortController()
const timeoutSignal = AbortSignal.timeout(10_000)
// Abort on either timeout or manual cancellation:
const combinedSignal = AbortSignal.any([controller.signal, timeoutSignal])
const res = await fetch(url, { signal: combinedSignal })
Pattern 2: Unbounded child process execution
MCP servers that spawn child processes (git, npm, grep, system tools) without a timeout option can be hung by a child process that waits for user input, holds a file lock, or blocks on a network resource. The child process keeps running; the Node.js event loop waits on its stdout stream; the tool handler never resolves.
import { execFile } from 'child_process'
import { promisify } from 'util'
const execFileAsync = promisify(execFile)
// WRONG: no timeout or maxBuffer
server.tool('run_tests', 'Run the test suite', {
testFile: z.string().regex(/^[a-zA-Z0-9_\/\-\.]{1,500}$/).refine(p => !p.includes('..'), 'no traversal'),
}, async ({ testFile }) => {
const { stdout } = await execFileAsync('/usr/bin/node', ['--test', testFile])
// If the test file opens a server and waits for connections, this hangs forever
// If the test produces gigabytes of output, this exhausts memory
return { content: [{ type: 'text', text: stdout }] }
})
// CORRECT: timeout + maxBuffer on every execFile call
server.tool('run_tests', 'Run the test suite', {
testFile: z.string().regex(/^[a-zA-Z0-9_\/\-\.]{1,500}$/).refine(p => !p.includes('..'), 'no traversal'),
}, async ({ testFile }) => {
const { stdout, stderr } = await execFileAsync(
'/usr/bin/node',
['--test', testFile],
{
shell: false,
timeout: 30_000, // Kill the process after 30 seconds
maxBuffer: 1024 * 1024 * 4, // 4 MB stdout + stderr cap
killSignal: 'SIGTERM', // Graceful shutdown first; SIGKILL after timeout
}
)
return { content: [{ type: 'text', text: stdout + (stderr ? `\nSTDERR: ${stderr}` : '') }] }
})
Pattern 3: Missing database query timeout
MCP servers that query SQLite, PostgreSQL, or other databases without per-query timeouts can be blocked by a long-running or lock-waiting query. In SQLite, a write that blocks on a WAL lock can hang indefinitely. In PostgreSQL, a query that hits a table lock or a slow sequential scan on a large table can run for minutes.
// SQLite via better-sqlite3
import Database from 'better-sqlite3'
// WRONG: no busy timeout — hangs on WAL lock contention
const db = new Database('./data.db')
// CORRECT: set busy_timeout (milliseconds) so locked reads don't hang forever
const db = new Database('./data.db', { timeout: 5000 })
// busy_timeout: SQLite waits up to 5 seconds for a lock, then throws SQLITE_BUSY
// PostgreSQL via node-postgres (pg)
import { Pool } from 'pg'
// WRONG: no query_timeout or statement_timeout
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
})
// CORRECT: statement_timeout kills any query running longer than 5 seconds
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
statement_timeout: 5000, // ms — kills the query server-side after 5 seconds
query_timeout: 5000, // ms — client-side timeout for the pool.query() call
connectionTimeoutMillis: 3000, // ms — fail fast if pool is exhausted
})
// Per-query timeout override (for specific slow queries):
await pool.query({
text: 'SELECT * FROM audit_results WHERE repo_url = $1',
values: [repoUrl],
// Note: per-query timeout requires pg 8.x+ with 'query_timeout' option
})
Pattern 4: Tool handler-level timeout wrapper
If individual resource timeouts are not sufficient (e.g., a tool chains multiple operations each with their own timeout, but the total wall-clock time is unbounded), wrap the entire tool handler in a timeout. This is a defense-in-depth pattern — it catches cases where individual resource timeouts are set but a chain of many short operations accumulates into a long total.
// Utility: wrap any async function with a wall-clock timeout
function withTimeout(fn: () => Promise, ms: number, label: string): Promise {
return Promise.race([
fn(),
new Promise((_, reject) =>
setTimeout(() => reject(new Error(`Tool handler timeout after ${ms}ms: ${label}`)), ms)
),
])
}
// Usage in a tool handler that chains multiple operations
server.tool('full_analysis', 'Run full repository analysis', {
repoUrl: z.string().url().max(500),
}, async (args) => {
return withTimeout(async () => {
const meta = await fetchRepoMetadata(args.repoUrl) // 10s timeout internally
const files = await listRepoFiles(meta) // 10s timeout internally
const findings = await analyzeFiles(files) // 10s timeout internally
return { content: [{ type: 'text', text: JSON.stringify(findings) }] }
}, 45_000, 'full_analysis') // Total: 45 seconds max regardless of individual timeouts
})
What SkillAudit checks
- fetch() calls without a signal option — WARN; resource exhaustion DoS if the remote server is slow or unresponsive
- execFile() or spawn() without a timeout option — WARN; handler hangs if the child process doesn't exit
- execFile() without maxBuffer — WARN; memory exhaustion if the child produces large output
- Database pool config without statement_timeout or query_timeout — WARN; handler blocks indefinitely on slow or locked queries
- Promise chains with no global wall-clock timeout — INFO; flagged when a tool chains 3+ async operations without a total timeout cap
See also
- MCP server command injection — shell: true and exec string injection patterns
- MCP server rate limiting — rate limiting tool calls to prevent abuse
- MCP server error handling security — safe error propagation patterns
- MCP server security checklist — comprehensive pre-submission checklist
- How to write a zero-finding MCP server — construction guide including timeout setup
Check your MCP server for missing timeout findings across all tool handlers.
Run a free audit → How grading works →