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.

ScenarioWithout RDS ProxyWith RDS Proxy
100 concurrent Lambda invocationsUp to 100 DB connections opened simultaneouslyProxy multiplexes — e.g., 20 upstream connections serve all 100
DB connection limit reachedNew MCP calls fail immediately with PostgreSQL FATALProxy queues up to connectionBorrowTimeout (default 120s)
Lambda cold start with DB connectionFull TCP + TLS + auth handshake on cold startProxy keeps warm upstream connections; Lambda only connects to proxy
RDS Multi-AZ failoverAll pool connections die; Lambda must reconnect to new endpoint after DNS TTLProxy reconnects to new primary transparently; Lambda pool stays connected to proxy
DB credential rotationLambda restarts required to pick up new secret from env varProxy 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:

// 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:

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 triggerTypical window
Hardware failure (detected by RDS)60–120 seconds
Manual failover (reboot-db-instance --force-failover)60–120 seconds
AZ outage120–180 seconds
OS-level crash on primary30–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:

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:

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.

AspectRDS Data APIDirect connection (pg / mysql2)
VPC requiredNo — HTTPS from any Lambda configYes — Lambda must be in VPC with DB
Connection pool managementNone — stateless HTTP per queryRequired — pool sizing, IAM refresh, failover handling
Max response size45 MB hard limitLimited by Lambda memory (~10 GB)
Query timeout1 minute (hard, cannot be raised)Configurable — up to Lambda timeout (15 min)
Supported databasesAurora Serverless v1 and v2 onlyAny RDS/Aurora in VPC
Multi-statement transactionsVia transactionId token (3-min expiry)Standard BEGIN/COMMIT within a connection
Cold start overheadNo connection setup — first query is fastFull TLS + auth handshake on cold start
Works in Lambda@EdgeYes — no VPC constraintNo — 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

Combined failure mode reference table

SymptomPatternCauseFix
Connections go directly to RDS; proxy has no effectRDS ProxyLambda's DATABASE_URL still points to the RDS cluster endpoint, not the proxy endpointUpdate the connection string to the proxy endpoint (proxy-name.proxy-xxxx.region.rds.amazonaws.com)
FATAL: PAM authentication failed for user "app_user"RDS ProxyLambda is using a static password to the proxy but iamAuth:true is set; or the IAM token is stale past 15-minute expiryUse IAM token as the password; refresh token before expiry; verify SSL is enabled on the connection
connection borrow timeout exceededRDS ProxyProxy has reached maxConnectionsPercent and all connections are pinned or activeReduce pinning operations; increase maxConnectionsPercent or DB instance size; lower reserved Lambda concurrency
DatabaseConnections metric at max despite low Lambda concurrencyRDS ProxyConnection pinning — each Lambda invocation holds an upstream connection without releasing itEnable 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 ProxyLambda and proxy are not in the same VPC, or proxy security group doesn't allow inbound TCP 5432 from Lambda SGEnsure 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 serverRDS ProxyUsing direct RDS connection — new secret version not picked up until Lambda restartSwitch to RDS Proxy; it fetches credentials from Secrets Manager and handles rotation transparently
password authentication failed on new connections after 15 minIAM AuthCached IAM token has expired; stale token passed to new pool creationImplement token refresh 60s before expiry; track tokenExpiresAt from pool creation time
AccessDenied: not authorized to perform rds-db:connectIAM AuthLambda execution role missing rds-db:connect policy, or resource ARN uses instance identifier instead of DbiResourceIdVerify policy is attached; use aws rds describe-db-instances --query 'DBInstances[0].DbiResourceId' for the correct resource ID
FATAL: role "app_user" does not existIAM AuthPostgreSQL user not created, or different username used in IAM policy vs DBConnect as master user; run CREATE USER app_user WITH LOGIN; GRANT rds_iam TO app_user;
SSL error; IAM auth rejected without TLS error messageIAM AuthSSL not enabled in pg.Pool options; RDS rejects IAM auth connections without TLSAdd ssl: { rejectUnauthorized: true }; bundle rds-combined-ca-bundle.pem with Lambda for CA verification
Connections succeed locally; fail in LambdaIAM AuthLambda environment lacks RDS CA bundle for SSL verificationDownload RDS CA bundle and bundle with Lambda deployment; set ssl.ca to certificate contents
MCP tool calls hang 60-120s during failover then errorMulti-AZconnectionTimeoutMillis not set; pool waits indefinitely for new connection to old IPSet connectionTimeoutMillis: 5000; detect failover errors; return fast MCP error response
Pool recreated post-failover but new connections still failMulti-AZOS DNS cache still holds old IP; new Pool still resolves to dead primarySleep 6s before recreating pool; verify IP changed via dns.lookup() before pool creation
Some Lambda contexts recover from failover; others remain brokenMulti-AZDifferent execution contexts have different pool states; some haven't encountered a failover error yetUse 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-120sMulti-AZApplication-level DNS caching longer than OS TTL; pool not recreating on failover errorsVerify 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 scalingAurora Serverless v2Lambda concurrency outpaced Aurora scale-up; connections rejected in the ~1s scale-up windowPut 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 costAurora Serverless v2Aurora v2 minimum is 0.5 ACU — it cannot scale to zero; idle cost is ~$0.06/hrUse 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+ minutesAurora Serverless v2Aurora v2 scales down in 5-min increments, taking 15-30 min to fully scale downExpected behavior; budget for sustained peak ACU during and after bursts; RDS Proxy reuse reduces how aggressively Aurora must scale
HttpEndpointEnabled must be set to trueData APIData 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 APIData APIQuery 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 APIData APIResult set exceeded 45 MB limitAdd LIMIT; use cursor-based pagination (WHERE id > :last_id ORDER BY id LIMIT 100)
TransactionNotFoundException on subsequent Data API callsData APItransactionId expired (3-minute inactivity); or stored across MCP tool call boundaryComplete all transaction statements in one handler invocation; do not pass transactionId to MCP client for later use
SQL syntax error: named parameter not substitutedData APIUsing $1 positional syntax (node-postgres style) instead of Data API's :param1 named syntaxConvert 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.