Guide · AWS RDS
MCP Server RDS Data API — HTTP SQL, no VPC, no connection pools needed
RDS Data API lets Lambda-based MCP servers execute SQL over HTTPS with no VPC, no connection management, and no pool exhaustion — but three hard limits define when it fits and when it breaks. First, the Data API only works with Aurora Serverless clusters (v1 and v2) — it is not available for provisioned RDS instances or Aurora Provisioned clusters; if you are running a db.t3.micro RDS PostgreSQL instance, the Data API is not an option. Second, the response payload limit is 45 MB: if a query returns more data than that, the API returns an error — for MCP tools that could fetch large result sets, you must paginate at the SQL level (using LIMIT and OFFSET or cursor-based pagination) or use the traditional connection-based approach instead. Third, transactions are stateful but connectionless: the Data API's beginTransaction API returns a transactionId token that expires after 3 minutes of inactivity — unlike a connection-based transaction that lives for the lifetime of the TCP socket, the Data API transaction requires you to pass the transactionId token explicitly in every subsequent statement, making it harder to use across multiple MCP tool calls in a session.
TL;DR
Use RDS Data API when your MCP server runs in Lambda without VPC (or in Lambda@Edge), your queries return under 45 MB, and you don't need multi-statement transactions spanning tool calls. The Data API eliminates connection pool management and VPC complexity entirely — each SQL call is a simple AWS SDK invocation. Store Secrets Manager secret ARNs in Lambda environment variables; the Data API retrieves credentials from Secrets Manager automatically.
Data API vs connection-based access for MCP servers
| Aspect | RDS Data API | Direct connection (pg, mysql2) |
|---|---|---|
| VPC required | No — HTTPS endpoint, works from any Lambda config | Yes — Lambda must be in the same VPC as the DB |
| Connection pool management | None — stateless HTTP per query | Required — pool size, timeout, IAM token refresh |
| Max response size | 45 MB | Limited only by Lambda memory (typically 10 GB) |
| Query timeout | 1 minute (hard limit) | Configurable — up to Lambda timeout (15 min) |
| Supported databases | Aurora Serverless v1 and v2 only | Any RDS/Aurora instance in VPC |
| Transactions across requests | Via transactionId token (expires in 3 min) | Standard BEGIN/COMMIT within a connection |
| Cold start overhead | No connection setup — first query immediately fast | Full TLS + auth handshake on cold start |
| Credentials | Secrets Manager ARN only — no static passwords | Password or IAM token in Lambda config |
| Works with Lambda@Edge | Yes — no VPC constraint | No — Lambda@Edge cannot be in a VPC |
Enabling the Data API on Aurora Serverless v2
# Enable the Data API on an existing Aurora Serverless cluster
aws rds modify-db-cluster \
--db-cluster-identifier mcp-aurora-cluster \
--enable-http-endpoint \
--apply-immediately
# Verify it is enabled
aws rds describe-db-clusters \
--db-cluster-identifier mcp-aurora-cluster \
--query 'DBClusters[0].HttpEndpointEnabled'
# Should return: true
# Store DB credentials in Secrets Manager (required by Data API)
# The secret must contain: username, password, host, port, dbname
aws secretsmanager create-secret \
--name prod/mcp-server/aurora-data-api \
--description "Aurora credentials for RDS Data API" \
--secret-string '{
"username": "app_user",
"password": "...",
"host": "mcp-aurora-cluster.cluster-xxxx.us-east-1.rds.amazonaws.com",
"port": 5432,
"dbname": "mcpdb"
}'
The Data API endpoint is managed by AWS — you do not configure a hostname for it. The SDK resolves the endpoint from the cluster ARN and region automatically.
Executing queries with the AWS SDK
import {
RDSDataClient,
ExecuteStatementCommand,
Field,
} from "@aws-sdk/client-rds-data";
const client = new RDSDataClient({ region: process.env.AWS_REGION });
const CLUSTER_ARN = process.env.AURORA_CLUSTER_ARN!;
const SECRET_ARN = process.env.AURORA_SECRET_ARN!;
const DATABASE = "mcpdb";
// Simple query — no connection setup, no pool management
async function queryRow(sql: string, params: any[] = []): Promise {
const response = await client.send(
new ExecuteStatementCommand({
resourceArn: CLUSTER_ARN,
secretArn: SECRET_ARN,
database: DATABASE,
sql,
// Parameters must be typed explicitly for the Data API
parameters: params.map((value, i) => ({
name: `param${i + 1}`,
value: typedField(value),
})),
// Return column metadata so we can build result objects
includeResultMetadata: true,
// Format results as JSON records (cleaner than the default field-value structure)
formatRecordsAs: "JSON",
})
);
// With formatRecordsAs: "JSON", the response is a JSON string
return JSON.parse(response.formattedRecords ?? "[]");
}
// Helper to create typed Field values for RDS Data API parameters
function typedField(value: any): Field {
if (value === null || value === undefined) {
return { isNull: true };
}
if (typeof value === "string") {
return { stringValue: value };
}
if (typeof value === "number") {
if (Number.isInteger(value)) return { longValue: value };
return { doubleValue: value };
}
if (typeof value === "boolean") {
return { booleanValue: value };
}
// For UUIDs, dates, and other types — pass as string with typeHint
return { stringValue: String(value) };
}
// Usage in an MCP tool handler:
export async function getSession(sessionId: string) {
const rows = await queryRow(
"SELECT * FROM mcp_sessions WHERE session_id = :param1",
[sessionId]
);
return rows[0] ?? null;
}
Note the named parameter syntax: RDS Data API uses :param_name in the SQL and a matching name in the parameters array — not the positional $1, $2 syntax used by node-postgres. This is a common porting mistake when migrating from direct connection code to the Data API.
Transactions across MCP tool calls
Most MCP tools complete a single logical operation (query + response) without needing a multi-statement transaction. But some workflows — like creating a session and logging the first tool call atomically — require a transaction. The Data API handles this with a transactionId token.
import {
RDSDataClient,
BeginTransactionCommand,
ExecuteStatementCommand,
CommitTransactionCommand,
RollbackTransactionCommand,
} from "@aws-sdk/client-rds-data";
async function createSessionWithAudit(
clientId: string,
toolName: string
): Promise {
// Begin transaction — returns a transactionId valid for 3 minutes
const txn = await rdsClient.send(
new BeginTransactionCommand({
resourceArn: CLUSTER_ARN,
secretArn: SECRET_ARN,
database: DATABASE,
})
);
const transactionId = txn.transactionId!;
try {
// Insert session — pass transactionId to join the transaction
const session = await rdsClient.send(
new ExecuteStatementCommand({
resourceArn: CLUSTER_ARN,
secretArn: SECRET_ARN,
database: DATABASE,
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;
// Insert audit log — same transactionId
await rdsClient.send(
new ExecuteStatementCommand({
resourceArn: CLUSTER_ARN,
secretArn: SECRET_ARN,
database: DATABASE,
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 } },
],
})
);
// Commit
await rdsClient.send(
new CommitTransactionCommand({
resourceArn: CLUSTER_ARN,
secretArn: SECRET_ARN,
transactionId,
})
);
return sessionId;
} catch (err) {
// Rollback on error
await rdsClient.send(
new RollbackTransactionCommand({
resourceArn: CLUSTER_ARN,
secretArn: SECRET_ARN,
transactionId,
})
).catch(() => {}); // Ignore rollback errors (transactionId may have expired)
throw err;
}
}
The 3-minute transactionId expiry is a hard constraint. If you begin a transaction in one MCP tool call and try to continue it in a later MCP tool call (for example, in a long-running agentic workflow where the user's LLM makes multiple tool calls over 5+ minutes), the transactionId will have expired. Design MCP tools to complete their transactions within a single handler invocation — do not return a transactionId to the MCP client and expect it to be passed back in a later call.
IAM policy for Data API access
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"rds-data:ExecuteStatement",
"rds-data:BatchExecuteStatement",
"rds-data:BeginTransaction",
"rds-data:CommitTransaction",
"rds-data:RollbackTransaction"
],
"Resource": "arn:aws:rds:us-east-1:123456789012:cluster:mcp-aurora-cluster"
},
{
"Effect": "Allow",
"Action": "secretsmanager:GetSecretValue",
"Resource": "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/mcp-server/aurora-data-api-*"
}
]
}
Both rds-data:* permissions and secretsmanager:GetSecretValue are required. The Data API service uses the Lambda's role to fetch the secret from Secrets Manager on behalf of the query — the Lambda does not need to fetch the secret itself, but it does need the permission because the API call chain uses the caller's credentials.
Common failure modes
| Symptom | Cause | Fix |
|---|---|---|
HttpEndpointEnabled must be set to true or BadRequestException: HTTP endpoint is not enabled for cluster | The Data API is not enabled on the Aurora cluster, or the cluster is not Aurora Serverless (provisioned clusters do not support the Data API) | Run aws rds modify-db-cluster --enable-http-endpoint; verify the cluster type is Aurora Serverless v1 or v2, not provisioned |
Query fails with StatementTimeoutException | Query exceeded the 1-minute execution timeout hard limit of the Data API | Optimize the query; add indexes; or switch to a direct connection for long-running queries — the Data API timeout cannot be raised |
Result set truncated or PayloadSizeLimitExceededException | Query returned more than 45 MB of data | Add LIMIT and paginate using OFFSET or a cursor (WHERE id > :last_id ORDER BY id LIMIT 100); the 45 MB limit cannot be raised |
TransactionNotFoundException when using transactionId | The transaction ID has expired (3-minute inactivity timeout) or was already committed/rolled back | Complete all statements in the transaction within 3 minutes; do not store transactionId across Lambda invocations or MCP tool call boundaries |
Named parameter :param1 not substituted — SQL syntax error | Using positional $1 syntax (node-postgres style) instead of the Data API's named :paramName syntax | Convert all parameters to named syntax: WHERE id = :param1 with parameters: [{ name: "param1", value: { stringValue: id } }] |
| Cold Lambda with Data API queries is not faster than connection-based | Data API adds ~50-150ms per HTTP round-trip to the RDS service endpoint; for multi-query handlers, this overhead accumulates | Data API reduces cold start setup time (no connection handshake) but adds per-query overhead; for latency-sensitive MCP tools that run many queries, connection-based access with a warm pool is faster |