2026-07-13 09:52 UTC
DANGMUAAI & Developer Tools, Decoded
BackAgents

How to Make an MCP Server Read-Only by Construction, Not Prompt

A Redis job-queue MCP server proves 'read-only' in a system prompt is just documentation. Real enforcement means registration logic and ACLs, not vibes.

DangMua EditorialJul 13, 20267 min read
How to Make an MCP Server Read-Only by Construction, Not Prompt

A description in a system prompt is not a lock

Tell an AI agent “this tool is read-only, do not modify data” and you have written documentation, not a control. In a dev.to write-up on hardening an MCP server that talks to a Redis-backed job queue, developer Yusuf Ihsan Gorgel argues the instruction-in-the-prompt approach breaks down under exactly the conditions agents create: “A confused multi-step chain, a poisoned job payload, a future model that's more eager than today's, and that sentence is just words. A description is documentation. It is not a permission check.”

His fix does not add a stricter warning. It removes the ability to write at three separate layers, so that even a model that tries to call a mutating function has nothing to call and no credential that would let the call succeed.

Layer one: don't advertise the tool that could hurt you

Only four of the job-queue server's tools ever reach the model when it starts in read-only mode. According to the write-up, “the two tools that mutate a queue, retry_job and delete_job, are registered only when the server is not started in read-only mode. Run it with --read-only and those two registration calls never execute, so the process advertises four tools and nothing else.”

A tool that was never registered cannot be hallucinated into existence by a confused chain, and a malicious payload cannot invoke it by name. Gorgel is explicit about the failure mode this closes: “If the model invents delete_job out of thin air, or a malicious payload instructs it to call delete_job, the request resolves to 'unknown tool' and dies there.”

The pattern behind that behavior is simple enough to port to any MCP server: gate tool registration on a startup flag, not on the tool's own internal logic. The illustration below is a reconstruction of the shape of that check, not the project's exact source:

// Illustrative reconstruction of the pattern described in the write-up
function registerTools(server, { readOnly }) {
  server.tool("get_job", getJobHandler);
  server.tool("list_jobs", listJobsHandler);
  if (!readOnly) {
    server.tool("retry_job", retryJobHandler);
    server.tool("delete_job", deleteJobHandler);
  }
}

Layer two: make the credential physically incapable of writing

Registration gating still runs inside your own code, which means it inherits your own bugs. Gorgel connects the read-only inspector with a scoped Redis user instead of the application's normal credential: “I hand the inspector a Redis URL for an ACL user that has read commands and nothing else.”

# ACL command from the write-up, defining the read-only inspector user
ACL SETUSER inspector on >secret ~* -@all +@read -@dangerous

Each clause does specific work. Per the write-up, “+@read grants the read command category. -@dangerous strips the footguns that happen to be reads, the clearest being KEYS, which scans the entire keyspace and will stall a busy server.” The same source notes a subtler detail worth checking on any Redis-backed tool: “Redis classifies GETEX and GETDEL as writes, because GETEX can change a key's TTL and GETDEL removes the key. +@read correctly leaves both out.”

The payoff of pushing enforcement down to the ACL is that it survives code you haven't written yet: “If a handler has a bug, or a future edit sneaks a write in, Redis rejects it at the protocol level with a NOPERM error. The datastore itself becomes the backstop.” Gorgel goes further, saying he would rather lean on the database's own command categories than on his memory of which commands mutate state, and points to an even stricter option: “point the inspector at a read replica, which refuses writes with a READONLY error regardless of what you send it.”

Three layers, three different failure modes

None of the three mechanisms substitutes for the others; each closes a gap the previous one leaves open. Gorgel frames the combined guarantee this way: “The model only ever sees read tools in tools/list. A hallucinated or injected write call hits no handler. The handlers run on a credential that physically cannot write. None of the three depends on the model behaving well.”

LayerWhat it stopsWhat it can't stop
Conditional tool registrationHallucinated or injected calls to a tool the model never saw in tools/listAnything routed through a tool that is legitimately registered
Scoped Redis ACL user (+@read, -@dangerous)A bug in the server's own handler code, or a future edit that adds a write path by accidentA model that reaches a different, write-capable server on the same host
Read replicaAny write attempt at all, regardless of how the ACL is configuredCases where a true replica isn't available or lag is unacceptable

Which layer to reach for first

If the team controls the MCP server's code but not the datastore's access rules, start with conditional registration — it costs one startup flag and closes off hallucinated or injected calls to tools the model should never see. If the datastore is shared or touched by code the team didn't write, add the ACL user before shipping, because that layer survives a bug six months from now. Teams handling regulated or production-critical data should treat the read replica as the default, not the escalation, since it is the only layer that refuses writes no matter what generated the request.

What “read-only” does not buy you

A server that cannot write is not a server that cannot leak or cannot cost money. Gorgel's own accounting of the gaps is direct. On exposure: “get_job hands back payloads. If those payloads carry tokens, email addresses, or internal ids, then a read-only tool is an efficient way to feed that data to a model and whatever sits behind it.”

On load: “'List every pending job' over a large queue, or a stray KEYS, can spike latency for real production traffic, and a model will happily call something in a loop while it 'explores.' Read-only says nothing about load.”

On second-order prompt injection: “A job payload can contain text aimed at the model, and if that same model holds other tools, reading my queue can influence what it does elsewhere. Making my queue unwritable does nothing about that.”

And on scope: “this makes a single server read-only by construction. It says nothing about a host that also has a second, write-capable server loaded next to it. The blast radius is per-connection, not global.” A read-only MCP server is one control among several a production deployment needs, not a substitute for the rest of them.

The instinct is showing up in other MCP servers, too

Read-only-by-construction is not the only case of a team building a custom MCP server instead of trusting a generic one. A separate write-up describes the problem — the current Jira MCP server doesn't support Jira Service Management, so posting internal notes on tickets wasn't possible — and the fix: “Build my own MCP server that does support Jira Service Management.” That is one team's specific gap, not evidence of a broader consensus — but it points at the same instinct: when a generic server's permissions or coverage don't match the workflow, the fix is a narrower, purpose-built server rather than a longer prompt.

Before wiring an agent to production data

Check two things before an agent gets a datastore connection: does the MCP server register mutating tools conditionally, and does the credential it runs on carry a role the datastore itself enforces. If the honest answer to either question is “a boolean in application code decides,” that boolean is a description of intent, not a permission check — and it will hold up exactly as well as a system prompt does.

More from DangMua