Read-only MongoDB access for an AI agent, and what still gets through
A read-only flag that works by reading the query text can be walked past in four lines. Where the check runs matters more than what it is called.
Giving an assistant a database connection is now a normal thing to do, and the standard advice is sound as far as it goes: turn on read-only mode, use a least-privilege user, do not point it at production.
The part that gets skipped is where the read-only check actually runs. That decides whether it is a guard or a suggestion, and the difference is not visible from the setting’s name. “Read-only” is one label covering at least three different mechanisms, and only some of them hold.
Some notes here describe how Monghoul does this, because it is the implementation I can describe precisely. The failure modes are not specific to it.
On the official MongoDB MCP Server specifically. It ships a --readOnly flag, and that one does
not work by scanning query text: it withholds the write tools from the tool list altogether, so the
model has nothing to call. That is a structurally sound design and the criticism below does not land
on it. Use it. The two things it does not answer are what happens when a tool that is allowed can
still be pointed somewhere it should not reach, and what the connection’s MongoDB user is permitted
to do, which is the backstop this whole piece ends on.
Three places a check can live, and only one of them holds
In the prompt. “You are a read-only assistant. Never write to the database.” This is not a control. It is a request to a system that is designed to be persuadable, and anything that reaches the model’s context can argue with it, including data the model just read out of your database.
In the tool layer, by reading the query. The server takes the query the assistant wrote, scans it
for insertOne, deleteMany, $out and friends, and refuses when it finds one. This is a real
improvement and it is where a lot of implementations stop. It is also defeatable, and not by anything
exotic.
At the driver. The call is inspected as it is about to execute, after every layer of indirection has resolved into an actual operation against an actual collection. There is nothing left to hide behind at that point.
Four ways past a text scan
None of these require the assistant to be adversarial. A model writing idiomatic JavaScript produces some of them by accident.
Bracket notation. The scanner is looking for .deleteMany(.
const op = 'deleteMany';
db.collection('orders')[op]({});
An alias. The method is captured before it is called, so the call site names nothing.
const wipe = db.collection('orders').deleteMany;
wipe.call(db.collection('orders'), {});
A raw command. Plenty of destructive work does not use a method name a scanner would recognise.
db.command({ drop: 'orders' });
An aggregation output stage. This one is the most likely to appear innocently, because the pipeline genuinely is a read right up until its last stage.
db.collection('orders').aggregate([
{ $match: { status: 'draft' } },
{ $out: 'orders' }
]);
That last one replaces the collection with the matched subset. It is spelled like analysis.
A scanner can be taught each of these individually. It cannot be taught the general case, because the general case is “any expression that evaluates to a write”, and deciding that from source text is not a thing you can finish.
What enforcement at the driver changes
The useful property is not that the scan gets better. It is that the scan stops being the thing that decides.
In Monghoul, the query text is still scanned, and that scan is what raises the confirmation the user
sees, because a person needs to be told what they are approving before it runs. But the enforcement
is at the driver: a write reaches the server only if the connection allows it or the user confirmed
it. A destructive call the text could not show, written in bracket notation, reached through an alias
or a variable method name, or sent as a raw db.command, is refused when it runs. The refusal names
the operation, the target and the connection.
So the text scan is a courtesy to the human, and the driver is the boundary. Those are two jobs, and conflating them is what produces a read-only mode that reads well and does not hold.
The ordering matters too
Two rules that sound like details and are not.
Scope is checked before protection. An assistant that cannot reach a connection never learns that a database on it is protected. If protection were checked first, a refusal would leak the existence and shape of things outside the assistant’s scope.
An approved operation is re-checked when it runs. Approval and execution are not the same moment. Narrowing access after approving something still stops it, which matters because the natural human response to a suspicious request is to tighten the setting, and that response should work.
There is also a rule about what can never be silenced: an approval marked “allow for this session” never covers a write-protected target. A convenience that can be turned into a standing grant over a protected collection is not a convenience, it is the hole.
The thing worth copying if you build your own
Whatever tool you use, the questions are the same three:
- Where does the check run? If the answer is “we look at the query the model wrote”, you have a
linter. Ask what happens with
db.command({ drop: 'x' }). - Can the assistant change its own limits? It should be able to read what it is allowed to reach, so it can tell you a query will need approval instead of springing it on you. It should never be able to widen that. A guard the guarded party can disarm is not one.
- Does the assistant’s code get network access? A query written by a model and run inside your
client, with your credentials, that can also make outbound requests, is an exfiltration path with
extra steps. Monghoul withdraws
fetchfrom an AI-initiated query for exactly that reason; a query you wrote and ran yourself is unaffected.
The least-privilege MongoDB user is still the right backstop and none of this replaces it. Grant the assistant’s connection a user that cannot write, and the question of whether the client’s guard holds becomes much less interesting. Defence in depth is not a slogan here: the client-side control is convenience and speed, and the server-side grant is the thing that is actually true.