DEV Community

Cover image for readOnlyHint isn't a permission: blocking a destructive MCP tool by policy
DVARA
DVARA

Posted on Originally published at dvarahq.com

readOnlyHint isn't a permission: blocking a destructive MCP tool by policy

MCP lets a tool describe itself. readOnlyHint: true says "I only read". destructiveHint: true says "I can delete or overwrite something". Clients can use those hints to decide when to ask a person before running a tool.

Two things go wrong with that in practice.

The hint is often missing. We looked at about 20,000 MCP tools for a report on MCP tool annotations. In Docker's MCP Catalog, 69% of tools that delete or overwrite something carry no hint at all.

The hint can be wrong. One server in that catalogue marked 15 delete tools as read-only, "delete ALL integrations" among them. The tools never set a hint, and a framework default filled in readOnlyHint: true for every one. The vendor fixed it within a week; the catalogue still served the old version three months later.

The MCP specification already tells clients what to do about this:

For trust & safety and security, clients MUST consider tool annotations to be untrusted unless they come from trusted servers.

So where should the decision about what an agent may do live? Not in the tool's description of itself. Here's what it looks like to put it somewhere you control instead, with a real run you can reproduce in about five minutes.

The setup

The DVARA MCP quickstart starts a gateway, its console and a small Spring AI MCP server with three tools:

Tool What it does
get_order_status Reads an order's status
lookup_customer Returns a customer record, including an email, phone and card number
delete_customer Deletes a customer record. Irreversible.

Your agent talks to the gateway, not to the server. The gateway lists the server's tools to the agent as demo-tools__<tool> and applies the workspace's rules to every call.

git clone --depth 1 https://github.com/dvarahq/dvara-examples.git
cd dvara-examples/docker-compose/mcp-quickstart
./start.sh
Enter fullscreen mode Exit fullscreen mode

You need Docker and JBang. No licence key and no AI provider account.

The rule

The quickstart loads one policy into the workspace. This is all of it:

version: "1"
rules:
  - id: deny-delete
    conditions:
      mcp_tool:
        denylist: [delete_customer]
    action: DENY
    deny_message: "delete_customer is irreversible and not allowed in this workspace"
Enter fullscreen mode Exit fullscreen mode

Notice what it doesn't depend on: the tool's annotations. If delete_customer claimed to be read-only, or said nothing at all, the rule would apply just the same.

The call

Ask the gateway to run the tool, the way any MCP client would. Replace <your-api-key> with DEMO_API_KEY from the quickstart's .env:

curl -s http://localhost:8080/mcp \
  -H "Authorization: Bearer <your-api-key>" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call",
       "params":{"name":"demo-tools__delete_customer","arguments":{"customerId":"C-42"}}}'
Enter fullscreen mode Exit fullscreen mode

The gateway refuses it before it reaches the server:

{"jsonrpc":"2.0","id":1,"error":{"code":-32001,"message":"delete_customer is irreversible and not allowed in this workspace","data":{"dvara_code":"MCP_POLICY_DENIED","http_status":403,"trace_id":"3054f7e845e4437ea9ec3fb704fe9a0e"}}}
Enter fullscreen mode Exit fullscreen mode

The demo server writes a line to its log whenever delete_customer actually runs. After this call, there's no such line: the server never received it.

The record

Open the console at http://localhost:8090 and go to MCP → Tool Calls. The refused call is there with status 403, next to the allowed get_order_status and the lookup_customer call whose result had its email, phone and card number redacted.

That record is the other half of the point. When someone asks what an agent did, or tried to do, the answer doesn't depend on each server's logging.

Adapting it to your own tools

  • Deny by name. Add the tools that should never run unattended to the denylist. Start with the ones whose names say delete, drop, revoke, transfer or send.
  • Hold for a person instead of denying. Some actions should happen, just not without a human saying yes. DVARA can hold a tool call for approval; the native MCP endpoint docs cover how.
  • Treat a missing hint as unknown. If a tool you haven't classified shows up, the safe default is to ask, not to assume it only reads.
  • Pin your servers. Catalogues can serve an older version than upstream. Know which version you run.

Annotations are still worth setting if you write MCP servers: they help every client that does read them. Just don't make them the thing that stands between an agent and an irreversible action.


Data in this post comes from our report on MCP tool annotations, which has the full method. Tool data: Glama (glama.ai); Docker MCP Catalog data is MIT-licensed.

Top comments (0)