Guide · Database
MCP server connection pooling
Connection pool sizing for MCP servers is different from stateless REST APIs. A REST handler acquires a connection, runs a query, and releases the connection — all within a single HTTP request. An MCP session is long-lived: the initialize handshake opens a session that may last minutes, and each tool call within that session may need a database connection. If sessions hold connections for their entire lifetime, pool exhaustion happens at a much lower concurrent-session count than you expect. The safe pattern: acquire connections per tool call, release them immediately after the query, and size the pool for concurrent tool calls — not concurrent sessions.
TL;DR
Never hold a database connection for the lifetime of an MCP session. Acquire per tool call, release in a finally block. Pool size should equal your target concurrent tool calls (start with max_sessions × 0.3, since most sessions are idle at any moment). Set an acquireTimeoutMillis of 5-10 seconds and return an isError: true result when the pool is exhausted rather than letting the tool hang. AliveMCP's probe measures initialize latency — pool exhaustion shows up as probe latency spikes because the new session cannot run initialization queries.
Why MCP changes connection pool math
In a stateless REST API with a pool of 20 connections and average query time of 50ms, you can serve 400 requests/second (20 connections ÷ 0.05s). In an MCP server, if 20 concurrent sessions each hold their connection from initialize to session close, you have zero connections available for new sessions — the pool is exhausted at 20 sessions. The fix is simple: do not hold connections across tool calls:
| Pattern | Connection held for | Pool exhaustion at |
|---|---|---|
| Hold for session lifetime (wrong) | Minutes | pool_size concurrent sessions |
| Hold for request handler (REST pattern) | 1 HTTP request | Higher, but still problematic for MCP |
| Acquire per tool call (correct) | Duration of one DB query | Effectively unlimited relative to sessions |
Acquire-per-tool-call pattern with Knex
Using Knex.js (which wraps pg, mysql2, or better-sqlite3) as an example of the acquire-per-tool-call pattern:
// db.ts — pool configuration
import knex from 'knex';
export const db = knex({
client: 'pg',
connection: process.env.DATABASE_URL,
pool: {
min: 2,
max: 20,
acquireTimeoutMillis: 8000, // fail fast instead of queueing indefinitely
idleTimeoutMillis: 30000, // release idle connections after 30s
reapIntervalMillis: 1000, // check for idle connections every 1s
},
asyncStackTraces: process.env.NODE_ENV !== 'production',
});
// Structured pool stats for monitoring
export function getPoolStats() {
const pool = (db.client as any).pool;
return {
total: pool.numUsed() + pool.numFree() + pool.numPendingAcquires(),
used: pool.numUsed(),
free: pool.numFree(),
pending: pool.numPendingAcquires(),
};
}
// tool handler — acquire per call, release in finally
server.tool(
'search_documents',
'Search documents by keyword',
{ query: z.string().min(1) },
async (args) => {
// Do NOT use a connection held at session scope — acquire per call
let rows: unknown[];
try {
rows = await db('documents')
.where('content', 'ilike', `%${args.query}%`)
.limit(20)
.select('id', 'title', 'snippet');
} catch (err: any) {
// Pool exhaustion throws with a message containing "acquire timeout"
if (err.message?.includes('acquire')) {
return {
isError: true,
content: [{ type: 'text', text: 'Service temporarily busy — please retry.' }],
};
}
throw err; // Propagate unexpected errors as MCP protocol errors
}
return { content: [{ type: 'text', text: JSON.stringify(rows) }] };
}
);
Knex automatically returns the connection to the pool after every query chain completes. You do not need an explicit release call. If you use raw connection objects (e.g., pg's pool.connect()), always release in a finally block — a missing release on the error path is the most common cause of pool exhaustion in production.
Pool size formula
Start with this formula, then tune based on observed pool stats:
pool_max = target_concurrent_sessions × avg_tool_calls_per_session × db_query_fraction × concurrency_factor
# Realistic example:
# 100 concurrent sessions
# 3 tool calls per session on average (but not all at once)
# 60% of tool calls hit the database
# At peak, ~20% of sessions are in an active tool call simultaneously
#
# pool_max = 100 × 3 × 0.6 × 0.2 = 36 → round up to 40
# PostgreSQL rule: total connections across all app instances must be below
# max_connections (default 100) minus connections reserved for admin/replication.
# For a single instance: pool_max ≤ 80 (leaving 20 for admin).
# For N instances: pool_max_per_instance ≤ 80 / N.
PostgreSQL's max_connections is a hard server-side limit. If your application opens more connections than the server allows, connections are refused with FATAL: sorry, too many clients already. For multiple application instances on a single database, use a connection pooler (PgBouncer in transaction mode) between the app and PostgreSQL — PgBouncer multiplexes many application connections onto a small number of server-side connections.
Pool exhaustion detection and alerting
Log pool stats on every tool call and alert when pending acquisitions exceed zero for more than one probe cycle:
// Middleware: log pool stats on every MCP request
app.use('/mcp', (req, res, next) => {
const stats = getPoolStats();
// Alert when more than 10% of pool is waiting
if (stats.pending > 0) {
console.warn({ event: 'pool_pressure', ...stats });
}
res.on('close', () => {
// Log post-request pool state to detect connection leaks
const after = getPoolStats();
if (after.used > stats.used) {
console.error({ event: 'possible_connection_leak', before: stats, after });
}
});
next();
});
AliveMCP measures initialize latency on every probe. If your initialize handler runs a startup query (e.g., loading user context from the DB), pool pressure will cause probe latency to spike. This is an early warning of pool exhaustion before it starts failing user-facing tool calls. Configure an AliveMCP latency alert at P95 > 500ms to catch pool pressure before it becomes pool exhaustion.
Redis connection pooling
For Redis (used in caching, rate limiting, or distributed session state), the same acquire-per-operation discipline applies. The ioredis client maintains an internal connection pool; use a single shared client instance rather than creating a new client per session or tool call:
// redis.ts — shared client, do NOT create per-session
import Redis from 'ioredis';
export const redis = new Redis(process.env.REDIS_URL!, {
maxRetriesPerRequest: 3,
enableOfflineQueue: false, // fail fast when Redis is unreachable
connectTimeout: 5000,
lazyConnect: true,
// Connection pool via ioredis Cluster or explicit pool size:
// For ioredis single-node: one TCP connection, commands pipelined
// For high-throughput: use ioredis.Cluster or a dedicated pooler
});
// Graceful shutdown
export async function closeRedis() {
await redis.quit();
}
Unlike PostgreSQL, Redis uses a single multiplexed connection by default (ioredis pipelines commands). Connection pool exhaustion is rarely a Redis problem unless you're creating a new client per session. The common mistake is forgetting to call quit() during graceful shutdown — leaving Redis connections open causes NOAUTH errors on the next startup if the Redis server reuses the connection.
Related questions
Should I use a global pool or create a pool per MCP session?
Always use a global pool shared across all sessions. Creating a pool per session defeats the purpose of pooling — N concurrent sessions create N pools, each with their own minimum connections, and you open N × pool_min connections instead of pool_max. One global pool at module scope, created once at process start, is the correct pattern.
How do I handle a database connection failure during an active MCP session?
Return isError: true from the tool handler for the specific tool call that failed. The MCP session remains open — only the tool call fails. The client can retry the tool call. Do not close the MCP session on a database error. If the database is completely unreachable, subsequent tool calls in the same session will also return isError: true, which is the correct behavior. The session continues until the client closes it or the session times out.
Does AliveMCP's probe consume a connection from my pool?
Only if your initialize handler runs a database query. AliveMCP's probe sends a standard initialize JSON-RPC request and then immediately sends tools/list. If your initialize handler (or any startup middleware) queries the database, the probe acquires and releases a connection on every 60-second cycle. This is a minor load — one connection acquired per minute. If this concerns you, skip the database query on probe requests by checking clientInfo.name === "AliveMCP" in the initialize handler.
What is the right acquireTimeoutMillis for my pool?
Set it to the same value as your tool-call timeout. If you expect tool calls to return within 10 seconds, set acquireTimeoutMillis to 8 seconds — this leaves 2 seconds for the actual query and result serialization. A timeout shorter than the query duration causes the pool to give up on a connection that would have been available in the next millisecond. A timeout longer than the tool-call timeout means the client waits for a pool timeout that will never resolve before the session itself times out.
Further reading
- MCP server caching — LRU cache to reduce DB queries per tool call
- MCP server rate limiting — Redis-based sliding window for tool call limits
- MCP server graceful shutdown — draining pool connections before process exit
- MCP server load testing — finding pool exhaustion ceiling under concurrent sessions
- MCP server error handling — surfacing pool exhaustion as isError results
- MCP server observability — exporting pool metrics to Prometheus
- AliveMCP — probe latency spikes as an early warning for connection pool pressure