Amazon DocumentDB · Document Storage · Aggregation Pipeline · Change Streams · Vector Search · 2026-11-12 · DocumentDB arc
Amazon DocumentDB for MCP Servers: Document Storage, Aggregation, Change Streams, and Vector Search — Complete Playbook
AliveMCP monitors MCP endpoints every 60 seconds. Building the monitoring system that persists probe results as flexible JSON documents, runs analytical aggregations over time-series event records, propagates real-time status changes to downstream alert consumers, and retrieves semantically similar incident reports for context — all of those workloads point toward a document database. Amazon DocumentDB is the MongoDB-compatible managed document database in AWS, and it covers all four patterns in a single service when running the 5.0 engine. DocumentDB is not MongoDB. It implements the MongoDB 3.6, 4.0, and 5.0 wire protocol against a purpose-built distributed storage engine derived from Aurora. That distinction is what makes it simultaneously familiar and treacherous for developers who learned MongoDB: 90% of queries and drivers work identically, but a handful of widely-used patterns — retryable writes, $function aggregation operators, fullDocumentBeforeChange change stream option, IAM database authentication — do not exist in DocumentDB and produce errors that are genuinely confusing the first time you encounter them. This guide synthesizes four deep-dives — Amazon DocumentDB connection setup, CRUD tools, and MongoDB compatibility gaps, DocumentDB aggregation pipeline: analytics, $lookup joins, and $facet queries, DocumentDB change streams: resume tokens, real-time sync, and Lambda triggers, and DocumentDB vector search: $vectorSearch, HNSW indexes, and semantic retrieval — into a complete DocumentDB playbook for MCP server developers building document-oriented, analytical, event-driven, and semantic search tools on AWS.
TL;DR
- Connection setup — always set
retryWrites=false(MongoDB drivers 4.x+ enable retryable writes by default; DocumentDB does not support them and returnsTransaction numbers are only allowed on a replica set member or mongoswithout this flag), settls=true+tlsCAFilepointing to the AWS CA bundle at an absolute filesystem path, setreplicaSet=rs0for change stream topology, cache the Mongoose connection in a Lambda global variable and checkmongoose.connection.readyState === 1on warm invocations to avoid reconnecting, use Secrets Manager for username/password (DocumentDB does not support IAM database authentication), setserverSelectionTimeoutMS: 5000not the 30-second default (which causes Lambda timeouts when the cluster is unreachable), keepmaxPoolSize: 5for Lambda concurrency, convert ID strings to BSON ObjectId withnew ObjectId(idString)before using them in filters (passing a raw string never matches and returns null with no error): The three-flag connection URI requirement is DocumentDB's most universal gotcha. Every MongoDB tutorial, every ODM quickstart, every sample project uses a URI that works perfectly against MongoDB Atlas and fails immediately against DocumentDB.retryWrites=falseeliminates the transaction error.tlsCAFilewith the absolute path to the PEM bundle eliminates the TLS certificate validation error.replicaSet=rs0ensures the driver selects replica set topology, which change streams require. All three must be present before any other work begins. The Lambda connection pooling pattern then determines whether your MCP server can sustain load: an uncached connection means a full TLS handshake + Secrets Manager call on every Lambda invocation, adding 200-400ms of cold start latency to every tool call and exhausting DocumentDB's connection limit at moderate Lambda concurrency. Themongoose.connection.readyState === 1check is the correct way to test whether the cached connection is still live — not null-checking the cached reference, which does not detect dropped connections. - Aggregation Pipeline — put
$matchfirst (it is the only stage that uses a collection index; a$matchafter$groupforces a full scan of the in-memory intermediate result), setallowDiskUse: trueon aggregations with large$sortor$groupstages (the in-memory stage limit is 100 MB without it), usecursor: { batchSize: 100 }to avoid loading a full large result set at once, prefer$addToSetonly for low-cardinality fields (it accumulates all distinct values in memory — use it for status enums not user IDs), always add a tiebreaker secondary sort on_idto prevent duplicate/missing documents in paginated results, verify index usage withexplain('queryPlanner')and look forIXSCANnotCOLLSCAN, use$facetto return paginated results + category counts + total count + price histogram in a single round trip, DocumentDB does not support$function/$accumulator(custom JS operators) or$setWindowFields— rewrite custom logic with$cond/$switch/$reduceor move post-processing to the application layer: The$match-first rule is the single most impactful aggregation performance principle in DocumentDB. It is not about code style — it is about whether a query uses an index or performs a collection scan. A pipeline that starts with$groupfollowed by a$matchfilter must load every document in the collection into the first stage's 100 MB memory budget before filtering. The same query with$matchfirst and a compound index on the filtered fields can reduce the input set by orders of magnitude before any grouping happens. The$facetpattern is the aggregation framework's most underused feature for MCP analytics tools: a single aggregation query with a$facetstage can simultaneously return the paginated sorted page of results, the category breakdown with document counts, the total count for pagination metadata, and a price histogram — four sub-pipelines over the same filtered input, one round trip, no application-layer fan-out queries required. - Change Streams — enable with
change_stream_log_retention_durationcluster parameter (default is 3 hours; set to at least 86400 seconds / 24 hours for production to survive container restarts and deployment windows), store the resume token (event._id) in DynamoDB after each event is processed (not before) for at-least-once semantics, usefullDocument: 'updateLookup'to get the full post-update document on update events (without it you only see the delta fields), always implement a reconnect loop with try/catch around thefor awaititerator (change stream cursors close on network errors, idle timeouts, and cluster failovers — they are not permanent connections), handle error code 136 (stale resume token) by clearing the stored token and restarting from the current time, prefer DocumentDB Streams + Lambda event source mapping for serverless architectures (AWS manages cursor lifecycle, retry, and batching automatically) over long-runningcollection.watch()consumers which require ECS or EC2: The resume token pattern separates reliable change stream consumers from fragile ones. Without it, a consumer crash means reconnecting to the current oplog position and missing every event that occurred during the downtime. With resume tokens stored in DynamoDB after each successful event processing, a crash recovers by loading the last saved token and replaying only from that exact oplog position. The order matters critically: save the token after processing, not before. Saving before means a crash between save and process loses the event permanently; saving after means a crash replays the last event — and idempotent event handlers (using the document_idas an idempotency key) absorb the duplicate safely. The retention duration is the other half of the reliability equation: a 3-hour window is sufficient for a healthy consumer but not for a deployment that takes the consumer offline for 4 hours during a blue-green rollout. 24 hours is the production minimum; 48-72 hours for critical pipelines where even a weekend outage must not cause a gap. - Vector Search (DocumentDB 5.0) — requires DocumentDB 5.0 or DocumentDB Elastic (attempting vector index creation on 4.0 returns
MongoServerError: unknown option: vectorOptions), create an HNSW index withtype: 'hnsw'+ exactdimensionscount matching your embedding model (1536 for Titan Embed V2 default, 1024 for Cohere Embed English, 3072 for OpenAI text-embedding-3-large), usesimilarity: 'cosine'for text embeddings (all major embedding models return L2-normalized vectors), useinput_type: 'search_query'for Cohere query embeddings and'search_document'for indexed content (mixing types degrades recall), pass query embeddings as a plain JavaScriptnumber[](notFloat32Array), project{ score: { $meta: 'vectorSearchScore' } }in the stage immediately following$vectorSearch(the metadata is only accessible in the directly adjacent$project), apply$matchpre-filters before$vectorSearchfor tenant isolation (the filtered fields must have a standard DocumentDB index — without an index, the pre-filter performs a collection scan and the performance benefit is lost), dimension mismatch between index and inserted embedding is a silent failure (the document is not indexed and never returned from vector queries): DocumentDB 5.0 vector search eliminates the need for a separate vector store service when your MCP tools already use DocumentDB for document storage. The same collection holds the documents, their metadata, and the embedding vectors. The same connection, the same driver, the same Lambda function. The tradeoff versus dedicated vector databases (OpenSearch k-NN, pgvector) is feature surface: DocumentDB vector search does not support BM25 + vector hybrid search in a single query (OpenSearch does), does not support filtering on arbitrary nested array fields within the vector search stage, and has fewer tuning knobs than purpose-built ANNS systems. For most MCP server semantic search tools — knowledge base retrieval, similar document lookup, intent routing — DocumentDB 5.0 vector search is sufficient and significantly simpler operationally than adding a second database to the architecture.
Part 1: When to choose DocumentDB — architecture and decision framework
The first decision for any MCP server touching document data is which document database to use. DocumentDB sits in a three-way tradeoff against DynamoDB and MongoDB Atlas. The right answer depends on your data model complexity, operational constraints, and AWS integration requirements.
| Consideration | DocumentDB | DynamoDB | MongoDB Atlas |
|---|---|---|---|
| Data model | Flexible JSON documents; nested objects; arrays of sub-documents; rich query language including aggregation pipeline | Key-value with optional sort key; limited secondary index types; item size 400 KB; suited for simple and predictable access patterns | Same flexible JSON document model as MongoDB; full feature set including transactions, $graphLookup, Atlas Search, Atlas Vector Search |
| Query power | MongoDB-compatible aggregation pipeline (subset — no $function/$accumulator/$setWindowFields); secondary indexes; vector search in 5.0 |
Scan + filter or GSI queries; no aggregation; expression language is limited; designed for sub-millisecond access on known keys | Full aggregation pipeline; Atlas Search (Lucene-backed full-text); Atlas Vector Search; $graphLookup on arrays; $function and $accumulator |
| Operational model | Managed in your VPC; instance-based pricing (no serverless standard variant — Elastic Clusters add sharding and pay-per-use); high availability via storage replication and read replicas | Fully serverless; global tables; millisecond at any scale; no VPC required; pay per request or provisioned WCU/RCU | Fully managed multi-cloud; global clusters; no VPC required unless PrivateLink configured; highest MongoDB compatibility |
| AWS integration | Deepest AWS integration: VPC, Secrets Manager, CloudWatch native metrics, Lambda triggers via DocumentDB Streams, same billing as RDS; no IAM database auth | Native IAM database authentication; Streams with Lambda triggers; Kinesis integration; zero config; truly serverless | Third-party SaaS; PrivateLink available but requires setup; billed separately from AWS; full MongoDB feature set |
| Choose when | You need MongoDB-compatible document storage within your VPC with no third-party dependency; you are migrating an existing MongoDB application to AWS; compliance requires data residency within your AWS account | Access patterns are simple and predictable; you need serverless scale-to-zero; latency requirements are sub-millisecond; item size is small | You need full MongoDB compatibility including features DocumentDB does not support; you already run MongoDB Atlas and have operational familiarity |
Cluster architecture fundamentals: a DocumentDB cluster has two DNS endpoints you need to know about:
- Cluster endpoint (
mycluster.cluster-xxx.us-east-1.docdb.amazonaws.com) — always routes to the current primary; use this for all writes and reads that must see the latest written data. - Reader endpoint (
mycluster.cluster-ro-xxx.us-east-1.docdb.amazonaws.com) — load-balances across read replicas; use this for read-heavy analytical MCP tools to offload the primary.
DocumentDB Elastic Clusters are a separate product at docdb-elastic.amazonaws.com. They offer sharding, pay-per-use billing, and DocumentDB 5.0 with vector search. Standard provisioned clusters use instance types (T3, T4g, R5, R6g) with per-instance pricing. Elastic Clusters are better for large-scale vector search workloads and variable traffic patterns; standard provisioned clusters are better for steady workloads where you need predictable performance.
VPC-only by default. DocumentDB clusters have no public endpoint. MCP servers on Lambda, ECS, or EC2 must be in the same VPC and subnets as the DocumentDB cluster, with the Lambda security group allowed inbound TCP 27017 on the DocumentDB security group. For local development, use an SSH bastion or AWS Systems Manager Session Manager port-forward to the cluster endpoint.
Part 2: Connection setup, CRUD tools, and indexes
The DocumentDB connection string has three non-obvious requirements relative to a standard MongoDB Atlas URI. All three must be present before any driver operation works correctly:
import { SecretsManagerClient, GetSecretValueCommand } from '@aws-sdk/client-secrets-manager';
import mongoose from 'mongoose';
import { writeFileSync, existsSync } from 'fs';
// RDS CA bundle — download from AWS and store as base64 environment variable at deploy time
// Source: https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem
const CA_CERT_PATH = '/tmp/rds-ca.pem';
function ensureCaCert(): void {
if (!existsSync(CA_CERT_PATH)) {
writeFileSync(CA_CERT_PATH, process.env.DOCUMENTDB_CA_CERT ?? '');
}
}
let cachedConnection: typeof mongoose | null = null;
export async function getConnection(): Promise<typeof mongoose> {
// Lambda warm invocation — reuse existing connection
if (cachedConnection && mongoose.connection.readyState === 1) {
return cachedConnection;
}
ensureCaCert();
const sm = new SecretsManagerClient({ region: process.env.AWS_REGION });
const { SecretString } = await sm.send(new GetSecretValueCommand({
SecretId: process.env.DOCUMENTDB_SECRET_ARN!,
}));
const { username, password } = JSON.parse(SecretString!);
const clusterEndpoint = process.env.DOCUMENTDB_ENDPOINT!;
const uri = [
`mongodb://${encodeURIComponent(username)}:${encodeURIComponent(password)}`,
`@${clusterEndpoint}:27017`,
'/?tls=true',
`&tlsCAFile=${CA_CERT_PATH}`, // absolute filesystem path — not a URL
'&replicaSet=rs0', // required for change stream topology
'&readPreference=secondaryPreferred',
'&retryWrites=false', // REQUIRED — DocumentDB does not support retryable writes
].join('');
mongoose.set('bufferCommands', false); // fail immediately if connection not ready
cachedConnection = await mongoose.connect(uri, {
serverSelectionTimeoutMS: 5000, // fail fast — 30s default hangs Lambda on cluster unavailability
connectTimeoutMS: 5000,
socketTimeoutMS: 10000,
maxPoolSize: 5, // keep pool small for Lambda concurrency (high concurrency = many Lambdas, each with pool of 5)
minPoolSize: 1,
});
return cachedConnection;
}
Why retryWrites=false is non-negotiable: MongoDB Node.js drivers 4.x and later enable retryable writes by default. Retryable writes require a MongoDB replica set that supports multi-document transactions. DocumentDB does not implement the MongoDB retryable write protocol — when a driver attempts one, DocumentDB returns MongoServerError: Transaction numbers are only allowed on a replica set member or mongos. This error appears on the very first write operation, with no other obvious explanation, because the driver injects the retryable write overhead silently. Setting retryWrites=false disables this feature at the driver level entirely.
Why serverSelectionTimeoutMS: 5000 matters for Lambda: the MongoDB driver's default server selection timeout is 30 seconds. When a Lambda function is in the wrong VPC subnet, or the DocumentDB security group lacks the inbound 27017 rule, every invocation hangs for 30 seconds before timing out. Setting this to 5 seconds means the Lambda function fails fast with a clear error rather than consuming its full timeout budget.
CRUD tools with the native MongoDB driver: for unstructured or dynamic data models, the native MongoDB Node.js driver without a Mongoose schema layer is the right choice:
import { MongoClient, ObjectId } from 'mongodb';
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
export function registerDocumentDBTools(server: McpServer) {
const DB = process.env.DOCUMENTDB_DB_NAME!;
const COLL = process.env.DOCUMENTDB_COLLECTION!;
server.tool('find_documents', 'Query documents from DocumentDB', {
filter: z.record(z.unknown()).default({}).describe('MongoDB filter query'),
limit: z.number().int().min(1).max(100).default(20),
skip: z.number().int().min(0).default(0),
sort: z.record(z.number()).optional().describe('Sort spec e.g. {"createdAt":-1}'),
}, async ({ filter, limit, skip, sort }) => {
const db = (await getClient()).db(DB);
const cursor = db.collection(COLL).find(filter).skip(skip).limit(limit);
if (sort) cursor.sort(sort);
const docs = await cursor.toArray();
return {
content: [{ type: 'text', text: JSON.stringify(
docs.map(d => ({ ...d, _id: d._id.toString() }))
)}],
};
});
server.tool('update_document', 'Update document(s) by filter', {
filter: z.record(z.unknown()).describe('Filter to select document(s)'),
update: z.record(z.unknown()).describe('Update operators e.g. {"$set":{"status":"active"}}'),
upsert: z.boolean().default(false),
multi: z.boolean().default(false),
}, async ({ filter, update, upsert, multi }) => {
const db = (await getClient()).db(DB);
const method = multi ? 'updateMany' : 'updateOne';
const result = await db.collection(COLL)[method](filter, update, { upsert });
return {
content: [{ type: 'text', text: JSON.stringify({
matchedCount: result.matchedCount,
modifiedCount: result.modifiedCount,
upsertedId: result.upsertedId?.toString() ?? null,
})}],
};
});
}
ObjectId handling — the silent filter bug: DocumentDB stores _id values as BSON ObjectIds by default. When an MCP tool receives an ID string from the caller (e.g., "507f1f77bcf86cd799439011") and uses it directly as a filter value, DocumentDB matches it as a string against BSON ObjectId values — and finds nothing, returning an empty result with no error. The fix is a one-line conversion:
// Correct — convert string to ObjectId before filtering
const doc = await collection.findOne({ _id: new ObjectId(idString) });
// Wrong — string "507f1f77bcf86cd799439011" != BSON ObjectId("507f1f77bcf86cd799439011")
const doc = await collection.findOne({ _id: idString }); // always returns null
Index strategy for DocumentDB: create indexes before loading data for large collections. DocumentDB 4.0 and earlier acquire a foreground write lock during index builds — in production, build indexes during low-traffic windows or use DocumentDB 5.0 which supports concurrent index builds.
const db = (await getClient()).db(DB);
const coll = db.collection('events');
// Compound index — field order must match query predicate left-to-right
await coll.createIndex({ userId: 1, createdAt: -1 });
// TTL index — documents expire when Date field value passes current time
await coll.createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 });
// Partial index — only indexes documents matching a filter expression
await coll.createIndex(
{ status: 1, updatedAt: -1 },
{ partialFilterExpression: { status: { $in: ['pending', 'processing'] } } }
);
MongoDB compatibility gaps to know before you start: the most impactful DocumentDB limitations for MCP server development are: retryable writes (fix: retryWrites=false), $function and $accumulator aggregation operators (fix: rewrite with $cond/$switch/$reduce or post-process in application), Atlas Search full-text indexing (fix: use Amazon OpenSearch Service for Lucene-quality search), fullDocumentBeforeChange change stream option (fix: read document before update if before-state is required), and IAM database authentication (fix: always use Secrets Manager for username/password with automatic rotation).
Part 3: Aggregation pipeline — analytics tools, joins, and faceted queries
The aggregation pipeline is DocumentDB's most powerful query mechanism for MCP analytical tools. It processes documents through a sequential chain of stages — each stage transforms the input set and passes results to the next. The critical performance rule governs all pipeline design: $match is the only stage that can use a collection index, and it only uses an index when it appears at the start of the pipeline.
const db = (await getClient()).db(DB);
const coll = db.collection('events');
// Analytics tool — event counts by type for a user over a date range
const result = await coll.aggregate([
// $match FIRST — uses compound index on { userId: 1, createdAt: -1 }
{ $match: { userId: userId, createdAt: { $gte: startDate, $lte: endDate } } },
{ $group: {
_id: '$eventType',
count: { $sum: 1 },
lastSeen: { $max: '$createdAt' },
avgValue: { $avg: '$amount' },
}},
{ $sort: { count: -1 } },
{ $limit: 20 },
], {
allowDiskUse: true, // allow spilling to disk when intermediate sets exceed 100 MB
cursor: { batchSize: 100 },
}).toArray();
$addToSet memory warning: $addToSet accumulates all distinct values in memory within the $group stage. For low-cardinality fields (status enums, event types), this is safe and useful. For high-cardinality fields (user IDs, session IDs, order numbers), $addToSet can easily exhaust the 100 MB stage budget — even with allowDiskUse: true, it must hold the full distinct value set in RAM. DocumentDB does not support HyperLogLog-based approximate distinct counts ($approximateDistinctCount is not available). For counting distinct high-cardinality values, use a two-pass approach: first group to get one document per distinct value, then count in a second aggregation.
$lookup for cross-collection joins: $lookup performs a left outer join with another collection in the same database. DocumentDB supports both the basic equality join and the pipeline-based correlated subquery syntax in 4.0+:
// Basic equality join — supported in all DocumentDB versions
const ordersWithProducts = await db.collection('orders').aggregate([
{ $match: { status: 'pending', createdAt: { $gte: yesterday } } },
{ $lookup: {
from: 'products',
localField: 'productId',
foreignField: '_id',
as: 'productDetails',
}},
{ $unwind: { path: '$productDetails', preserveNullAndEmpty: true } },
{ $project: {
orderId: '$_id',
amount: 1,
status: 1,
productName: '$productDetails.name',
productPrice: '$productDetails.price',
}},
]).toArray();
$lookup performance rule: each document in the input stage triggers a lookup into the joined collection. For 10,000 orders, that is 10,000 individual lookups into the products collection. The foreignField must have an index — without an index, every lookup is a full collection scan. For very large join volumes, consider application-layer batching instead: fetch distinct product IDs from the orders, batch-fetch the products, then join in application memory.
$facet for multi-faceted search results: the $facet stage runs multiple sub-pipelines over the same input documents and returns all results in a single aggregation response. It is the standard pattern for search results pages that need paginated results alongside category counts, price histograms, and total counts — without multiple round trips:
const searchResults = await db.collection('products').aggregate([
// Filter BEFORE $facet — $match inside each $facet sub-pipeline cannot use indexes
{ $match: {
category: { $in: selectedCategories },
price: { $gte: minPrice, $lte: maxPrice },
inStock: true,
}},
{ $facet: {
results: [
{ $sort: { score: -1, _id: 1 } }, // _id tiebreaker ensures stable pagination
{ $skip: page * pageSize },
{ $limit: pageSize },
{ $project: { name: 1, price: 1, category: 1 } },
],
categoryFacets: [
{ $group: { _id: '$category', count: { $sum: 1 } } },
{ $sort: { count: -1 } },
],
totalCount: [
{ $count: 'n' },
],
priceHistogram: [
{ $bucket: {
groupBy: '$price',
boundaries: [0, 25, 50, 100, 200, 500, 1000],
default: 'over_1000',
output: { count: { $sum: 1 } },
}},
],
}},
], { allowDiskUse: true }).toArray();
const { results, categoryFacets, totalCount, priceHistogram } = searchResults[0];
const total = totalCount[0]?.n ?? 0;
Verifying index usage with explain(): before deploying any aggregation-based MCP tool to production, run explain('queryPlanner') and verify that the first $match stage shows an IXSCAN in the winning plan — not a COLLSCAN. A COLLSCAN means the query will perform a full collection scan on every invocation, which does not scale.
const explanation = await db.command({
aggregate: 'events',
pipeline: [
{ $match: { userId: userId, createdAt: { $gte: startDate } } },
{ $group: { _id: '$eventType', count: { $sum: 1 } } },
],
cursor: {},
explain: 'executionStats', // 'queryPlanner' for the plan only; 'executionStats' for actual metrics
});
// Healthy: winningPlan shows IXSCAN + totalDocsExamined ≈ nReturned
// Problem: winningPlan shows COLLSCAN + totalDocsExamined >> nReturned
Pagination sort stability: always include a tiebreaker secondary sort on _id when paginating with $sort + $skip + $limit. DocumentDB does not guarantee a stable sort order for documents with equal values in the primary sort field. Without a tiebreaker, documents at sort-value boundaries can appear on multiple pages or be skipped entirely as the underlying document order shifts between pages. { $sort: { createdAt: -1, _id: 1 } } ensures stable pagination at the cost of one additional sort key.
Part 4: Change streams — real-time document sync, resume tokens, and Lambda triggers
MCP server tools that need to propagate document changes in real time — cache invalidation on writes, search index updates after content changes, audit log pipelines, event-driven downstream notifications — consume DocumentDB change streams. Change streams deliver a tailable cursor over an ordered log of insert, update, replace, and delete events on a collection or database.
Enabling change streams — the cluster parameter that most teams forget:
# Change streams are disabled by default — set the cluster parameter to enable them
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 = 24 hours; valid range: 3600 (1 hour) to 604800 (7 days)
# Apply the parameter group to the cluster
aws docdb modify-db-cluster \
--db-cluster-identifier my-cluster \
--db-cluster-parameter-group-name my-docdb-params \
--apply-immediately
The retention duration defines how long the oplog entries backing each resume token remain valid. If your consumer is offline for longer than the retention period, the token becomes stale and you receive MongoServerError: Resume of change stream was not possible, as the resume point may no longer be in the oplog (error code 136). For production, 24 hours is the minimum; for critical pipelines, 48-72 hours ensures a weekend maintenance window does not cause a data gap.
The reconnect loop — the production change stream pattern:
import { DynamoDBClient, PutItemCommand, GetItemCommand } from '@aws-sdk/client-dynamodb';
const ddb = new DynamoDBClient({ region: process.env.AWS_REGION });
const STREAM_ID = 'orders-stream-v1';
async function saveResumeToken(token: object): Promise<void> {
await ddb.send(new PutItemCommand({
TableName: 'docdb-resume-tokens',
Item: {
streamId: { S: STREAM_ID },
token: { S: JSON.stringify(token) },
updatedAt: { S: new Date().toISOString() },
},
}));
}
async function loadResumeToken(): Promise<object | null> {
const { Item } = await ddb.send(new GetItemCommand({
TableName: 'docdb-resume-tokens',
Key: { streamId: { S: STREAM_ID } },
}));
if (!Item?.token?.S) return null;
return JSON.parse(Item.token.S);
}
async function runChangeStreamWithReconnect(client: MongoClient): Promise<void> {
while (true) {
const resumeToken = await loadResumeToken();
const coll = client.db('mydb').collection('orders');
const stream = coll.watch(
[{ $match: { operationType: { $in: ['insert', 'update', 'replace', 'delete'] } } }],
{
fullDocument: 'updateLookup', // fetch full post-update doc on update events
batchSize: 100,
...(resumeToken ? { resumeAfter: resumeToken } : {}),
},
);
try {
for await (const event of stream) {
await processEvent(event); // process first
await saveResumeToken(event._id); // then save token (at-least-once semantics)
}
} catch (error: any) {
await stream.close().catch(() => {});
if (error.code === 136 || error.message?.includes('Resume point too far back')) {
// Stale token — oplog truncated; restart from current time
await saveResumeToken(null as any);
await new Promise(r => setTimeout(r, 1000));
continue;
}
if (error.name === 'MongoNetworkError' || error.name === 'MongoServerSelectionError') {
await new Promise(r => setTimeout(r, 5000)); // backoff before reconnect
continue;
}
throw error; // non-retryable
}
}
}
At-least-once vs exactly-once: saving the token after processing gives at-least-once semantics — on crash/restart, the last event is reprocessed. Exactly-once requires writing to the downstream system and saving the resume token in a single transaction (a two-phase commit), which is complex and rarely worth it. The pragmatic approach: make event handlers idempotent using the document _id as an idempotency key. When the downstream system is DynamoDB, use a conditional put that ignores duplicates; when it is a cache, an overwrite is inherently idempotent.
fullDocument updateLookup timing: the full document in updateLookup is fetched at the time the event is consumed by the stream, not at the time of the write. If another write occurs between the original update and the lookup, the returned document may reflect the second write. This race condition is acceptable for cache invalidation and search index updates, but is not suitable for event sourcing where you need a precise snapshot at each write timestamp. Also, fullDocument can be null on update events if the document was deleted between the update and the lookup — always handle event.fullDocument === null as a valid case.
DocumentDB Streams + Lambda triggers for serverless architectures: for Lambda-based consumers, the native DocumentDB Lambda trigger integration (via DocumentDB Streams) is usually a better choice than the MongoDB collection.watch() API. DocumentDB Streams publishes changes to an internal AWS-managed stream, and Lambda polls it via an event source mapping — the same pattern as DynamoDB Streams + Lambda. AWS manages cursor lifecycle, retry, and batching automatically.
// CDK: wire up DocumentDB Lambda trigger
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,
documentDbEventSourceConfig: {
databaseName: 'mydb',
collectionName: 'orders',
fullDocument: 'UpdateLookup',
},
});
// Lambda handler receives DocumentDB Streams events (different format from MongoDB ChangeStreamDocument)
export async function handler(event: DocumentDBEvent): Promise<void> {
for (const record of event.records) {
for (const docRecord of record) {
const { event: changeEvent } = docRecord;
// changeEvent.operationType: INSERT | MODIFY | REMOVE
// changeEvent.fullDocument: full document for INSERT/MODIFY
// changeEvent.documentKey: { _id: ... }
await processDocumentDBEvent(changeEvent);
}
}
}
The tradeoff: the MongoDB collection.watch() API supports pipeline filters, cluster-level watches, and complex cursor options, but requires a long-running process (ECS task, EC2 instance) to maintain the cursor — it cannot run inside a Lambda function whose execution is bounded by the invocation timeout. DocumentDB Streams + Lambda is the right choice for serverless architectures; collection.watch() is the right choice when you need MongoDB driver features or are deploying to a long-running container.
Part 5: Vector search — semantic retrieval with DocumentDB 5.0 and Bedrock embeddings
DocumentDB 5.0 added native vector search via the $vectorSearch aggregation stage, turning DocumentDB into a combined document and vector database. For MCP servers that already use DocumentDB for document storage, adding semantic search capabilities means adding a vector index to an existing collection — no separate vector store service to provision, operate, or pay for.
DocumentDB 5.0 version requirement: $vectorSearch and vector index creation both require the DocumentDB 5.0 engine. Attempting either on DocumentDB 4.0 returns MongoServerError: unknown option: vectorOptions. DocumentDB Elastic Clusters also support vector search on the 5.0 engine and are better suited for large-scale vector workloads requiring sharding.
Similarity metrics — choosing the right one: DocumentDB supports cosine (best for text embeddings — measures angle, not magnitude), euclidean (L2 distance — best for numeric feature vectors where magnitude matters), and dotProduct (fastest; equivalent to cosine for pre-normalized vectors). The rule of thumb for MCP server semantic search: use cosine. All major embedding models — Bedrock Titan Embed V2, Cohere Embed English, OpenAI embeddings — produce L2-normalized vectors, making cosine and dot product numerically equivalent, but cosine is the more conventional and readable choice.
HNSW vs ivfFlat index types: HNSW (Hierarchical Navigable Small World) is the right choice for most MCP server workloads — it supports online inserts without rebuilding the entire index, provides excellent recall at good query latency, and is well-suited for collections under 10 million vectors with ongoing inserts. ivfFlat (Inverted File Index, Flat) has a shorter build time and uses less memory, but its recall degrades as new documents are inserted (the centroids drift), requiring periodic index rebuilds for read-write collections. Use ivfFlat only for read-mostly bulk-loaded collections.
Creating vector indexes and generating embeddings:
// Create HNSW vector index
await coll.createIndex(
{ embedding: 'vector' },
{
vectorOptions: {
type: 'hnsw',
dimensions: 1536, // must exactly match your embedding model's output dimensions
similarity: 'cosine',
m: 16, // bidirectional links per node — higher = better recall, more memory
efConstruction: 64, // candidates during build — higher = better quality, slower build
},
}
);
// Generate embedding with Amazon Bedrock Titan Embed V2
import { BedrockRuntimeClient, InvokeModelCommand } from '@aws-sdk/client-bedrock-runtime';
const bedrock = new BedrockRuntimeClient({ region: process.env.AWS_REGION });
async function embedWithTitanV2(text: string, dimensions = 1536): Promise<number[]> {
const { body } = await bedrock.send(new InvokeModelCommand({
modelId: 'amazon.titan-embed-text-v2:0',
contentType: 'application/json',
accept: 'application/json',
body: JSON.stringify({
inputText: text.slice(0, 8192), // Titan V2 max: 8,192 tokens
dimensions, // 256, 1024, or 1536 (default)
normalize: true, // normalize to unit length for cosine similarity
}),
}));
const { embedding } = JSON.parse(Buffer.from(body).toString('utf-8'));
return embedding; // number[] of exact length `dimensions`
}
// Cohere Embed English — IMPORTANT: different input_type for queries vs documents
async function embedWithCohere(text: string, inputType: 'search_query' | 'search_document'): Promise<number[]> {
const { body } = await bedrock.send(new InvokeModelCommand({
modelId: 'cohere.embed-english-v3',
contentType: 'application/json',
accept: 'application/json',
body: JSON.stringify({
texts: [text.slice(0, 2048)],
input_type: inputType, // 'search_document' for indexing; 'search_query' for queries — mixing degrades recall
embedding_types: ['float'],
}),
}));
const response = JSON.parse(Buffer.from(body).toString('utf-8'));
return response.embeddings.float[0]; // number[] of length 1024
}
Dimension mismatch is a silent failure: if you create a vector index with dimensions: 1536 and then insert a document with an embedding of 1024 elements, DocumentDB does not reject the document — it silently skips indexing it. $vectorSearch queries never return that document, with no error or warning. Always validate embedding dimensions before inserting to prevent missing results that have no visible failure signal.
$vectorSearch aggregation stage and hybrid pre-filtering:
// MCP semantic search tool — tenant-isolated vector search with pre-filter
server.tool('semantic_search', 'Find semantically similar documents', {
query: z.string().describe('Natural language search query'),
category: z.string().optional().describe('Optional category filter'),
limit: z.number().int().min(1).max(50).default(10),
}, async ({ query, category, limit }) => {
const { tenantId } = getRequestContext();
const queryEmbedding = await embedWithTitanV2(query);
// Pre-filter BEFORE $vectorSearch restricts candidates to this tenant's documents
// The tenantId + category fields must have a standard DocumentDB index
const preFilter: Record<string, unknown> = { tenantId };
if (category) preFilter.category = category;
const results = await coll.aggregate([
// 1. Pre-filter — reduces candidate set; must use an index to be effective
{ $match: preFilter },
// 2. Vector search over pre-filtered documents only
{
$vectorSearch: {
queryVector: queryEmbedding, // number[] — not Float32Array
path: 'embedding',
numCandidates: limit * 10, // candidates before reranking (10x limit is a good starting point)
limit,
},
},
// 3. Project score — MUST be immediately after $vectorSearch
{
$project: {
_id: 1, text: 1, category: 1,
score: { $meta: 'vectorSearchScore' }, // accessible only in next $project after $vectorSearch
},
},
// 4. Post-filter: exclude low-relevance results
{ $match: { score: { $gte: 0.70 } } },
]).toArray();
return {
content: [{
type: 'text',
text: JSON.stringify(results.map(d => ({
id: d._id.toString(),
text: d.text,
score: Math.round(d.score * 1000) / 1000,
}))),
}],
};
});
Pre-filter index requirement for security: the tenantId pre-filter must use an index — not for performance alone, but for security. Without an index on tenantId, DocumentDB evaluates the pre-filter as a collection scan. Documents from all tenants enter the vector search candidate pool; the filter removes them from the final results but they are scored and potentially present in HNSW traversal paths. Use explain() to verify the pre-filter stage shows an IXSCAN on the tenantId index. Create a compound index: db.collection.createIndex({ tenantId: 1, category: 1 }) for queries filtering on both fields.
DocumentDB vector search vs alternatives — when to use what:
| Solution | Best for | Key tradeoffs |
|---|---|---|
| DocumentDB 5.0 vector search | MCP servers already using DocumentDB; teams wanting a single data store for documents and embeddings; AWS-native compliance requirements | Pro: no separate vector store; documents and embeddings in the same collection; full CRUD + aggregation pipeline + vector search in one service. Con: requires DocumentDB 5.0; no BM25+vector hybrid search; fewer tuning options than dedicated systems |
| Bedrock Knowledge Bases | Fully managed RAG with zero vector infrastructure; Q&A tools needing a simple retrieve() API; teams without vector database experience | Pro: zero infrastructure; native Bedrock Retrieve API; built-in chunking and embedding. Con: data must be in S3; limited control over chunking and embedding; no arbitrary filter expressions |
| OpenSearch with k-NN | Use cases needing both BM25 full-text search and vector search in the same query; rich filtering, scripted scoring, facets | Pro: best full-text + vector hybrid search; rich aggregation; both approximate and exact k-NN. Con: separate service to operate; higher baseline cost; different query language from MongoDB |
| pgvector on Aurora PostgreSQL | MCP servers on relational data with SQL joins between vector fields and normalized relational tables | Pro: SQL joins combining vectors and relational predicates; Aurora Serverless v2; full PostgreSQL. Con: PostgreSQL — not MongoDB-compatible; different driver ecosystem; requires RDS Proxy for Lambda connection pooling |
Putting it together — a DocumentDB-backed MCP server for probe result storage
To make this concrete: AliveMCP stores probe results as JSON documents in DocumentDB. Each probe creates a document with the endpoint ID, timestamp, response time, status code, and optional error details. The document model is flexible — different endpoint types carry different metadata fields. DocumentDB's schema-less model handles this naturally where a relational table with a fixed schema would require an extra_metadata JSONB column workaround.
The analytical MCP tools use the aggregation pipeline: aggregate mean response times by endpoint over a time window using $match (uses the compound index on endpointId + timestamp) + $group + $sort; return the last 24 hours of probe results with $facet returning the status breakdown, hourly histogram, and p95 latency alongside the paginated event list in one query. The change streams consumer propagates new probe failures to the alert pipeline in real time, using resume tokens stored in DynamoDB to survive Lambda restarts without missing any failure events. And when a probe result arrives with an unusual error message, the semantic search tool retrieves historically similar probe results from DocumentDB 5.0 vector search, allowing the alert enrichment pipeline to include context from past incidents with similar signatures.
The full DocumentDB playbook for this use case fits in a single VPC, a single data store, and a coherent MongoDB-compatible driver across all four capabilities. The operational complexity is real — the connection string gotchas, the aggregation pipeline ordering rules, the change stream retention configuration, the DocumentDB 5.0 version requirement for vector search — but each has a clear fix, and knowing them in advance means you do not discover them in production under load.