Guide · Amazon DocumentDB · Aggregation Pipeline · Analytics · MCP Server Tools
DocumentDB Aggregation Pipeline for MCP Servers — Analytics, $lookup Joins, and $facet
MCP server tools that answer analytical questions — "summarize events by user this week", "join orders with product details", "return faceted category counts alongside paginated results" — are built on the aggregation pipeline. DocumentDB supports the core MongoDB aggregation framework stages and operators, but with meaningful gaps that affect real-world query design. This guide covers every stage you will actually use in production MCP tools ($match, $project, $group, $unwind, $lookup, $facet, $sort, $limit, $skip, $addFields, $replaceRoot), how to control memory and batch size, how to use explain() to verify index usage, and the DocumentDB-specific operators that do not exist and will error at runtime.
TL;DR
Put $match first in every pipeline — it's the only stage that can use an index. Set allowDiskUse: true on aggregations with large $sort or $group stages (the in-memory limit is 100 MB). Use cursor: { batchSize: 100 } to avoid loading an entire large result set into memory. DocumentDB does not support $function, $accumulator, or $expr with $regex — replace custom JS with standard operators or move computation to the application layer. $lookup works for left outer joins but the pipeline subfield (correlated subquery syntax) has limitations in DocumentDB 4.0 — test before relying on it.
Pipeline execution model
DocumentDB processes aggregation stages as a sequential pipeline. Data flows through each stage in order; a later stage can only see fields that earlier stages passed through. The critical performance rule is that only $match (and to a lesser extent $sort) at the beginning of the pipeline can use a collection index. Any $match placed after a $group or $unwind forces a full scan of the intermediate result set in memory.
const db = (await getClient()).db(DB);
const coll = db.collection('events');
// ---- Correct: $match first to use index, then transform ----
const result = await coll.aggregate([
{ $match: { userId: userId, createdAt: { $gte: startDate, $lte: endDate } } },
{ $group: {
_id: '$eventType',
count: { $sum: 1 },
lastSeen: { $max: '$createdAt' },
}},
{ $sort: { count: -1 } },
{ $limit: 20 },
], {
allowDiskUse: true,
cursor: { batchSize: 100 },
}).toArray();
// ---- Wrong: $match after $group cannot use index ----
const slow = await coll.aggregate([
{ $group: { _id: '$userId', total: { $sum: 1 } } },
{ $match: { total: { $gt: 10 } } }, // full scan of group result — no index
]).toArray();
Memory limit: without allowDiskUse: true, each pipeline stage is limited to 100 MB of RAM. A $group that produces a large intermediate document set, or a $sort over millions of documents, will throw MongoServerError: Exceeded memory limit for $group/$sort. Setting allowDiskUse: true causes DocumentDB to spill intermediate results to storage when the 100 MB limit is exceeded — slower but it completes.
$group — aggregation accumulator patterns
// Time-bucketed event counts — common analytics MCP tool pattern
const dailyCounts = await coll.aggregate([
{ $match: { createdAt: { $gte: new Date('2026-11-01'), $lt: new Date('2026-12-01') } } },
{ $group: {
_id: {
year: { $year: '$createdAt' },
month: { $month: '$createdAt' },
day: { $dayOfMonth: '$createdAt' },
},
count: { $sum: 1 },
totalRevenue: { $sum: '$amount' },
avgAmount: { $avg: '$amount' },
minAmount: { $min: '$amount' },
maxAmount: { $max: '$amount' },
uniqueUsers: { $addToSet: '$userId' }, // array of distinct userIds — large!
}},
{ $project: {
_id: 1,
count: 1,
totalRevenue: 1,
avgAmount: { $round: ['$avgAmount', 2] },
uniqueUserCount: { $size: '$uniqueUsers' }, // $size on the addToSet array
// $uniqueUsers itself is omitted — we only need the count
}},
{ $sort: { '_id.year': 1, '_id.month': 1, '_id.day': 1 } },
], { allowDiskUse: true }).toArray();
// Multi-field group with conditional counting
const statusBreakdown = await coll.aggregate([
{ $match: { orgId: orgId } },
{ $group: {
_id: '$status',
total: { $sum: 1 },
hasNotes: { $sum: { $cond: [{ $gt: [{ $strLenCP: { $ifNull: ['$notes', ''] } }, 0] }, 1, 0] } },
firstSeen: { $min: '$createdAt' },
lastSeen: { $max: '$createdAt' },
}},
{ $sort: { total: -1 } },
]).toArray();
$addToSet memory warning: $addToSet accumulates all distinct values in an in-memory array within the $group stage. For high-cardinality fields (user IDs, session IDs), this array grows to the full distinct value count and can exhaust the 100 MB stage limit. Use $addToSet only for low-cardinality fields; for counting distinct high-cardinality values, the HyperLogLog-based $approximateDistinctCount is not available in DocumentDB — either use a two-pass approach or accept approximate counts from sampled queries.
$unwind — decomposing arrays
$unwind deconstructs an array field into multiple documents, one per array element. It is the gateway for aggregating over nested arrays — without it, $group treats the entire array as a single value.
// Documents have shape: { userId, tags: ['billing', 'support', 'enterprise'] }
// Count how many documents each tag appears in
const tagCounts = await coll.aggregate([
{ $match: { createdAt: { $gte: thirtyDaysAgo } } },
{ $unwind: { path: '$tags', preserveNullAndEmpty: false } },
// preserveNullAndEmpty: false (default) — skips docs where tags is null/missing/empty array
{ $group: { _id: '$tags', count: { $sum: 1 } } },
{ $sort: { count: -1 } },
{ $limit: 50 },
]).toArray();
// $unwind with includeArrayIndex — keep track of original array position
const withIndex = await coll.aggregate([
{ $unwind: { path: '$lineItems', includeArrayIndex: 'lineItemIndex' } },
{ $match: { 'lineItems.sku': targetSku } },
{ $project: { orderId: '$_id', lineItemIndex: 1, lineItem: '$lineItems' } },
]).toArray();
$lookup — left outer joins between collections
$lookup performs a left outer join with another collection in the same database. DocumentDB supports the basic equality join syntax in all versions and the pipeline-based correlated subquery syntax in 4.0+, but with limitations.
// Basic equality join — supported in all DocumentDB versions
// Join orders (localField: productId) with products (_id)
const ordersWithProducts = await db.collection('orders').aggregate([
{ $match: { status: 'pending', createdAt: { $gte: yesterday } } },
{ $lookup: {
from: 'products',
localField: 'productId', // field in orders collection
foreignField: '_id', // field in products collection
as: 'productDetails', // output array field name
}},
// $lookup always returns an array; for 1:1 joins, unwind to get the object
{ $unwind: { path: '$productDetails', preserveNullAndEmpty: true } },
// preserveNullAndEmpty: true = keep orders with no matching product (outer join)
{ $project: {
orderId: '$_id',
amount: 1,
status: 1,
productName: '$productDetails.name',
productPrice: '$productDetails.price',
}},
]).toArray();
// Pipeline-based correlated $lookup — DocumentDB 4.0+ (test before relying on this)
// Useful for complex join conditions that go beyond simple equality
const withFilteredHistory = await db.collection('users').aggregate([
{ $match: { active: true } },
{ $lookup: {
from: 'events',
let: { uid: '$_id' },
pipeline: [
{ $match: {
$expr: { $eq: ['$userId', '$$uid'] },
}},
{ $match: { eventType: 'purchase' } },
{ $sort: { createdAt: -1 } },
{ $limit: 5 },
],
as: 'recentPurchases',
}},
], { allowDiskUse: true }).toArray();
$lookup performance: each document in the input stage triggers a lookup into the joined collection. For a pipeline that processes 10,000 orders, that is 10,000 lookups into the products collection. Ensure the foreignField has an index — without an index, each lookup is a collection scan. For large join volumes, consider pre-joining with application-layer batching instead.
DocumentDB $lookup with pipeline limitation: in DocumentDB 4.0, the pipeline-based correlated $lookup (let + pipeline syntax) works for many use cases but does not support all MongoDB 5.x+ features within the sub-pipeline (e.g., nested $lookup within the sub-pipeline). Test your specific pipeline in a staging environment before deploying.
$facet — multi-faceted queries in a single round trip
$facet runs multiple sub-pipelines over the same input documents and returns all results in a single document. It is the standard pattern for search results pages that return paginated results alongside category counts, date histograms, or price ranges.
// Product search — return paginated results + facet counts + total count
// in a single aggregation query
const searchResults = await db.collection('products').aggregate([
// 1. Filter: apply search criteria BEFORE $facet
{ $match: {
category: { $in: selectedCategories },
price: { $gte: minPrice, $lte: maxPrice },
inStock: true,
}},
// 2. $facet runs three sub-pipelines over the filtered set
{ $facet: {
// Sub-pipeline 1: paginated, sorted results
results: [
{ $sort: { score: -1, createdAt: -1 } },
{ $skip: page * pageSize },
{ $limit: pageSize },
{ $project: { name: 1, price: 1, category: 1, imageUrl: 1 } },
],
// Sub-pipeline 2: category breakdown with counts
categoryFacets: [
{ $group: { _id: '$category', count: { $sum: 1 } } },
{ $sort: { count: -1 } },
],
// Sub-pipeline 3: total count (for pagination metadata)
totalCount: [
{ $count: 'n' },
],
// Sub-pipeline 4: price range histogram
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;
// Return structured response to MCP caller
return {
results,
pagination: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) },
facets: { categories: categoryFacets, priceHistogram },
};
$bucket note: DocumentDB supports $bucket (uniform boundaries) and $bucketAuto (automatically computed equal-frequency boundaries). $bucket requires the boundaries array to be sorted and cover the full value range; documents outside all boundaries go to the default bucket if specified, or cause an error if not.
explain() — verifying index usage
// Run explain on an aggregation pipeline — returns query plan without executing
const explanation = await db.command({
aggregate: 'events',
pipeline: [
{ $match: { userId: userId, createdAt: { $gte: startDate } } },
{ $group: { _id: '$eventType', count: { $sum: 1 } } },
],
cursor: {},
explain: 'queryPlanner', // or 'executionStats' for actual execution metrics
});
// Look for IXSCAN (index scan) in the winning plan — not COLLSCAN (collection scan)
// A $match that uses no index will show:
// winningPlan: { stage: 'COLLSCAN' }
// A properly indexed $match will show:
// winningPlan: { stage: 'FETCH', inputStage: { stage: 'IXSCAN', indexName: '...' } }
console.log(JSON.stringify(explanation, null, 2));
// Alternatively, use the cursor's explain() method
const cursor = db.collection('events').aggregate(pipeline, { allowDiskUse: true });
const plan = await cursor.explain('executionStats');
// plan.stages[0].$cursor.executionStats shows:
// nReturned, executionTimeMillis, totalKeysExamined, totalDocsExamined
// totalDocsExamined >> nReturned = missing index
Reading the execution stats: totalKeysExamined is the number of index entries scanned; totalDocsExamined is the number of documents fetched from storage; nReturned is the number of documents emitted by the stage. A healthy query has totalDocsExamined ≈ nReturned. When totalDocsExamined is orders of magnitude larger than nReturned, add a more selective index or reorder the $match filter fields to match an existing compound index (index fields must appear in left-to-right order relative to the query predicate).
Supported aggregation operators reference
DocumentDB supports the following core pipeline stages and operators (verified as of DocumentDB 5.0):
| Category | Supported operators | Notable gaps |
|---|---|---|
| Pipeline stages | $addFields, $bucket, $bucketAuto, $count, $facet, $group, $limit, $lookup, $match, $out, $project, $replaceRoot, $replaceWith, $sample, $skip, $sort, $sortByCount, $unset, $unwind, $vectorSearch (5.0 only) |
$merge (merge into another collection), $setWindowFields (window functions), $densify, $fill |
| Accumulator operators ($group) | $avg, $first, $last, $max, $min, $push, $addToSet, $sum, $stdDevPop, $stdDevSamp |
$accumulator (custom JS), $percentile, $median, $bottom, $top, $topN, $bottomN, $firstN, $lastN, $maxN, $minN |
| Arithmetic | $abs, $add, $ceil, $divide, $exp, $floor, $ln, $log, $log10, $mod, $multiply, $pow, $round, $sqrt, $subtract, $trunc |
None significant |
| String | $concat, $indexOfCP, $ltrim, $regexFind, $regexFindAll, $regexMatch, $rtrim, $split, $strLenCP, $strcasecmp, $substrCP, $toLower, $toUpper, $trim |
None significant |
| Conditional | $cond, $ifNull, $switch |
$function (custom JS within aggregation), $getField |
| Array | $arrayElemAt, $arrayToObject, $concatArrays, $filter, $in, $indexOfArray, $isArray, $map, $range, $reduce, $reverseArray, $size, $slice, $zip |
$sortArray, $firstN/$lastN/$minN/$maxN as array operators |
| Date | $dateAdd, $dateDiff, $dateFromParts, $dateFromString, $dateToParts, $dateToString, $dayOfMonth, $dayOfWeek, $dayOfYear, $hour, $millisecond, $minute, $month, $second, $toDate, $week, $year |
None significant |
Common errors
| Error | Cause | Fix |
|---|---|---|
MongoServerError: Exceeded memory limit for $group/$sort |
Aggregation stage exceeded the 100 MB in-memory limit | Add allowDiskUse: true to aggregation options; or add a $match before the stage to reduce the input set size |
MongoServerError: unknown operator: $function or $accumulator |
DocumentDB does not support custom JavaScript aggregation operators | Rewrite logic using supported operators: $cond, $switch, $reduce, $map; or move post-processing to the application layer after the aggregation returns |
MongoServerError: $lookup with pipeline is not supported |
Correlated $lookup (let + pipeline) is not supported in DocumentDB 3.6 or the specific DocumentDB engine version in use |
Upgrade to DocumentDB 4.0 or 5.0; or rewrite as multiple separate queries in the application layer |
Aggregation returns unexpected results with $sort + $skip + $limit pagination |
DocumentDB (like MongoDB) does not guarantee stable sort order for documents with equal sort key values; pagination can return duplicates or skip documents across pages | Always include a tiebreaker field (_id) as a secondary sort key: { $sort: { createdAt: -1, _id: 1 } } |
$out stage fails with permission error |
$out writes results to a collection and requires write permissions; it also drops and recreates the target collection atomically |
Ensure the IAM role has DocumentDB write permissions; note that $out completely replaces the target collection (unlike $merge which is not available in DocumentDB) |
| COLLSCAN in explain() despite having an index | DocumentDB's query planner did not select the index because the selectivity estimate predicts a full scan is cheaper, the index field order does not match the query predicate, or the index was built after the query plan was cached | Verify the index covers the exact fields used in $match in the same order; use hint() to force index selection; rebuild statistics with db.runCommand({ planCacheClear: 'collectionName' }) |