Typed risk annotations for 455 MCP tools
Blog · mcp

Typed risk annotations for 455 MCP tools

Why tool names are not a safety policy, and what a server-side risk table needs

My MCP server exposes 455 tools to AI agents. Until October 2026 the only thing telling my agent which of them were dangerous was the tool’s name. That heuristic marked 40 mutating tools as read-only, including a Kubernetes restart, a firewall address-list editor and a tool that bans IP addresses.

The fix was a typed risk table: every tool declares read, write or critical, the server publishes that level in the standard MCP annotations, and the build fails if any tool is missing from the table. This post explains the design, the cases that a simple level cannot express, and the bugs a careful classification still missed.

Contents · 9 sections

TL;DR #

  • Never infer risk from a tool name. systemd_service(action=...) reads or restarts depending on one argument, and its name says neither.
  • Keep one central risk table on the server, publish it as MCP ToolAnnotations, and make unlisted tools default to write.
  • Add flags that a single level cannot carry: arg_dependent (the real risk depends on a command or query) and critical_when (specific argument values that make a call critical).
  • Gate the build: a test fails when a new tool is not in the table.
  • A correct label does not make a tool safe. Several of my “read” tools passed arguments into a shell unquoted.

How it went wrong #

My agent, lsn-agent-v3, has a “monitor mode”, a non-admin filter and read-only reviewer roles. All three depended on knowing which tools change nothing. The first implementation guessed from substrings in the name: anything with get, list, status or logs looked safe, anything with delete or restart did not.

An inventory on 30 September 2026 counted 551 tools in 96 families across all MCP servers. Comparing them with what they actually do found 40 mutating tools labelled read-only: the dossier add_* tools, a Kubernetes restart and scale, a CrowdSec ban, web-threat rule updates, router address lists, mail server settings and a blocklist writer. So the “read-only” roles were not read-only.

The first patch switched to whole-word verbs plus an explicit list of mutating tools. Two days later systemd_service turned up: its action parameter can be status, but also restart or stop. It went on the list by hand. That is when I accepted that the client cannot know. Only the server that implements a tool knows what it does.

The design: a risk table on the server #

On 4 October the MCP server got a central table, one line per tool:

# tools/risk.py
READ, WRITE, CRITICAL = "read", "write", "critical"
DEFAULT_LEVEL = WRITE          # fail-safe for an unlisted tool

LEVELS = {
    "k8s_get":          "read",
    "k8s_restart":      "write",
    "k8s_delete":       "critical",
    "crowdsec_ban":     "write",
    "stalwart_create_account": "critical",
    "telegram_send":    "write",
    # ... one entry per tool
}

The three levels mean:

Level Meaning What my agent does
read no side effects runs freely
write changes state, bounded and reversible asks the admin each time
critical irreversible or wide blast radius asks, plus a second-factor code

At startup the server applies the table to every registered tool as MCP ToolAnnotations. The standard hints are filled in from the level, and the extra fields ride along, because the annotations model accepts extra keys:

from mcp.types import ToolAnnotations

def annotation(name):
    lvl = LEVELS.get(name, DEFAULT_LEVEL)
    a = {
        "title": f"[{lvl}] {name}",
        "readOnlyHint": lvl == "read",
        "destructiveHint": lvl == "critical",
        "openWorldHint": name in OPEN_WORLD,
        "level": lvl,                              # extras
        "arg_dependent": name in ARG_DEPENDENT,
        "notify": name in NOTIFY,
    }
    if lvl == "read":
        a["idempotentHint"] = True
    return a

for name, tool in registered_tools():
    if name not in LEVELS:
        log.error("RISK: %r not in risk table, treating as write", name)
    tool.annotations = ToolAnnotations(**annotation(name))

A client reads the level from tools/list at result.tools[i].annotations.level. The same values are mirrored in the tool’s _meta, for clients that drop unknown annotation fields. A unit test fails the build when any registered tool is missing from the table, so a new tool cannot ship unrated.

The first version rated 443 tools: 352 read, 69 write, 22 critical. A few results surprised me. The router firewall tool is read-only despite a docstring that says “Manage”, because it only lists rules. The bot command relay and the security job launcher are critical, because each can start anything behind it.

What a single level cannot express #

Tools whose risk lives in the arguments #

run_command, ssh_exec, k8s_exec, docker_exec, the SQL query tools and the code runners are not “read” or “write”. Their risk is whatever the command says. In the table they get a floor level and the arg_dependent flag, which tells the client: classify the concrete call yourself, at this level or higher. My agent then rates the command text with deterministic rules (rm -rf, DROP and kubectl delete are critical; a short allowlist of read commands is read) before asking anyone.

Tools that become critical with one argument #

Some tools are ordinary most of the time and dangerous with one specific value. On 18 October several whole-tool “critical” ratings became write with a critical_when map instead:

PERSISTENCE_PATHS = [
    r"re:^(~|\$HOME|/home/[^/]+|/root)/\.",          # dotfiles: .bashrc, .config, .ssh
    r"re:\.(service|timer|socket|path)$",             # systemd units
    r"re:^/etc/(cron\.(d|daily|hourly|weekly|monthly)|crontab)",
]

ARG_CRITICAL = {
    "systemd_service": {"action": ["stop", "disable", "mask", "enable"]},
    "rsync_copy":      {"delete": [True], "dest": PERSISTENCE_PATHS},
    "docker_compose":  {"action": ["down"]},
    "tar_archive":     {"action": ["extract"]},
    "write_file":      {"path": PERSISTENCE_PATHS},
}

The persistence paths came from a review by a second AI session: a write to a dotfile, a systemd unit or a crontab can plant something that runs later, so it deserves the second factor even when the tool itself is “just a file write”.

Notifications and the open internet #

Two more flags: notify (the tool’s effect is a message to my own channels, which scheduled reports must be able to send) and openWorldHint (the tool talks to third parties). They let a client allow reports without allowing every write, and keep anonymous viewers on public-only tools.

Same idea, other servers #

The library server followed the same day with its own table for 28 tools: 17 read, 9 write and 2 critical (merging two works and applying a bulk file relocation). It keeps the risk table separate from its role matrix, and a test keeps both in sync. Before that, my agent treated the unannotated library tools as non-read and hid them from read-only roles.

Third-party servers are the other direction. The official Blender MCP server marks its code-execution tool destructiveHint, so my agent rated it critical and asked for a code on every call. That tool runs in a container with no network, so my studio tools now publish their own level annotations (read or write, none critical), and the client can also carry a local policy per server: an allowlist of tools, a name prefix, risk overrides and argument guards, for any server whose own annotations are missing or wrong.

A label is not a guarantee #

The risk table answers “what is this tool supposed to do?”. It does not answer “what can it be made to do?”. An audit on 8 October found that several read-rated tools (cat_file, tail_file, file_find, journalctl_logs and some Kubernetes arguments) put their arguments into a shell command unquoted. Any user allowed to call a read tool could have run commands as the server’s account. The next releases fixed it:

  • shlex.quote on every argument that reaches a shell, refusal of arguments that start with -, integer casts for numeric fields;
  • a path guard, so file readers cannot open key folders;
  • a network guard on URL fetchers, with a separate tool for internal URLs;
  • the Secret reader re-rated, because decoding any Secret is not a “read”;
  • a redaction wrapper on every tool’s output, so credentials do not reach the model even when a tool prints them.

Two smaller rules from the same codebase belong here too, because an agent trusts tool output as much as tool labels:

  • A tool that appears in tools/list has only proven that it registered. Call it once with real data before calling a deploy done.
  • A tool that truncates a list must say so and give the dropped count. A silently truncated list reads as the complete one. One of my Kubernetes tools cut output at 4,000 characters and a count of pods came out as 33 of 172.

Checklist #

  • Put the risk level on the server, next to the code that does the side effect.
  • Default unknown tools to write, log an error, and fail the build in a test.
  • Publish the level in standard MCP annotations and mirror it in _meta.
  • Flag exec-style tools as argument-dependent and let the client classify the command.
  • Use critical_when for single dangerous argument values, including persistence paths.
  • Treat third-party annotations as input, with a local override per server.
  • Audit “read” tools for shell interpolation, path access and SSRF, because read is where nobody looks.

Frequently asked questions #

What are MCP tool annotations? #

They are optional hints a server attaches to each tool: readOnlyHint, destructiveHint, idempotentHint and openWorldHint, plus a title. Clients can use them to decide how carefully to treat a call. They are hints, so a client should combine them with its own policy.

Can a client trust a server’s annotations? #

Only as much as it trusts the server. For my own servers the table is reviewed and tested. For third-party servers I keep a local override per server and default anything unannotated to “write”.

How do you rate a tool that runs arbitrary commands? #

Give it a floor level and mark it argument-dependent, then classify each concrete command on the client: deterministic rules first, and a model only as a second opinion that can raise the level, never lower it.

Why default unknown tools to write instead of read? #

Because the cost of the two mistakes is not symmetric. A read tool wrongly rated write costs one extra approval click. A write tool wrongly rated read runs unattended.

The lesson #

Agents decide what to run from metadata, so the metadata is a security boundary. A name is not metadata; it is a guess. Put a typed, tested risk level on every tool where the tool is implemented, let the risky cases carry their arguments, and fail the build when a tool is missing. Then go and read the “harmless” tools again, because that is where my worst bug was hiding.