Guide · AWS DynamoDB
MCP Server DynamoDB Transactions — idempotent multi-step tool calls, TransactWriteItems
DynamoDB transactions (TransactWriteItems) let MCP servers atomically write to up to 25 items across one or more tables — all succeed or all fail — but the cost is 2× the write capacity units of the equivalent individual writes. For MCP tool calls, three patterns matter most: idempotency tokens — passing a client-generated token to TransactWriteItems makes the call idempotent for 10 minutes; retrying with the same token returns the original result without re-executing the transaction; ConditionCheck — include a ConditionCheck item in the transaction to assert that a prerequisite item exists or has the expected version before committing the write; and TransactionConflictException — this is the only retriable error from transactions; it means another transaction modified one of the same items concurrently; backoff and retry (the recommended strategy is 50ms + jitter, up to 3 retries).
TL;DR
Use TransactWriteItems for MCP tool calls that must atomically update multiple items or assert preconditions. Pass a ClientRequestToken (UUID derived from the tool call ID) for idempotency. Include a ConditionCheck to guard against concurrent modification. Cap at 25 items per transaction. Retry on TransactionConflictException with exponential backoff (50ms base, up to 3 retries). Never retry on TransactionCanceledException with a ConditionalCheckFailed reason — that is a business logic failure, not a transient error.
Basic TransactWriteItems pattern for MCP tool calls
An MCP tool call that creates a resource and logs the operation atomically — both writes succeed or both are rolled back:
import {
DynamoDBClient,
TransactWriteItemsCommand,
TransactWriteItemsCommandInput,
} from "@aws-sdk/client-dynamodb";
import { marshall } from "@aws-sdk/util-dynamodb";
const ddb = new DynamoDBClient({});
export async function createResourceWithAudit(
toolCallId: string, // MCP tool call ID — use as idempotency key
sessionId: string,
resourceId: string,
resourceData: Record
): Promise {
const nowIso = new Date().toISOString();
const ttlSec = Math.floor(Date.now() / 1000) + 86400; // 24h
const input: TransactWriteItemsCommandInput = {
// ClientRequestToken makes this transaction idempotent for 10 minutes.
// Use the MCP tool call ID — if the caller retries the same tool call,
// the same token ensures the transaction doesn't double-execute.
ClientRequestToken: toolCallId,
TransactItems: [
// 1. Assert the session is alive (ConditionCheck — read-only assertion)
{
ConditionCheck: {
TableName: "mcp-sessions",
Key: marshall({ pk: `session#${sessionId}`, sk: "metadata" }),
ConditionExpression: "attribute_exists(pk) AND #ttl > :now",
ExpressionAttributeNames: { "#ttl": "ttl" },
ExpressionAttributeValues: marshall({ ":now": Math.floor(Date.now() / 1000) }),
},
},
// 2. Create the resource item
{
Put: {
TableName: "mcp-resources",
Item: marshall({
pk: `resource#${resourceId}`,
sk: "v1",
resourceId,
sessionId,
data: resourceData,
status: "active",
createdAt: nowIso,
ttl: ttlSec,
}),
// Prevent accidental overwrites of existing resources
ConditionExpression: "attribute_not_exists(pk)",
},
},
// 3. Append an audit log entry
{
Put: {
TableName: "mcp-sessions",
Item: marshall({
pk: `session#${sessionId}`,
sk: `audit#${nowIso}#${toolCallId}`,
toolCallId,
action: "create_resource",
resourceId,
recordedAt: nowIso,
ttl: ttlSec,
}),
},
},
],
};
await ddb.send(new TransactWriteItemsCommand(input));
}
Retry strategy for TransactionConflictException
TransactionConflictException means two transactions tried to modify the same item at the same time. DynamoDB uses optimistic concurrency — the loser is rejected and must retry. This is the only error from transactions that should be retried. All other errors (TransactionCanceledException, ValidationException) indicate either a business logic failure or a programming error and should not be retried.
import { TransactionConflictException } from "@aws-sdk/client-dynamodb";
const MAX_RETRIES = 3;
const BASE_DELAY_MS = 50;
export async function executeToolCallTransaction(
toolCallId: string,
buildTransaction: () => TransactWriteItemsCommandInput
): Promise {
let attempt = 0;
while (true) {
try {
await ddb.send(new TransactWriteItemsCommand(buildTransaction()));
return; // success
} catch (err) {
if (err instanceof TransactionConflictException && attempt < MAX_RETRIES) {
// Exponential backoff with jitter for concurrent transaction conflicts
const delay = BASE_DELAY_MS * Math.pow(2, attempt) + Math.random() * 25;
await new Promise((resolve) => setTimeout(resolve, delay));
attempt++;
continue;
}
// Re-throw all other errors — TransactionCanceledException is a business
// logic failure (e.g., ConditionCheck failed) and must NOT be retried.
throw err;
}
}
}
// Parse TransactionCanceledException to surface which condition failed
import { TransactionCanceledException } from "@aws-sdk/client-dynamodb";
function handleTransactionCanceled(err: unknown): string {
if (!(err instanceof TransactionCanceledException)) throw err;
const reasons = err.CancellationReasons ?? [];
for (let i = 0; i < reasons.length; i++) {
const code = reasons[i].Code;
if (code === "ConditionalCheckFailed") {
// Map index to the relevant TransactItem for a descriptive error
return `Precondition failed for item ${i}: ${reasons[i].Message}`;
}
if (code === "ItemSizeLimitExceeded") {
return `Item ${i} exceeds 400 KB DynamoDB item size limit`;
}
}
return "Transaction canceled for unknown reason";
}
25-item limit and capacity cost
DynamoDB transactions have two constraints that affect MCP server design:
| Constraint | Value | Implication for MCP servers |
|---|---|---|
| Max items per transaction | 25 items across all tables | An MCP tool call that touches more than 25 items (e.g., batch-creating resources) must be split across multiple transactions; this breaks atomicity — use saga pattern or accept partial failure |
| Write capacity cost | 2× individual write WCUs | Each transactional write consumes 2 WCUs instead of 1; on provisioned throughput tables, transaction-heavy workloads may need 2× the write capacity compared to non-transactional writes |
| Read capacity cost (TransactGetItems) | 2× individual read RCUs | Transactional reads are always strongly consistent; if you only need eventual consistency for reads, use BatchGetItem (1× RCU) instead |
| Max aggregate transaction size | 4 MB total payload | The sum of all item sizes in a transaction cannot exceed 4 MB; for MCP sessions storing large context blobs, check total payload before transacting |
| Cross-table transactions | Supported (any tables in same region, same account) | MCP servers can atomically write session state + audit log + resource record across three different tables in one transaction |
| Cross-region transactions | Not supported | If you use DynamoDB Global Tables, transactions are local to the Region they are sent to; they will eventually replicate but are not globally atomic |
Idempotency token lifetime and semantics
The ClientRequestToken makes a TransactWriteItems call idempotent for exactly 10 minutes. If you retry with the same token within that window, DynamoDB returns the original response without re-executing the transaction. After 10 minutes, the token is forgotten and the same token would execute a new transaction.
For MCP tool calls, the tool call ID (which the MCP framework provides) is the ideal idempotency token. If the MCP client or framework retries a timed-out tool call within 10 minutes, the same token prevents double execution. After 10 minutes, a re-submitted tool call with the same ID would create a duplicate — guard against this with a ConditionExpression: "attribute_not_exists(toolCallId)" on the primary item write.
Token requirements: must be 1–36 characters (UUID fits), only alphanumerics and hyphens.
Common failure modes
| Symptom | Cause | Fix |
|---|---|---|
| Tool call creates duplicate resources when retried | No ClientRequestToken or idempotency ConditionExpression; each retry executes a new transaction | Pass ClientRequestToken: toolCallId for retries within 10 minutes; add ConditionExpression: "attribute_not_exists(pk)" on the resource Put as a belt-and-suspenders guard |
TransactionCanceledException with ConditionalCheckFailed | A ConditionCheck or Put condition failed — the session is expired, the resource already exists, or the version check failed | Do not retry — this is a business logic failure; inspect CancellationReasons[i].Code to identify which item's condition failed and surface the appropriate error to the MCP caller |
Transaction fails with ValidationException: Transaction request cannot include multiple operations on one item | The transaction includes two operations touching the same item (e.g., a Put and a ConditionCheck on the same key) | Each item can appear only once in a transaction; merge the condition into the Put's own ConditionExpression instead of adding a separate ConditionCheck |
| High latency (P99 spikes) on transactions | Transaction conflict rate is high; retries compound latency; 2× WCU cost under sustained load causes throttling | Monitor TransactionConflict CloudWatch metric; if high, redesign to reduce hot items; consider optimistic locking with version attributes + conditional update instead of transactions for single-item atomic updates |
| Transaction fails with 25-item limit error | A batch MCP tool call (e.g., "create 30 tasks") exceeds the 25-item transaction limit | Split into multiple transactions of ≤25 items; accept partial atomicity or implement a saga pattern with compensating writes for rollback |