I keep seeing the same pattern: take an LLM, give it access to kubectl or the k8s API, write something like "you can only read, don't delete or modify anything" in the system prompt or an attached skill, and consider the problem solved. I went through this myself and at some point realized that this isn't a security boundary — it's a polite request.
This isn't a hypothetical risk: you probably remember how in July 2025, the Replit agent deleted the SaaStr database despite a direct prohibition on making any changes — not Kubernetes and not MCP, but the same pattern. The "don't touch anything" instruction was right there in the context, and there was simply no one to enforce it except the model itself. Giving an agent write access to a k8s cluster means assembling exactly the same construct that already cost SaaStr their database.
I'm far from the first to cover this topic, and a lot of read-only MCP servers have appeared recently. But it's surprising how often "read-only" is misunderstood in them. For example, in MCP servers for Kubernetes, "read-only" is often implemented as an environment variable that filters the tools/list response, rather than as the absence of a function in the registry. That's exactly how mcp-server-kubernetes (20k weekly downloads on npm) worked: the ALLOW_ONLY_READONLY_TOOLS flag hid mutating tools from the list, while tools/call still accepted kubectl_delete directly, bypassing the filter.
This became CVE-2026-46519, CVSS 8.8 — the same principle this article is about, taken to an actual exploit: a function hidden from the list is not the same as a function that doesn't exist. And this isn't just a community problem — Azure/mcp-kubernetes, Microsoft's official MCP server for Kubernetes, is built exactly the same way: --access-level readonly|readwrite instead of simply not having mutating tools in the first place.
Why Instructions Don't Work as a Restriction
A model is not a sandbox. If it has delete_pod or scale_deployment in its list of available tools, it can technically call it regardless of what's written in the system prompt. For example, an attacker doesn't need cluster access for this — a regular HTTP request with a spoofed header value like User-Agent is enough. Nginx or the app itself will log it as-is: Kubernetes simply captures the container's stdout/stderr without any sanitization.
Then someone (or the assistant itself) asks the model to "check this pod's logs" — a completely innocuous request — and the model reads that line as part of the context, without distinguishing it from the system prompt. The same story applies to instructions from an attached skill or another plugin, which end up in the context as equally "trusted"; to jailbreaks; to ordinary hallucinations in an attempt to "fix" a problem you just asked it to explain. An instruction — whether in a system prompt, a skill, or a container log — is data that the model interprets, not code that constrains it.
This means the only boundary that actually holds is which tools exist in the registry available to it. If the delete_pod function doesn't exist, it doesn't matter what prompt injection, jailbreak, or the model itself in a fit of "helpfulness" says: there's nothing to call.
What This Looks Like in a Tool Registry
Let's take a concrete example: an MCP server for Kubernetes. In its registry, it makes sense to register only read tools — list_pods, list_deployments, get_yaml, get_events, read_pod_logs, start_pod_log_stream, and so on, around thirty in total. And not a single delete_*, scale_*, exec_*, apply_*, or port_forward_* — not because they're disabled by some flag, but because those functions simply don't exist in the code.
All mutating operations — scale, rollout restart, delete, cordon/drain — in this design live in a separate, human path: through a GUI with a confirmation dialog, through a CLI with an explicit flag or a "y/n" prompt. It doesn't matter which — what matters is that a human confirms it, and only then does a direct Kubernetes API call happen, without the model and without the AI tool registry at all. These are two different code paths, not one with a "allowed/forbidden" flag.
One Server, Two Transports
A separate problem arises when an AI assistant is available in two forms: as a built-in panel inside a larger tool, and as a standalone binary for external MCP clients (Claude Desktop, Claude Code, etc.). The temptation is to quickly assemble a separate set of tools for the built-in variant. Over time, the sets diverge, and one accidentally ends up with an extra tool that the other doesn't have.
It's more reliable to run the same MCP server in both cases and communicate with it genuinely through the MCP protocol, just over different transports: in-memory for the built-in variant, stdio for the external client:
server, shutdown := mcpserver.NewServer(ctx)
serverTransport, clientTransport := mcp.NewInMemoryTransports()
server.Connect(ctx, serverTransport, nil)
client := mcp.NewClient(&mcp.Implementation{Name: "desktop-assistant"}, nil)
session, _ := client.Connect(ctx, clientTransport, nil)
list, _ := session.ListTools(ctx, nil) // the same ListTools any external MCP client would call
This means the codebase physically has one server and one list of read-only tools — not an original and a separate copy for the GUI that someone will forget about. In practice, this eliminates exactly one class of bugs: when six months of refactoring later, a tool gets added to one list and forgotten in the other.
Read-Only Doesn't Mean "Nothing Is Visible"
Read-only solves the problem of state mutation, but not the problem of leaking data that's already in the cluster. If your kubeconfig has access to read Secrets in a namespace, the model theoretically can too by calling get_yaml. This needs to be addressed at the data level, not the prompt level: Secret values are redacted before the YAML reaches the tool response —
sec.Data[k] = []byte("<redacted>")
— and the AI (whether the built-in assistant or the MCP client) physically never sees the decrypted value, because it's replaced with a placeholder before the YAML string is even constructed. In this design, it also makes sense to reject apply if <redacted> remains in the YAML — otherwise the placeholder could accidentally overwrite the real value.
What This Approach Doesn't Solve
The model can still read a lot of data you already have RBAC access to. Read-only limits what can be done, not what can be seen within the same permissions.
Load on the API server from a chatty tool-calling loop isn't limited by read-only alone — that requires reasonable measures (a limit on the number of iterations in a conversation, a byte limit on tool results, timeouts on log reads, capped and idle-reaped streams), not cryptographic guarantees.
If keeping prompts and tool outputs on the machine entirely is important, that's a separate configuration (a local model via Ollama/vLLM/LM Studio), not a consequence of read-only architecture by itself.
A compromised registry or poisoned tool descriptions are a separate issue: tool poisoning works against read-only tools too if the model trusts instructions inside a description just as much as the system prompt. A list of thirty read tools doesn't by itself guarantee that each one does exactly what it says — for a detailed breakdown of this topic, see "MCP and Agent Security."
The entire approach described here is simple precisely because it deliberately doesn't solve the more general problem — giving an agent any write access at all. If you really need it (for example, for production debugging with the ability to fix things), that's a fundamentally different, much heavier architecture: whitelisting specific commands, rate-limiting, role-based restrictions, an immutable audit log with alerts.
Top comments (0)