Guide · Amazon DocumentDB · MCP Server Document Storage · MongoDB Compatibility

Amazon DocumentDB for MCP Servers — Connection Setup, CRUD Tools, and MongoDB Compatibility

MCP server tools that expose document-oriented data — user profiles, event records, nested configuration objects, audit logs, or any schema-flexible data — benefit from Amazon DocumentDB's managed MongoDB-compatible API. DocumentDB is not MongoDB: it implements the MongoDB 3.6, 4.0, and 5.0 wire protocol against a purpose-built Aurora-derived distributed storage engine. That distinction matters when writing MCP tools, because a handful of widely-used MongoDB patterns do not exist in DocumentDB and produce confusing errors the first time an author hits them. This guide covers DocumentDB architecture and when to choose it over DynamoDB or MongoDB Atlas, the mandatory TLS connection string with its non-obvious flags, Lambda-safe connection pooling with Mongoose, complete CRUD tool implementations, index strategy, and the most common MongoDB-compatibility gaps that break MCP server integrations.

TL;DR

Always connect with tls=true, tlsCAFile pointing to the AWS CA bundle, and retryWrites=false (DocumentDB does not support retryable writes — omitting this flag causes immediate errors with MongoDB drivers 4.x+). Cache the Mongoose connection in a Lambda global variable and check mongoose.connection.readyState to avoid opening a new connection on every warm invocation. Use Secrets Manager to store the username and password; DocumentDB does not support IAM database authentication. Set serverSelectionTimeoutMS: 5000 — the default 30-second timeout causes Lambda functions to hang until they time out when the DocumentDB cluster is unreachable.

When to choose DocumentDB vs DynamoDB vs MongoDB Atlas

Consideration DocumentDB DynamoDB MongoDB Atlas
Data model Flexible JSON documents; nested objects; arrays of sub-documents; rich query language Key-value with optional sort key; limited secondary index types; item size 400KB Same flexible JSON document model; full MongoDB feature set including transactions, $graphLookup, text search, Atlas Vector Search
Query power MongoDB-compatible aggregation pipeline (subset); secondary indexes; Atlas Search not available; vector search available in 5.0 Scan + filter or GSI queries; no aggregation; expression language is limited; suited for predictable access patterns Full aggregation pipeline; Atlas Search (Lucene-backed); Atlas Vector Search; $graphLookup on arrays; $function and $accumulator
Operational model Managed in your VPC; instance-based pricing (no serverless standard variant); high availability via storage replication and read replicas Fully serverless; global tables; millisecond at any scale; no VPC required; pay per request or provisioned Fully managed multi-cloud; global clusters; no VPC required unless PrivateLink configured; highest MongoDB compatibility
AWS integration Deepest AWS integration: VPC, IAM for management API, Secrets Manager, CloudWatch native metrics, Lambda triggers via DocumentDB Streams, same billing as RDS Native AWS service; IAM database auth; Streams; Kinesis integration; zero config Third-party SaaS; PrivateLink available but requires setup; billed separately from AWS
Compliance and data residency Data stays in your VPC and AWS account; suitable for HIPAA, PCI, FedRAMP workloads Fully within AWS account; similar compliance posture Third-party data processor; Atlas has compliance certifications but data leaves your AWS account
Choose when You need MongoDB-compatible document storage within AWS with no third-party dependency; existing MongoDB application moving to AWS Access patterns are simple and predictable; you need serverless scale-to-zero; latency requirements are sub-millisecond You need full MongoDB compatibility including features DocumentDB does not support; you are already running MongoDB Atlas

Cluster architecture

A DocumentDB cluster consists of a primary instance (read/write) and zero or more read replicas. The cluster has two DNS endpoints:

DocumentDB also supports DocumentDB Elastic Clusters (a separate product: docdb-elastic.amazonaws.com) that offers sharding, pay-per-use billing, and MongoDB 5.0 with vector search. Standard provisioned DocumentDB clusters use instance types (T3, T4g, R5, R6g) with per-instance pricing.

No public endpoint by default. DocumentDB clusters are VPC-only. MCP servers on Lambda, ECS, or EC2 must be in the same VPC or use VPC endpoints. For local development, use an SSH bastion or AWS Systems Manager Session Manager port-forward.

Connection string — mandatory flags

The DocumentDB connection string has three non-obvious requirements compared to a MongoDB Atlas URI:

// DocumentDB connection string — all three flags are required
const uri = [
  `mongodb://${encodeURIComponent(username)}:${encodeURIComponent(password)}`,
  `@${clusterEndpoint}:27017`,
  '/?tls=true',
  '&tlsCAFile=/tmp/rds-combined-ca-bundle.pem',  // path where cert is written
  '&replicaSet=rs0',
  '&readPreference=secondaryPreferred',
  '&retryWrites=false',         // REQUIRED — DocumentDB does not support retryable writes
].join('');

TLS certificate bundle: download rds-combined-ca-bundle.pem from AWS and include it in your Lambda deployment package or write it to /tmp at cold start from an environment variable. The certificate path in tlsCAFile must be an absolute filesystem path, not a URL. Alternatively, use the tlsAllowInvalidCertificates=true flag for development only — never production.

import { SecretsManagerClient, GetSecretValueCommand } from '@aws-sdk/client-secrets-manager';
import mongoose from 'mongoose';
import { writeFileSync, existsSync } from 'fs';

// RDS CA bundle — embed as base64 environment variable or bundle in deployment
// Download from: 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)) {
    // CA_CERT env var contains the PEM content (set at deploy time)
    writeFileSync(CA_CERT_PATH, process.env.DOCUMENTDB_CA_CERT ?? '');
  }
}

let cachedConnection: typeof mongoose | null = null;

export async function getConnection(): Promise {
  // Reuse connection on Lambda warm invocation
  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}`,
    '&replicaSet=rs0',
    '&readPreference=secondaryPreferred',
    '&retryWrites=false',
  ].join('');

  mongoose.set('bufferCommands', false);  // fail immediately if connection not ready

  cachedConnection = await mongoose.connect(uri, {
    serverSelectionTimeoutMS: 5000,   // fail fast — default 30s hangs Lambda
    connectTimeoutMS: 5000,
    socketTimeoutMS: 10000,
    maxPoolSize: 5,                   // keep pool small for Lambda concurrency
    minPoolSize: 1,
  });

  return cachedConnection;
}

Why retryWrites=false is required: MongoDB drivers 4.x and later enable retryable writes by default. Retryable writes require a MongoDB 3.6+ replica set with transactions support. DocumentDB does not implement the MongoDB retryable write protocol — when a driver attempts a retryable write, DocumentDB returns MongoServerError: Transaction numbers are only allowed on a replica set member or mongos. Setting retryWrites=false disables this feature at the driver level and eliminates the error.

Why replicaSet=rs0: DocumentDB presents itself as a replica set named rs0 even with a single instance. The replica set topology mode in the driver allows change streams, which require replica set topology. Without this flag, some driver versions fall back to standalone topology mode and change streams fail.

Basic CRUD MCP tools

The following example shows a complete set of CRUD tools using the native MongoDB Node.js driver (without Mongoose schema layer) for cases where the data model is unstructured or dynamic:

import { MongoClient, ObjectId } from 'mongodb';
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';

let client: MongoClient | null = null;

async function getClient(): Promise {
  if (client && client.topology?.isConnected()) return client;

  const uri = buildUri();  // same URI as above
  client = new MongoClient(uri, {
    serverSelectionTimeoutMS: 5000,
    connectTimeoutMS: 5000,
    maxPoolSize: 5,
  });
  await client.connect();
  return client;
}

export function registerDocumentDBTools(server: McpServer) {
  const DB = process.env.DOCUMENTDB_DB_NAME!;
  const COLL = process.env.DOCUMENTDB_COLLECTION!;

  // ---- insert_document ----
  server.tool('insert_document', 'Insert a document into DocumentDB', {
    document: z.record(z.unknown()).describe('JSON object to insert'),
  }, async ({ document }) => {
    const db = (await getClient()).db(DB);
    const result = await db.collection(COLL).insertOne(document as Document);
    return {
      content: [{ type: 'text', text: JSON.stringify({
        insertedId: result.insertedId.toString(),
        acknowledged: result.acknowledged,
      })}],
    };
  });

  // ---- find_documents ----
  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}'),
    projection: z.record(z.number()).optional().describe('Field projection'),
  }, async ({ filter, limit, skip, sort, projection }) => {
    const db = (await getClient()).db(DB);
    const cursor = db.collection(COLL)
      .find(filter)
      .skip(skip)
      .limit(limit);
    if (sort) cursor.sort(sort);
    if (projection) cursor.project(projection);
    const docs = await cursor.toArray();
    return {
      content: [{ type: 'text', text: JSON.stringify(
        docs.map(d => ({ ...d, _id: d._id.toString() }))
      )}],
    };
  });

  // ---- update_document ----
  server.tool('update_document', 'Update a document by _id or 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).describe('Update all matching documents'),
  }, 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,
      })}],
    };
  });

  // ---- delete_document ----
  server.tool('delete_document', 'Delete document(s) by filter', {
    filter: z.record(z.unknown()).describe('Filter to select document(s)'),
    multi:  z.boolean().default(false),
  }, async ({ filter, multi }) => {
    const db = (await getClient()).db(DB);
    const method = multi ? 'deleteMany' : 'deleteOne';
    const result = await db.collection(COLL)[method](filter);
    return {
      content: [{ type: 'text', text: JSON.stringify({ deletedCount: result.deletedCount })}],
    };
  });

  // ---- count_documents ----
  server.tool('count_documents', 'Count documents matching a filter', {
    filter: z.record(z.unknown()).default({}).describe('MongoDB filter query'),
  }, async ({ filter }) => {
    const db = (await getClient()).db(DB);
    const count = await db.collection(COLL).countDocuments(filter);
    return { content: [{ type: 'text', text: String(count) }] };
  });
}

ObjectId handling: DocumentDB uses BSON ObjectIds as the default _id value. When a tool receives an ID string from the caller, convert it with new ObjectId(idString) before using it in a filter. Passing the raw string "507f1f77bcf86cd799439011" as the filter value does not match the BSON ObjectId — it matches nothing and returns zero documents without an error.

// Correct — convert string to ObjectId before querying
const doc = await collection.findOne({ _id: new ObjectId(idString) });

// Wrong — string does not match BSON ObjectId
const doc = await collection.findOne({ _id: idString });  // always returns null

Index strategy

DocumentDB supports standard MongoDB index types with some limitations. Create indexes before loading data for large collections — building an index on an existing collection acquires a foreground lock in DocumentDB 3.6/4.0 (background builds available in 5.0+).

const db = (await getClient()).db(DB);
const coll = db.collection('events');

// Single-field ascending index
await coll.createIndex({ userId: 1 });

// Compound index — field order matters for range queries and sorts
await coll.createIndex({ userId: 1, createdAt: -1 });

// Sparse index — only indexes documents that have the field
// Avoids polluting a unique index with many null values
await coll.createIndex({ email: 1 }, { unique: true, sparse: true });

// TTL index — DocumentDB supports TTL (documents expire after ttlSeconds)
// expireAfterSeconds is relative to the Date value in the indexed field
await coll.createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 });
// expiresAt should be a Date object; DocumentDB deletes documents when Date < now

// Partial index — only index documents matching a filter
// DocumentDB 4.0+ supports partialFilterExpression
await coll.createIndex(
  { status: 1, updatedAt: -1 },
  { partialFilterExpression: { status: { $in: ['pending', 'processing'] } } }
);

// List indexes
const indexes = await coll.listIndexes().toArray();
console.log(indexes.map(i => i.name));

Index limits: DocumentDB supports a maximum of 64 indexes per collection (up from 32 in earlier engine versions). Index key size is limited to 1,024 bytes. Text indexes ({ field: 'text' }) are supported for basic text search, but Atlas Search full-text indexing is not available — use Amazon OpenSearch Service when you need Lucene-quality text search. 2dsphere geospatial indexes are supported.

Index build impact: in DocumentDB 4.0 and earlier, createIndex without {background: true} acquires a write lock and blocks all reads/writes on the collection until the build completes. For large collections in production, build indexes during low-traffic windows or use DocumentDB 5.0 which supports concurrent index builds.

MongoDB compatibility — features DocumentDB does not support

The most common compatibility gaps that break MCP server integrations:

Feature MongoDB (Atlas) DocumentDB status Workaround
Retryable writes Enabled by default in driver 4.x+ Not supported — returns Transaction numbers are only allowed on a replica set member or mongos Set retryWrites=false in connection URI
Multi-document ACID transactions Supported since MongoDB 4.0 Supported in DocumentDB 4.0 within a single collection; cross-collection and cross-document transactions have limitations Test thoroughly; for complex cross-document atomicity consider application-level saga patterns
$function and $accumulator aggregation operators Allow custom JavaScript functions in aggregation Not supported Rewrite logic using supported operators ($cond, $switch, $reduce) or move computation to application layer
$expr with $regex Supported Not supported in older DocumentDB versions Use top-level $regex filter outside $expr when possible
$graphLookup on array fields Full support including connectFromField as array Supported in DocumentDB 4.0 with some constraints Test with your specific graph depth and array sizes; fall back to application-level recursion for complex traversals
Atlas Search ($search aggregation stage) Lucene-backed full-text search with faceting, scoring, synonyms Not available — Atlas-only feature Use DocumentDB text indexes for basic text search; use OpenSearch for production full-text search
Change stream fullDocumentBeforeChange Supported since MongoDB 6.0 with pre- and post-images feature Not supported Read the document before the update if you need the before-state; or use updateLookup for the after-state only
IAM database authentication Not applicable (Atlas authentication model) Not supported — unlike RDS/Aurora, DocumentDB does not have IAM database auth Always use Secrets Manager for username/password; rotate via Secrets Manager automatic rotation

Common errors

Error Cause Fix
MongoServerError: Transaction numbers are only allowed on a replica set member or mongos MongoDB driver 4.x+ has retryable writes enabled by default; DocumentDB does not support them Add retryWrites=false to the connection URI
MongoServerSelectionError: connect ECONNREFUSED or timeout in Lambda Lambda function is not in the same VPC and subnets as the DocumentDB cluster, or the security group lacks an inbound rule for port 27017 Place the Lambda function in the same VPC; add a security group inbound rule allowing TCP 27017 from the Lambda security group; set serverSelectionTimeoutMS: 5000 to fail fast instead of waiting 30 seconds
MongoServerError: SSL peer certificate or SSH host key not OK TLS CA certificate path is wrong, the file is empty, or the cert was not written to /tmp before connection Verify the PEM file exists at the path specified in tlsCAFile; write it in cold-start initialization before calling connect()
Filter by _id returns no results Passing a string ID when DocumentDB expects a BSON ObjectId Wrap the ID string with new ObjectId(idString) from the mongodb package
MongoServerError: BSONObj size: X is invalid. Max: 16777216 Document exceeds the 16 MB BSON document size limit Store large blobs (attachments, raw embeddings) in S3 and store only the S3 key reference in DocumentDB; chunk large arrays across multiple documents
High connection count causing MongoError: too many connections Each Lambda instance opens its own connection pool; with high concurrency, the total pool size exceeds the DocumentDB instance connection limit (varies by instance type, e.g., 1,000 for r5.large) Set maxPoolSize: 5 in MongoClient options; consider Amazon RDS Proxy for DocumentDB (available as a preview) for connection multiplexing at high Lambda concurrency