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.

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 -@dangerousEach 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.”
| Layer | What it stops | What it can't stop |
|---|---|---|
| Conditional tool registration | Hallucinated or injected calls to a tool the model never saw in tools/list | Anything 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 accident | A model that reaches a different, write-capable server on the same host |
| Read replica | Any write attempt at all, regardless of how the ACL is configured | Cases 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