# MCP Server Least Privilege: Read-Only Mode and Toolsets

> MCP server least privilege from zero: a credential that cannot write, read-only mode, toolsets, allow lists and a client deny list, proven with tools/list.

URL: https://www.matthewswong.com/en/learn/ai-agents-mcp/scope-an-mcp-server-read-only-toolsets/

AI agents and MCP · Lesson 2 of 4 · Reviewed on 17 October 2026 · Checked against MCP specification 2026-07-28 · By Matthews Wong (https://www.matthewswong.com/en/about/)

> Least privilege for an MCP server means the agent can reach only what the job needs. Give the server a credential that cannot write, turn on its read-only mode, load only the toolsets you need, deny risky tools in the client, and then prove it by listing the tools a client actually receives.

## You will be able to

- Rank the layers that limit an MCP server, and name the one that holds when every filter fails.
- Shrink a server's tool list with read-only mode, toolsets, allow lists and a client deny list.
- Prove the result by reading the tools/list a client actually receives.

## Three owners of permission between an agent and a cluster

A delete request passes the client's rules and the server's filters, then meets the credential, which refuses it; a tools/list audit proves what the server really offers.

1. **Meet the layers.** An agent reaches a cluster through three owners of permission: the client's rules, the MCP server's own filters, and the credential the server holds. Each can say no.
2. **The default is wide.** With the core and helm toolsets, the Kubernetes MCP server v0.0.67 advertised 22 tools in this site's audit. Eight of them can change the cluster.

   ```
   helm_uninstall · pods_delete · pods_exec · pods_run · …
   ```

   *Predict:* If every server filter were misconfigured, which layer would still refuse a delete? (The server's read-only mode / The credential the server holds)
3. **The credential is the wall.** Bind the server's identity to a role that cannot write, such as Kubernetes' built-in view role. The API server refuses any verb that identity lacks, whatever the agent asks.

   ```
   kubectl auth can-i delete pods --as=…:mcp-viewer → no
   ```
4. **Read-only mode.** read_only = true makes the server offer only tools annotated readOnlyHint. The audit's 22 tools became 14. Annotations are labels someone wrote, so this is a second wall.

   ```
   read_only = true
   ```
5. **Fewer tools, plus a deny list.** Load only the toolsets the job needs, and deny risky tools in the client. In Claude Code, a deny rule naming an MCP tool removes it from the model's context.

   ```
   toolsets = core, helm · deny: mcp__kubernetes__helm_uninstall
   ```

   *Predict:* A config file says readonly = true, one underscore short. What did v0.0.67 do? (Refused to start / Started and offered 20 tools)
6. **Prove it with tools/list.** On v0.0.67 that typo started silently and offered 20 tools, pods_delete among them. Only the list a client receives is evidence, so audit tools/list in CI.

   ```
   fail the job if any tool lacks readOnlyHint: true
   ```

## What does least privilege mean for an MCP server?

An MCP server holds a credential and offers tools that use it. Least privilege means both are only as big as the job: a server that explains why a pod keeps crashing needs to read pods, logs and events, not to delete them or read Secrets. Everything beyond the job is blast radius, the damage one wrong or hijacked call can do.

The tool list is the blast radius you can see. The site's Kubernetes MCP article audited containers/kubernetes-mcp-server v0.0.67 with the core and helm toolsets: it advertised 22 tools, and 8 of them could change the cluster, including helm_uninstall, pods_delete and pods_exec. An agent asked only to explain a crash loop was being offered all of them.

Least privilege has two halves. One is what the credential can do, enforced by the system the server talks to. The other is what the server offers, enforced by the server and by your client. The first is the boundary; the second keeps the model from even seeing options it should never use.

## Which layers say no, and which one is the real boundary?

Put the hard limit in the credential. Give the server its own identity with a read-only role, such as a Kubernetes ServiceAccount bound to the built-in view role with a short-lived token, and keep write credentials somewhere the agent cannot reach. The API server then refuses any verb the identity lacks, whatever the agent asks and whatever the server's filters say.

Then shrink what the server offers. In the Kubernetes MCP server, read_only = true exposes only tools annotated readOnlyHint=true; toolsets loads only the groups you name, with core and config as the default; enabled_tools and disabled_tools allow or deny tools by name; and denied_resources blocks kinds such as Secret even if the credential is broader than intended. These run inside the server, so they are a second wall, not the first.

Annotations are labels, not physics. The MCP specification says clients must treat tool annotations as untrusted unless they come from a trusted server, and in the audit, helm_install and pods_run were write tools that were not flagged destructive. That is why disable_destructive left 16 tools, two of them able to run any image or chart, while read_only left 14.

Finally, the client can refuse too. In Claude Code, permission rules name MCP tools as mcp__server__tool, deny rules are checked before ask and allow, and a denied tool is removed from the model's context. A client deny list is cheap insurance for the day a server upgrade adds a tool nobody reviewed.

## How do you prove the server exposes only what you meant?

Ask the server. A tools/list request returns exactly what a client will offer the model, so list it with the configuration you deploy and fail the check if any tool lacks readOnlyHint=true. A config file is a claim; the tool list is the evidence.

The site's audit caught the failure this check exists for. A file with readonly = true, one underscore short, started v0.0.67 without complaint and advertised 20 tools under the default toolsets, pods_delete and pods_exec among them. The project's current documentation says unknown keys now fail the load, so the same typo breaks in a different direction depending on the version you pinned.

Prove the credential the same way. kubectl auth can-i answers whether an identity may perform a verb, so check yes for listing pods and no for deleting pods, reading Secrets and exec before the agent ever sees the token. When an incident needs writes, grant them as a separate, time-boxed configuration, then remove it.

## The real config behind this lesson

`app/components/blogs/KubernetesMcpServerReadOnlyToolsets.tsx:218-221`

```bash
# 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
```

From the site's Kubernetes MCP article: questions asked of the agent's ServiceAccount before it holds a token, with the answers that should come back. If any no comes back yes, the credential is too broad, and no server setting can make up for that.

`app/components/blogs/KubernetesMcpServerReadOnlyToolsets.tsx:233-245`

```toml
# 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"
```

The base configuration from the same article. read_only and toolsets shrink what the agent is offered; denied_resources keeps Secrets out of reach even if someone later binds a broader role to the identity. The counts in the comment are the article's own tools/list measurements on v0.0.67.

## What breaks

- **Treating disable_destructive as read-only.** It hides only tools flagged destructiveHint. On v0.0.67 it left helm_install and pods_run, which can install any chart or run any image the credential allows. Use read_only, or disable those two by name.
- **A typo that fails open.** readonly instead of read_only loaded silently on v0.0.67 and exposed 20 tools. Newer versions reject unknown keys instead. Either way, audit tools/list after every config change and every upgrade.
- **One credential for reading and writing.** If the agent's server holds the same token you deploy with, the filters are all that stand between a prompt and a delete. Mint a separate read-only identity with a short-lived token, and keep write access out of the agent's reach.
- **An allow rule wider than the job.** An allow rule such as mcp__kubernetes__* pre-approves every tool that server offers, including write tools a later version adds. Allow named read tools, and deny the write tools explicitly.

### The layers that limit an MCP server, and who enforces each

| Layer | Setting | Enforced by |
| --- | --- | --- |
| Credential | A view role and a short-lived token | The system the server talks to, here the Kubernetes API server |
| Read-only mode | read_only | The MCP server, from tool annotations |
| Toolsets and allow lists | toolsets, enabled_tools, disabled_tools | The MCP server |
| Resource deny list | denied_resources | The MCP server |
| Client deny list | Permission rules such as mcp__server__tool | The MCP client, for example Claude Code |

## Try it yourself

You need Node.js 22.19 or newer for the MCP Inspector, and Claude Code for the last two steps. The filesystem reference server stands in for a server that holds access: its allowed directories play the part of the credential, and it refuses paths outside them.

1. Make a practice folder that holds nothing you care about. It is the only place the server will be allowed to touch.

   ```bash
   mkdir -p ~/mcp-practice && echo hello > ~/mcp-practice/note.txt
   ```
2. List the server's tools and sort them by their readOnlyHint annotation. Note the write tools: write_file, edit_file, move_file and create_directory.

   ```bash
   npx -y @modelcontextprotocol/inspector --cli npx @modelcontextprotocol/server-filesystem ~/mcp-practice --method tools/list
   ```
3. Ask for a file outside the allowed folder. The server should refuse with an error, because /etc is outside its allowed directories.

   ```bash
   npx -y @modelcontextprotocol/inspector --cli npx @modelcontextprotocol/server-filesystem ~/mcp-practice --method tools/call --tool-name read_text_file --tool-arg path=/etc/hosts
   ```
4. Register the server in Claude Code for this project, then deny the four write tools in .claude/settings.json.

   ```bash
   claude mcp add fs -- npx -y @modelcontextprotocol/server-filesystem ~/mcp-practice
   
   {
     "permissions": {
       "deny": [
         "mcp__fs__write_file",
         "mcp__fs__edit_file",
         "mcp__fs__move_file",
         "mcp__fs__create_directory"
       ]
     }
   }
   ```
5. Start Claude Code and ask which fs tools it can call. The four denied tools should be missing, because a deny rule removes a tool from the model's context.

   ```bash
   claude "List every tool you can call from the fs MCP server."
   ```

## Quick quiz

1. **Your MCP server's filters are perfect, but the token it holds can delete pods. What really limits a hijacked agent?**
   A. The server's read_only setting
   B. What that token is allowed to do
   C. The model's instructions
   Answer: B. Filters run inside the server and depend on config and labels; the API server enforces the credential no matter what. Least privilege starts with a token that cannot write.

2. **What does disable_destructive actually hide?**
   A. Every tool that can write
   B. Only tools annotated destructiveHint=true
   C. Every tool outside the core toolset
   Answer: B. It keys on the destructive annotation. helm_install and pods_run write but are not flagged destructive, so on v0.0.67 it left 16 tools, while read_only left 14.

3. **How do you know read_only actually took effect?**
   A. The server started without errors
   B. The config file contains read_only = true
   C. tools/list returns only read-only tools
   Answer: C. On v0.0.67 a misspelt key started silently and exposed 20 tools. Only the tool list a client receives is evidence of what the model is offered.

## Think it through

- **Why keep a separate write credential instead of one token plus a read-only server setting?** A setting inside the server is only as correct as its config and its tool labels. A credential that cannot write is enforced by the system itself, so a typo, an upgrade or a mislabelled tool cannot turn into a delete.
- **A server upgrade adds a new write tool. Which layers catch it, and which let it through?** The read-only credential still refuses what the tool tries, and a tools/list audit in CI flags the new name. A broad allow rule such as mcp__server__* pre-approves it silently, which is why named allow rules and explicit deny rules age better.
- **Why set denied_resources for Secret when the view role already cannot read Secrets?** It fails safe when the credential drifts. If someone later binds a broader role to the agent's identity, the server still refuses that kind.

## Go deeper

- [Kubernetes MCP Server: read_only, Toolsets, Denied Resources](https://www.matthewswong.com/en/blog/kubernetes-mcp-server-go-read-only-toolsets/)
- [MCP Server Security Checklist for Developers](https://www.matthewswong.com/en/blog/mcp-server-security-checklist/)
- [AI Agent Least Privilege: Credentials, Tokens and Secrets](https://www.matthewswong.com/en/blog/ai-agent-least-privilege-credentials/)
- [MCP Write Controls: Cloudflare WriteGuard vs Approval Prompts](https://www.matthewswong.com/en/blog/cloudflare-writeguard-mcp-write-controls/)

## Sources

- [Kubernetes MCP Server docs: configuration (read_only, toolsets, denied_resources)](https://github.com/containers/kubernetes-mcp-server/blob/main/docs/configuration.md)
- [Kubernetes docs: RBAC authorization and the view role](https://kubernetes.io/docs/reference/access-authn-authz/rbac/)
- [MCP specification 2026-07-28: tools and untrusted annotations](https://modelcontextprotocol.io/specification/2026-07-28/server/tools)
- [MCP specification 2026-07-28: security best practices (scope minimization)](https://modelcontextprotocol.io/specification/2026-07-28/basic/security_best_practices)
- [Claude Code docs: permission rules for MCP tools](https://code.claude.com/docs/en/permissions)

---

- HTML version: https://www.matthewswong.com/en/learn/ai-agents-mcp/scope-an-mcp-server-read-only-toolsets/
- Bahasa Indonesia: https://www.matthewswong.com/id/learn/ai-agents-mcp/scope-an-mcp-server-read-only-toolsets/
- Site index for agents: https://www.matthewswong.com/llms.txt
- Sitemap: https://www.matthewswong.com/sitemap.xml
- API description (OpenAPI): https://www.matthewswong.com/openapi.json
