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);
| Control | What it prevents | Config |
|---|---|---|
| connectionTimeoutMillis | Infinite queue when pool is exhausted | 3000–5000 ms |
| statement_timeout (PostgreSQL) | Long queries holding connections indefinitely | 5000–30000 ms per query type |
| Per-caller semaphore | Single session monopolizing the pool | 3–5 concurrent per caller |
| Pool metrics monitoring | Detecting exhaustion before it causes timeouts | Alert on waitingCount > 0 |
| try/finally with release() | Connection leaks on error paths | Code pattern — required in all handlers |
SkillAudit findings for connection pool exhaustion
Related: Rate limiting deep dive · Payload size DoS security · Resilience and fail-secure design