Guide · AWS DynamoDB

MCP Server DynamoDB Streams — audit trails, session event sourcing, Lambda triggers

DynamoDB Streams gives every MCP server write a durable, ordered change record — but the ordering guarantee is per partition key, not across the whole table. When an MCP tool call writes a session record, the corresponding stream record captures the before-image and after-image (depending on StreamViewType) and makes it available for up to 24 hours. Three details cause the most production incidents: StreamViewType — NEW_AND_OLD_IMAGES is required for diff-based audit logs; NEW_IMAGE alone cannot reconstruct what changed; shard ordering — a shard emits records in strict write order for each partition key, but a single Lambda invocation may process records from multiple shards, which have no ordering guarantee relative to each other; and bisect-on-error — without BisectBatchOnFunctionError: true on the event source mapping, a single poison-pill record blocks the entire shard for up to 24 hours.

TL;DR

Enable DynamoDB Streams with NEW_AND_OLD_IMAGES on your session table. Create a Lambda event source mapping with StartingPosition: TRIM_HORIZON, BisectBatchOnFunctionError: true, and a dead-letter queue. In the Lambda, iterate event.Records — each record has eventName (INSERT / MODIFY / REMOVE), dynamodb.NewImage, and dynamodb.OldImage. Write audit records to a separate DynamoDB table or S3 log. Never assume cross-partition ordering.

Enabling streams and choosing StreamViewType

DynamoDB Streams is enabled per table. The StreamViewType controls what data each stream record contains:

StreamViewTypeWhat's includedUse case
KEYS_ONLYOnly the partition key and sort key of the modified itemLightweight change notification; triggers re-fetch from the table; no data payload in stream
NEW_IMAGEThe entire item after the modificationRead-through cache invalidation; snapshot-based audit where "what it became" is enough
OLD_IMAGEThe entire item before the modificationUndo log; rarely useful alone; requires secondary read to know new state
NEW_AND_OLD_IMAGESBoth the pre-write and post-write item imagesCorrect for MCP audit trails — compute diffs, detect field-level changes, reconstruct history; required for GDPR right-to-erasure audit evidence

Changing StreamViewType on an existing stream is not possible — you must disable and re-enable streams, which resets the 24-hour retention window. Set NEW_AND_OLD_IMAGES from the start.

// CDK: DynamoDB table with streams enabled
import * as dynamodb from "aws-cdk-lib/aws-dynamodb";
import * as lambda from "aws-cdk-lib/aws-lambda";
import * as lambdaEventSources from "aws-cdk-lib/aws-lambda-event-sources";

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,
  stream: dynamodb.StreamViewType.NEW_AND_OLD_IMAGES,  // required for audit
  timeToLiveAttribute: "ttl",
  removalPolicy: cdk.RemovalPolicy.RETAIN,
});

const auditFn = new lambda.Function(this, "AuditStreamHandler", {
  runtime: lambda.Runtime.NODEJS_22_X,
  handler: "index.handler",
  code: lambda.Code.fromAsset("src/audit-stream"),
  environment: {
    AUDIT_TABLE: auditTable.tableName,
  },
});

auditFn.addEventSource(
  new lambdaEventSources.DynamoEventSource(sessionTable, {
    startingPosition: lambda.StartingPosition.TRIM_HORIZON,
    batchSize: 100,
    bisectBatchOnError: true,       // isolate poison-pill records
    retryAttempts: 3,
    onFailure: new lambdaEventSources.SqsDlq(dlq),
    filters: [
      // Only process MODIFY and REMOVE events (skip INSERT noise if desired)
      // Remove this filter to capture INSERTs too
      lambda.FilterCriteria.filter({
        eventName: lambda.FilterRule.isEqual("MODIFY"),
      }),
    ],
  })
);

Processing stream records in the Lambda handler

Each Lambda invocation receives a batch of stream records. Records within a shard are delivered in strict write order for each partition key. Your handler must be idempotent — Lambda retries the batch on any unhandled exception.

import {
  DynamoDBStreamEvent,
  DynamoDBRecord,
  Context,
} from "aws-lambda";
import { DynamoDBDocumentClient, PutCommand } from "@aws-sdk/lib-dynamodb";
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { unmarshall } from "@aws-sdk/util-dynamodb";
import { AttributeValue } from "@aws-sdk/client-dynamodb";

const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({}));

export async function handler(
  event: DynamoDBStreamEvent,
  _ctx: Context
): Promise {
  for (const record of event.Records) {
    if (record.eventName === "REMOVE") {
      // REMOVE records have OldImage but no NewImage
      await writeAuditEntry(record, "session_deleted");
      continue;
    }
    if (record.eventName === "INSERT") {
      await writeAuditEntry(record, "session_created");
      continue;
    }
    if (record.eventName === "MODIFY") {
      await writeAuditEntry(record, "session_updated");
    }
  }
}

async function writeAuditEntry(
  record: DynamoDBRecord,
  eventType: string
): Promise {
  const newImage = record.dynamodb?.NewImage
    ? unmarshall(record.dynamodb.NewImage as Record)
    : null;
  const oldImage = record.dynamodb?.OldImage
    ? unmarshall(record.dynamodb.OldImage as Record)
    : null;

  // Sequence number is unique per shard — use as idempotency key
  const sequenceNumber = record.dynamodb?.SequenceNumber;

  await ddb.send(
    new PutCommand({
      TableName: process.env.AUDIT_TABLE!,
      Item: {
        pk: `audit#${record.dynamodb?.Keys?.pk.S}`,
        sk: `${Date.now()}#${sequenceNumber}`,
        eventType,
        eventSource: record.eventSourceARN,
        sequenceNumber,
        newImage,
        oldImage,
        // Compute diff for MODIFY events
        changedFields:
          eventType === "session_updated" && newImage && oldImage
            ? Object.keys(newImage).filter(
                (k) => JSON.stringify(newImage[k]) !== JSON.stringify(oldImage[k])
              )
            : null,
        recordedAt: new Date().toISOString(),
      },
      // Idempotency: skip if we already processed this sequence number
      ConditionExpression: "attribute_not_exists(sequenceNumber)",
    })
  );
}

The ConditionExpression: "attribute_not_exists(sequenceNumber)" on the audit write makes the handler idempotent — a duplicate delivery from Lambda retries produces a ConditionalCheckFailedException which should be caught and swallowed (not re-thrown), since the record was already written.

Ordering guarantees and fan-out patterns

DynamoDB Streams partitions into shards, and each shard contains records for a subset of the table's partition key space. The ordering guarantee is:

For MCP audit logs, per-session ordering is usually sufficient — you care that session abc's events are in order, not that session abc's events are interleaved correctly with session xyz's events.

Fan-out to multiple consumers: DynamoDB Streams supports up to 2 concurrent Lambda triggers per stream. For more consumers (e.g., audit Lambda + search indexer Lambda), route the stream through Kinesis Data Streams (KDS) using the Kinesis Adapter, which supports up to 20 consumers via enhanced fan-out.

Common failure modes

SymptomCauseFix
Stream processing stops; no new records processed for hoursLambda handler throws an unhandled exception; without BisectBatchOnFunctionError, the entire shard is blocked until the batch expires (up to 24 hours)Set bisectBatchOnError: true on the event source mapping; the batch is halved on error, isolating the bad record. Also add a DLQ so poison pills don't block forever.
Audit records are duplicatedLambda was retried (timeout, error) and the batch was re-deliveredMake the handler idempotent using the stream record's SequenceNumber as an idempotency key; use a ConditionExpression on the audit write
NewImage is undefined on REMOVE eventsREMOVE records do not have a NewImage — only OldImageCheck record.eventName === "REMOVE" before accessing dynamodb.NewImage; always guard both image fields
Stream records are 24 hours stale by the time Lambda processes themLambda function errors caused the stream to stall at an old shard position for the full 24-hour retention windowSet retryAttempts: 3 and bisectBatchOnError: true to avoid prolonged stalls; monitor IteratorAge CloudWatch metric — alert when it exceeds 5 minutes
OLD_IMAGE is undefined on INSERT eventsINSERT records do not have an OldImage — the item did not exist beforeGuard record.dynamodb?.OldImage with a null check; this is expected and correct behavior for insertions
Stream suddenly loses records after table restore from backupPoint-in-time recovery (PITR) restore creates a new table with a new stream ARN; the old event source mapping points to the old streamAfter restoring, update the Lambda event source mapping to point to the new table's stream ARN; the old stream's 24-hour window is also lost