AWS RDS · 2026-09-03 · AWS RDS arc
AWS RDS for MCP Servers: RDS Proxy, IAM Auth, Multi-AZ Failover, and Data API — Four Production Patterns
Lambda-based MCP servers and relational databases are a friction-heavy pairing that generates four predictable failure classes. The first is connection exhaustion: Lambda's per-invocation execution model creates one database connection per concurrent session, and a db.t3.micro caps at 87 connections — a hundred simultaneous MCP users saturates it immediately without a proxy. The second is credential sprawl: rotating a static password requires redeploying every Lambda that reads it from an environment variable, and most teams discover this only during a rotation event when MCP sessions start failing with auth errors. The third is failover blindness: Multi-AZ promotion flips a DNS CNAME in 60-120 seconds, but Node.js connection pools cache the old IP at the OS level — the pool recreates connections that still route to the dead primary for minutes after the CNAME has updated. The fourth is wrong tier selection: teams reach for connection-based access even when their MCP server runs in Lambda@Edge (where no VPC access is possible) or Aurora Serverless (where the Data API's HTTP SQL endpoint eliminates connection pool management entirely). This post synthesizes the complete RDS arc: RDS Proxy as the connection multiplexer, IAM database authentication as the credential model, pool drain-and-recreate as the failover recovery pattern, and the Aurora Serverless v2 / RDS Data API fork as the tier selection framework.
Pattern 1 — RDS Proxy: connection multiplexing, pool sizing, IAM auth to proxy, connection pinning conditions and detection
The fundamental problem is arithmetic. An RDS db.t3.micro defaults to 87 max_connections. A db.r6g.large manages about 4,000. Each connection holds 5–10 MB of DB server RAM for its session state. Lambda invocations are stateless and ephemeral — under load, 500 concurrent invocations each attempt to open a connection, exhausting a small instance in milliseconds. The failure surface is FATAL: remaining connection slots are reserved for non-replication superuser connections — every MCP tool call in flight fails simultaneously.
RDS Proxy sits between Lambda and RDS and multiplexes N Lambda connections through M upstream DB connections, where M is set by the proxy's maxConnectionsPercent setting. The proxy queues connection requests rather than immediately rejecting them when the upstream pool is full. The difference is connectionBorrowTimeout vs. immediate FATAL — for interactive MCP sessions that difference is a recoverable wait vs. a failed tool call.
| Scenario | Without RDS Proxy | With RDS Proxy |
|---|---|---|
| 100 concurrent Lambda invocations | Up to 100 DB connections opened simultaneously | Proxy multiplexes — e.g., 20 upstream connections serve all 100 |
| DB connection limit reached | New MCP calls fail immediately with PostgreSQL FATAL | Proxy queues up to connectionBorrowTimeout (default 120s) |
| Lambda cold start with DB connection | Full TCP + TLS + auth handshake on cold start | Proxy keeps warm upstream connections; Lambda only connects to proxy |
| RDS Multi-AZ failover | All pool connections die; Lambda must reconnect to new endpoint after DNS TTL | Proxy reconnects to new primary transparently; Lambda pool stays connected to proxy |
| DB credential rotation | Lambda restarts required to pick up new secret from env var | Proxy reads from Secrets Manager — rotation is transparent to Lambda |
Configuring RDS Proxy in CDK
import * as rds from "aws-cdk-lib/aws-rds";
import { Duration } from "aws-cdk-lib";
const proxy = new rds.DatabaseProxy(this, "McpDbProxy", {
proxyTarget: rds.ProxyTarget.fromCluster(auroraCluster),
secrets: [dbSecret], // Credentials for proxy→DB; Lambda never sees these
vpc,
securityGroups: [proxySecurityGroup],
iamAuth: true, // Lambda authenticates to proxy with IAM token
maxConnectionsPercent: 80, // Leave 20% for admin connections + monitoring
maxIdleConnectionsPercent: 50,
connectionBorrowTimeout: Duration.seconds(30),
requireTLS: true,
dbProxyName: "mcp-server-proxy",
});
// Grant Lambda IAM permission to connect as "app_user" via the proxy
proxy.grantConnect(mcpLambdaFunction, "app_user");
The grantConnect call attaches the rds-db:connect IAM policy targeting the specific proxy + DB username combination. The Lambda role never needs access to the Secrets Manager secret — the proxy fetches DB credentials from Secrets Manager itself, and handles rotation events without any Lambda-side changes.
Pool sizing in Lambda: max:1 per invocation is correct
The most common misconfiguration is setting max: 10 or max: 5 in the Lambda's pg.Pool constructor. Each Lambda invocation processes one MCP session at a time. A pool of size 10 in a Lambda invocation means 10 connections from one invocation — with 100 concurrent invocations, that is 1,000 connections from the Lambda side to the proxy. The proxy reduces these to a smaller set of upstream connections, but the Lambda→proxy TCP connection count still scales with concurrency × pool size. Set max: 1 in Lambda; let the proxy provide the aggregate pooling.
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!, // NOT the RDS cluster endpoint
port: 5432,
username: "app_user",
});
let pool: Pool | null = null;
let tokenExpiry = 0;
async function getPool(): Promise {
const now = Date.now();
if (!pool || now > tokenExpiry - 60_000) {
const token = await signer.getAuthToken(); // Local SigV4 signing, ~50-200ms
tokenExpiry = now + 900_000; // 15-minute token lifetime
const oldPool = pool;
pool = new Pool({
host: process.env.RDS_PROXY_ENDPOINT,
port: 5432,
database: process.env.DB_NAME,
user: "app_user",
password: token,
ssl: { rejectUnauthorized: true },
max: 1, // Correct: one connection per Lambda invocation
idleTimeoutMillis: 10_000,
});
if (oldPool) await oldPool.end(); // Drain after creating new pool, not before
}
return pool!;
}
Two details matter here. First: process.env.RDS_PROXY_ENDPOINT, not the RDS cluster or instance endpoint. The most common RDS Proxy misconfiguration is leaving the old cluster endpoint in the Lambda environment variable — connections go directly to RDS, bypassing the proxy entirely, and the connection pool problem remains unsolved. The proxy endpoint looks like mcp-server-proxy.proxy-xxxx.us-east-1.rds.amazonaws.com, which is visually similar to the cluster endpoint. Always verify which endpoint is configured.
Second: create the new pool before draining the old one. The pattern above stores the old pool, creates the new pool with the fresh token, then calls oldPool.end(). This prevents a gap where the pool is null and concurrent invocations race to recreate it.
Connection pinning: the silent multiplexing killer
RDS Proxy can share upstream connections between Lambda invocations only when it can safely reassign a connection from one client to another between transactions. Connection pinning is the condition where the proxy determines a connection cannot be safely reassigned — because the DB session carries client-specific state. A pinned connection is monopolized by one Lambda invocation for the duration of the session, eliminating multiplexing for that connection.
Operations that cause pinning in PostgreSQL mode:
SETstatements —SET search_path TO myschema,SET statement_timeout = '5s'— change session-level configuration- Temporary tables —
CREATE TEMP TABLE ...creates session-scoped state - Advisory locks —
pg_advisory_lock()is session-scoped; the lock would transfer to the next client on reassignment - Named prepared statements — the extended query protocol; named statements are session-scoped in PostgreSQL
- Open transactions spanning multiple MCP tool calls — if a tool begins a transaction and returns without committing or rolling back, the connection is pinned for the life of the MCP session
// WRONG: causes pinning — SET is session-scoped
await client.query("SET search_path TO myschema");
await client.query("SELECT * FROM sessions"); // pinned to this Lambda
// RIGHT: schema-qualify without SET
await client.query("SELECT * FROM myschema.sessions WHERE id = $1", [id]);
// WRONG: causes pinning — temp table is session-scoped
await client.query("CREATE TEMP TABLE batch_ids AS SELECT id FROM ...");
// RIGHT: use a CTE instead
await client.query(`
WITH batch_ids AS (SELECT id FROM ...)
SELECT * FROM data WHERE id IN (SELECT id FROM batch_ids)
`);
// WRONG: open transaction across MCP tool call boundary
await client.query("BEGIN");
return mcpResult; // handler returns — transaction still open — connection pinned
// RIGHT: complete transaction in one handler call
await client.query("BEGIN");
try {
await client.query("INSERT INTO ...");
await client.query("COMMIT");
} catch (err) {
await client.query("ROLLBACK");
throw err;
}
Detecting pinning in production: watch the DatabaseConnections and MaxDatabaseConnectionsAllowed CloudWatch metrics on the proxy. If DatabaseConnections approaches MaxDatabaseConnectionsAllowed while Lambda concurrency is well below what should require that many upstream connections, pinning is the cause. Enable debugLogging on the proxy to log pinning events to CloudWatch.
Pattern 2 — IAM database authentication: SigV4 token as password, DbiResourceId, SSL, token refresh strategy
IAM database authentication eliminates static database passwords from Lambda environment variables and Secrets Manager entirely — the Lambda's IAM role becomes the credential. The mechanism is a SigV4-signed URL — the same signature algorithm used for all AWS API calls — generated locally by RDSSigner and passed as the PostgreSQL password field. RDS validates the signature against AWS IAM on each new connection.
Three details trip up nearly every team implementing this for the first time.
Detail 1: Token lifetime and what it governs
The token is valid for 15 minutes. Critically, the token is checked only at connection open time — not on each query. An existing, authenticated connection continues working indefinitely after the token that opened it has expired. Only new connection attempts with a stale token fail. This means:
- A warm Lambda invocation with a pool opened 16 minutes ago using a now-expired token will continue serving queries on its existing connection successfully
- The same invocation trying to open a second connection (because the first was closed by idle timeout) will fail auth immediately
- Refresh strategy: regenerate the token 60 seconds before expiry, drain the old pool, create a new pool with the fresh token
Detail 2: DbiResourceId is not the instance identifier
The IAM policy for rds-db:connect uses the DbiResourceId — not the instance identifier. This is the single most common IAM auth configuration mistake. The resource ARN format is:
arn:aws:rds-db:{region}:{account-id}:dbuser:{db-resource-id}/{db-user-name}
The db-resource-id starts with db- followed by an uppercase alphanumeric string (e.g., db-ABCDEFGHIJKLMNOP). It is not the instance identifier like mcp-prod. Get it with:
# For RDS instances:
aws rds describe-db-instances \
--db-instance-identifier mcp-prod \
--query 'DBInstances[0].DbiResourceId'
# Returns: "db-ABCDEFGHIJKLMNOP"
# For Aurora clusters:
aws rds describe-db-clusters \
--db-cluster-identifier mcp-prod-cluster \
--query 'DBClusters[0].DbClusterResourceId'
# Returns: "cluster-ABCDEFGHIJKLMNOP"
Using the instance identifier in the resource ARN produces an AccessDenied error at connection time that is indistinguishable from a misconfigured policy — both fail with authentication errors because the signature doesn't match an authorized resource.
Detail 3: SSL is mandatory
The token travels over the PostgreSQL wire protocol as a cleartext password field. RDS mandates TLS to prevent interception — without SSL enabled in the client config, the connection is rejected before authentication begins. The error looks like a standard auth failure, not a network error, which makes it confusing to debug.
import { RDSSigner } from "@aws-sdk/rds-signer";
import { Pool } from "pg";
const signer = new RDSSigner({
region: process.env.AWS_REGION!,
hostname: process.env.RDS_ENDPOINT!, // For direct RDS (not proxy)
port: 5432,
username: "app_user",
});
const TOKEN_TTL_MS = 900_000; // 15 minutes
const REFRESH_BUFFER_MS = 60_000; // Refresh 60s before expiry
let pool: Pool | null = null;
let tokenExpiresAt = 0;
async function getPool(): Promise {
const now = Date.now();
if (!pool || now > tokenExpiresAt - REFRESH_BUFFER_MS) {
const token = await signer.getAuthToken(); // ~50-200ms local SigV4 signing
tokenExpiresAt = now + TOKEN_TTL_MS;
const oldPool = pool;
pool = new Pool({
host: process.env.RDS_ENDPOINT!,
port: 5432,
database: process.env.DB_NAME!,
user: "app_user",
password: token,
ssl: {
rejectUnauthorized: true,
ca: process.env.RDS_CA_CERT, // Bundle rds-combined-ca-bundle.pem with Lambda
},
max: 1,
idleTimeoutMillis: 10_000,
connectionTimeoutMillis: 5_000,
});
if (oldPool) {
oldPool.end().catch(() => {}); // Fire and forget; old connections already stale
}
}
return pool!;
}
PostgreSQL user setup
The DB user must be granted the rds_iam role. After this grant, the user can only be authenticated via IAM — regular password authentication is disabled for this user. No password needs to be set.
-- As the master user:
CREATE USER app_user WITH LOGIN;
GRANT rds_iam TO app_user;
GRANT CONNECT ON DATABASE mcpdb TO app_user;
GRANT USAGE ON SCHEMA public TO app_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO app_user;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO app_user;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO app_user;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT USAGE, SELECT ON SEQUENCES TO app_user;
When to use IAM auth vs Secrets Manager
When using RDS Proxy, the IAM auth trust relationship changes: the Lambda authenticates to the proxy with an IAM token, but the proxy connects to the DB using credentials stored in Secrets Manager. You never need to set up direct RDS IAM auth in this topology — just enable iamAuth: true on the proxy and use proxy.grantConnect() in CDK. For direct RDS connections without a proxy, IAM auth is the correct pattern for Lambda.
Pattern 3 — Multi-AZ failover: DNS CNAME flip, DNS caching in node-postgres, pool drain-and-recreate, fail-fast vs queue
RDS Multi-AZ maintains a hot standby in a different Availability Zone. Failover promotes the standby to primary and updates the RDS endpoint's DNS CNAME to the new primary's IP. The CNAME TTL is 5 seconds. The full failover window — from primary failure to DNS propagation complete — is 60-120 seconds for hardware or manual failovers, and 120-180 seconds for AZ outages.
This sounds manageable. The problem is that Node.js DNS resolution does not honor short TTLs. The OS resolver caches the answer for its own configured duration, which is often longer than the 5-second RDS TTL. A pg.Pool holds TCP sockets to the primary's IP. When the primary goes down, those sockets die with ECONNRESET or Connection terminated unexpectedly. When the pool tries to open new connections, it re-resolves the hostname — but gets the cached old IP for however long the OS has remaining on its TTL, potentially minutes. New connections that should be going to the promoted primary are still routing to the dead one.
The failover timeline
| Failover trigger | Typical window |
|---|---|
| Hardware failure (detected by RDS) | 60–120 seconds |
Manual failover (reboot-db-instance --force-failover) | 60–120 seconds |
| AZ outage | 120–180 seconds |
| OS-level crash on primary | 30–60 seconds (faster heartbeat detection) |
| Aurora Multi-AZ (cluster endpoint, not CNAME) | 30–60 seconds |
Option A: Use RDS Proxy (eliminates the problem)
The cleanest solution is RDS Proxy. The proxy's endpoint hostname never changes — it is always the same DNS name regardless of which RDS instance is primary. The proxy reconnects to the new primary internally after failover in 5-30 seconds, compared to the 60-120 second window Lambda-to-RDS direct connections experience. Lambda connection pools connected to the proxy endpoint see a brief period of connection borrow timeout errors (the proxy reconnection window), after which everything works normally with no Lambda-side changes.
// Connect to the proxy endpoint, not the RDS cluster endpoint
const pool = new Pool({
host: process.env.RDS_PROXY_ENDPOINT,
// proxy-name.proxy-xxxx.us-east-1.rds.amazonaws.com
// This hostname never changes across failovers
port: 5432,
database: process.env.DB_NAME,
ssl: { rejectUnauthorized: true },
max: 1,
});
Option B: Pool drain-and-recreate for direct connections
If using direct RDS connections, the recovery pattern is: detect a failover error, sleep 6 seconds (one second past the 5-second RDS DNS TTL), verify DNS re-resolution via dns.lookup(), then create a new pool instance. Creating a new Pool object forces a fresh hostname resolution — reusing the existing pool object does not re-resolve DNS.
import { Pool, DatabaseError } from "pg";
import dns from "dns/promises";
const DB_HOST = "mcp-prod.cluster-xxxx.us-east-1.rds.amazonaws.com";
function createPool(): Pool {
return new Pool({
host: DB_HOST,
port: 5432,
database: process.env.DB_NAME!,
user: process.env.DB_USER!,
password: process.env.DB_PASSWORD!,
ssl: { rejectUnauthorized: true },
max: 1,
connectionTimeoutMillis: 5_000,
idleTimeoutMillis: 10_000,
});
}
let pool = createPool();
let recreating = false;
function isFailoverError(err: Error): boolean {
return (
err.message.includes("Connection terminated unexpectedly") ||
err.message.includes("ECONNRESET") ||
err.message.includes("ETIMEDOUT") ||
err.message.includes("terminating connection due to administrator command") ||
(err instanceof DatabaseError && err.code === "57P01") // admin_shutdown
);
}
async function drainAndRecreatePool(): Promise {
if (recreating) return; // Prevent concurrent recreation
recreating = true;
try {
// Wait for DNS TTL to expire (RDS TTL is 5s; sleep 6s to ensure it has)
await new Promise((r) => setTimeout(r, 6_000));
const resolved = await dns.lookup(DB_HOST);
console.log("Post-failover DNS resolved to:", resolved.address);
const oldPool = pool;
pool = createPool(); // Fresh Pool object forces fresh DNS resolution
oldPool.end().catch(() => {}); // Fire and forget; old connections are dead
} finally {
recreating = false;
}
}
export async function dbQuery(sql: string, values?: any[]): Promise {
const client = await pool.connect();
try {
return await client.query(sql, values);
} catch (err) {
if (err instanceof Error && isFailoverError(err)) {
drainAndRecreatePool().catch(console.error); // Async — don't await
// Return error immediately; let MCP client retry in 30-60s
throw new Error("Database unavailable during failover. Retry in 30-60 seconds.");
}
throw err;
} finally {
client.release();
}
}
Fail-fast vs. queue during failover: the UX tradeoff
During a 60-120 second Multi-AZ failover window, you have two options for in-flight MCP tool calls:
- Fail fast: detect the failover error, return a structured MCP error immediately (
Database unavailable, retry in 30-60 seconds), let the client's LLM surface the error. The user sees a transient failure. The UX is honest — the database is actually down. - Queue and wait: hold the Lambda invocation open, retrying the DB connection every few seconds for up to 120 seconds. If the failover completes, the queued request eventually succeeds. The user sees a slow tool call rather than an error.
For interactive MCP sessions, fail-fast is almost always the right choice. Holding Lambda invocations open for 60-120 seconds burns concurrency quota, holds the SSE connection open (which may time out at the ALB or CloudFront layer before the DB recovers), and hides the failure from the user who might otherwise route around it. The 60-120 second window is well-defined and communicable — return it as the retry window in the error message.
Subscribing to failover events
aws rds create-event-subscription \
--subscription-name mcp-db-failover-alerts \
--sns-topic-arn arn:aws:sns:us-east-1:123456789012:mcp-ops-alerts \
--source-type db-instance \
--source-ids mcp-prod \
--event-categories '["failover","failure","availability"]' \
--enabled
# RDS-EVENT-0025: Multi-AZ failover initiated
# RDS-EVENT-0049: Multi-AZ failover completed
Subscribing to SNS failover events lets you take proactive action: temporarily rate-limit MCP tool calls that require DB access, surface a maintenance banner via an environment variable flip, or trigger a pre-warming Lambda invocation to establish a new connection pool after the failover window closes.
Pattern 4 — Data access tier selection: Aurora Serverless v2 ACU cliff vs RDS Data API no-VPC path
Once you've decided to use a relational database for MCP session state, the second decision is which access tier to use. For Lambda-based MCP servers, the choice comes down to two paths: Aurora Serverless v2 with connection-based access (via RDS Proxy), or RDS Data API for HTTP-based SQL with no connection pools at all.
Aurora Serverless v2: the ACU connection limit cliff
Aurora Serverless v2 scales in Aurora Capacity Units (ACUs) from a configurable minimum to a maximum. Each ACU provides approximately 2 GB of memory. max_connections is computed dynamically from available memory:
-- Connection limit by ACU (approximate, for Aurora PostgreSQL):
-- 0.5 ACU (1 GB RAM) → ~90 connections
-- 1 ACU (2 GB RAM) → ~190 connections
-- 2 ACU (4 GB RAM) → ~390 connections
-- 4 ACU (8 GB RAM) → ~800 connections
-- 8 ACU (16 GB RAM) → ~1600 connections
-- 16 ACU (32 GB RAM) → ~3200 connections
The scaling is fast — approximately 1 second from scale trigger to capacity increase. The problem is the connection limit cliff during the scale-up window. If the cluster sits at 0.5 ACU at idle (~90 connections) and a burst of 200 Lambda invocations all attempt to connect simultaneously, the 91st through 200th connections fail with FATAL: remaining connection slots are reserved for non-replication superuser connections. Aurora scales up in ~1 second, but the connections attempted during that second are already rejected.
RDS Proxy solves this: the proxy maintains its own connection pool to Aurora, and Lambda invocations connect to the proxy. When the proxy's upstream pool is temporarily exhausted (during Aurora scale-up), the proxy queues new connection requests rather than immediately rejecting them — connectionBorrowTimeout buys the ~1 second needed for Aurora to scale and expand the connection budget.
Three other Aurora Serverless v2 properties that affect MCP deployments:
- Cannot scale to zero: v2 minimum is 0.5 ACU (~$0.06/hr per ACU-hour for PostgreSQL-compatible). For dev/test workloads that need true scale-to-zero, use Aurora Serverless v1 (which can scale to zero) or DynamoDB. Do not budget Aurora v2 on the assumption it will cost nothing at idle.
- Scale-down is slow: Aurora v2 scales down in 5-minute increments and waits to ensure traffic doesn't return — a 15-minute traffic spike can result in 30-45 minutes at peak capacity cost. Budget for sustained peak ACU during and after burst periods.
- Readers scale independently: with
scaleWithWriter: false, a reader replica may not scale up in sync with the writer during a burst. SetscaleWithWriter: trueif read-heavy MCP tools need to absorb the same burst the writer is handling.
import * as rds from "aws-cdk-lib/aws-rds";
const cluster = new rds.DatabaseCluster(this, "McpSessionDb", {
engine: rds.DatabaseClusterEngine.auroraPostgres({
version: rds.AuroraPostgresEngineVersion.VER_15_4,
}),
serverlessV2MinCapacity: 0.5, // ~90 connections at idle minimum
serverlessV2MaxCapacity: 16, // ~3200 connections at peak
writer: rds.ClusterInstance.serverlessV2("writer", {
scaleWithWriter: true,
}),
vpc,
defaultDatabaseName: "mcpdb",
storageEncrypted: true,
backup: { retention: Duration.days(7) },
iamAuthentication: true,
});
RDS Data API: HTTP SQL, no VPC, no pools
RDS Data API is a fundamentally different access model: SQL over HTTPS, with no TCP connection pools, no VPC placement required, and no connection management at all. Each query is a stateless HTTP call to the rds-data.* AWS service endpoint. The Data API is only available on Aurora Serverless clusters (v1 and v2) — not on provisioned RDS instances or Aurora Provisioned clusters.
| Aspect | RDS Data API | Direct connection (pg / mysql2) |
|---|---|---|
| VPC required | No — HTTPS from any Lambda config | Yes — Lambda must be in VPC with DB |
| Connection pool management | None — stateless HTTP per query | Required — pool sizing, IAM refresh, failover handling |
| Max response size | 45 MB hard limit | Limited by Lambda memory (~10 GB) |
| Query timeout | 1 minute (hard, cannot be raised) | Configurable — up to Lambda timeout (15 min) |
| Supported databases | Aurora Serverless v1 and v2 only | Any RDS/Aurora in VPC |
| Multi-statement transactions | Via transactionId token (3-min expiry) | Standard BEGIN/COMMIT within a connection |
| Cold start overhead | No connection setup — first query is fast | Full TLS + auth handshake on cold start |
| Works in Lambda@Edge | Yes — no VPC constraint | No — Lambda@Edge cannot be in VPC |
| Per-query overhead | ~50-150ms HTTP round-trip to service endpoint | ~0-5ms for warm pool connection |
Data API query pattern
import {
RDSDataClient,
ExecuteStatementCommand,
} from "@aws-sdk/client-rds-data";
const rdsData = new RDSDataClient({ region: process.env.AWS_REGION });
const CLUSTER_ARN = process.env.AURORA_CLUSTER_ARN!;
const SECRET_ARN = process.env.AURORA_SECRET_ARN!;
// IMPORTANT: named parameter syntax ":param1", NOT positional "$1"
async function queryRows(sql: string, params: any[] = []): Promise {
const response = await rdsData.send(
new ExecuteStatementCommand({
resourceArn: CLUSTER_ARN,
secretArn: SECRET_ARN,
database: "mcpdb",
sql,
parameters: params.map((value, i) => ({
name: `param${i + 1}`,
value: typedField(value),
})),
includeResultMetadata: true,
formatRecordsAs: "JSON",
})
);
return JSON.parse(response.formattedRecords ?? "[]");
}
// Usage in MCP tool handler:
const session = await queryRows(
"SELECT * FROM mcp_sessions WHERE session_id = :param1",
[sessionId]
);
The named parameter syntax (:param1 instead of $1) is the most common porting mistake when migrating from node-postgres to the Data API. The Data API rejects positional parameter syntax with a SQL parsing error.
Transactions and the 3-minute transactionId constraint
The Data API supports multi-statement transactions via a transactionId token returned by BeginTransactionCommand. The token expires after 3 minutes of inactivity. Complete all statements in a transaction within a single Lambda handler invocation. Do not return the transactionId to the MCP client or store it in session state for use in a later tool call — in a multi-turn agentic workflow where tool calls are separated by seconds of LLM processing, the token will have expired by the time the next tool call arrives.
import {
BeginTransactionCommand,
CommitTransactionCommand,
RollbackTransactionCommand,
} from "@aws-sdk/client-rds-data";
// All statements must complete within the same handler call — do not pass
// transactionId back to the MCP client for use in a later tool invocation
async function atomicSessionCreate(clientId: string, toolName: string) {
const txn = await rdsData.send(
new BeginTransactionCommand({ resourceArn: CLUSTER_ARN, secretArn: SECRET_ARN, database: "mcpdb" })
);
const transactionId = txn.transactionId!;
try {
const session = await rdsData.send(new ExecuteStatementCommand({
resourceArn: CLUSTER_ARN, secretArn: SECRET_ARN, database: "mcpdb",
transactionId,
sql: "INSERT INTO mcp_sessions (client_id) VALUES (:param1) RETURNING session_id",
parameters: [{ name: "param1", value: { stringValue: clientId } }],
formatRecordsAs: "JSON",
}));
const sessionId = JSON.parse(session.formattedRecords!)[0].session_id;
await rdsData.send(new ExecuteStatementCommand({
resourceArn: CLUSTER_ARN, secretArn: SECRET_ARN, database: "mcpdb",
transactionId,
sql: "INSERT INTO mcp_tool_calls (session_id, tool_name) VALUES (:param1, :param2)",
parameters: [
{ name: "param1", value: { stringValue: sessionId } },
{ name: "param2", value: { stringValue: toolName } },
],
}));
await rdsData.send(new CommitTransactionCommand({
resourceArn: CLUSTER_ARN, secretArn: SECRET_ARN, transactionId,
}));
return sessionId;
} catch (err) {
await rdsData.send(new RollbackTransactionCommand({
resourceArn: CLUSTER_ARN, secretArn: SECRET_ARN, transactionId,
})).catch(() => {}); // Ignore rollback errors; token may have expired
throw err;
}
}
When to use each tier
- Aurora Serverless v2 + RDS Proxy: burst traffic patterns, session state requiring relational integrity, queries that may exceed 45 MB or 1 minute, multi-statement transactions that span complex workflows. Lambda must be in VPC.
- RDS Data API: Lambda@Edge (no VPC available), simple CRUD operations under 45 MB and 1 minute, team wants to eliminate connection pool management entirely, Aurora Serverless cluster already in use.
- Neither: if session state is simple key-value (session ID → JSON blob), DynamoDB eliminates the connection and VPC constraints entirely and scales to zero at idle.
Combined failure mode reference table
| Symptom | Pattern | Cause | Fix |
|---|---|---|---|
| Connections go directly to RDS; proxy has no effect | RDS Proxy | Lambda's DATABASE_URL still points to the RDS cluster endpoint, not the proxy endpoint | Update the connection string to the proxy endpoint (proxy-name.proxy-xxxx.region.rds.amazonaws.com) |
FATAL: PAM authentication failed for user "app_user" | RDS Proxy | Lambda is using a static password to the proxy but iamAuth:true is set; or the IAM token is stale past 15-minute expiry | Use IAM token as the password; refresh token before expiry; verify SSL is enabled on the connection |
connection borrow timeout exceeded | RDS Proxy | Proxy has reached maxConnectionsPercent and all connections are pinned or active | Reduce pinning operations; increase maxConnectionsPercent or DB instance size; lower reserved Lambda concurrency |
DatabaseConnections metric at max despite low Lambda concurrency | RDS Proxy | Connection pinning — each Lambda invocation holds an upstream connection without releasing it | Enable proxy debug logging; identify SET, temp table, or open-transaction patterns in MCP tool handlers; refactor to eliminate them |
| Lambda cannot connect to proxy (timeout) | RDS Proxy | Lambda and proxy are not in the same VPC, or proxy security group doesn't allow inbound TCP 5432 from Lambda SG | Ensure Lambda is in the same VPC as the proxy; add inbound rule on proxy SG allowing TCP 5432 from Lambda SG |
| Credentials rotation breaks MCP server | RDS Proxy | Using direct RDS connection — new secret version not picked up until Lambda restart | Switch to RDS Proxy; it fetches credentials from Secrets Manager and handles rotation transparently |
password authentication failed on new connections after 15 min | IAM Auth | Cached IAM token has expired; stale token passed to new pool creation | Implement token refresh 60s before expiry; track tokenExpiresAt from pool creation time |
AccessDenied: not authorized to perform rds-db:connect | IAM Auth | Lambda execution role missing rds-db:connect policy, or resource ARN uses instance identifier instead of DbiResourceId | Verify policy is attached; use aws rds describe-db-instances --query 'DBInstances[0].DbiResourceId' for the correct resource ID |
FATAL: role "app_user" does not exist | IAM Auth | PostgreSQL user not created, or different username used in IAM policy vs DB | Connect as master user; run CREATE USER app_user WITH LOGIN; GRANT rds_iam TO app_user; |
| SSL error; IAM auth rejected without TLS error message | IAM Auth | SSL not enabled in pg.Pool options; RDS rejects IAM auth connections without TLS | Add ssl: { rejectUnauthorized: true }; bundle rds-combined-ca-bundle.pem with Lambda for CA verification |
| Connections succeed locally; fail in Lambda | IAM Auth | Lambda environment lacks RDS CA bundle for SSL verification | Download RDS CA bundle and bundle with Lambda deployment; set ssl.ca to certificate contents |
| MCP tool calls hang 60-120s during failover then error | Multi-AZ | connectionTimeoutMillis not set; pool waits indefinitely for new connection to old IP | Set connectionTimeoutMillis: 5000; detect failover errors; return fast MCP error response |
| Pool recreated post-failover but new connections still fail | Multi-AZ | OS DNS cache still holds old IP; new Pool still resolves to dead primary | Sleep 6s before recreating pool; verify IP changed via dns.lookup() before pool creation |
| Some Lambda contexts recover from failover; others remain broken | Multi-AZ | Different execution contexts have different pool states; some haven't encountered a failover error yet | Use module-level pool shared across all handler invocations; first error triggers recreation for all subsequent calls |
| Manual failover test shows 3-5 minute outage, not 60-120s | Multi-AZ | Application-level DNS caching longer than OS TTL; pool not recreating on failover errors | Verify no explicit DNS caching in application code; confirm pool is recreated (new object, not reconnected) on failover errors |
| Connection failures during Lambda burst despite Aurora scaling | Aurora Serverless v2 | Lambda concurrency outpaced Aurora scale-up; connections rejected in the ~1s scale-up window | Put RDS Proxy in front of Aurora v2 — proxy queues requests during scale-up instead of immediately rejecting them |
| High cost at off-peak hours; expected near-zero idle cost | Aurora Serverless v2 | Aurora v2 minimum is 0.5 ACU — it cannot scale to zero; idle cost is ~$0.06/hr | Use Aurora Serverless v1 or DynamoDB if true scale-to-zero is required; budget 0.5 ACU idle cost for v2 |
| Cost spike after traffic burst persists 30+ minutes | Aurora Serverless v2 | Aurora v2 scales down in 5-min increments, taking 15-30 min to fully scale down | Expected behavior; budget for sustained peak ACU during and after bursts; RDS Proxy reuse reduces how aggressively Aurora must scale |
HttpEndpointEnabled must be set to true | Data API | Data API not enabled on cluster, or cluster is provisioned (not Aurora Serverless) | Run aws rds modify-db-cluster --enable-http-endpoint; verify cluster type is Aurora Serverless v1 or v2 |
StatementTimeoutException from Data API | Data API | Query exceeded the 1-minute hard timeout (cannot be raised) | Optimize query; add indexes; or switch to direct connection for long-running queries |
PayloadSizeLimitExceededException from Data API | Data API | Result set exceeded 45 MB limit | Add LIMIT; use cursor-based pagination (WHERE id > :last_id ORDER BY id LIMIT 100) |
TransactionNotFoundException on subsequent Data API calls | Data API | transactionId expired (3-minute inactivity); or stored across MCP tool call boundary | Complete all transaction statements in one handler invocation; do not pass transactionId to MCP client for later use |
| SQL syntax error: named parameter not substituted | Data API | Using $1 positional syntax (node-postgres style) instead of Data API's :param1 named syntax | Convert all parameters to :paramName syntax with matching name fields in the parameters array |
Monitoring RDS-backed MCP servers with AliveMCP
RDS failure modes are database-internal, but they surface to users as MCP server downtime. An MCP client trying to call a tool sees connection refused, a hanging request, or a tool error — not a PostgreSQL FATAL message. By the time a user reports the issue, you've already lost the context: was it a connection pool exhaustion event, a Multi-AZ failover, a pinning cascade that consumed all upstream connections, or an IAM token expiry that closed a cold Lambda's pool?
AliveMCP pings your MCP server endpoints every 60 seconds from multiple regions. When an RDS-induced outage occurs — whether a failover window, a connection limit cliff during Aurora scale-up, or a pool recreation gap — AliveMCP captures the exact start time, duration, and which endpoints were affected. Combined with RDS CloudWatch metrics (DatabaseConnections, FailoverTime, ServerlessDatabaseCapacity), you get the full picture: the DB event that caused it, the duration of the outage as seen externally, and whether your failover handling reduced the visible window or let the full 60-120 seconds show to users.
Connect your MCP endpoint at alivemcp.com — the free tier monitors one endpoint at 60-second intervals with Slack and email alerting.