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.
| Scenario | Without RDS Proxy | With RDS Proxy |
|---|---|---|
| 100 concurrent Lambda invocations | Up to 100 DB connections opened | Proxy multiplexes via shared pool (e.g., 20 upstream connections) |
| DB connection limit reached | New MCP calls fail with PostgreSQL FATAL error | Proxy queues connection requests up to configured timeout |
| Lambda cold start with connection setup | Full TCP + TLS + auth handshake to RDS on each cold start | Proxy maintains warm connections; cold start only needs to reach proxy |
| RDS instance failover (Multi-AZ) | All connections drop; pool must reconnect to new endpoint | Proxy reconnects to new primary transparently; Lambda pool stays connected to proxy |
| DB credentials rotation | Lambda restarts required to pick up new secret | Proxy 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:
SETstatements —SET search_path TO myschema,SET statement_timeout = '5s', etc. These change session-level configuration and make the connection non-shareable.- Temporary tables —
CREATE TEMP TABLE ...creates a table scoped to the current session; the proxy cannot hand the connection to another client without losing that table. - Advisory locks —
pg_advisory_lock()is session-scoped; the lock would transfer to the next client if the connection were reassigned. - Prepared statements with the extended query protocol — named prepared statements are session-scoped in PostgreSQL.
- Open transactions spanning multiple tool calls — if an MCP tool begins a transaction and does not commit or roll back before returning, the connection is pinned for the life of the MCP session.
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
| Symptom | Cause | Fix |
|---|---|---|
| Connections still going directly to RDS, proxy not engaged | Lambda's DATABASE_URL still points to the RDS instance or cluster endpoint, not the proxy endpoint | Update 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 exceeded | Proxy has reached maxConnectionsPercent of the DB's max_connections and all connections are pinned or in use | Reduce 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 concurrency | Pinning is occurring — each Lambda invocation holds an upstream connection without releasing it | Enable 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 group | Ensure 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 connections | Using direct RDS connection (not proxy) — new secret version is not picked up until Lambda restart | Switch to proxy: the proxy monitors Secrets Manager and updates credentials on rotation without requiring Lambda restart or redeployment |