DEV Community

Yusuf İhsan Görgel
Yusuf İhsan Görgel

Posted on

Read-only by construction: an MCP server that can't write to Redis

The first tool I gave an LLM was a way to look at a job queue I run. The queue lives in Redis. The second thing I wanted was a guarantee that looking was all the model could do: no enqueue, no delete, no retry.

The obvious first move is to say so. Put "read-only, do not modify anything" in the tool description, or in the system prompt, and move on. I did that, then I stared at it for a minute and realized it protects nothing. The caller here is a language model. It reads "do not modify" the same way it reads every other sentence you give it: as a suggestion it is free to ignore. 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.

Read-only by construction: only read tools registered, the write tools never enter the dispatch table.

So I stopped trying to ask nicely and made the write path structurally absent instead. If the capability isn't wired up, there is nothing for a prompt, an injection, or a jailbreak to reach for.

Where the boundary actually lives

A quick recap of how MCP tools work, because the enforcement point falls right out of it. Your server exposes tools. The client calls tools/list and gets back the names and input schemas. When the model wants to act, the host sends tools/call with a tool name and arguments, and your server routes that to the handler you registered for that name.

Two things follow. First, the advertised surface is exactly the set you register, and nothing else. The model can only aim at tools it has been told exist. Second, dispatch is a table lookup. A name with no handler is an error, not an action. 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.

That means the boundary does not live in the tool description, where I first tried to put it. It lives in the registration table, and one layer below that, in the credential the handlers use. Move the boundary to those two places and the prompt layer stops mattering.

Register only read tools

The inspector exposes four read tools: list_queues, queue_stats, list_jobs, and get_job. 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.

const server = new McpServer({ name: "queue-inspector-mcp", version });

// The read surface is always registered.
server.registerTool("list_queues", listQueuesSpec, handleListQueues);
server.registerTool("queue_stats", queueStatsSpec, handleQueueStats);
server.registerTool("list_jobs",   listJobsSpec,   handleListJobs);
server.registerTool("get_job",     getJobSpec,     handleGetJob);

// The write surface sits behind the read-only switch. With --read-only this
// block is skipped, so retry_job and delete_job are absent from this process's
// tool table — not disabled per call, just never registered.
if (!readOnly) {
  server.registerTool("retry_job",  retryJobSpec,  handleRetryJob);
  server.registerTool("delete_job", deleteJobSpec, handleDeleteJob);
}
Enter fullscreen mode Exit fullscreen mode

The point is where the boundary sits. In read-only mode, retry_job has no entry in the dispatch table, so a call to it resolves to "unknown tool" and dies there, the same as a name the model invented. A reviewer can confirm the write path is gone for that mode by reading this one branch, not by trusting a description. "Can't write" becomes something you can see in the wiring, not something asserted in prose.

A connection that can't write either

Registration covers the advertised surface. But I write the handlers, and I make mistakes, and someone may add a tool later without thinking. So the second boundary is the credential. I hand the inspector a Redis URL for an ACL user that has read commands and nothing else.

ACL SETUSER inspector on >secret ~* -@all +@read -@dangerous
Enter fullscreen mode Exit fullscreen mode

+@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. 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.

One detail I like here: don't trust your own eyeballing of what counts as a read. 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. I would rather lean on the server's own command categories than on my mental model of which commands mutate state. If you want an even harder line, point the inspector at a read replica, which refuses writes with a READONLY error regardless of what you send it.

Stacked up, the guarantees are:

  • 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. That is the whole reason to build it this way.

What read-only does not buy you

Read-only is a narrow guarantee, and it's worth being blunt about its edges. It says the model can't change my queue. It does not say the model is safe to point at my queue.

Information disclosure. 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. Read-only and safe-to-expose are different questions, and I still scrub or gate sensitive fields before returning them.

Cost. A read can be expensive. "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. The tools still need pagination, hard limits, and timeouts. This is also why -@dangerous earns its place.

Second-order injection. The data I read is not neutral, because the thing reading it is a model. 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. It is a property of the model's entire tool set, not of my one server.

And one scope note: 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.

The short version

None of this is clever. Register only the tools the mode is meant to expose, keep the mutating ones behind the read-only switch so they never enter the table, and connect with a credential that can't perform the thing you're preventing. I like it for MCP specifically because the caller is a model, and a model treats a careful "read-only, please" like any other string it's handed. So I stopped spending the boundary on sentences and put it in the wiring, where it holds whether the model cooperates or not.

Top comments (0)