← Blog

Claude skill connection pool exhaustion

Connection pooling exists because opening a database or network connection is expensive — it involves a TCP handshake, TLS negotiation, and server-side authentication. A skill that opens one connection per tool call is a resource exhaustion vulnerability waiting for the first load spike. MCP skills can be invoked in parallel by multiple LLM sessions simultaneously: a busy Claude deployment with 50 concurrent users each triggering a tool call produces 50 simultaneous invocations of the same handler. If each invocation opens its own database connection, those 50 calls consume 50 of the database’s max_connections (typically 100), leaving the other half available for legitimate application traffic — until the next request burst empties the pool entirely. SA-SKL-RCL covers four patterns where connection lifecycle management is absent or incomplete, turning normal parallel LLM usage into a denial-of-service against the skill’s own backing infrastructure.

By SkillAudit  |  3 Nov 2026

Resource exhaustion in MCP skills differs from traditional web application resource exhaustion in one important way: the LLM is an amplifier. A single user prompt can trigger multiple sequential or parallel tool calls within the same session. A multi-step agentic workflow — retrieve context, look up user record, fetch configuration, write result — may invoke four tool handlers in sequence, each opening a new database connection that may not be released before the next call begins. Multiply that by the number of concurrent LLM sessions and by the number of tool calls per session, and the connection pressure on backing services grows faster than raw user-count metrics suggest. Proper connection pooling at module load time, with bounded pool sizes and acquire timeouts, is the only reliable mitigation.

SA-SKL-RCL-001: database connection created per tool call without pooling

The most common form of connection exhaustion in MCP skills is a database client that is instantiated inside the tool handler function. Every invocation of the handler — regardless of whether a previous invocation is still in progress — creates a brand-new connection to the database server. Because the handler is async, Node.js event loop concurrency means that many invocations can be in-flight simultaneously, each holding its own connection open until its query completes. With MySQL and PostgreSQL, the database server enforces a hard limit on simultaneous connections (max_connections, default 100 in PostgreSQL and 151 in MySQL). Once that limit is reached the database refuses new connections with an error — not just for the skill, but for every other application process sharing the same database server. The fix is a module-level pool singleton, instantiated once at startup and shared across all handler invocations.

// VULNERABLE: new connection per tool call — exhausts max_connections under load
import mysql from 'mysql2/promise';

// Handler called by every tool invocation — each creates a new TCP connection
server.setToolHandler('get_user_record', async (args) => {
  // ← This runs inside the handler body: a new connection on EVERY call
  const db = await mysql.createConnection({
    host: process.env.DB_HOST,
    user: process.env.DB_USER,
    password: process.env.DB_PASSWORD,
    database: process.env.DB_NAME
  });

  const [rows] = await db.query(
    'SELECT * FROM users WHERE id = ?',
    [args.userId]
  );

  // Connection closed after query — but 50 parallel calls = 50 simultaneous opens
  await db.end();
  return rows[0];
});

// ALSO VULNERABLE: new Pool({ max: 1 }) per call — pool per invocation, not shared
server.setToolHandler('get_order', async (args) => {
  const pool = new Pool({ max: 1, ...dbConfig });  // ← pool created here, not reused
  const result = await pool.query('SELECT * FROM orders WHERE id = $1', [args.orderId]);
  await pool.end();
  return result.rows[0];
});

// SECURE: module-level pool singleton — created once, shared across all invocations
import mysql from 'mysql2/promise';

// Instantiated at module load time — ONE pool for all handler calls
const pool = mysql.createPool({
  host: process.env.DB_HOST,
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
  database: process.env.DB_NAME,
  connectionLimit: 10,    // max simultaneous connections from this process
  waitForConnections: true,
  queueLimit: 50          // max queued requests before rejecting
});

server.setToolHandler('get_user_record', async (args) => {
  // getConnection() borrows from pool; released automatically when promise settles
  const connection = await pool.getConnection();
  try {
    const [rows] = await connection.query(
      'SELECT * FROM users WHERE id = ?',
      [args.userId]
    );
    return rows[0];
  } finally {
    connection.release();  // always returned to pool — no leak on error
  }
});

// DETECTION (SkillAudit):
// mysql.createConnection() | new Pool({ ... }) inside handler function body
// (not at module top-level scope)
const connPerCall = searchAST(
  'FunctionDeclaration|ArrowFunction > mysql.createConnection() ' +
  'OR FunctionDeclaration|ArrowFunction > new Pool()'
);
if (connPerCall.length > 0) -> CRITICAL  // SA-SKL-RCL-001

One connection per call is a time bomb, not a design choice: in development with a single test session the pattern works fine — the database never sees more than one or two connections from the skill. In production with 50 concurrent LLM sessions it exhausts the database in seconds. The pool singleton pattern costs nothing in steady-state performance (connections are reused) and eliminates the exhaustion risk entirely. If a pool singleton is impractical for some reason, at minimum use a connection cache keyed on session ID with a bounded size and TTL-based eviction.

CRITICAL
SA-SKL-RCL-001 — Database connection per tool call: mysql.createConnection() or new Pool() inside handler body; parallel LLM sessions each calling the tool simultaneously exhaust the database’s max_connections. Detection: connection constructor inside handler function scope → CRITICAL.

SA-SKL-RCL-002: Redis client created per tool call without connection reuse

The ioredis and redis Node.js clients each maintain a persistent TCP connection to the Redis server for the lifetime of the client object. Unlike a database query connection that is opened, used, and closed, a Redis client is designed to be long-lived: it maintains its connection across many commands, uses pipelining to batch requests, and handles reconnection automatically. Creating a new Redis client inside a tool handler body defeats all of these design properties. Each tool call creates a new TCP connection; the connection persists after the handler returns because the client has no explicit .quit() call and no out-of-scope finalizer in JavaScript; and over time the number of idle TCP connections to Redis accumulates. Redis’s default maxclients is 10,000, so exhaustion takes longer than with a database — but each persistent connection holds memory on both the Redis server and the Node.js process heap, and at sufficient scale the memory pressure becomes its own availability problem.

// VULNERABLE: new Redis client per handler call — TCP connections accumulate
import Redis from 'ioredis';

server.setToolHandler('get_cached_result', async (args) => {
  // New Redis client = new TCP connection on EVERY tool call
  const redis = new Redis({
    host: process.env.REDIS_HOST,
    port: 6379,
    password: process.env.REDIS_PASSWORD
  });

  // Client connects, executes command, but is NOT explicitly closed
  const cached = await redis.get(`result:${args.queryId}`);

  // Handler returns — redis object goes out of scope but TCP connection stays open
  // 50 parallel tool calls = 50 persistent TCP connections to Redis
  return cached ? JSON.parse(cached) : null;
});

// ALSO VULNERABLE: explicit quit() called but still per-invocation
server.setToolHandler('set_cache', async (args) => {
  const redis = new Redis(redisConfig);  // ← new connection per call
  await redis.set(`result:${args.queryId}`, JSON.stringify(args.result), 'EX', 300);
  await redis.quit();  // quit() called — but connection setup overhead on every call
  // Still burns a full TCP handshake + Redis AUTH per invocation
});

// SECURE: module-level Redis singleton — one persistent connection shared by all calls
import Redis from 'ioredis';

// Created once at module load — reused by every handler invocation
const redis = new Redis({
  host: process.env.REDIS_HOST,
  port: 6379,
  password: process.env.REDIS_PASSWORD,
  maxRetriesPerRequest: 3,
  enableReadyCheck: true,
  lazyConnect: false   // connect at startup — fail fast if Redis is unreachable
});

redis.on('error', (err) => {
  // Log but do not crash — ioredis reconnects automatically
  console.error('Redis connection error:', err.message);
});

server.setToolHandler('get_cached_result', async (args) => {
  // Reuses the existing TCP connection — no new connection overhead
  const cached = await redis.get(`result:${args.queryId}`);
  return cached ? JSON.parse(cached) : null;
});

server.setToolHandler('set_cache', async (args) => {
  await redis.set(
    `result:${args.queryId}`,
    JSON.stringify(args.result),
    'EX',
    300
  );
  return { cached: true };
});

// DETECTION (SkillAudit):
// new Redis(...) | new IORedis(...) | createClient() inside handler function body
const redisPerCall = searchAST(
  'FunctionDeclaration|ArrowFunction > new Redis() ' +
  'OR FunctionDeclaration|ArrowFunction > new IORedis() ' +
  'OR FunctionDeclaration|ArrowFunction > createClient()'
);
if (redisPerCall.length > 0) -> HIGH  // SA-SKL-RCL-002

Redis connection accumulation is slower than database exhaustion but harder to diagnose: Redis does not refuse new connections until maxclients is reached (default 10,000), so the failure mode is gradual memory growth and eventually an OOM kill of the Redis process, not an immediate error. By the time memory pressure is visible in monitoring, thousands of idle connections may have accumulated. The ioredis singleton pattern also enables connection pooling for cluster mode, subscriber channels, and pipeline batching — all of which are impossible with a per-call client.

HIGH
SA-SKL-RCL-002 — Redis client per tool call: new Redis(redisConfig) in handler body; each of 50 parallel tool calls creates a persistent TCP connection; idle connections stay open past handler return; accumulated connections consume Redis server memory. Detection: Redis constructor inside handler function scope → HIGH.

SA-SKL-RCL-003: Node http.Agent created without maxSockets limit

Node.js HTTP and HTTPS requests use an http.Agent to manage socket reuse and connection pooling to upstream servers. When no agent is specified (the default in fetch() and older http.request() calls) or when an agent is constructed with default settings, the maxSockets property defaults to Infinity — there is no upper bound on how many simultaneous TCP sockets the agent will open to a given hostname. In a tool handler that makes upstream HTTP requests, maxSockets: Infinity means that 200 parallel tool call invocations each trying to fetch the same upstream API will attempt to open 200 simultaneous TCP sockets. Operating systems enforce a per-process file descriptor limit (the nofile ulimit, default 1024 on many Linux distributions). When the skill process reaches this limit, all subsequent calls to open(), socket(), or connect() fail with EMFILE: too many open files — affecting not just new HTTP requests but also log file writes, database connections, and any other I/O in the process.

// VULNERABLE: no agent specified — fetch() uses default agent with maxSockets=Infinity
server.setToolHandler('fetch_upstream_data', async (args) => {
  // fetch() with no agent option uses global agent — maxSockets defaults to Infinity
  const response = await fetch(
    `https://api.upstream.example/data/${args.resourceId}`
  );
  return response.json();
});

// ALSO VULNERABLE: new https.Agent() with default maxSockets
import https from 'https';

server.setToolHandler('fetch_with_agent', async (args) => {
  // Agent created per call AND with default maxSockets=Infinity — double problem
  const agent = new https.Agent();  // ← maxSockets: Infinity by default

  const response = await fetch(`https://api.upstream.example/data/${args.id}`, {
    // @ts-ignore — Node.js fetch accepts agent in undici options
    dispatcher: agent
  });
  return response.json();
});

// ALSO VULNERABLE: axios without custom httpAgent
import axios from 'axios';

server.setToolHandler('axios_call', async (args) => {
  // axios default adapter uses http.globalAgent with maxSockets=Infinity
  const result = await axios.get(`https://api.upstream.example/data/${args.id}`);
  return result.data;
});

// SECURE: module-level agent singleton with bounded maxSockets
import https from 'https';
import http from 'http';

// Created once — shared across all handler invocations
const httpsAgent = new https.Agent({
  maxSockets: 50,       // max simultaneous connections to any one host
  maxFreeSockets: 10,   // keep-alive pool: sockets to keep open when idle
  keepAlive: true,      // reuse TCP connections across requests
  timeout: 10_000       // socket inactivity timeout — prevents leaked idle sockets
});

const httpAgent = new http.Agent({
  maxSockets: 50,
  maxFreeSockets: 10,
  keepAlive: true,
  timeout: 10_000
});

server.setToolHandler('fetch_upstream_data', async (args) => {
  // Pass module-level agent — bounded sockets, keep-alive reuse
  const response = await fetch(
    `https://api.upstream.example/data/${args.resourceId}`,
    { agent: (url) => url.protocol === 'http:' ? httpAgent : httpsAgent }
  );
  if (!response.ok) {
    throw new Error(`Upstream error: ${response.status}`);
  }
  return response.json();
});

// SECURE: axios with custom httpAgent and httpsAgent at instance level
const apiClient = axios.create({
  baseURL: 'https://api.upstream.example',
  httpAgent,
  httpsAgent,
  timeout: 15_000
});

server.setToolHandler('axios_call', async (args) => {
  const result = await apiClient.get(`/data/${args.id}`);
  return result.data;
});

// DETECTION (SkillAudit):
// new http.Agent() | new https.Agent() without maxSockets option
// OR fetch()/axios.get() inside handler with no agent/dispatcher option
const unboundedAgent = searchAST(
  'new https.Agent() without { maxSockets } ' +
  'OR new http.Agent() without { maxSockets } ' +
  'OR fetch() in handler with no agent option'
);
if (unboundedAgent.length > 0) -> HIGH  // SA-SKL-RCL-003

EMFILE errors affect the entire process, not just HTTP requests: when the OS file descriptor limit is reached, every I/O operation in the process fails — not just new HTTP connections. Database connection pool attempts, log file writes, and even new inbound MCP tool call connections are refused. The skill becomes completely unresponsive until in-flight requests complete and sockets are released. A bounded maxSockets of 50 is sufficient for most MCP use cases and still allows significant parallelism while leaving headroom for other process I/O.

HIGH
SA-SKL-RCL-003 — Unbounded http.Agent maxSockets: new http.Agent() or new https.Agent() without maxSockets, or no agent passed to fetch()/axios; hundreds of parallel requests exhaust OS file descriptor limit; all process I/O fails. Detection: HTTP agent without maxSockets or missing agent in handler HTTP calls → HIGH.

SA-SKL-RCL-004: database connection pool without acquire timeout starves all callers

Using a connection pool is the correct fix for RCL-001 — but a pool without an acquire timeout introduces its own failure mode. When the pool is at capacity (all connectionLimit connections are checked out), new requests to pool.getConnection() or pool.acquire() are queued. If any of the checked-out connections is held by an invocation that never completes — because it is waiting on a slow query, blocked in a deadlock, or holding a transaction open while waiting for an LLM response that times out on the LLM side but not on the tool side — those connections are never released back to the pool. The queue of waiting handlers grows; each queued handler holds a pending Promise; eventually the queue fills (queueLimit) and new requests are rejected. But even before the queue is full, all callers of the skill are stalled indefinitely. An acquire timeout — a maximum time to wait for a connection before rejecting the request with an error — breaks the deadlock: the blocked handler fails fast, the connection is eventually returned (or the server times it out), and the pool recovers.

// VULNERABLE: pool.getConnection() without acquire timeout — callers queue forever
import mysql from 'mysql2/promise';

const pool = mysql.createPool({
  host: process.env.DB_HOST,
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
  database: process.env.DB_NAME,
  connectionLimit: 10,
  waitForConnections: true
  // ← No acquireTimeout: if all 10 connections are held, new callers wait indefinitely
});

server.setToolHandler('run_query', async (args) => {
  // If pool is at capacity, this waits with no timeout
  // One slow transaction + 10 concurrent callers = all 10 queue here indefinitely
  const connection = await pool.getConnection();  // ← no acquire timeout

  try {
    // Suppose this query is in a deadlock — connection is held open
    const [rows] = await connection.query(args.sql);
    return rows;
  } finally {
    connection.release();
  }
});

// ALSO VULNERABLE: pg Pool without connectionTimeoutMillis
import { Pool } from 'pg';

const pgPool = new Pool({
  connectionString: process.env.DATABASE_URL,
  max: 10
  // ← connectionTimeoutMillis not set — defaults to 0 (wait forever)
});

server.setToolHandler('pg_query', async (args) => {
  // Waits indefinitely for a client if pool is exhausted
  const client = await pgPool.connect();  // ← no timeout
  try {
    const result = await client.query(args.sql, args.params);
    return result.rows;
  } finally {
    client.release();
  }
});

// SECURE: pool with acquire timeout — fast failure instead of indefinite stall
import mysql from 'mysql2/promise';

const pool = mysql.createPool({
  host: process.env.DB_HOST,
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
  database: process.env.DB_NAME,
  connectionLimit: 10,
  waitForConnections: true,
  queueLimit: 50,
  acquireTimeout: 5000    // ← reject acquire after 5 s — fail fast, not starve
});

server.setToolHandler('run_query', async (args) => {
  let connection;
  try {
    connection = await pool.getConnection();
    // Pool enforces acquireTimeout — throws if wait exceeds 5 s
  } catch (err) {
    if (err.code === 'POOL_ENQUEUELIMIT' || err.message.includes('timed out')) {
      // Surface a meaningful error to the LLM rather than hanging indefinitely
      throw new Error('Database temporarily unavailable — pool exhausted, retry shortly');
    }
    throw err;
  }

  try {
    const [rows] = await connection.query(args.sql, args.params);
    return rows;
  } finally {
    connection.release();
  }
});

// SECURE: pg Pool with connectionTimeoutMillis
import { Pool } from 'pg';

const pgPool = new Pool({
  connectionString: process.env.DATABASE_URL,
  max: 10,
  connectionTimeoutMillis: 5000,   // ← throw after 5 s if no connection available
  idleTimeoutMillis: 30_000,        // release idle connections after 30 s
  allowExitOnIdle: false
});

server.setToolHandler('pg_query', async (args) => {
  const client = await pgPool.connect();  // throws after 5 s if pool exhausted
  try {
    const result = await client.query(args.sql, args.params);
    return result.rows;
  } catch (err) {
    if (err.message.includes('timeout')) {
      throw new Error('Query pool exhausted — service temporarily unavailable');
    }
    throw err;
  } finally {
    client.release();
  }
});

// DETECTION (SkillAudit):
// pool.getConnection() without acquireTimeout in pool config
// OR new Pool({ max: N }) without connectionTimeoutMillis
const noAcquireTimeout = searchAST(
  'createPool() without acquireTimeout property ' +
  'OR new Pool() without connectionTimeoutMillis property'
);
if (noAcquireTimeout.length > 0) -> HIGH  // SA-SKL-RCL-004

A pool without an acquire timeout converts a single deadlock into a full service outage: one tool invocation that holds a connection open — due to a deadlock, a slow query, or an LLM session that timed out on the client side but left the handler running — will eventually fill all pool slots. Without an acquire timeout, every subsequent tool call stalls indefinitely at the getConnection() call. The skill stops responding without throwing an error, making the failure invisible to monitoring that only tracks error rates. An acquire timeout of 5 seconds turns this scenario into a predictable, loggable error that the LLM can surface to the user and that an alerting system can detect.

HIGH
SA-SKL-RCL-004 — Pool acquire without timeout: pool.getConnection() or pgPool.connect() without acquireTimeout or connectionTimeoutMillis; one deadlocked connection fills all pool slots; all subsequent callers queue indefinitely; skill becomes unresponsive without throwing an error. Detection: pool config missing acquire timeout property → HIGH.

Remediation summary

FindingSeverityFix
RCL-001: Database connection per tool callCRITICALMove mysql.createConnection() or new Pool() to module top-level scope; create one pool singleton at startup with connectionLimit: 10 and queueLimit: 50; always call connection.release() in a finally block
RCL-002: Redis client per tool callHIGHCreate one new Redis(config) at module load time; share the singleton across all handler invocations; attach an error event listener to handle reconnection events without crashing the process
RCL-003: Unbounded http.Agent maxSocketsHIGHCreate module-level new https.Agent({ maxSockets: 50, maxFreeSockets: 10, keepAlive: true }); pass to fetch() via agent option or to axios.create() via httpsAgent; never create agents inside handler bodies
RCL-004: Pool acquire without timeoutHIGHAdd acquireTimeout: 5000 to mysql2 createPool() config; add connectionTimeoutMillis: 5000 to pg Pool config; catch timeout errors in handler and surface a retryable error message to the LLM

SkillAudit detects all four SA-SKL-RCL-001 through RCL-004 connection pool exhaustion patterns through AST-based analysis of connection lifecycle in tool handler code. Related families: SA-SKL-TMO timeout and rate limit absence covers the complementary resource exhaustion patterns where missing timeouts on external calls allow slow upstream services to hold connections open indefinitely. See the complete 15-family SA-SKL attack surface reference for the full taxonomy.

Scan your Claude skill for connection pool vulnerabilities

SkillAudit detects per-call connection creation, unbounded HTTP agents, and missing acquire timeouts across the full SA-SKL-RCL family in 60 seconds.

Get your free audit