Skip to content
Monghoul

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.

· ·updated 13 September 2026 ·checked against Monghoul 1.10
A bold vertical line with a narrow aperture cut into it. Small squares sit to the left, a solid circle to the right. One line passes through the aperture; a second stops at a green triangle filling the opening.
Reads cross the boundary. Writes reach it and stop, because the block lives at the driver rather than in the prompt.

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:

  1. 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' }).
  2. 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.
  3. 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 fetch from 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.