We Gave Every MCP Tool a readOnlyHint: Agent-Safety by Design
Trawl's MCP server gives ten tools to AI agents. We hand-annotated all ten so a grounding agent never fires a billable scrape run by accident.
An AI agent will fire a billable API call just to see what it does, unless the tool itself says not to.
A human reads the button label, weighs the cost, and decides. An autonomous agent has none of that judgment. It reads the tool’s self-description and acts. For an agent, your tool’s metadata isn’t documentation. It’s the only safety contract it will ever read. So we wrote ours like one.
Trawl exposes its API to agents through an MCP server: ten tools, one of which spends compute the moment it is called. We annotated all ten by hand so a grounding agent never trips that one while it is still exploring. Here is why the annotations exist, what the four-axis safety matrix looks like, and the single row that earns its asterisk.
In this article
- The problem: the agent is the one holding the trigger
- How the tools are wired
- The artifact: the 10-row annotation matrix
- Before / after: what the hints buy you
- What we learned
- Key takeaways
- FAQ
The problem: the agent is the one holding the trigger
The instant a non-human picks which function to call, your tool's metadata stops being documentation and becomes a control surface.
Most API safety thinking assumes a human in the loop. The confirmation modal. The "are you sure?" dialog. The type-the-resource-name-to-delete gate.
None of it survives contact with an agent that decides, on its own, which function to invoke next.
An agent exploring a new MCP server will call things to learn what they do. That's perfectly reasonable behavior, right up until one of those tools isn't a read. The instant a single call creates a new run and burns compute quota, "let me just see what trigger_scrap_run returns" becomes a line item on your bill.
You do not want curiosity to be billable.
The Model Context Protocol saw this coming. Tool definitions can carry annotations, declarative hints about a tool's behavior that a client can read before it decides how to handle the call:
readOnlyHint: does this tool only read, or can it change state?destructiveHint: if it writes, does it destroy or overwrite existing data?idempotentHint: does calling it twice do the same thing as calling it once?openWorldHint: does it reach outside the system into the open world (e.g. the live web)?
These are hints, not enforcement. The MCP spec is explicit that they describe intent, and that clients must not treat them as security guarantees from untrusted servers.
But for a first-party server talking to a cooperating client, that's the whole game. The difference is between a client that auto-runs the safe nine and pauses on the one, and a client that has to treat all ten identically, either nagging you about harmless reads or, worse, auto-firing the one that costs money.
How the tools are wired
Trawl's MCP server runs over Streamable HTTP using the official @modelcontextprotocol/sdk (McpServer + StreamableHTTPServerTransport).
Tools aren't hand-registered in one central file. Each module that wants to expose a tool drops a *.mcp.js file into its own folder; a registry auto-discovers them by glob, shape-validates each one, and passes its declared annotations straight through to registerTool.
That detail is the safety detail. The annotations are author-owned and per-tool. The engineer writing a module declares how their own tool behaves, right next to the tool that does the thing. Safety metadata isn't a spreadsheet in another repo waiting to drift out of sync. It ships with the code.
modules/*/mcp/*.mcp.js → registry (shape-validates) → server.registerTool(name, { annotations }, handler)Ten tools come out the other side. Nine are reads. One writes.
The artifact: the 10-row annotation matrix
Here is every tool Trawl exposes to an agent, with the four hints it carries and the one-line reason.
| Tool | readOnly | destructive | idempotent | openWorld | Why |
|---|---|---|---|---|---|
trawl_whoami | ✅ | ❌ | ✅ | ❌ | Returns the caller's own identity. Pure read, closed-world. |
trawl_health_ping | ✅ | ❌ | ✅ | ❌ | Liveness check. Same answer every time. |
trawl_get_scrap_history | ✅ | ❌ | ✅ | ❌ | Reads stored run history. |
trawl_get_run | ✅ | ❌ | ✅ | ❌ | Reads one stored run by id. |
trawl_list_scraps | ✅ | ❌ | ✅ | ❌ | Lists the caller's saved scrap definitions. |
trawl_get_scrap | ✅ | ❌ | ✅ | ❌ | Reads one saved scrap definition. |
trawl_scrap_latest_result | ✅ | ❌ | ✅ | ❌ | Reads the stored result of a past fetch (see nuance below). |
trawl_list_tasks | ✅ | ❌ | ✅ | ❌ | Lists tasks. |
trawl_get_task | ✅ | ❌ | ✅ | ❌ | Reads one task. |
trawl_trigger_scrap_run | ❌ | ❌ | ❌ | ✅ | Creates a new run and fetches arbitrary external web content. Non-destructive, but each call produces a new run and consumes compute quota. |
Nine rows are identical: readOnlyHint: true, idempotentHint: true, openWorldHint: false. One row breaks the pattern.
The entire design exists to make that one row legible to an agent before it acts.
The punchline row, decoded
trawl_trigger_scrap_run carries readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true. Read those four together and you get a precise behavioral fingerprint, not a vibe:
- Not read-only → it changes state. A client should consider pausing for approval.
- Not destructive → but it won't delete or overwrite anything you already have. The pause is "this spends something," not "this could wreck your data."
- Not idempotent → calling it twice produces two runs and consumes quota twice. A retry isn't free. An agent that blindly re-issues on timeout would double-spend.
- Open-world → it reaches out to the live web, so its output isn't a deterministic function of your stored state.
That's a four-axis answer, not a binary "safe / dangerous" flag. Which is the entire point of the next nuance.
destructiveHint: false ≠ readOnlyHint: true
It's tempting to collapse safety into one bit: read is green, write is red. Two colors, done.
The trigger tool is where that model falls apart. It writes, so it isn't read-only. But it doesn't destroy, so it isn't red-alert either.
A client that only understands "read vs write" would treat starting a run the same as deleting an account. A client that reads all four axes can offer the right UX instead: this will start something new and cost a bit of compute. Proceed?
One bit can't say that. Four can.
The subtle one: reading the result of a web fetch is still closed-world
trawl_scrap_latest_result is the row most likely to be annotated wrong, and it's the one we're proudest of getting right.
It returns the stored result of a past run. The original run absolutely hit the open web. But this tool? It reads a database row that already exists. So it's openWorldHint: false, with the reasoning recorded right in the source: reading the stored result is closed-world even though the run that produced it was open-world.
You annotate the tool's behavior, not the behavior of whatever once created the data the tool reads. The fetch was open-world. Reading the receipt is not. (When a stored payload is very large, the result becomes a REST pointer the client can follow, but that's a transport detail, not a change in safety class.)
Before / after: what the hints buy you
Without annotations, a client sees ten tools that all look the same. It has two bad options. Prompt the user on every single read: death by a thousand confirmations, and your agent is now useless. Or treat them all as safe and let the agent auto-fire trawl_trigger_scrap_run while it's still just exploring: a billable surprise. Annoying or expensive: pick one.
With annotations, the client splits the set. Auto-run the nine idempotent, closed-world reads with no friction. Surface a single approval prompt on the one tool that starts new work and spends quota.
The spec defines the hints; how each client uses them is up to that client's UX. Our job was to make sure the information is there to be used, and we did.
Want to point your own agent at a scraping API that's honest about what each tool does? Try Trawl free →
This is the same instinct behind everything we ship for agent callers: be honest, up front, about what each surface does. If you want the broader picture of how LLMs, MCP, and self-healing extraction fit together, see how to automate web scraping with AI in 2026.
What we learned
The lesson generalizes well past MCP.
Any time the caller is an autonomous agent rather than a human, your tool's metadata stops being documentation and becomes a control surface. The agent reads it to decide. So the work of safety moves up-front, into honest, per-tool self-description: say plainly what each tool reads, what it writes, whether a retry is free, and whether it touches the open world.
We annotated ten tools by hand. Nine were boring on purpose. The tenth had to be exactly right, and being precise about which of the four axes it trips (writes, yes; destroys, no; idempotent, no; open-world, yes) is what lets a client do the smart thing instead of the blunt thing.
Boring nine, careful one. That's the whole craft.
Key takeaways
- The agent reads the label. When a non-human picks the tool, the tool's self-description is the safety contract. Write it like one.
- Four axes, not one bit.
readOnly/destructive/idempotent/openWorlddescribe genuinely different risks. Collapsing them to "safe vs dangerous" loses the signal that lets a client behave well. destructiveHint: false≠readOnlyHint: true. "Writes but doesn't destroy" deserves its own, lighter handling than "deletes data."- Annotate the tool, not the data's history. Reading the stored result of a web fetch is closed-world, even if the fetch wasn't.
- Make safety author-owned. Each module declares its own hints next to its own code, so the metadata can't drift away from behavior.
- Hints aren't enforcement. They let cooperating clients auto-run reads and pause on spend; they are not a security boundary against an untrusted server.
FAQ
What are MCP tool annotations?
Annotations are declarative hints attached to a Model Context Protocol tool definition that describe how the tool behaves: whether it only reads, whether it destroys data, whether calling it twice is safe, and whether it reaches the open web. A client reads them before deciding how to handle a call.
What does readOnlyHint mean?
readOnlyHint: true declares that the tool only reads state and cannot change it. A cooperating client can run such tools automatically without prompting the user, because there is no side effect to confirm.
Is destructiveHint: false the same as readOnlyHint: true?
No. A tool can write new state without destroying existing state. "Writes but doesn't destroy" (readOnlyHint: false, destructiveHint: false) deserves lighter handling than "deletes data," but it still isn't a pure read. Collapsing both into one bit loses that distinction.
Why is reading a stored web-fetch result openWorldHint: false?
Because the tool reads a database row that already exists. It doesn't reach the live web itself. The original run was open-world; reading its stored receipt is closed-world. You annotate the tool's own behavior, not the history of the data it returns.
Why does idempotentHint matter for an agent?
A non-idempotent tool produces a new effect on every call. If an agent blindly retries on timeout, it can double-spend quota or create duplicate work. The hint tells a client that a retry is not free, so it can ask before re-issuing.
Are MCP annotations a security boundary?
No. The MCP spec is explicit that hints describe intent and must not be treated as security guarantees from an untrusted server. They make a cooperating, first-party client behave well; they do not enforce anything against a malicious one.
Why annotate tools by hand instead of inferring them?
Because the engineer writing a tool knows its real behavior better than any inference can. Trawl makes annotations author-owned and per-tool. They ship in the same file as the handler, so the safety metadata can't drift out of sync with what the code actually does.
How can a client use these hints in practice?
A client can split the tool set: auto-run the idempotent, closed-world reads with no friction, and surface a single approval prompt on any tool that changes state or spends quota. The spec supplies the information; each client decides the UX.
Written by Pierre | June 2026
Disclaimer: You are responsible for complying with all applicable laws and the terms of service of any site you target. Use Trawl only to access data you are permitted to access, and only public data where consent isn't otherwise established. Trawl is an orchestration layer. How you use it is your responsibility.