Guide · AWS RDS

MCP Server RDS Proxy — connection pooling for Lambda, pinning pitfalls, IAM auth

RDS Proxy solves the core problem of Lambda-based MCP servers hitting PostgreSQL or MySQL connection limits — but three things catch teams off guard. First, connection pinning defeats multiplexing silently: if your MCP tool executes a SET statement, creates a temporary table, or holds an advisory lock, RDS Proxy pins that client connection to a single upstream DB connection for the session, preventing it from being shared with other Lambda invocations — your pool effective size shrinks to the number of pinned connections. Second, the proxy endpoint is a different hostname from the RDS instance endpoint: update your DATABASE_URL to the proxy endpoint or connections bypass the proxy entirely and go directly to RDS. Third, IAM authentication to the proxy works differently from IAM auth to RDS directly: the Lambda authenticates to the proxy using a temporary IAM token, but the proxy connects to RDS using a Secrets Manager secret — you configure the DB credentials once in Secrets Manager, and the proxy handles rotation transparently to your application.

TL;DR

Point your Lambda MCP server at the RDS Proxy endpoint instead of the RDS instance endpoint. Set the proxy's maxConnectionsPercent to a value that leaves headroom for direct admin connections (80% is a safe starting point). Enable IAM authentication on the proxy and generate a short-lived token in your Lambda initialization code. Avoid session-level SET statements, temporary tables, and advisory locks in MCP tool handlers — they cause pinning that defeats connection multiplexing.

Why Lambda needs RDS Proxy

PostgreSQL enforces a hard connection limit set by max_connections in postgresql.conf. For an RDS db.t3.micro instance, the default is around 87 connections. For a db.r6g.large, it is around 4000 connections. Each connection consumes approximately 5–10 MB of RAM on the DB server.

Lambda invocations are not pre-warmed to a fixed pool. Under load, 500 concurrent Lambda invocations can each try to open a connection, exhausting a db.t3.micro in seconds. The symptom is FATAL: remaining connection slots are reserved for non-replication superuser connections from PostgreSQL — every new MCP tool call fails until existing connections are released.

ScenarioWithout RDS ProxyWith RDS Proxy
100 concurrent Lambda invocationsUp to 100 DB connections openedProxy multiplexes via shared pool (e.g., 20 upstream connections)
DB connection limit reachedNew MCP calls fail with PostgreSQL FATAL errorProxy queues connection requests up to configured timeout
Lambda cold start with connection setupFull TCP + TLS + auth handshake to RDS on each cold startProxy maintains warm connections; cold start only needs to reach proxy
RDS instance failover (Multi-AZ)All connections drop; pool must reconnect to new endpointProxy reconnects to new primary transparently; Lambda pool stays connected to proxy
DB credentials rotationLambda restarts required to pick up new secretProxy handles rotation transparently via Secrets Manager

Configuring RDS Proxy with CDK

import * as rds from "aws-cdk-lib/aws-rds";
import * as ec2 from "aws-cdk-lib/aws-ec2";
import * as secretsmanager from "aws-cdk-lib/aws-secretsmanager";
import { Duration } from "aws-cdk-lib";

// The secret must contain { username, password, host, port, dbname }
const dbSecret = secretsmanager.Secret.fromSecretNameV2(
  this,
  "DbSecret",
  "prod/mcp-server/db"
);

const proxy = new rds.DatabaseProxy(this, "McpDbProxy", {
  proxyTarget: rds.ProxyTarget.fromCluster(auroraCluster),
  secrets: [dbSecret],
  vpc,
  // Allow Lambda SGs to connect to the proxy
  securityGroups: [proxySecurityGroup],
  // Use IAM auth so no static password in Lambda env
  iamAuth: true,
  // Keep 80% of DB max_connections for the pool;
  // leave 20% for admin connections and monitoring agents
  maxConnectionsPercent: 80,
  // How long a connection can sit idle in the pool
  maxIdleConnectionsPercent: 50,
  // Abort a connection request if the pool is exhausted for 30s
  connectionBorrowTimeout: Duration.seconds(30),
  requireTLS: true,
  dbProxyName: "mcp-server-proxy",
});

// Grant Lambda function IAM auth to the proxy
proxy.grantConnect(mcpLambdaFunction, "app_user"); // app_user is the DB username

The grantConnect call attaches an IAM policy that allows the Lambda role to call rds-db:connect on this specific proxy for the given DB user. The Lambda does not need access to the Secrets Manager secret — the proxy retrieves DB credentials itself.

Generating an IAM auth token in Lambda

When iamAuth: true is set on the proxy, connections to the proxy endpoint must use an IAM-signed token as the password. The token is generated using the AWS SDK's RDSSigner and is valid for 15 minutes. Generate it once during Lambda initialization and refresh it before the 15-minute expiry.

import { RDSSigner } from "@aws-sdk/rds-signer";
import { Pool } from "pg";

const signer = new RDSSigner({
  region: process.env.AWS_REGION!,
  hostname: process.env.RDS_PROXY_ENDPOINT!,
  port: 5432,
  username: "app_user",
});

let pool: Pool | null = null;
let tokenExpiry = 0;

async function getPool(): Promise {
  const now = Date.now();
  // Refresh token 60s before expiry (token valid for 15 minutes = 900s)
  if (!pool || now > tokenExpiry - 60_000) {
    const token = await signer.getAuthToken();
    tokenExpiry = now + 900_000; // 15 minutes

    if (pool) {
      // Drain existing pool before recreating with new token
      await pool.end();
    }

    pool = new Pool({
      host: process.env.RDS_PROXY_ENDPOINT,
      port: 5432,
      database: process.env.DB_NAME,
      user: "app_user",
      password: token, // IAM token as the password
      ssl: { rejectUnauthorized: true }, // Required for proxy IAM auth
      max: 1,   // Lambda: keep pool size to 1 per invocation
      idleTimeoutMillis: 10_000,
    });
  }
  return pool!;
}

export const handler = async (event: any) => {
  const client = await (await getPool()).connect();
  try {
    const result = await client.query("SELECT NOW()");
    return { body: JSON.stringify(result.rows) };
  } finally {
    client.release();
  }
};

Note the pool size of max: 1 in the Lambda function. Each Lambda invocation handles one MCP session at a time, so a pool of one connection per invocation is correct. The proxy provides the aggregate pooling across all invocations — do not set max higher in Lambda.

Connection pinning: the silent performance killer

RDS Proxy multiplexes connections by routing multiple client connections (Lambda → proxy) through fewer upstream DB connections (proxy → RDS). Multiplexing is possible only when the proxy can safely reassign an upstream connection from one client to another between transactions.

Pinning occurs when the proxy cannot safely reassign the connection, because the DB session carries state that is specific to one client. When a connection is pinned, one Lambda invocation monopolizes one upstream DB connection for the duration of the session, eliminating the multiplexing benefit.

Operations that cause pinning in PostgreSQL:

How to detect pinning: Check the DatabaseConnections and MaxDatabaseConnectionsAllowed CloudWatch metrics on the proxy. If DatabaseConnections approaches MaxDatabaseConnectionsAllowed while your Lambda concurrency is much higher than expected, pinning is likely. The proxy also logs pinning events to CloudWatch when you enable debugLogging.

// Avoid pinning: do NOT use SET at the session level
// BAD: causes pinning
await client.query("SET search_path TO myschema");

// GOOD: use schema-qualified names instead
await client.query("SELECT * FROM myschema.sessions WHERE id = $1", [sessionId]);

// Avoid pinning: do NOT use temporary tables
// BAD: causes pinning
await client.query("CREATE TEMP TABLE batch_ids AS SELECT ...");

// GOOD: use CTEs or subqueries instead
await client.query("WITH batch_ids AS (SELECT ...) SELECT ...");

// Avoid pinning: complete transactions within a single tool call
// BAD: begins a transaction, returns from handler leaving it open
await client.query("BEGIN");
// ... handler returns without COMMIT/ROLLBACK

// GOOD: complete the transaction before the handler returns
await client.query("BEGIN");
try {
  await client.query("INSERT INTO ...");
  await client.query("COMMIT");
} catch (err) {
  await client.query("ROLLBACK");
  throw err;
}

Common failure modes

SymptomCauseFix
Connections still going directly to RDS, proxy not engagedLambda's DATABASE_URL still points to the RDS instance or cluster endpoint, not the proxy endpointUpdate the connection string to use the proxy endpoint (visible in the RDS console under "Proxies" → Endpoint); the proxy endpoint looks like proxy-name.proxy-xxxx.region.rds.amazonaws.com
FATAL: PAM authentication failed for user "app_user"Lambda is trying to authenticate with a static password to the proxy, but IAM auth is required; or the IAM token is stale (past 15-minute expiry)Ensure the connection uses the IAM token as the password; refresh the token before it expires; verify SSL is enabled (ssl: { rejectUnauthorized: true })
Connection pool exhausted; proxy returns connection borrow timeout exceededProxy has reached maxConnectionsPercent of the DB's max_connections and all connections are pinned or in useReduce pinning operations; lower Lambda concurrency reserved for MCP; increase DB instance size to raise max_connections; or raise maxConnectionsPercent
DatabaseConnections metric equals MaxDatabaseConnectionsAllowed even at low Lambda concurrencyPinning is occurring — each Lambda invocation holds an upstream connection without releasing itEnable proxy debug logging; identify which SET, temp table, or transaction pattern is causing pinning; refactor MCP tool handlers to avoid those patterns
Lambda cannot connect to proxy (connection timeout)Lambda and proxy are not in the same VPC or the proxy's security group does not allow inbound 5432 from Lambda's security groupEnsure Lambda and proxy are in the same VPC; add an inbound rule on the proxy's security group allowing TCP 5432 from the Lambda security group
Secrets rotation breaks MCP server connectionsUsing direct RDS connection (not proxy) — new secret version is not picked up until Lambda restartSwitch to proxy: the proxy monitors Secrets Manager and updates credentials on rotation without requiring Lambda restart or redeployment