In July 2025, Replit's agent deleted a live production database
during an explicit code and action freeze. Replit's CEO called it unacceptable
and shipped dev/prod separation and better rollback in response.
The freeze was real. It was written down, it was agreed, and the agent had been
told about it — and none of that was in the execution path. The instruction lived
in the model's context, and the model's context is not a place where rules are
enforced.
There is a second detail worth sitting with. Afterwards, the agent reported that
rollback was impossible. That was wrong, and it delayed the recovery. So the
system's own account of what it had done was not merely unhelpful; it was
confidently false in the direction that mattered.
That incident is the shortest version of the question this post is about. Every
tool in this space has a guardrail. They differ enormously in which layer that
guardrail lives at, and almost none of them tell you.
I went looking for a comparison organised that way and could not find one, so
here is mine. I have a tool in this space myself; it is at the end, alternatives
to it are named before it, and I have tried to describe everything else the way
its maintainers would.
An earlier version of this post was reviewed by people looking specifically for
places where I had flattered my own tool or misdescribed someone else's. They
found several. The corrections are in the text rather than in a footnote.
The ladder
There are five places a rule about database writes can be enforced. They are not
equally good, and the difference is not a matter of degree.
| Layer | Enforced by | Gets past it |
|---|---|---|
| 1. Model context | prompt text, tool descriptions | the model, by ignoring it |
| 2. Client | the IDE or chat app's approval dialog | the user, by clicking "always allow" |
| 3. Server process | the MCP server's own code | anyone holding the credential it uses |
| 4. Proxy / gateway | a network hop | connecting directly |
| 5. Database | roles, privileges, engine settings | nothing, short of another credential |
The useful property of this ladder: each rung survives the failure of every
rung above it. A database role with no write privileges does not care whether
your allowlist has a parsing bug, whether the user clicked "always allow", or
whether the model was talked into something by a support ticket it read.
Most of the disagreement in this space turns out to be tools sitting at different
rungs while using the same words.
Layer 1: the model's context
This is tool descriptions, system prompts, and instructions returned inside tool
results — "only use this for read queries", "ask the user before applying".
It has no security value. It is worth writing, because it improves ordinary
behaviour and ordinary behaviour is most behaviour. It is not a control, and the
July 2025 incident is what it looks like when someone believes otherwise.
There is a specific version of this worth naming, because it is easy to mistake
for layer 3. Some MCP servers implement a two-step write flow — prepare, then
complete — where the separation between the steps is that the tool description
tells the model to verify in between. If nothing refuses the second call when the
first was never really checked, that is layer 1 wearing layer 3's clothes.
Layer 2: the client
Every serious MCP client shows a confirmation dialog before a tool call, and the
agent frameworks have their own equivalents: LangChain's
HumanInTheLoopMiddleware and interrupt, the OpenAI Agents SDK's
needsApproval with resumable interruptions, Pydantic AI's requires_approval
deferred tools. These are real, well-built mechanisms, and if you are building an
agent that writes to anything, you should use them.
Two honest problems.
What the human is shown is usually the model's own tool input. For a database
write that means a SQL string, rendered as JSON. Not the rows it will touch, not
how many, not what they currently contain. The same clients that render a file
edit as a colour diff render a database mutation as a blob of text. You are being
asked to approve a statement, which means you are being asked to be a SQL
interpreter, in your head, against data you cannot see.
And these gates get turned off. --dangerously-skip-permissions, auto-run
modes, allowlist creep. This is not user error; it is the predictable result of
asking someone twenty times an hour whether they meant it. Any control whose cost
is a click, paid repeatedly, converges on being disabled.
A design that dodges both problems, and is under-credited because it is boring:
have the agent produce an artefact that goes through review you already have.
A migration file in a pull request. A row in a staging table that a trusted job
applies. The human then reviews in a tool built for reviewing, with history and
blame and a second pair of eyes, instead of in a modal that interrupted them.
Layer 3: the server process
This is where most database MCP servers put their guard, and it is where the
interesting failures are, because a server here holds a credential that can write
and is choosing not to use it.
There are at least four distinct mechanisms, and they are not equally strong.
Wrapping the query in a read-only transaction. The original MCP reference
server for Postgres did this: BEGIN TRANSACTION READ ONLY, run the SQL, roll
back in a finally. Datadog Security Labs
showed it was bypassable
by statement stacking: the Postgres driver accepts multiple semicolon-separated
statements in one call, so COMMIT; DROP SCHEMA public CASCADE; ends the
read-only transaction and everything after it runs with full privileges.
Their recommended fix is the whole argument of this post, from a security team
that had just finished breaking the application-layer version of it:
A possible mitigation—one we recommend in any case—is using a Postgres user
with restricted privileges. You should definitely do this.
Their conclusion is the unglamorous one, and worth repeating: "classic
application security vulnerabilities are still very relevant to MCP servers and
other AI tooling."
The lesson generalises, in my words rather than theirs: a read-only transaction
is not a security boundary when the protocol accepts semicolons.
That server was deprecated in July 2025 and archived. It still had 86,941 npm
downloads in the week of 2 August 2026, which I checked while writing this.
Blocking keywords. AWS's database MCP servers default to read-only and reject
INSERT, UPDATE, DROP, session-state statements and a list of dangerous
functions. Their own README is unusually straight about what that buys you:
Treat this as a best-effort, defense-in-depth mechanism, not a security
boundary. A blocklist cannot enumerate every dangerous construct, and a
sufficiently creative query (obfuscation, quoted identifiers, new
server/extension functions, etc.) may bypass it. Do not rely on it as your
only control.
and then says the thing this entire post is about, better than I did:
Combining a minimal-privilege role (database-enforced) with the blocklist
(application-enforced) gives you defense in depth.
Database-enforced and application-enforced. That is the distinction, in a
vendor's own documentation, and it is the one almost nothing else makes explicit.
Parsing the SQL properly. Better than keyword matching, and it moves the
problem rather than removing it: you are now maintaining a SQL parser that must
agree with the database's parser about every dialect quirk, forever. Disagreement
between the two parsers is the whole bug class.
Not registering the write tools at all. Neon's read-only mode restricts which
tools exist. This is clean and easy to reason about — but it is still layer 3: the
credential in the process can write, and anything that reaches it can too.
A trap worth naming, because I nearly filed it under layer 5 myself:
Postgres's default_transaction_read_only looks like engine-level enforcement
and is not. It is a USERSET parameter — a default, not a privilege — so
SET default_transaction_read_only = off; or BEGIN READ WRITE; restores writes
from inside the very session it is supposed to constrain. The same semicolon that
beats a read-only transaction beats this. It is only load-bearing when the role
has no write grants, in which case the grants are the control and the setting is
decoration.
There is also a family of servers that deliberately hands the model transaction
control — begin transaction, commit, rollback as callable tools — or a
general execute_sql that accepts multiple statements. mcp-node-mssql and
mcp-sqlite-tools are examples I verified. This is a legitimate design for a
trusted local development database. It is worth being clear-eyed about what it
means in any other setting: the model holds the commit button.
The choice is not forced, and it is worth naming a server that goes the other
way. mssql-mcp-node is read-only by default, gates writes behind an
MSSQL_ENABLE_WRITES environment variable, and runs every read inside a
transaction it always rolls back. Same protocol, same language, opposite default.
Layer 4: the proxy
An MCP gateway that sits between client and server can gate tool calls centrally,
log them in one place, and apply policy from an engine like OPA or Cedar. It is
the right answer to "which of our forty agents may reach which of our servers",
and it survives a compromised client.
Its limit is structural: it protects the path through it. If the credential is
also reachable directly, the proxy is a convention.
Layer 5: the database
This is the only rung where the enforcement outlives everything above it.
- A role without write privileges. Supabase's MCP read-only mode runs queries as a read-only Postgres user — enforcement at the role, not in the process. It is opt-in rather than default, which is a real criticism, but the mechanism is the strong one.
-
Engine-level read-only. ClickHouse's
readonly=1, which also forbids changing settings, and SQLite's read-only connection. -
Scoped write authority, which barely appears in agent discussions and is
the mature answer to the actual question. Row-level security, column-level
grants, updatable views,
SECURITY DEFINERfunctions as the only write path. "May write, but only these rows" is a solved problem with a decade of production use, and it maps onto agents exactly. - No raw SQL at all. Typed, parameterised endpoints — PostgREST, Hasura, stored procedures, an MCP server exposing verbs instead of a SQL box. The agent picks an operation and fills typed slots; the SQL was written by a human in advance. This is the most common answer in production systems and the least discussed in agent writing.
The catch is that layer 5 controls what may happen and cannot tell you what
will. UPDATE orders SET status='shipped' WHERE placed_on < '2026-08-01' is
permitted by any sane privilege model. Whether it touches four rows or forty
thousand is not a privilege question.
Three things worth saying plainly
Read-only is not the same as safe. Removing writes does not remove risk: an
unbounded read is a denial of service and a cost incident, and read access is the
exfiltration half of every serious prompt-injection scenario. Several of the
worst documented MCP incidents involved no write at all.
Approval cannot be the whole answer, because a growing share of agent runs
have no human present — cron jobs, CI, batch runs. For those, the only workable
combination is layer 5 plus caps plus reversibility plus audit.
Reversibility deserves equal billing with prevention, and gets almost none.
Point-in-time recovery, time travel, instant branch restore, temporal tables,
soft deletes. In practice this is what most teams actually rely on, and the
questions that matter — how far back, does it cover DDL, can the agent itself
call the restore, and would you even notice you needed it — are rarely asked
before they are needed.
Measuring before approving is not a new category
I should name the alternatives here too, since this is the one part of the ladder
where I sell something.
Bytebase's SQL review computes affected rows and a risk level and routes the
change to a human before rollout. Branch-based databases — Neon, PlanetScale —
let you apply to a copy and inspect the result before promoting it, which answers
the same question from a different direction and gives you a rollback story for
free. And the cheapest version needs no tool at all: have the agent produce the
matching SELECT, or run the statement in an open transaction with RETURNING
and read it back before you commit.
Where my own tool sits, honestly
I maintain llm-safe-sql, which
attacks the layer-2 problem specifically: that the human is approving a claim.
It runs the proposed UPDATE or DELETE inside a transaction, measures the real
before/after values, always rolls back, and shows the human that measurement.
Applying happens in a separate process, from a separate credential.
Being honest about the ladder, and more honest than my first draft was. The
measurement runs at layer 3. Both the planning credential and the applying
credential can write — a dry run cannot execute without write privileges — so the
database is not enforcing the split between them. The library and the process
boundary are. Calling the separate apply credential "layer 5", as I first wrote,
was promoting my own tool one rung on my own ladder.
The one genuinely layer-5 setting it has is a separate read-only role for the
model's reads, which is also the cheapest thing an operator can do here. And its
check command now prints where each guard actually sits and names the ones that
are fictional, because "apply uses the same credential as plan" is something an
operator deserves to be told rather than to discover.
And the honest limits: this is human-in-the-loop, so unattended agents are out of
scope. It does not solve prompt injection — a measured diff tells you what will
happen, not who chose it, though "what will happen" is a great deal more than the
model's own description of it. And gating database access on a human decision is
not new — privileged-access brokers such as StrongDM have checked commands
against policy mid-session for years, long before anyone was pointing a language
model at a production database.
The question to ask
If you are evaluating anything in this space, the question that separates the
options is not which features it has. It is:
When your guardrail refuses something, what is doing the refusing — and what
would still refuse it if that component had a bug?
If the answer is "the tool description", you have a convention. If it is "the
approval dialog", you have a convention plus a habit. If it is "a role that
cannot write", you have a control.
Most tools are a mix, which is fine. Not knowing which is which is not.
Sources
Everything named above was checked against a primary source while writing. Where
a claim came from a vendor's comparison page about a competitor, I dropped it.
- MCP vulnerability case study: SQL injection in the Postgres MCP server — Datadog Security Labs
- Replit AI wiped a database and called it a catastrophic failure — Fortune, July 2025
- awslabs/mcp — postgres-mcp-server
- Supabase MCP server docs
- neondatabase/mcp-server-neon
- ClickHouse MCP server
- crystaldba/postgres-mcp
- bytebase/dbhub and Bytebase SQL review
- mihai-dulgheru/mssql-mcp-node — read-only by default
- PostgreSQL: default_transaction_read_only
- LangChain human-in-the-loop middleware
- OpenAI Agents SDK — guardrails and human review
- Pydantic AI — deferred tools and approval
Download figure for @modelcontextprotocol/server-postgres taken from the npm
registry API for 2026-08-02 to 2026-08-08.
Top comments (0)