We Gave Every MCP Tool a readOnlyHint: Agent-Safety by Design

Trawl's MCP server gives nine tools to AI agents, and two of them spend compute. We hand-annotated all nine so a grounding agent never fires a billable scrape run by accident.

Trawl banner: build grainy gradient with filet mesh

Updated August 2026 for API v2.0. The tool set changed after this post first ran: two task-reading tools were retired and trawl_create_scrap arrived, so the server now exposes nine tools and two of them write. The matrix below is the current one, and the last section is new.

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: nine tools, two of which spend compute the moment they are called. We annotated all nine by hand so a grounding agent never trips either one while it is still exploring. Here is why the annotations exist, what the four-axis safety matrix looks like, and the two rows that earn their asterisk.

In this article

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 trawl_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 seven and pauses on the two, and a client that has to treat all nine identically, either nagging you about harmless reads or, worse, auto-firing the ones that cost 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)

Nine tools come out the other side. Seven are reads. Two write.

That "ships with the code" claim is also why this post needed an update rather than a correction notice. When the task-reading tools were retired and scrap creation was added, the annotations moved with them: the matrix below was regenerated from the source, not from memory.

The artifact: the nine-row annotation matrix

Here is every tool Trawl exposes to an agent, with the four hints it carries and the one-line reason.

ToolreadOnlydestructiveidempotentopenWorldWhy
trawl_whoamiReturns the caller's own identity. Pure read, closed-world.
trawl_health_pingLiveness check. Same answer every time.
trawl_get_scrap_historyReads stored run history.
trawl_get_runReads one stored run by id.
trawl_list_scrapsLists the caller's saved scrap definitions.
trawl_get_scrapReads one saved scrap definition.
trawl_scrap_latest_resultReads the stored result of a past fetch (see nuance below).
trawl_trigger_scrap_runCreates a new run and fetches arbitrary external web content. Non-destructive, but each call produces a new run and consumes compute quota.
trawl_create_scrapCreates a new scrap definition and fetches the target to validate it. Non-destructive, but each call adds a persistent object and consumes compute quota.

Seven rows are identical: readOnlyHint: true, idempotentHint: true, openWorldHint: false. Two rows break the pattern, and they break it in exactly the same way.

The entire design exists to make those two rows legible to an agent before it acts.

The punchline rows, decoded

Both writers carry 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 is not readOnlyHint: true

It's tempting to collapse safety into one bit: read is green, write is red. Two colors, done.

The two writers are where that model falls apart. They write, so they aren't read-only. But they don't destroy, so they aren'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 nine 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 the two spending tools while it's still just exploring: a billable surprise. Annoying or expensive: pick one.

With annotations, the client splits the set. Auto-run the seven idempotent, closed-world reads with no friction. Surface an approval prompt on the two that start new work and spend 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.

Where four axes still aren't enough

Adding a second writer taught us something the first one couldn't.

trawl_trigger_scrap_run and trawl_create_scrap carry identical annotations. All four axes agree. And yet their consequences are not the same size at all.

Triggering a run spends compute once and ends. Creating a scrap spends compute once and leaves behind a persistent object: a definition that can carry a schedule, and that will go on consuming quota every time it fires, long after the conversation that created it is over.

The four axes have no word for that difference. There is no recurringHint, no createsPersistentStateHint. "Not idempotent" tells a client that a retry costs twice; it does not tell it that one call may cost every day for a month.

We are not proposing a fifth axis. A vocabulary everyone implements beats a richer one nobody does, and the spec's four cover the common cases well. But it is worth naming honestly: the hints describe what a call does, not what it commits you to. If your server exposes a tool that creates recurring work, the annotations will not carry that weight for you. Say it in the description, where an agent will actually read it.

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 nine tools by hand. Seven were boring on purpose. The two that spend had to be exactly right, and being precise about which of the four axes they trip (writes, yes; destroys, no; idempotent, no; open-world, yes) is what lets a client do the smart thing instead of the blunt thing.

Seven boring, two careful. 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 / openWorld describe genuinely different risks. Collapsing them to "safe vs dangerous" loses the signal that lets a client behave well.
  • destructiveHint: false is not 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, and a changing tool set updates its own safety contract.
  • The axes describe the call, not the commitment. Two tools can share all four hints and still differ in whether they leave recurring work behind. Put that in the description.
  • 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.

Do the annotations distinguish a one-off cost from a recurring one?

No, and that is a real limit. trawl_trigger_scrap_run and trawl_create_scrap carry identical hints, but only the second leaves behind a persistent object that can keep consuming quota on a schedule. The four axes describe what a call does, not what it commits you to. Put that in the tool description instead.

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 an 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 · updated August 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.