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

See also

Check your MCP server for missing timeout findings across all tool handlers.

Run a free audit → How grading works →