Guide · AWS DynamoDB
MCP Server DynamoDB TTL — session cleanup, expiry lifecycle, GSI on TTL attribute
DynamoDB TTL deletes expired items automatically — but deletion is eventual, not instant, and expired items can still be returned by reads for up to 48 hours after the TTL timestamp passes. For MCP session management, three details matter most: TTL must be a Unix epoch timestamp in seconds — not milliseconds, not ISO 8601; a value in milliseconds will be treated as a date far in the future and the item will never expire; filter expired items in your application — a GetItem or Query call may return an item whose ttl attribute is in the past because DynamoDB hasn't deleted it yet; always compare item.ttl to Math.floor(Date.now() / 1000) before using the session; and GSI on the TTL attribute enables pre-expiry queries (find sessions expiring in the next 15 minutes for warm-up or graceful termination) but consumes extra write capacity on every TTL update.
TL;DR
Set timeToLiveAttribute: "ttl" on the table. Store TTL as Unix epoch seconds: Math.floor(Date.now() / 1000) + sessionDurationSeconds. Filter expired items in application code — do not rely on DynamoDB to have already deleted them. If you need near-expiry queries, add a GSI with ttl as the sort key on a status-based partition. Integrate with DynamoDB Streams (eventName === "REMOVE") to trigger cleanup side effects when sessions actually expire.
Configuring TTL on the session table
TTL is enabled per table by specifying the attribute name that holds the expiry timestamp. DynamoDB checks items periodically and deletes those whose TTL value is in the past. The deletion process is best-effort within 48 hours of expiry — it is not guaranteed to be instantaneous.
// CDK: Enable TTL on session table
import * as dynamodb from "aws-cdk-lib/aws-dynamodb";
const sessionTable = new dynamodb.Table(this, "McpSessionTable", {
tableName: "mcp-sessions",
partitionKey: { name: "pk", type: dynamodb.AttributeType.STRING },
sortKey: { name: "sk", type: dynamodb.AttributeType.STRING },
billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
timeToLiveAttribute: "ttl", // must be a Number attribute
stream: dynamodb.StreamViewType.NEW_AND_OLD_IMAGES,
});
Writing a session item with TTL:
import { DynamoDBDocumentClient, PutCommand } from "@aws-sdk/lib-dynamodb";
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({}));
const SESSION_DURATION_SECONDS = 3600; // 1 hour
export async function createSession(
sessionId: string,
data: Record
): Promise {
const nowSec = Math.floor(Date.now() / 1000);
const ttl = nowSec + SESSION_DURATION_SECONDS;
await ddb.send(
new PutCommand({
TableName: "mcp-sessions",
Item: {
pk: `session#${sessionId}`,
sk: "metadata",
sessionId,
data,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
// TTL MUST be epoch seconds (not milliseconds)
ttl,
},
})
);
}
// Extend session TTL on activity (sliding window)
export async function touchSession(sessionId: string): Promise {
const nowSec = Math.floor(Date.now() / 1000);
const newTtl = nowSec + SESSION_DURATION_SECONDS;
await ddb.send(
new UpdateCommand({
TableName: "mcp-sessions",
Key: { pk: `session#${sessionId}`, sk: "metadata" },
UpdateExpression: "SET #ttl = :ttl, updatedAt = :now",
ExpressionAttributeNames: { "#ttl": "ttl" },
ExpressionAttributeValues: {
":ttl": newTtl,
":now": new Date().toISOString(),
":minTtl": nowSec, // only extend if not already expired
},
// Only touch if the session is still alive
ConditionExpression: "attribute_exists(pk) AND #ttl > :minTtl",
})
);
}
Filtering expired items in application code
Because DynamoDB TTL deletion is eventual, your application must filter out expired items on reads. Do not assume that a successful GetItem response means the session is still valid.
import { DynamoDBDocumentClient, GetCommand } from "@aws-sdk/lib-dynamodb";
export async function getSession(
sessionId: string
): Promise | null> {
const result = await ddb.send(
new GetCommand({
TableName: "mcp-sessions",
Key: { pk: `session#${sessionId}`, sk: "metadata" },
ConsistentRead: true, // avoid stale reads from replica
})
);
if (!result.Item) return null;
// Filter expired items that DynamoDB hasn't deleted yet
const nowSec = Math.floor(Date.now() / 1000);
if (result.Item.ttl && result.Item.ttl <= nowSec) {
return null; // treat as expired even if DynamoDB hasn't removed it
}
return result.Item as Record;
}
For Query operations, add a FilterExpression to exclude expired items from the result set. Note that FilterExpression is applied after the items are read — you are billed for the read capacity of all items scanned, including those filtered out:
import { QueryCommand } from "@aws-sdk/lib-dynamodb";
const nowSec = Math.floor(Date.now() / 1000);
const result = await ddb.send(
new QueryCommand({
TableName: "mcp-sessions",
KeyConditionExpression: "pk = :pk",
FilterExpression: "#ttl > :now OR attribute_not_exists(#ttl)",
ExpressionAttributeNames: { "#ttl": "ttl" },
ExpressionAttributeValues: {
":pk": `session#${sessionId}`,
":now": nowSec,
},
})
);
GSI on TTL for pre-expiry queries
A GSI with ttl as the sort key enables efficient queries like "find all sessions expiring in the next 15 minutes" — useful for graceful warmup, pre-expiry notifications, or proactive session refresh. This requires a stable partition key for the GSI (typically a status attribute or a date bucket).
// CDK: Add GSI for TTL-based range queries
sessionTable.addGlobalSecondaryIndex({
indexName: "status-ttl-index",
partitionKey: {
name: "status",
type: dynamodb.AttributeType.STRING,
},
sortKey: {
name: "ttl",
type: dynamodb.AttributeType.NUMBER,
},
projectionType: dynamodb.ProjectionType.INCLUDE,
nonKeyAttributes: ["sessionId", "createdAt", "updatedAt"],
});
// Query: sessions expiring in the next 15 minutes
const nowSec = Math.floor(Date.now() / 1000);
const in15min = nowSec + 15 * 60;
const result = await ddb.send(
new QueryCommand({
TableName: "mcp-sessions",
IndexName: "status-ttl-index",
KeyConditionExpression: "#status = :active AND #ttl BETWEEN :now AND :soon",
ExpressionAttributeNames: {
"#status": "status",
"#ttl": "ttl",
},
ExpressionAttributeValues: {
":active": "active",
":now": nowSec,
":soon": in15min,
},
})
);
Write cost: every session write that updates ttl or status also writes a new entry to the GSI. For high-throughput MCP servers, consider whether the pre-expiry query pattern is worth the extra write capacity cost. An alternative is to use DynamoDB Streams (see the Streams guide) to detect REMOVE events and trigger downstream cleanup without a GSI.
Stream-triggered cleanup on expiry
DynamoDB Streams emits a REMOVE event when a TTL deletion occurs, just like a manual DeleteItem. The difference: TTL-caused REMOVE events have a userIdentity.type of "Service" and userIdentity.principalId of "dynamodb.amazonaws.com". Use this to distinguish TTL cleanup from application-initiated deletes.
// Lambda stream handler: clean up side effects on TTL expiry
export async function handler(event: DynamoDBStreamEvent): Promise {
for (const record of event.Records) {
if (record.eventName !== "REMOVE") continue;
// Distinguish TTL-triggered delete from application DeleteItem
const isTtlExpiry =
record.userIdentity?.type === "Service" &&
record.userIdentity?.principalId === "dynamodb.amazonaws.com";
if (!isTtlExpiry) continue; // skip application deletes if only cleaning up TTL expiry
const oldImage = record.dynamodb?.OldImage;
if (!oldImage) continue;
const sessionId = oldImage.sessionId?.S;
if (!sessionId) continue;
// Trigger cleanup: revoke tokens, update status in another system, log
await cleanupExpiredSession(sessionId);
}
}
Common failure modes
| Symptom | Cause | Fix |
|---|---|---|
| Sessions never expire; items accumulate indefinitely | TTL attribute is stored in milliseconds instead of seconds (e.g., Date.now() instead of Math.floor(Date.now() / 1000)); DynamoDB treats the value as a timestamp far in the future | Always use Math.floor(Date.now() / 1000) + durationSeconds; verify with aws dynamodb get-item and check the numeric value |
Expired sessions still returned by GetItem | DynamoDB TTL deletion is eventual — expired items may persist for up to 48 hours after their TTL timestamp | Add application-level expiry check: compare item.ttl to current epoch seconds; treat items with ttl <= nowSec as expired regardless of whether DynamoDB has deleted them |
| GSI queries return fewer items than expected | Items without a status attribute are not included in the status-ttl-index GSI — sparse index behavior | Ensure all session items include both the GSI partition key (status) and sort key (ttl) attributes; items missing either attribute are silently excluded from the GSI |
| TTL stream REMOVE events not firing for cleanup Lambda | Streams are not enabled on the table, or the Lambda event source mapping filters out REMOVE events | Enable stream: dynamodb.StreamViewType.NEW_AND_OLD_IMAGES on the table; remove any Lambda event source filter that excludes eventName === "REMOVE" |
| Cleanup Lambda cannot distinguish TTL expiry from application deletes | Both produce REMOVE events; without checking userIdentity, all deletes trigger cleanup | Check record.userIdentity?.principalId === "dynamodb.amazonaws.com" to identify TTL-triggered removals |