← Trust & Access

Read-only MCP servers: what they are and why I only build them

A read-only MCP server can read but never write: no create, update, or delete. Why that removes an entire class of agent failures, and how I actually enforce it.

A read-only MCP server exposes only safe operations to an agent: list, search, get, read. No create, update, delete, activate, or purge. Every MCP server I build works this way, on purpose.

I’ve been running Claude Code with --dangerously-skip-permissions in environments where the agent has no write-capable MCP tools and no direct path to mutate production systems. Months of that, and not a single unwanted action against a production system. The model still hallucinates. I just haven’t given it a tool that can turn a hallucination into a real API write.

Read-only won’t make an agent trustworthy. But a read-only tool can’t act on a bad guess, so an entire category of failure never gets the chance to happen.

The failure mode isn’t hypothetical

There’s a post on r/ClaudeCode where Claude suggested tearing down a GPU instance, then executed it. The user never confirmed. The model said “tear down the H100 too,” treated its own suggestion as user confirmation, and destroyed a running instance with hours of cached build artifacts and compiled kernels on it.

Claude hallucinated user confirmation and destroyed a running GPU instance. Source: r/ClaudeCode

The model later admitted: “I hallucinated you saying that. You never said those words. I said it, then executed it as if you’d agreed.”

Give that same agent read-only tools and the story ends early. It reads the instance list, maybe suggests tearing something down, and then nothing happens. The suggestion dies as text. Nobody loses a machine.

How I actually use read-only tools

My workflow with Claude Code is plain. I ask it to investigate something. It reads logs, searches code, pulls data from MCP servers, and comes back with an analysis. If that analysis leads to an action, like creating a ticket or shipping a config change, Claude drafts it. I review the draft. I do the action myself.

The questions I ask look like “what’s the current CDN config for checkout?”, “which build failed last night?”, “compare caching rules between production and staging.” The agent reads and analyzes. I act.

I trust the model’s judgment on what to put in a ticket. What I don’t trust is that it will never hallucinate that I asked for something I didn’t. If the tool is read-only, the worst case is it reads data it was going to read anyway. If the tool can write, the worst case is the Reddit post above.

Approval fatigue is the real problem

The obvious objection: there’s a confirmation prompt before destructive actions. Claude Code asks before running commands. But after you confirm 50 read operations, you stop reading the prompts. You click yes. Then the 51st one is vastai destroy instance 34122719.

Anthropic hit the same wall in their sandboxing work. Constant permission prompts paradoxically reduce security, because people stop paying attention. Their fix was to restrict what the agent can reach so they don’t have to ask as often, and it cut permission prompts by 84%.

Read-only servers follow the same logic. If the server can’t write, there’s nothing to confirm. The agent works freely inside the read boundary, and there’s no destructive prompt buried in the stream for you to rubber-stamp. That’s why --dangerously-skip-permissions stops sounding reckless once the entire toolkit is read-only. There’s nothing dangerous left to skip.

Where a read-only MCP server actually enforces the rule

What surprised me building five of these: “read-only” isn’t one implementation. The guarantee lives at a different layer depending on how the upstream API is shaped.

For a plain REST API, it’s the HTTP verb. Allow GET, refuse everything else. That’s how the read-only Akamai server works: the client has no method that issues anything but GET, so there’s no code path to a write.

GraphQL breaks that. New Relic’s NerdGraph is a single endpoint, and GraphQL talks over POST whether it’s reading or writing. The verb tells you nothing. So the guarantee moves up a level, to a guard that inspects the query document and rejects anything that isn’t a query, never a mutation.

Those two cases look like this in code:

# REST: there's simply no write method to call.
class ReadOnlyClient:
    async def get(self, path, params=None): ...
    # no post(), put(), patch(), or delete() anywhere

# GraphQL: the HTTP verb can't tell read from write,
# so reject any document that isn't a query.
def assert_query_only(document: str) -> None:
    if re.search(r"\b(mutation|subscription)\b", document):
        raise ValueError("read-only: queries only, never mutations")

Slack is POST-for-reads too. The read-only Slack server calls a Web API that uses POST even to fetch messages, so “no POST” would break the whole thing. Every method the server calls is a read. It also probes the token at startup and hides the tools that token can’t use, so the agent never sees a write it couldn’t make anyway.

Log search is stranger. In SumoLogic, a search is an async job you create with a POST, poll, then read. That POST creates server-side state, which sounds like a write. The server issues it, reads the result, then leaves the job to expire on its own instead of deleting it, so it never issues a DELETE at all.

And sometimes it’s just GET. The TeamCity server only ever issues GET requests across projects, builds, logs, and test results. Nothing else exists in the client.

Five servers, five different places the read-only promise actually lives. The README says read-only; that’s marketing. What makes it true is that the write method doesn’t exist anywhere in the code.

What this doesn’t cover

A read-only MCP server is one boundary, not a whole agent security model. Give the agent bash access, cloud CLIs, kubectl, or production credentials through other channels and this design won’t save you. Claude Code with --dangerously-skip-permissions can still run shell commands, edit files, and touch whatever’s reachable from the host. Anthropic’s own docs recommend isolated environments for bypass mode, and their sandboxing combines filesystem isolation, network restrictions, and permission controls, not just tool-level limits.

The MCP boundary is the one I lean on hardest, because my agents reach external systems almost entirely through MCP. It’s still only one layer of the setup.

Why read-only MCP servers matter for headless agents

Read-only servers are portable in a way that counts for more in headless systems than in my IDE. When there’s no human in the loop and no shell, the MCP boundary isn’t one layer among many. It’s the only interface the agent has to external systems. If every server it can reach is read-only, the agent literally cannot mutate production state. You don’t need a sandbox or a permission prompt, because the tools themselves are the boundary.

There’s an organizational version of the same point. Getting a read-only credential approved is a short conversation: “I need read access to the CDN config API for an assistant that helps engineers investigate issues.” Most teams say yes. Getting a write credential is a meeting, a security review, a rollback discussion, and often a “let’s revisit next quarter.” Read-only credentials have a smaller blast radius and a simpler approval path, and they happen to cover every use case I actually have.

What I tell people who want write tools

The MCP security best practices call scope minimization a core principle: start with the minimum privileges, elevate only when required. My servers don’t elevate. When someone opens an issue asking for write tools, the answer is that the server is read-only on purpose, and they’re welcome to fork it. Not out of laziness. I already decided what an agent gets to reach on the day it invents an instruction I never gave, the way that one invented an order to kill the H100.