Skip to main content
BlogDevOps

Kubernetes MCP Server: read_only, Toolsets, Denied Resources

Lock down the Kubernetes MCP server so an agent cannot helm uninstall: a view ServiceAccount, read_only, toolsets and denied_resources, tested on v0.0.67.

Matthews Wong

Software Engineer

October 202612 min read
Kubernetes MCP Server: read_only, Toolsets, Denied Resources

Key Takeaway

To stop an AI agent running helm uninstall through the Kubernetes MCP server, enforce read-only at the cluster with a view-role ServiceAccount, then set read_only, a minimal toolsets list and denied_resources in TOML. Verify with tools/list: on v0.0.67, read_only exposed 14 of 22 tools, while a mistyped key silently exposed 20.

I wanted an agent to answer why a pod was crash-looping, and I noticed that the same server answering that question also advertised helm_uninstall. The server is containers/kubernetes-mcp-server, a Go program that talks to the API server directly instead of shelling out to kubectl or helm. Its default install is a credential plus a list of tools, and the list is longer than the job.

This post is the safe configuration, built only from the project's own documentation and from a tools/list audit I ran against v0.0.67, released on 18 September 2026. I did not call any tool or touch a real cluster: the audit used a stub kubeconfig, so what I measured is what the server offers an agent, not how any filter behaves under load.

The tool list is the blast radius

With the core and helm toolsets enabled, v0.0.67 advertised 22 tools over stdio. Fourteen carry readOnlyHint=true. The other eight can change the cluster, and they are the whole risk surface of the server. The annotations below are the ones the server itself reported, and the notes paraphrase the upstream README.

ToolreadOnlyHintdestructiveHintWhat it does
helm_installfalsefalseInstalls a chart. Any oci or https reference is accepted unless allowed_registries is set.
helm_uninstallfalsetrueRemoves a Helm release.
pods_deletefalsetrueDeletes a Pod by name.
pods_execfalsetrueRuns a command inside a container.
pods_runfalsefalseStarts a Pod from any container image.
resources_create_or_updatefalsetrueServer-Side Apply of a complete manifest, any kind.
resources_deletefalsetrueDeletes any resource by apiVersion, kind and name.
resources_scalefalsetrueReads or sets the replica count.

Two rows deserve a second look. helm_install and pods_run are write tools that are not flagged destructive, so anything keyed on destructiveHint lets them through. And resources_create_or_update takes a complete manifest for any kind, which makes it a general-purpose write tool rather than a pod helper. The README says the manifest is the complete desired state, so fields it omits are removed.

Seven layers, and only one is the real boundary

The server offers several ways to say no. They are not equal. One is enforced by Kubernetes itself, five are enforced by the MCP server in front of your credential, and one depends on a human being reachable.

LayerSettingWhat it stopsEnforced by
ServiceAccount RBACview ClusterRole plus a short-lived tokenAny verb the identity lacks, whatever the agent asks forThe Kubernetes API server
Read-only moderead_onlyEvery tool not annotated readOnlyHint=trueThe MCP server, from tool annotations
No destructive toolsdisable_destructiveTools annotated destructiveHint=trueThe MCP server, from tool annotations
Tool allowlist or denylistenabled_tools, disabled_toolsNamed tools, flagged or notThe MCP server
Resource denylistdenied_resourcesNamed group, version and kind such as SecretThe MCP server
Human confirmationconfirmation_rulesCalls matching a tool or a Kubernetes verb and kind, until a person agreesThe MCP client; confirmation_fallback decides when it cannot ask
Chart sourcestoolset_configs.helm allowed_registrieshelm_install pulling from an unlisted registryThe helm toolset

The Model Context Protocol specification is blunt about the middle layers: clients must treat tool annotations as untrusted unless they come from a trusted server, and there should always be a human in the loop able to deny a call. Here the server is yours, so the annotations are trustworthy in practice, but read_only is only as correct as each tool's label. pods_run being unflagged is the proof that labels are a policy someone wrote, not a physical limit.

The project's own tool RBAC metadata document says the same from the other direction: the metadata is a planning aid, it does not grant access, and the Kubernetes API server remains authoritative. It publishes per-tool requirements under the key io.kubernetes-mcp-server/rbac, and its example of an unbounded declaration is a Helm tool whose permissions depend on the resources inside the chart. That is the honest summary of helm_install: no filter can bound what an arbitrary chart creates, so the credential must.

Start with a ServiceAccount that cannot write

The upstream getting-started guide recommends a dedicated read-only identity, and it is the one layer I would not skip. It binds the built-in view ClusterRole to a ServiceAccount and mints a time-bound token with the TokenRequest API. Run the can-i checks before the agent ever sees the kubeconfig.

# From the upstream getting-started guide: a read-only identity for the agent.
kubectl create namespace mcp
kubectl create serviceaccount mcp-viewer -n mcp

# The built-in "view" ClusterRole: read most objects, NOT Secrets, NOT roles.
kubectl create clusterrolebinding mcp-viewer-crb \
  --clusterrole=view \
  --serviceaccount=mcp:mcp-viewer

# Short-lived token (TokenRequest API, Kubernetes 1.24+). The guide uses 2 hours.
TOKEN="$(kubectl create token mcp-viewer --duration=2h -n mcp)"

# Prove the boundary BEFORE the agent ever sees it. These are the checks that matter:
kubectl auth can-i list pods   --as=system:serviceaccount:mcp:mcp-viewer --all-namespaces  # yes
kubectl auth can-i delete pods --as=system:serviceaccount:mcp:mcp-viewer --all-namespaces  # no
kubectl auth can-i get secrets --as=system:serviceaccount:mcp:mcp-viewer --all-namespaces  # no
kubectl auth can-i create pods --subresource=exec \
  --as=system:serviceaccount:mcp:mcp-viewer -n default                                     # no

The Kubernetes RBAC documentation describes view as read-only access to most objects in a namespace that does not allow viewing roles or role bindings, and it states that the role does not allow viewing Secrets, because reading them would give access as any ServiceAccount in the namespace. One consequence I expect but did not test on a live cluster: the helm toolset documents secret as its default storage driver, and Helm release state is stored in Secrets, so helm_list may need an extra grant or storage_driver set to configmap. Test it before you promise the agent a helm view.

The config file I would ship

Runtime settings live in TOML, and the documentation recommends drop-in files via --config-dir so a base policy and an override stay separate. The base file below is deliberately boring: read-only, two toolsets, two denied kinds, and instructions that tell the model what the server is for.

# /etc/kubernetes-mcp-server/conf.d/00-base.toml
# Start: kubernetes-mcp-server --config-dir /etc/kubernetes-mcp-server/conf.d

# Only tools annotated readOnlyHint=true are exposed. On v0.0.67 with core + helm
# this left 14 of 22 tools (measured with tools/list, see the verification section).
read_only = true

# Load only what the job needs. The default is ["core", "config"]; helm, kiali,
# kubevirt, tekton and the observability sets are opt-in.
toolsets = ["core", "helm"]

# Belt and braces: even a credential broader than "view" cannot read these kinds.
[[denied_resources]]
group = ""
version = "v1"
kind = "Secret"

[[denied_resources]]
group = "rbac.authorization.k8s.io"
version = "v1"
kind = "ClusterRoleBinding"

# Tell the model what this server is for, so it does not reach for write tools.
server_instructions = """
Read-only Kubernetes diagnostics. List and inspect workloads, read logs and events.
Never attempt changes; propose a kubectl or helm command for a human instead.
"""

denied_resources is a defence for the day someone binds a broader role to the agent by mistake. With the view role it is redundant for Secrets, which is exactly why it is worth having: it fails safe when the credential drifts. server_instructions is advice, not enforcement, so it never replaces the layers above it.

Use numbered drop-ins and SIGHUP. The documentation lists read_only, disable_destructive, denied_resources, toolsets, tool filters and confirmation rules as reloadable, so granting or revoking write access needs no restart. Port, bind address and kubeconfig still need one, and a reload that fails validation exits the process.

disable_destructive is not read-only

When an incident needs writes, the tempting setting is disable_destructive, since it sounds like the safe subset. I measured the subset. On v0.0.67 it left 16 tools: the 14 read-only ones plus helm_install and pods_run. Neither is flagged destructive, and between them an agent can run any image or install any chart the credential permits. The drop-in below closes both gaps and makes an unanswered confirmation a refusal.

# /etc/kubernetes-mcp-server/conf.d/90-incident.toml  (drop in only for an incident)
read_only = false
disable_destructive = true      # hides tools with destructiveHint=true ...
                                # ... but helm_install and pods_run stay: neither is flagged.

# Closing the two gaps disable_destructive leaves open:
disabled_tools = ["pods_run"]

[toolset_configs.helm]
allowed_registries = ["oci://ghcr.io/myorg"]   # helm_install may only pull from here
storage_driver = "secret"                      # the documented default

# Anything that still reaches a human prompt. If the client cannot ask, DENY.
confirmation_fallback = "deny"

[[confirmation_rules]]
destructive = true
message = "Destructive operation."

[[confirmation_rules]]
verb = "get"
kind = "Secret"
message = "Accessing a Secret."

The documentation defaults confirmation_fallback to allow, meaning a client that cannot show a prompt proceeds silently with only a warning in the log. For a production cluster that default is backwards, so set deny. The allowed_registries list is a prefix match on URL-based chart references. When it is set, repo-style names and local paths are rejected, and file and plain http references are always blocked.

Install options in the README are npx -y kubernetes-mcp-server@latest, uvx, a native binary, or the quay.io/containers/kubernetes_mcp_server image. Replace latest with an exact version such as 0.0.67 in anything that guards a cluster, then read the release notes before bumping.

The failure: a typo that fails open

I tested the cheapest mistake deliberately: a config file containing readonly = true instead of read_only = true. v0.0.67 started without complaint and advertised 20 tools under the default core and config toolsets, including pods_delete, pods_exec and resources_delete. With the key spelt correctly and core plus helm enabled, the same server advertised 14, all read-only. Nothing in the process output told me the first file had done nothing.

# Wrong: one missing underscore. On v0.0.67 this loaded without complaint and the
# server exposed 20 tools (default core + config sets), incl. pods_delete and pods_exec.
readonly = true

# Right: the key is read_only. With core + helm enabled it exposed 14 read-only tools.
read_only = true

# Wrong: a flag copied from an older tutorial. Current main documents that
# runtime flags are gone and a leftover one fails with cobra's "unknown flag".
#   kubernetes-mcp-server --read-only --toolsets core,helm
# Right:
#   kubernetes-mcp-server --config /etc/kubernetes-mcp-server/config.toml

Do not treat a config file as the test. A misspelt key was not an error on the version I ran, so the only proof that read_only is on is the tool list a client actually receives. Audit tools/list in CI and on every upgrade.

The documentation on the main branch says the opposite behaviour is now intended: unknown TOML keys fail the load, and runtime flags such as --read-only, --toolsets and --port are dropped in favour of TOML, with leftover flags failing as unknown. On v0.0.67 I found the old --read-only flag still worked, and it exposed 14 tools. So tutorials copied from either era break in a different direction, and the version you pinned decides which. The same page records that the 0.0.66 sts settings were replaced by a token_exchange table, and the 0.0.67 release notes list removal of the SSE transport as a breaking change.

Prove it: ask the server what it exposes

The audit that caught the typo is thirty lines of Node. It starts the server over stdio, sends initialize and tools/list, and prints each tool with its annotations. Point it at the exact config you deploy.

// list-tools.mjs  -  node list-tools.mjs /etc/kubernetes-mcp-server/config.toml
// Starts the server over stdio, asks tools/list, prints what an agent would see.
import { spawn } from "node:child_process";

const config = process.argv[2];
const server = spawn(
  "npx",
  ["-y", "kubernetes-mcp-server@0.0.67", "--config", config],
  { stdio: ["pipe", "pipe", "inherit"] },
);

const send = (m) => server.stdin.write(JSON.stringify(m) + "\n");
let buffer = "";
server.stdout.on("data", (chunk) => {
  buffer += chunk;
  for (let i; (i = buffer.indexOf("\n")) >= 0; ) {
    const line = buffer.slice(0, i);
    buffer = buffer.slice(i + 1);
    const msg = JSON.parse(line);
    if (msg.id !== 2) continue;
    for (const tool of msg.result.tools) {
      const a = tool.annotations ?? {};
      console.log(tool.name.padEnd(28), "readOnly:", a.readOnlyHint, "destructive:", a.destructiveHint);
    }
    server.kill();
  }
});

send({ jsonrpc: "2.0", id: 1, method: "initialize",
  params: { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "audit", version: "1" } } });
setTimeout(() => {
  send({ jsonrpc: "2.0", method: "notifications/initialized" });
  send({ jsonrpc: "2.0", id: 2, method: "tools/list" });
}, 3000);

// CI gate idea: fail the job if any listed tool has readOnly !== true.

My three runs against the stub kubeconfig, all with toolsets core and helm: no restriction gave 22 tools with 8 not read-only, read_only gave 14, and disable_destructive gave 16. I did not exercise denied_resources, confirmation_rules or allowed_registries, so for those I am relying on the documentation, and you should test them against a scratch cluster before trusting them. Turn the script into a gate by failing when any listed tool has readOnlyHint other than true.

What to do on Monday

In order, and each step is checkable on its own:

  1. Pin the version. Replace @latest with an exact release and read its notes when you bump: 0.0.67 removed the SSE transport and changed token exchange configuration.
  2. Create the mcp-viewer ServiceAccount with the view role and run the four can-i checks, expecting yes for list pods and no for delete pods, get secrets and exec.
  3. Ship the base drop-in: read_only, the smallest toolsets list that does the job, and denied_resources for Secret and ClusterRoleBinding.
  4. Run the tools/list audit in CI against that file and fail on any tool that is not readOnlyHint true.
  5. Treat write access as a time-boxed drop-in: disable_destructive, disabled_tools for pods_run, allowed_registries, confirmation_fallback deny. Remove it and send SIGHUP when the incident ends.
  6. If the server runs in a pod, set port in TOML for HTTP mode and remember the default bind address is 0.0.0.0, which the server warns about without TLS or OAuth. Bind to 127.0.0.1 behind a proxy, or enable require_oauth. configuration_view is off by default in HTTP mode because it exposes kubeconfig contents.

None of this makes the agent smart. It makes the worst case boring: a model that decides to uninstall a release meets a credential that cannot, and a tool list that does not offer the option.

The rule I would carry to any MCP server that holds a credential: let the credential be the boundary, use the server's filters as a second wall, and prove the wall by listing what a client actually receives. read_only, toolsets and denied_resources are good settings, but a typo can switch them off without a sound, and only a tools/list audit notices.

FAQ

Frequently Asked Questions

Short answers to what readers ask most about this topic.

How do I make the Kubernetes MCP server read-only?

Set read_only = true in the TOML file you pass with --config or --config-dir. The documentation says it exposes only tools annotated readOnlyHint=true, and on v0.0.67 with the core and helm toolsets that left 14 of 22 tools. Pair it with a ServiceAccount bound to the view ClusterRole so the cluster refuses writes even if a filter fails.

Can the Kubernetes MCP server run helm uninstall?

Yes, if the helm toolset is enabled and the credential allows it, because helm_uninstall is one of its tools. The helm toolset is not in the default set, which is core and config, so it only appears when you add it to toolsets. read_only removes helm_uninstall, and disabled_tools can remove it by name.

What is the difference between read_only and disable_destructive?

read_only keeps only tools annotated readOnlyHint=true, while disable_destructive hides only tools annotated destructiveHint=true. On v0.0.67 the second setting still left helm_install and pods_run, because neither carries the destructive label. If you need a safe write mode, add disabled_tools and an allowed_registries list.

How do I stop the agent reading Kubernetes Secrets?

Use a ServiceAccount bound to the built-in view ClusterRole, which the Kubernetes documentation says does not allow viewing Secrets. Then add a denied_resources entry with group empty, version v1 and kind Secret as a second layer. You can also add a confirmation rule for the get verb on kind Secret.

Which toolsets does the Kubernetes MCP server enable by default?

Only config and core are enabled by default. The helm, kcp, kiali, kubevirt, netobserv, tekton and observability toolsets must be added to the toolsets list. Enabling fewer toolsets also shrinks the tool list the model has to choose from.