Build-it
How to Build an MCP Server for MongoDB
Build a MongoDB MCP server in Python that lists collections and queries documents, with a filter guard that turns a model's {"$where": "..."} into a tool error instead of server-side JavaScript.
Mark
Head of Marketing, MCPOrbit
- Published
- Updated
- · Updated
- Read time
- · 9 min read

To build a Model Context Protocol (MCP) server for MongoDB, expose a small set of read tools (list collections, find documents, count documents) from the official Python mcp SDK, and run every model-supplied filter through one guard that allows plain field-equality and rejects MongoDB operators. That guard is the whole job: without it, a filter like {"$where": "..."} runs JavaScript on your database, and {"$ne": ""} quietly returns every document.
Below is a complete MongoDB server in one file. It gives a client three read-only tools, connects through a read-only user, and treats every filter as hostile input. The security spine is a 12-line function; the rest is wiring.
What does a MongoDB MCP server do?
A MongoDB MCP server turns a database into tools an AI client can call. Instead of writing a query, the model asks find_documents for users where status is active, and gets structured documents back. It is the most requested database server after Postgres, because so many application backends already store their data in MongoDB.
It is also the one where the naive version is dangerous in a way SQL developers do not expect. A MongoDB filter is a JSON object, and the model builds that object. If you pass it straight to PyMongo, the model can insert query operators: {"$where": "sleep(5000) || true"} executes JavaScript on the server, and {"price": {"$gt": ""}} matches every document regardless of the price the caller asked about. This is NoSQL injection, and the fix is to never let model input reach the operator layer.
Set up the project
Create a project with uv and add three dependencies: the SDK, the MongoDB driver, and mongomock for a database-free test. The mcp package pulls in Pydantic, which the SDK uses to build tool schemas from your type hints.
uv init --python 3.11 mongo-tools && cd mongo-tools
uv add "mcp[cli]==2.0.0" "pymongo==4.11" mongomockSanitize the filter first
The security spine of a MongoDB server is one function. It walks the filter the model sent and rebuilds it, allowing only string keys that name a field and scalar values. A key that starts with $ is a MongoDB operator; a key with a . reaches into a subdocument. A value that is itself an object is an operator expression like {"$ne": ""}. All three are rejected before the query is built.
import os
from datetime import datetime
from typing import Any
from bson import ObjectId
from pymongo import MongoClient
from mcp.server.mcpserver import MCPServer
# One connection, reused across every tool call. Point MONGO_URI at a
# read-only user so a bug in a tool can never write or drop a collection.
MONGO_URI = os.environ["MONGO_URI"]
DB_NAME = os.environ.get("MONGO_DB", "app")
MAX_LIMIT = 50
client: MongoClient = MongoClient(MONGO_URI)
db = client[DB_NAME]
mcp = MCPServer(name="mongo-tools", version="1.0.0")
def _safe_filter(filter: dict[str, Any]) -> dict[str, Any]:
"""Allow only flat field == value equality. Reject anything that could
inject a MongoDB operator: a $-prefixed key ($where runs JavaScript on the
server, $gt/$ne turn into match-all), or a dotted key that reaches into a
subdocument the caller was never meant to touch."""
if not isinstance(filter, dict):
raise ValueError("filter must be an object of field: value pairs")
clean: dict[str, Any] = {}
for key, value in filter.items():
if key.startswith("$") or "." in key:
raise ValueError(f"illegal filter key: {key!r}")
if isinstance(value, (dict, list)):
raise ValueError(f"value for {key!r} must be a scalar, not an operator object")
clean[key] = value
return clean
def _jsonable(doc: dict[str, Any]) -> dict[str, Any]:
"""BSON types are not JSON-serializable. Coerce ObjectId and datetime to
strings so the document survives the JSON-RPC boundary intact."""
out: dict[str, Any] = {}
for key, value in doc.items():
if isinstance(value, ObjectId):
out[key] = str(value)
elif isinstance(value, datetime):
out[key] = value.isoformat()
else:
out[key] = value
return outThe order matters: sanitize, then query. _safe_filter never mutates the caller's operators into something safe. It refuses the whole call. That is deliberate. A model that sends an operator is either malfunctioning or being driven by a prompt injection, and the right answer is an error the model can see and recover from, not a best-effort guess at what it meant.
Add the tools
Now the three tools. Each one runs its filter through _safe_filter before it touches the database, so the guard is impossible to forget. find_documents caps the result at 50 so a bare query cannot stream an entire collection into the model's context, and coerces each document through _jsonable so BSON types do not break serialization.
@mcp.tool()
def list_collections() -> list[str]:
"""List the collection names in the database."""
return sorted(db.list_collection_names())
@mcp.tool()
def find_documents(
collection: str, filter: dict[str, Any] | None = None, limit: int = 20
) -> list[dict[str, Any]]:
"""Find documents in a collection by exact field match. `filter` is a flat
map of field: value equality pairs; returns at most `limit` documents."""
query = _safe_filter(filter or {})
capped = max(1, min(limit, MAX_LIMIT))
cursor = db[collection].find(query).limit(capped)
return [_jsonable(doc) for doc in cursor]
@mcp.tool()
def count_documents(collection: str, filter: dict[str, Any] | None = None) -> int:
"""Count the documents in a collection matching a flat equality filter."""
return db[collection].count_documents(_safe_filter(filter or {}))Run it over stdio
Add the entry point. stdio is the transport a local client launches directly: the client starts your process and speaks JSON-RPC over its standard input and output. Save the connection setup, the two helpers, the three tools, and this block as server.py.
if __name__ == "__main__":
# stdio is the transport a local client (Claude Desktop, Cursor) launches.
mcp.run("stdio")Test it end to end before you trust it
A database server that takes model-built filters is exactly the kind of code you do not ship on faith. This test seeds an in-memory MongoDB with mongomock, so it needs no running database, then drives all three tools and fires the injection attempts a real client will eventually send. The last loop is the one that matters: every operator filter must raise, not return rows.
"""End-to-end test: seed an in-memory MongoDB, drive the tools, and assert
that operator injection is refused. Uses mongomock, so no real database or
network is required. Run it with: uv run python test_server.py"""
import mongomock
import server
# Swap the live connection for an in-memory one and seed three users.
server.db = mongomock.MongoClient()["app"]
server.db.users.insert_many(
[
{"name": "ada", "status": "active"},
{"name": "grace", "status": "active"},
{"name": "alan", "status": "disabled"},
]
)
# 1. list_collections sees the seeded collection.
assert "users" in server.list_collections()
# 2. An equality filter returns the matching docs, with _id coerced to a string.
active = server.find_documents("users", {"status": "active"})
assert len(active) == 2
assert all(isinstance(doc["_id"], str) for doc in active)
# 3. count_documents agrees with find.
assert server.count_documents("users", {"status": "active"}) == 2
# 4. Every injection attempt is refused as a tool error, not executed.
for attack in ({"$where": "true"}, {"name": {"$ne": ""}}, {"a.b": 1}):
try:
server.find_documents("users", attack)
raise SystemExit(f"injection not blocked: {attack}")
except ValueError:
pass
print("ALL CHECKS PASSED")Run it with one command. The final ALL CHECKS PASSED line prints only if the equality queries returned the right documents and all three injection attempts were refused.
uv run python test_server.py
# ALL CHECKS PASSEDConnect it to Claude Desktop
Point a local client at the server with an absolute directory and a read-only connection string. Use a MongoDB user that has read on the target database and nothing else, so the tools are physically incapable of writing even if a future tool has a bug.
{
"mcpServers": {
"mongo-tools": {
"command": "uv",
"args": ["--directory", "/absolute/path/to/mongo-tools", "run", "python", "server.py"],
"env": {
"MONGO_URI": "mongodb://readonly:secret@localhost:27017/",
"MONGO_DB": "app"
}
}
}
}Frequently asked questions
Frequently asked questions
- How do I build an MCP server for MongoDB?
- Expose
list_collections,find_documents, andcount_documentstools from the PythonmcpSDK, connect with a read-only PyMongo client, and run every model-supplied filter through a guard that allows only flat field-equality pairs. Serve it over stdio withmcp.run("stdio"). - How do I prevent NoSQL injection in a MongoDB MCP server?
- Never pass a model-built filter straight to PyMongo. Rebuild it and allow only string keys that name a field with scalar values. Reject any key that starts with
$(a query operator like$whereor$ne) or contains a.(a subdocument path), and reject any value that is itself an object. This whitelist blocks operator injection at the structure level, where escaping cannot help. - Why do ObjectId and datetime break the tool result?
- MongoDB returns BSON types that the JSON-RPC transport cannot serialize, so returning a raw document raises a
TypeErrorat the transport layer. CoerceObjectIdanddatetimeto strings before returning, which also gives the model a value it can read and pass back. - Should the MongoDB MCP server be read-only?
- Start read-only. Register only query tools and connect with a MongoDB user that has
readand nothing else, so no tool can write or drop a collection even by mistake. Add write tools later behind their own guards once the read path is trusted. - How do I let the model use operators like $gt or $in safely?
- Do not accept raw operators. Expose them as explicit typed tool parameters instead. For example, a
min_price: floatargument that your code turns into{"$gt": min_price}server-side. The model names the intent, your code owns the operator, and injection stays impossible. - What versions does this code target?
- It is pinned to
mcp2.0.0,pymongo4.11, and Python 3.11, which track the 2026-07-28 MCP specification. The filter-guard pattern itself is version-independent and applies to any driver.
That is a MongoDB MCP server that lists collections and queries documents without ever letting a model reach the operator layer. The same guard (whitelist the structure, expose operators as typed parameters, return a tool error on anything else) is how you keep every database MCP server honest. Pair it with the Postgres and SQLite builds for the full data-source set.
About the author
Mark
Head of Marketing, MCPOrbit
Mark writes MCPOrbit's build-it tutorials. Every line of code in them is run and asserted before it ships.

