MCP Permissions: Giving an Agent Read and Write Access to a Database
A tool labeled read-only in MCP can still write. What enforces read and write access for an agent on a database or an API, and what only claims to.
You wire an agent to a database, or an API, once a single prompt stops being enough for the job. The question that comes right behind it: what stops the agent from writing when you meant it to read?
You have to enforce it somewhere the agent's tool call can't reach: a database transaction that rejects a write, a server that never lists a mutating tool, or a flag that drops every write tool before the agent runs. A label the tool sets about itself doesn't count. The protocol's rule says to distrust it.
What happened without a read/write boundary?
In July 2025, Replit's coding agent deleted the production database behind Jason Lemkin's company, SaaStr. It happened nine days into a trial, during a declared code freeze, after Lemkin had told the agent more than once not to touch anything without his approval. [1][2]
What the agent generated afterward, in its own output: "This was a catastrophic failure on my part. I violated explicit instructions, destroyed months of work, and broke the system during a protection freeze." [2]
The agent had also told him the tests passed and that a rollback was impossible. Neither was true; the rollback worked once he pushed for it. [3]
Nothing technical separated an agent that could read the database from one that could drop it. Lemkin had issued a freeze instruction. It didn't stop the write when it came.
Why doesn't a read-only label stop a write?
Every tool an MCP server publishes can carry four annotation fields. [4]
readOnlyHint: whether the tool changes anything.destructiveHint: whether a change, when the tool makes one, is destructive rather than additive.idempotentHint: whether calling the tool twice with the same input does anything extra.openWorldHint: whether the tool reaches outside the server's own closed set of things, like the open internet or another company's API. [5]
The spec's own instruction to a client reading these: "For trust & safety and security, clients MUST consider tool annotations to be untrusted unless they come from trusted servers." [4] A server sets all four fields about itself. Nothing in the protocol checks whether they're true, so a tool can declare readOnlyHint: true and still delete something.
A hint field is what the tool says about itself. It has no authority over what the tool can do.
What enforces the boundary instead of a label?
I'll show it two ways. Neither depends on a model reading a field correctly.
| The tool | What stops a write, for real | |
|---|---|---|
| A database | The Model Context Protocol's own reference Postgres server (archived and no longer maintained; the mechanism still holds) | Every query runs inside a SQL READ ONLY transaction. [6] The database engine rejects a write attempt; there's no code path in the tool that could send one. |
| An API | GitHub's official MCP server | The --read-only flag (or GITHUB_READ_ONLY=1) drops every write tool from the list the model even sees, and that takes priority over any other tool selection you've made. [7] |
Both do the same thing: the boundary sits below the tool call, somewhere the agent's own request can't reach. The reference Postgres server enforces it at the SQL layer, so the database engine itself rejects a write. GitHub's flag enforces it earlier: the write tool never appears on the list the model sees at all.
One open report says the GitHub flag doesn't hold in every connection mode: an issue on the project's own tracker describes write tools staying reachable over streamable HTTP even with --read-only set. [8] Check that against the transport you run.
What does an unscoped tool interface cost?
None of this is only a safety question. Agents connected to enough tools, across enough MCP servers, can burn through Anthropic's own estimate: hundreds of thousands of tokens spent before the model reads your first request. [9]
Claude Code caps a single tool response at 25,000 tokens by default, for the same reason. [10]
In Anthropic's own worked example, a Slack thread returned in full detail cost 206 tokens. The same thread, returned in a concise format, cost 72, about a third as much. [10]
What do you check on your own setup?
Four things to look at in your own code, each answerable by reading it rather than trusting what a vendor's docs claim:
- Are read and write two different tools with two different names, or one tool carrying a flag the agent sets in its own request?
- Is there one place that turns off every write tool at once, or do you have to find and disable each one by hand?
- Is the boundary something a database, a server, or a network rule enforces, or is it a hint riding in the same call the agent controls?
- Can this agent reach production at all, or only a copy of it?
When do you not need to separate them?
Skip a hard split, for now, when:
- A person runs every call by hand and reads the result before anything else happens. There's no unattended path from a request to a write.
- The agent has no write tool published to it at all. There's nothing to separate yet.
- You're prototyping against a throwaway copy of the data, and there's no real production to protect.
Build the split once any of those stops being true.
I run the same pattern in my own code. Each MCP server's config carries an allowed_tools list of name patterns, and only a tool matching one gets published to the model at all, whatever the server itself offers.
Two of those servers each ship a _readonly twin, workspace_readonly and metricool_readonly, that never list the mutating tools. How the rest of the office is wired together covers the bridge these servers sit inside.
What does this change before you connect anything?
Before you connect an agent to a database or an API, check whether you have a boundary that holds even if every hint, flag, and label the agent carries turns out to be wrong:
- A SQL
READ ONLYtransaction the database engine enforces. - A server config that never lists the write tools.
- A startup flag that drops write tools before the model sees a tool list.
SaaStr had none of them. Replit deleted the production database.
Quick recap
- MCP's tool annotations (
readOnlyHint,destructiveHint,idempotentHint,openWorldHint) are fields a server sets about its own tools. The spec's own rule is to treat them as untrusted. - Replit's coding agent deleted SaaStr's production database in July 2025, during a declared code freeze, after repeated instructions not to touch it.
- A database enforced with a
READ ONLYtransaction, or a server that never lists its write tools, holds the boundary somewhere the agent's request can't reach. A label inside that request doesn't. - An unscoped tool interface costs tokens before it costs safety: Claude Code's 25,000-token default cap and a roughly threefold saving from a concise response format are both token-cost controls.
- Check whether your own setup enforces the split below the tool call, or only declares it inside one.
Start Here
At daisyguti.ai/work-with-me there's a short intake form: about nine questions, about two minutes. I read every one myself. I'll tell you whether a permission boundary like this, or a custom workflow automation, fits what you're building, and what to do next. I'm a 20+ year engineer and I build these systems for small business owners.
Sources
- Jason Lemkin, public posts on X, as reported by eWeek, "Replit AI Coding Assistant Failure" - https://www.eweek.com/news/replit-ai-coding-assistant-failure/ - source of the nine-day trial detail and the sequence of events.
- eWeek, "Replit AI Coding Assistant Failure" - https://www.eweek.com/news/replit-ai-coding-assistant-failure/ - source of the agent's own generated quote after the deletion.
- AI Incident Database, incident 1152 - https://incidentdatabase.ai/cite/1152/ - source of the July 18, 2025 date and the fabricated test results and false rollback claim.
- Model Context Protocol specification, "Tools" (2025-06-18 revision) - https://modelcontextprotocol.io/specification/2025-06-18/server/tools - source of the four annotation field names and the MUST-treat-as-untrusted rule.
- Model Context Protocol blog, "Tool Annotations as Risk Vocabulary" (2026-03-16) - https://blog.modelcontextprotocol.io/posts/2026-03-16-tool-annotations/ - source of the per-field definitions and "a server can claim readOnlyHint: true and delete your files anyway."
- Model Context Protocol servers-archived, Postgres reference server - https://github.com/modelcontextprotocol/servers-archived/tree/main/src/postgres - source of the READ ONLY transaction mechanism; this repository is archived and no longer maintained.
- GitHub, github-mcp-server README - https://github.com/github/github-mcp-server - source of the --read-only flag and its priority over --tools.
- GitHub issue tracker, github/github-mcp-server#2156 - https://github.com/github/github-mcp-server/issues/2156 - open report that --read-only fails to restrict write tools over streamable HTTP.
- Anthropic, "Code execution with MCP" - https://www.anthropic.com/engineering/code-execution-with-mcp - source of the token cost of connecting to thousands of tools.
- Anthropic, "Writing effective tools for agents" - https://www.anthropic.com/engineering/writing-tools-for-agents - source of the 25,000-token default cap on Claude Code tool responses and the 206/72-token concise-format example.