DoS Prevention · Database Security · Resource Limits

MCP server connection pool exhaustion security

LLM agents routinely make 50–200 tool calls in a single session. If each tool call acquires a database connection from a shared pool and holds it for the duration of the query (or longer, due to bugs), a single aggressive agent session can exhaust the pool — making the database unavailable to all other callers. This is not a theoretical attack: it occurs naturally under load and becomes a DoS vector when an attacker deliberately opens many concurrent sessions to maximize pool pressure.

How pool exhaustion happens

Database connection pools pre-allocate a fixed number of connections (typically 10–20 by default in pg, mysql2, and knex). Each tool call that queries the database checks out one connection from the pool, runs the query, and returns the connection. If the pool is empty when a new call arrives, the call either blocks (waits for a connection to be returned) or fails immediately with a "connection pool exhausted" error.

With LLM agents, the pattern is different from traditional web traffic. A single user session may drive 50 concurrent-ish tool calls as the agent parallelizes work. Even with sequential calls, each call holds the connection for the full query duration — long queries hold connections proportionally longer:

// PROBLEMATIC: each tool call creates or checks out a connection,
// and a long-running query holds it for its full duration
server.tool('search_database', async (req) => {
  const { query } = req.params;
  // This may hold a connection for 10+ seconds for a full-text search
  const results = await db.query(`SELECT * FROM documents WHERE content ILIKE $1`, [`%${query}%`]);
  return { results };
});

// If 10 concurrent sessions each call this tool, the pool (size 10) is fully held
// — the 11th call waits indefinitely (or until connectionTimeoutMillis elapses)

Pool sizing for MCP workloads

The standard pool sizing formula for web applications (pool size = (CPU cores × 2) + effective spindle count) assumes short queries from many users. MCP workloads have different characteristics: fewer concurrent users but deeper parallelism per user, and queries that may run longer due to AI-driven work (full-text search, data export, analytics queries). A larger pool may be needed, but larger pools also consume database server file descriptors and memory:

// pg (node-postgres) pool configuration for MCP workloads
import { Pool } from 'pg';

const pool = new Pool({
  max: 25,                    // default: 10 — increase for MCP workloads with high parallelism
  min: 2,                     // keep 2 connections warm at all times
  connectionTimeoutMillis: 3000,   // fail fast rather than queue indefinitely — default: 0 (infinite)
  idleTimeoutMillis: 30_000,  // release idle connections after 30s — default: 10000
  // Statement timeout at the connection level (all queries on this connection)
  // Set via PostgreSQL parameter on connect:
  application_name: 'mcp-server',
});

// Per-query timeout using SET statement_timeout
async function queryWithTimeout(sql, params, timeoutMs = 5000) {
  const client = await pool.connect();
  try {
    await client.query(`SET statement_timeout = ${timeoutMs}`);
    return await client.query(sql, params);
  } finally {
    client.release();  // ALWAYS release — or use the pool.query() shorthand which releases automatically
  }
}

Per-caller connection limits

A single authenticated user should not be able to consume the entire pool. Implement a per-caller connection counter that prevents any one caller from holding more than N connections simultaneously:

// Per-caller connection semaphore — prevents pool monopolization
class PerCallerSemaphore {
  private active = new Map();
  private readonly limit: number;

  constructor(perCallerLimit: number) {
    this.limit = perCallerLimit;
  }

  async acquire(callerId: string): Promise<() => void> {
    const current = this.active.get(callerId) ?? 0;
    if (current >= this.limit) {
      throw new ToolError('RESOURCE_LIMIT', `Too many concurrent DB connections for caller: ${callerId}`);
    }
    this.active.set(callerId, current + 1);
    return () => {
      const c = this.active.get(callerId) ?? 1;
      if (c <= 1) this.active.delete(callerId);
      else this.active.set(callerId, c - 1);
    };
  }
}

const callerSemaphore = new PerCallerSemaphore(3);  // max 3 concurrent DB calls per user

server.tool('search_database', async (req, ctx) => {
  const release = await callerSemaphore.acquire(ctx.userId);
  try {
    return await pool.query(/* ... */);
  } finally {
    release();
  }
});

Connection leak detection

The most common cause of pool exhaustion is connection leaks: a code path that checks out a connection but does not release it (missing client.release() in a try/catch path, or an unhandled promise rejection before release). The pg pool has a built-in leak detection option:

// pg pool leak detection (pg >= 8.0)
const pool = new Pool({
  max: 25,
  connectionTimeoutMillis: 3000,
  // Log a warning if a connection is checked out for longer than allowExitOnIdle ms
  // This is a debug option — disable in production or set to a high value
  allowExitOnIdle: true,   // allow the pool to exit when idle (useful in tests)
});

// Detect leaks by monitoring pool metrics
setInterval(() => {
  const { totalCount, idleCount, waitingCount } = pool;
  if (waitingCount > 0) {
    logger.warn({
      event: 'pool_pressure',
      total: totalCount,
      idle: idleCount,
      waiting: waitingCount,
    });
  }
  if (idleCount === 0 && totalCount >= pool.options.max) {
    logger.error({
      event: 'pool_exhausted',
      total: totalCount,
      waiting: waitingCount,
      // Alert: all connections are checked out, callers are waiting
    });
  }
}, 10_000);
ControlWhat it preventsConfig
connectionTimeoutMillisInfinite queue when pool is exhausted3000–5000 ms
statement_timeout (PostgreSQL)Long queries holding connections indefinitely5000–30000 ms per query type
Per-caller semaphoreSingle session monopolizing the pool3–5 concurrent per caller
Pool metrics monitoringDetecting exhaustion before it causes timeoutsAlert on waitingCount > 0
try/finally with release()Connection leaks on error pathsCode pattern — required in all handlers

SkillAudit findings for connection pool exhaustion

HIGH No connectionTimeoutMillis set on the pool. Default is 0 (infinite wait) — under pool exhaustion, all callers queue indefinitely instead of failing fast. Grade impact: −10.
HIGH No per-query statement timeout. Long-running queries (full-text search, analytics, export) hold connections for their full duration. A single slow-query-heavy tool call chain can starve the pool. Grade impact: −8.
MEDIUM No per-caller connection limit. A single authenticated caller can check out all pool connections simultaneously by parallelizing tool calls. Grade impact: −6.
MEDIUM Connection release not in finally block. Error paths in tool handlers may skip client.release() — connection leaks gradually exhaust the pool under normal operation. Grade impact: −6.

Related: Rate limiting deep dive · Payload size DoS security · Resilience and fail-secure design