Guide · Amazon DocumentDB · Change Streams · Real-time Sync · MCP Server Tools

DocumentDB Change Streams for MCP Servers — Resume Tokens, Real-time Sync, and Lambda Triggers

MCP server tools that propagate document changes in real time — cache invalidation, search index updates, audit log pipelines, event-driven downstream notifications — consume DocumentDB change streams. Change streams deliver a tailable cursor over a ordered log of insert, update, replace, and delete events on a collection or database. For MCP servers, the two most important aspects are reliability (using resume tokens to restart after a crash without missing events) and the deployment choice between a long-running polling process and AWS Lambda triggers via DocumentDB Streams. This guide covers enabling change streams, the collection.watch() API, pipeline filters, fullDocument retrieval, resume token storage, the reconnect loop pattern, and when to use native Lambda triggers instead of the MongoDB change stream API.

TL;DR

Change streams require the change_stream_log_retention_duration cluster parameter to be set (default is 3 hours; set to at least 24 hours for production to survive container restarts and deployment windows). Store the resume token (event._id) in DynamoDB or another durable store after each event is processed — not before — so that a crash replays the last unprocessed event rather than losing it. Use fullDocument: 'updateLookup' to get the full post-update document on update events; without it, update events only carry the delta fields. For event-driven Lambda architectures, consider DocumentDB's native Lambda trigger integration (DocumentDB Streams) instead of polling the MongoDB change stream API — it handles cursor management automatically.

Enabling change streams

Change streams are not enabled by default. You must set the cluster parameter change_stream_log_retention_duration to a non-zero value:

# AWS CLI — set change stream log retention on the cluster parameter group
aws docdb modify-db-cluster-parameter-group \
  --db-cluster-parameter-group-name my-docdb-params \
  --parameters \
    ParameterName=change_stream_log_retention_duration,\
    ParameterValue=86400,\
    ApplyMethod=immediate

# 86400 seconds = 24 hours
# Valid range: 1 hour (3600) to 7 days (604800)
# Default: 10800 (3 hours) — not set by default, change streams are disabled

# Apply to cluster — requires a cluster reboot if the parameter group is not already attached
aws docdb modify-db-cluster \
  --db-cluster-identifier my-cluster \
  --db-cluster-parameter-group-name my-docdb-params \
  --apply-immediately

What the retention duration means: DocumentDB retains the oplog (operation log) for the specified duration. A resume token only remains valid if it references an oplog entry within the retention window. If your consumer crashes and is offline for longer than the retention period, the resume token becomes stale and you receive MongoServerError: Resume point too far back in past. For production, set retention to at least 24 hours; for critical pipelines, consider 48–72 hours.

CDK configuration:

import { CfnDBClusterParameterGroup, CfnDBCluster } from 'aws-cdk-lib/aws-docdb';

const params = new CfnDBClusterParameterGroup(this, 'DocdbParams', {
  family: 'docdb5.0',
  description: 'DocumentDB cluster parameter group with change streams enabled',
  parameters: {
    change_stream_log_retention_duration: '86400',  // 24 hours
    tls: 'enabled',
  },
});

const cluster = new CfnDBCluster(this, 'DocdbCluster', {
  dbClusterParameterGroupName: params.ref,
  // ... other cluster properties
});

collection.watch() — basic change stream consumer

import { MongoClient, ChangeStreamDocument } from 'mongodb';

async function consumeChangeStream(client: MongoClient): Promise {
  const coll = client.db('mydb').collection('orders');

  // Pipeline filter — only receive insert and update events, not deletes
  const pipeline = [
    { $match: {
        operationType: { $in: ['insert', 'update', 'replace'] },
        // Filter to specific field changes — reduces noise
        // 'updateDescription.updatedFields.status' means only updates that changed 'status'
    }},
  ];

  const changeStream = coll.watch(pipeline, {
    fullDocument: 'updateLookup',
    // 'updateLookup' = on update events, fetch and include the full post-update document
    // Without this, update events only include the delta (updatedFields/removedFields)
    // Note: fullDocumentBeforeChange is NOT supported in DocumentDB
    batchSize: 100,
  });

  changeStream.on('change', async (event: ChangeStreamDocument) => {
    console.log('operationType:', event.operationType);
    console.log('documentKey:', event.documentKey);  // { _id: ObjectId(...) }

    switch (event.operationType) {
      case 'insert':
        await handleInsert(event.fullDocument!);
        break;
      case 'update':
        // event.updateDescription.updatedFields — only the changed fields
        // event.fullDocument — full document (available because of updateLookup)
        await handleUpdate(event.documentKey._id, event.fullDocument!);
        break;
      case 'replace':
        // Replace events have fullDocument set to the new complete document
        await handleReplace(event.fullDocument!);
        break;
      case 'delete':
        await handleDelete(event.documentKey._id);
        break;
    }
  });

  changeStream.on('error', (error) => {
    console.error('Change stream error:', error);
    // Error handler is called on network errors — stream is now closed
    // Reconnect logic must be in the outer loop (see below)
  });

  changeStream.on('close', () => {
    console.log('Change stream closed');
  });
}

// IMPORTANT: change stream cursor closes on network errors, idle timeouts, and
// cluster failovers. Never treat the cursor as permanent — always use a reconnect loop.

Resume tokens — reliable at-least-once processing

A resume token is the _id field of each change event. It encodes the oplog position and allows the stream to restart from exactly that position after a crash. Store it in a durable external store after successfully processing each event.

import { DynamoDBClient, PutItemCommand, GetItemCommand } from '@aws-sdk/client-dynamodb';

const ddb = new DynamoDBClient({ region: process.env.AWS_REGION });
const RESUME_TOKEN_TABLE = 'docdb-resume-tokens';
const STREAM_ID = 'orders-stream-v1';  // key in DynamoDB

async function saveResumeToken(token: object): Promise {
  await ddb.send(new PutItemCommand({
    TableName: RESUME_TOKEN_TABLE,
    Item: {
      streamId: { S: STREAM_ID },
      token:    { S: JSON.stringify(token) },
      updatedAt: { S: new Date().toISOString() },
    },
  }));
}

async function loadResumeToken(): Promise {
  const { Item } = await ddb.send(new GetItemCommand({
    TableName: RESUME_TOKEN_TABLE,
    Key: { streamId: { S: STREAM_ID } },
  }));
  if (!Item?.token?.S) return null;
  return JSON.parse(Item.token.S);
}

// Reconnect loop with resume token — the production pattern
async function runChangeStreamWithReconnect(client: MongoClient): Promise {
  while (true) {
    let resumeToken = await loadResumeToken();

    const watchOptions = {
      fullDocument: 'updateLookup' as const,
      batchSize: 100,
      // resumeAfter: use the resume token from the LAST SUCCESSFULLY PROCESSED event
      // If no token exists (first run), start from the current time
      ...(resumeToken ? { resumeAfter: resumeToken } : {}),
    };

    const coll = client.db('mydb').collection('orders');
    const stream = coll.watch([], watchOptions);

    try {
      for await (const event of stream) {
        // Process the event
        await processEvent(event);

        // Save resume token AFTER successful processing
        // If we crash before saving, we re-process this event on restart (at-least-once)
        // If we crash after saving but before processing the next event, we skip nothing
        await saveResumeToken(event._id);
      }
    } catch (error: any) {
      await stream.close().catch(() => {});  // ensure stream is closed

      if (error.code === 136 || error.message?.includes('Resume point too far back')) {
        // Stale resume token — oplog was truncated; must restart from current time
        console.error('Resume token expired — gap in change stream. Resetting to current time.');
        await saveResumeToken(null as any);  // clear token; next iteration starts from now
        await new Promise(r => setTimeout(r, 1000));
        continue;
      }

      if (error.name === 'MongoNetworkError' || error.name === 'MongoServerSelectionError') {
        console.error('Network error — will retry in 5s:', error.message);
        await new Promise(r => setTimeout(r, 5000));
        continue;
      }

      throw error;  // non-retryable error — rethrow
    }
  }
}

		

At-least-once vs exactly-once: saving the resume token after processing gives at-least-once semantics — on crash/restart, the last event is reprocessed. Make downstream processing idempotent (use the document _id as an idempotency key when writing to downstream systems). Exactly-once requires a two-phase commit pattern (write to downstream + save resume token in one transaction), which is complex and usually not worth it compared to idempotent event handling.

resumeAfter vs startAfter: resumeAfter resumes at the specified token and re-delivers that event. startAfter resumes from the event immediately after the token (skipping the specified event). Use resumeAfter when your checkpoint represents the last successfully processed event and you do not want to re-process it.

startAtOperationTime: instead of a resume token, you can start the stream at a specific cluster time (a MongoDB Timestamp with seconds and increment). This is useful for bootstrapping a stream from a point-in-time backup load: load the backup, then start the stream from the backup's cluster time to replay only the events that occurred after the backup.

Update events — delta vs full document

Without fullDocument: 'updateLookup', update events contain only the delta — the specific fields that were changed and the specific fields that were removed. This is efficient but requires consumers to maintain state to reconstruct the full document.

// Update event structure WITHOUT fullDocument: 'updateLookup'
{
  operationType: 'update',
  documentKey: { _id: ObjectId('...') },
  updateDescription: {
    updatedFields: { 'status': 'shipped', 'updatedAt': ISODate('...') },
    removedFields:  ['tempNote'],
    truncatedArrays: [],
  },
  // fullDocument: undefined — not included without 'updateLookup'
}

// Update event structure WITH fullDocument: 'updateLookup'
{
  operationType: 'update',
  documentKey: { _id: ObjectId('...') },
  updateDescription: {
    updatedFields: { 'status': 'shipped', 'updatedAt': ISODate('...') },
    removedFields:  ['tempNote'],
    truncatedArrays: [],
  },
  fullDocument: {
    _id: ObjectId('...'),
    orderId: 'ORD-001',
    userId: 'usr-123',
    status: 'shipped',          // new value
    updatedAt: ISODate('...'),  // new value
    // tempNote is absent (was removed)
    // all other unchanged fields are present
  },
}

updateLookup timing: the full document in updateLookup is fetched at the time the event is consumed, not at the time of the write. If another write occurs between the original update and the lookup, the returned full document may reflect the second write, not the first. This race condition is acceptable for most cache-invalidation and search-index-update patterns, but is not suitable for event sourcing where you need a precise snapshot at each write timestamp.

DocumentDB Streams — native Lambda trigger integration

DocumentDB supports a native AWS Lambda trigger integration via DocumentDB Streams. This is a different mechanism from the MongoDB change stream API: DocumentDB publishes changes to an internal stream, and Lambda polls the stream automatically via an event source mapping, similar to DynamoDB Streams + Lambda.

Aspect MongoDB Change Stream API (collection.watch()) DocumentDB Streams + Lambda trigger
Deployment model Requires a long-running process (ECS task, EC2 instance, or external container) to maintain the cursor; not suitable for Lambda by itself Lambda function invoked by DocumentDB event source mapping; AWS manages cursor lifecycle and retry
Resume token management Application must store and restore resume tokens Managed by Lambda event source mapping; AWS tracks position in the stream
Concurrency and batching Single consumer per cursor; application-level batching Lambda event source mapping handles concurrent shard consumers; configurable batch size and batch window
Event format Standard MongoDB ChangeStreamDocument with operationType, documentKey, fullDocument, updateDescription DocumentDB Streams event format in Lambda event: nested records array with data (base64-encoded BSON) and metadata
Supported operations All change stream event types INSERT, MODIFY, REMOVE events (similar to DynamoDB Streams)
When to use Complex consumer logic requiring MongoDB driver features (pipeline filters, cluster-level watches, custom cursor options); when deploying to ECS or long-running processes Serverless event-driven architectures; when you want AWS to manage cursor lifecycle; simple Lambda consumers triggered by DocumentDB writes
// Lambda handler for DocumentDB Streams event source mapping
// Event format is different from MongoDB change stream format
import type { DocumentDBEvent } from 'aws-lambda';

export async function handler(event: DocumentDBEvent): Promise {
  for (const record of event.records) {
    for (const docRecord of record) {
      const { eventSource, eventSourceArn, event: changeEvent } = docRecord;

      // DocumentDB Streams delivers a custom event structure, not MongoDB ChangeStreamDocument
      // event.operationType: INSERT | MODIFY | REMOVE
      // event.documentKey: { _id: ... }
      // event.fullDocument: full document (for INSERT) or current state (for MODIFY)
      // event.updateDescription: { updatedFields, removedFields } for MODIFY

      switch (changeEvent.operationType) {
        case 'INSERT':
          await handleInsert(changeEvent.fullDocument);
          break;
        case 'MODIFY':
          await handleModify(changeEvent.documentKey, changeEvent.fullDocument);
          break;
        case 'REMOVE':
          await handleRemove(changeEvent.documentKey);
          break;
      }
    }
  }
}

// CDK: create event source mapping
import { EventSourceMapping, StartingPosition } from 'aws-cdk-lib/aws-lambda';

new EventSourceMapping(this, 'DocdbTrigger', {
  target: myLambdaFunction,
  eventSourceArn: 'arn:aws:docdb:us-east-1:123456789012:cluster:my-cluster',
  startingPosition: StartingPosition.LATEST,
  batchSize: 100,
  bisectBatchOnError: true,
  // DocumentDB-specific options
  documentDbEventSourceConfig: {
    databaseName: 'mydb',
    collectionName: 'orders',
    fullDocument: 'UpdateLookup',
  },
});

Common errors

Error Cause Fix
MongoServerError: Change streams are only supported on replica sets Change streams require the connection to use replica set topology, but the connection URI is missing replicaSet=rs0 Add &replicaSet=rs0 to the DocumentDB connection URI
MongoServerError: Change stream is not enabled for this cluster change_stream_log_retention_duration parameter is not set or is set to 0 Modify the cluster parameter group to set a non-zero change_stream_log_retention_duration; reboot the cluster if the parameter requires a pending reboot
MongoServerError: Resume of change stream was not possible, as the resume point may no longer be in the oplog or error code 136 The stored resume token is older than the change stream log retention period; the oplog entry has been purged Increase change_stream_log_retention_duration; on gap detection, re-snapshot the collection and restart the stream from the current time; implement gap detection in the consumer loop
Change stream cursor becomes idle and returns no events for extended periods DocumentDB drops the change stream cursor after a period of inactivity (no new writes); the cursor closes silently Catch cursor-closed errors and reopen with the last saved resume token; use the async iterator pattern (for await (const event of stream)) which throws when the cursor closes, triggering the reconnect loop
fullDocument is null on update events despite setting updateLookup The document was deleted between the write and the updateLookup read; DocumentDB returns null for fullDocument when the lookup finds no document Always handle event.fullDocument === null as a possible case — the document may have been deleted between update and lookup; treat it as a delete event or fetch by documentKey._id before processing