Guide · MCP Database Integration
MCP Server DynamoDB — single-table design and query patterns for MCP tools on AWS
DynamoDB is AWS's managed NoSQL database, designed for predictable single-digit millisecond latency at any scale. For MCP servers running on AWS infrastructure — Lambda, ECS, or EC2 — DynamoDB is a natural fit: same-region queries add under 2ms, there's no connection pool to manage, and IAM role-based access means no credentials in environment variables. This guide covers setting up DynamoDB with the AWS SDK v3 in a TypeScript MCP server, implementing single-table design patterns for MCP tool data, querying with GSIs, writing safe update expressions, and monitoring DynamoDB health from your MCP server's health endpoint.
TL;DR
Use DynamoDBDocumentClient (the marshalling layer over DynamoDBClient) to work with plain JavaScript objects instead of DynamoDB's low-level attribute maps. Design your table with a single-table pattern: composite keys like CUSTOMER#id and ORDER#orderId encode entity type in the key, enabling efficient multi-entity queries. Use ExpressionAttributeNames for any attribute that collides with DynamoDB reserved words (#status, #name, #type). Handle ConditionalCheckFailedException as a business logic error, not a server error — return it as a McpError with a descriptive message rather than a 500.
AWS SDK v3 setup and single-table design
The AWS SDK v3 splits DynamoDB into two clients: DynamoDBClient (low-level, returns attribute maps with type descriptors like { S: 'value' }) and DynamoDBDocumentClient (high-level, marshals to/from plain JavaScript objects). Always use the Document client in MCP server code.
import {
DynamoDBClient,
DescribeTableCommand
} from '@aws-sdk/client-dynamodb';
import {
DynamoDBDocumentClient,
GetCommand,
PutCommand,
UpdateCommand,
DeleteCommand,
QueryCommand,
TransactWriteCommand
} from '@aws-sdk/lib-dynamodb';
const TABLE_NAME = process.env.DYNAMODB_TABLE_NAME!;
const AWS_REGION = process.env.AWS_REGION ?? 'us-east-1';
// DynamoDBClient uses the credential chain automatically:
// 1. Environment variables (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY)
// 2. ~/.aws/credentials file (local dev)
// 3. IAM role attached to Lambda/ECS/EC2 (production — preferred, no secrets needed)
const baseClient = new DynamoDBClient({
region: AWS_REGION,
maxAttempts: 3 // SDK v3 retries throttling with exponential backoff
});
export const ddb = DynamoDBDocumentClient.from(baseClient, {
marshallOptions: {
// Omit undefined values from DynamoDB writes (avoids storing 'null' for optional fields)
removeUndefinedValues: true,
convertEmptyValues: false
}
});
// Single-table key design
// PK examples: CUSTOMER#cust-123 ORDER#ord-456 PRODUCT#prod-789
// SK examples: METADATA ORDER#ord-456 PRODUCT#prod-789
// ORDER#ord-456 ITEM#item-001
//
// This lets you query all orders for a customer:
// KeyConditionExpression: 'pk = :pk AND begins_with(sk, :sk_prefix)'
// ExpressionAttributeValues: { ':pk': 'CUSTOMER#cust-123', ':sk_prefix': 'ORDER#' }
export function pk(entityType: string, id: string): string {
return `${entityType.toUpperCase()}#${id}`;
}
export function sk(entityType: string, id: string): string {
return `${entityType.toUpperCase()}#${id}`;
}
| Entity | PK | SK | Use case |
|---|---|---|---|
| Customer record | CUSTOMER#cust-123 |
METADATA |
Get customer by ID |
| Customer's orders | CUSTOMER#cust-123 |
ORDER#ord-456 |
Query all orders for a customer |
| Order record | ORDER#ord-456 |
METADATA |
Get order by ID (direct access) |
| Order line items | ORDER#ord-456 |
ITEM#item-001 |
Query all items in an order |
get_item tool
GetCommand returns undefined when the item doesn't exist — not an error. Handle this explicitly in the tool handler. Use ProjectionExpression to return only the fields the caller needs, reducing read capacity unit (RCU) consumption for large items.
import { z } from 'zod';
import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
server.tool(
'get_customer',
{
customer_id: z.string().min(1),
fields: z.array(z.string()).optional()
},
async ({ customer_id, fields }) => {
const projectionParts = fields?.map((f, i) => `#attr${i}`);
const expressionAttributeNames = fields
? Object.fromEntries(fields.map((f, i) => [`#attr${i}`, f]))
: undefined;
const { Item } = await ddb.send(new GetCommand({
TableName: TABLE_NAME,
Key: {
pk: pk('CUSTOMER', customer_id),
sk: 'METADATA'
},
ProjectionExpression: projectionParts?.join(', '),
ExpressionAttributeNames: expressionAttributeNames
}));
if (!Item) {
throw new McpError(
ErrorCode.InvalidParams,
`Customer ${customer_id} not found`
);
}
// Strip internal key fields before returning to caller
const { pk: _pk, sk: _sk, ...customerData } = Item;
return {
content: [{ type: 'text', text: JSON.stringify(customerData, null, 2) }]
};
}
);
query_items tool
DynamoDB Query is the primary read pattern for most access patterns. It requires the partition key (PK) and optionally a sort key condition. Use FilterExpression for non-key attribute filtering — but note that filters are applied after the query reads items from the partition, so they don't reduce RCU consumption. Design your key patterns so filters are rarely needed for high-volume queries.
server.tool(
'list_customer_orders',
{
customer_id: z.string().min(1),
status: z.enum(['pending', 'processing', 'shipped', 'delivered', 'cancelled']).optional(),
limit: z.number().int().min(1).max(100).default(25),
start_key: z.string().optional() // base64-encoded LastEvaluatedKey for pagination
},
async ({ customer_id, status, limit, start_key }) => {
const params: import('@aws-sdk/lib-dynamodb').QueryCommandInput = {
TableName: TABLE_NAME,
KeyConditionExpression: 'pk = :pk AND begins_with(sk, :sk_prefix)',
ExpressionAttributeValues: {
':pk': pk('CUSTOMER', customer_id),
':sk_prefix': 'ORDER#'
},
Limit: limit,
ScanIndexForward: false // descending by SK (most recent orders first)
};
// Filter by status (non-key attribute)
if (status) {
params.FilterExpression = '#status = :status';
params.ExpressionAttributeNames = { '#status': 'status' };
(params.ExpressionAttributeValues as Record<string, unknown>)[':status'] = status;
}
// Pagination: decode the continuation key
if (start_key) {
params.ExclusiveStartKey = JSON.parse(
Buffer.from(start_key, 'base64').toString('utf-8')
);
}
const { Items = [], LastEvaluatedKey } = await ddb.send(new QueryCommand(params));
// Encode pagination key for next call
const nextKey = LastEvaluatedKey
? Buffer.from(JSON.stringify(LastEvaluatedKey)).toString('base64')
: null;
return {
content: [{
type: 'text',
text: JSON.stringify({
items: Items.map(({ pk: _pk, sk: _sk, ...item }) => item),
next_start_key: nextKey,
count: Items.length
}, null, 2)
}]
};
}
);
put_item and update_item tools
PutCommand overwrites the entire item. UpdateCommand modifies specific attributes, preserving others. Use ConditionExpression for optimistic locking or to prevent overwrites on new items. Always handle ConditionalCheckFailedException as a 4xx business logic error, not a 5xx server error.
import { ConditionalCheckFailedException } from '@aws-sdk/client-dynamodb';
// update_order_status — safe partial update with condition check
server.tool(
'update_order_status',
{
order_id: z.string().min(1),
new_status: z.enum(['processing', 'shipped', 'delivered', 'cancelled']),
expected_status: z.enum(['pending', 'processing', 'shipped']).optional(),
version: z.number().int().optional() // optimistic locking
},
async ({ order_id, new_status, expected_status, version }) => {
const updateParams: import('@aws-sdk/lib-dynamodb').UpdateCommandInput = {
TableName: TABLE_NAME,
Key: {
pk: pk('ORDER', order_id),
sk: 'METADATA'
},
UpdateExpression: 'SET #status = :new_status, updatedAt = :now, #version = #version + :inc',
ExpressionAttributeNames: {
'#status': 'status', // 'status' is a DynamoDB reserved word
'#version': 'version'
},
ExpressionAttributeValues: {
':new_status': new_status,
':now': new Date().toISOString(),
':inc': 1
},
ReturnValues: 'ALL_NEW'
};
// Add optimistic locking condition if caller provides expected state
if (expected_status || version !== undefined) {
const conditions: string[] = [];
if (expected_status) {
conditions.push('#status = :expected_status');
(updateParams.ExpressionAttributeValues as Record<string, unknown>)[':expected_status'] = expected_status;
}
if (version !== undefined) {
conditions.push('#version = :expected_version');
(updateParams.ExpressionAttributeValues as Record<string, unknown>)[':expected_version'] = version;
}
updateParams.ConditionExpression = conditions.join(' AND ');
}
try {
const { Attributes } = await ddb.send(new UpdateCommand(updateParams));
return {
content: [{ type: 'text', text: JSON.stringify(Attributes, null, 2) }]
};
} catch (e) {
if (e instanceof ConditionalCheckFailedException) {
// This is a 4xx conflict — not a server error
throw new McpError(
ErrorCode.InvalidParams,
`Condition check failed: order may have been updated concurrently or status mismatch`
);
}
throw e; // Re-throw unexpected errors (throttling, etc.)
}
}
);
GSI queries
Global Secondary Indexes (GSIs) enable querying on non-key attributes. Common patterns for MCP server data: query orders by status, find customers by email, list products by category. GSI writes are eventually consistent — a write to the base table propagates to GSIs within milliseconds but not atomically.
// Assume GSI: GSI1PK = 'STATUS#pending', GSI1SK = createdAt (ISO string)
// This lets you query all pending orders efficiently
server.tool(
'list_orders_by_status',
{
status: z.enum(['pending', 'processing', 'shipped', 'delivered', 'cancelled']),
since: z.string().datetime().optional(),
limit: z.number().int().min(1).max(100).default(25)
},
async ({ status, since, limit }) => {
const params: import('@aws-sdk/lib-dynamodb').QueryCommandInput = {
TableName: TABLE_NAME,
IndexName: 'GSI1', // GSI index name
KeyConditionExpression: since
? 'GSI1PK = :gsi_pk AND GSI1SK >= :since'
: 'GSI1PK = :gsi_pk',
ExpressionAttributeValues: {
':gsi_pk': `STATUS#${status}`,
...(since ? { ':since': since } : {})
},
Limit: limit,
ScanIndexForward: false // most recent first
};
const { Items = [], LastEvaluatedKey } = await ddb.send(new QueryCommand(params));
return {
content: [{
type: 'text',
text: JSON.stringify({
items: Items,
count: Items.length,
// Note for caller: GSI data may be seconds behind the base table
consistency_note: 'GSI reads are eventually consistent — data may be up to a few seconds stale'
}, null, 2)
}]
};
}
);
Health endpoint: /health/dynamodb
The DynamoDB health endpoint should verify that the table is active and your IAM permissions are intact. DescribeTableCommand is a lightweight control-plane call that returns table status, item count, and provisioning mode.
import express from 'express';
import { DescribeTableCommand } from '@aws-sdk/client-dynamodb';
const app = express();
app.get('/health/dynamodb', async (_req, res) => {
const start = Date.now();
try {
const { Table } = await ddb.send(
// Note: DescribeTableCommand goes to base DynamoDBClient, not DocumentClient
// but our ddb DocumentClient wraps it — use the underlying client
new DescribeTableCommand({ TableName: TABLE_NAME })
);
const tableStatus = Table?.TableStatus;
const isActive = tableStatus === 'ACTIVE';
const billingMode = Table?.BillingModeSummary?.BillingMode ?? 'PROVISIONED';
res.status(isActive ? 200 : 503).json({
status: isActive ? 'ok' : 'degraded',
table: TABLE_NAME,
table_status: tableStatus,
item_count: Table?.ItemCount,
size_bytes: Table?.TableSizeBytes,
billing_mode: billingMode,
gsi_count: Table?.GlobalSecondaryIndexes?.length ?? 0,
elapsed_ms: Date.now() - start
});
} catch (err) {
res.status(503).json({
status: 'error',
error: (err as Error).message,
elapsed_ms: Date.now() - start
});
}
});
Silent failure modes and AliveMCP monitoring
| Failure mode | What happens | Detected by HTTP check? | How to detect |
|---|---|---|---|
| Provisioned throughput throttling | DynamoDB returns ProvisionedThroughputExceededException. SDK retries 3× with backoff — tool calls take up to ~3s before failing. |
No — MCP server is alive | Track throttling errors in tool handlers; surface in health endpoint as error count |
| On-demand burst limit | On-demand tables have burst limits; RequestLimitExceeded on sustained high traffic spikes. |
No | CloudWatch metric UserErrors and ThrottledRequests; or catch error type in tool handlers |
| IAM permission revocation | IAM role policy is changed; DynamoDB calls return AccessDeniedException. MCP server process alive. |
No | /health/dynamodb DescribeTable call fails with AccessDeniedException |
| GSI backfill in progress | Adding a GSI puts the table in UPDATING state. Queries against the new GSI may return incomplete results. |
Partial — DescribeTable shows UPDATING | Health endpoint reports table_status: UPDATING |
DynamoDB throttling is insidious: the AWS SDK retries automatically, so individual tool calls eventually either succeed (after 3 retries, adding seconds of latency) or fail with the final throttle error. Neither outcome produces an HTTP 503 from your MCP server. Set up AliveMCP monitoring on /health/dynamodb and also track tool call latency — a spike above 2s on normally sub-10ms operations is a reliable indicator of active throttling even when no errors are being returned.
Frequently asked questions
On-demand vs provisioned capacity for MCP workloads — which should I choose?
On-demand (pay-per-request) is the right default for most MCP server deployments. MCP tool call traffic is typically bursty and unpredictable — an AI agent may call a tool 0 times or 50 times in a minute depending on the task. Provisioned capacity requires you to predict and pre-provision read and write capacity units; under-provisioning causes throttling, and over-provisioning wastes money. On-demand handles burst automatically (with some warm-up on new tables) and you pay only for what you use. Switch to provisioned only if your traffic is steady and predictable, you're hitting on-demand burst limits, or your cost analysis shows provisioned is cheaper at your volume (roughly: if you sustain >50% of max provisioned throughput continuously, provisioned becomes cheaper).
Can I use BatchGet or BatchWrite for bulk MCP tool calls?
Yes. BatchGetCommand can fetch up to 25 items in a single request, and BatchWriteCommand can put or delete up to 25 items. This is useful for tools that need to fetch multiple entities at once (e.g., get_orders_batch with an array of order IDs). Important: batch operations are not atomic — some items may succeed while others fail (due to throttling). The response includes UnprocessedKeys / UnprocessedItems that your handler must retry. The SDK v3 does not retry these automatically — implement a retry loop with exponential backoff in your tool handler. For transactional multi-item writes (all-or-none), use TransactWriteCommand instead (limited to 100 items).
How do I store session data with TTL in DynamoDB?
DynamoDB's Time-to-Live (TTL) feature automatically deletes expired items without consuming write capacity. To enable: in the DynamoDB console (or via CloudFormation), enable TTL on a field named ttl (or any name you choose). When writing session data, set ttl to a Unix timestamp (seconds since epoch) representing the expiry time: ttl: Math.floor(Date.now() / 1000) + 3600 for 1-hour sessions. DynamoDB's TTL deletion is eventual — items may persist for up to 48 hours after their TTL timestamp, so your code must also check ttl > Date.now() / 1000 when reading. For MCP server session storage, use a PK of SESSION#sessionId with SK METADATA.
How do I run DynamoDB locally for MCP server development?
Run DynamoDB Local via Docker: docker run -p 8000:8000 amazon/dynamodb-local. Configure the AWS SDK to point to it: new DynamoDBClient({ region: 'us-east-1', endpoint: 'http://localhost:8000', credentials: { accessKeyId: 'fake', secretAccessKey: 'fake' } }). The fake credentials are required but not validated locally. Create your tables with CreateTableCommand at startup if they don't exist (wrap in a try/catch for ResourceInUseException). DynamoDB Local persists data in memory by default (lost on restart) or to a file with -dbPath /data. The AWS NoSQL Workbench tool provides a GUI for local DynamoDB and lets you import production table definitions.
Further reading
- MCP Server AWS — deploying and securing MCP servers on AWS infrastructure
- MCP Server AWS Bedrock — integrating Bedrock models with MCP tool use
- MCP Server Idempotency — safe retries for DynamoDB write tools
- MCP Server Retry Logic — exponential backoff for throttled operations
- MCP Server Error Handling — mapping AWS exceptions to McpError codes