An MCP server says three things about itself, and a model believes all three: what each tool is called and described as, what each tool declares it can break, and what each tool answers when it is called. They are read at different moments, from different copies, and they go wrong in different ways.
This is how we decided what juudd's tools say in each place. The short version is that a hint describes the handler and not the name, a description holds only what stays true, and anything a caller needs today belongs in the result.
A tool with no annotations is a tool nobody can reason about
MCP gives every tool three hints about its own blast radius: readOnlyHint, destructiveHint and openWorldHint. Until 2026-08-31 not one of our tools set any of them. A client had no way to tell read_site from apply_sql except by reading prose written for a model, and the protocol had carried a field for exactly that distinction the whole time.
What made it urgent was OpenAI's plugin review, which names an annotation that does not match a tool's real behaviour as a common reason to reject a submission. What made it right was the question underneath the review, which does not go away if the review changes: a client that asks a person before a destructive call can only ask at the right moment if the server tells it which calls are destructive.
Every tool here now states all three hints, including the ones that are false. An omitted hint and a false hint are different answers — one says unknown, the other says known not to — and for our own tools every one of them is known.
Read the handler, not the name
The rule we set the hints by is short: each hint describes what the function does, never what the tool is called. Applied honestly, it gave two answers that are the opposite of what the names suggest.
domain_status is not read-only. It sounds like a status check and it is a repair. On every call it can register a hostname whose first registration failed, withdraw one that has stopped validating, mark a domain verified and republish the mapping that routes it. It can start a domain serving and it can stop one. Set from its description, it would have been marked read-only, and that is precisely the mismatch a reviewer looks for and a client acts on.
transfer_database is destructive, although it moves nothing. It mints a link and records that the database has been handed over, and that link is the one irreversible act in this system: once it is used, we cannot read the database again.
Two more answers are worth writing down because the obvious one is wrong in the other direction.
| tool | the tempting answer | the answer | why |
|---|---|---|---|
deploy_site | destructive | not destructive | it overwrites what is served, and every version is kept |
publish_deploy | destructive | not destructive | a rollback is a publish of an older version |
create_database | open world | not open world | a database is not state a stranger can see |
transfer_database | harmless | destructive | the link it mints cannot be taken back |
Overwriting is not destroying while the thing overwritten is still there. That sentence is what keeps our two most frequent calls from carrying a warning they have not earned, and a warning shown on every deploy is a warning people learn to click through.
The protocol already tells clients not to trust you
The MCP specification is blunt about what these hints are worth on their own. Its tools page, in the 2025-11-25 revision, says:
For trust & safety and security, clients MUST consider tool annotations to be untrusted unless they come from trusted servers.
We read that as an instruction to the server as much as to the client. A hint is only useful to a client that has decided to believe it, and the cheapest way to lose that belief is one annotation that turns out to be wrong. So the rule and its reasoning live in one file beside the tools, and the seven tools that only look share a single constant. A tool that stops being a pure read has to stop using that constant, rather than quietly editing it into a lie for the other six.
The honest limit: nothing enforces the pairing. A handler that gains a write and keeps readOnlyHint: true is worse than one that never declared anything, because a client will act on it. The comment beside each tool names the behaviour its hints were read from, which is the cheapest defence available and is not the same thing as a test.
A credential does not belong in a tool result
The same reading found something that was not a hint at all. create_database used to return the database owner's connection string in its result. Its own description had been arguing against that for months, warning that the string was for someone with a terminal and must never go into a site's files — two warnings aimed at a person, delivered to a model.
A credential in a tool result is a credential in the model's context, in whatever transcript the client keeps, and in every log between the two. That was true before any review checklist existed and it stays true if the checklist is withdrawn tomorrow, which is why the review found it and is not why it changed.
The string now lives in the panel, behind one click, fetched by a request that is recorded and never on page load, so it does not appear in a screenshot because a route rendered. Nothing was lost by moving it. The assistant already runs SQL through apply_sql, and a site's own functions receive a separate, restricted role at deploy time. The owner credential was never the assistant's to use.
Descriptions are cached, results are not
A client keeps a copy of the tool list from the moment it connects. The specification gives servers a way to say that copy is out of date, notifications/tools/list_changed, and until 2026-09-01 this server had declared that capability on every connection and never once sent it. A client that believes the capability has every reason not to poll.
So we measured the question nobody had asked. A connector to our development server was still holding eleven tools, from before the domain tools shipped, while the server offered fourteen. We made hello send the notification and called it once:
The client came back for the list in the same millisecond, and three tools it had been missing for days became available inside that single call. hello now sends the notification every time. One client was measured, Anthropic's, and we claim nothing about the others; the notification is harmless to a client that ignores it.
That makes a stale list recoverable. It does not make it impossible. A client that was offline during a deploy, a call already in flight, a client that ignores the notification and a client that calls nothing at all are each still acting on an older copy. A description is read from a copy taken earlier; a result is produced now. So a description carries only what will stay true, and anything a caller must know to act correctly today goes in the result.
We learned that one the slow way. deploy_site's description once said that publishing to a host was not configured yet. The sentence had been false for a day and six deploys, and it was read as fact the whole time.
The surface only grows
Because callers hold copies we cannot enumerate, expire or ask about, the tool surface follows five rules, each earned by something that went wrong here.
- Never remove or rename a tool. A removed tool is still in someone's cached list and will still be called.
- Never make an optional argument required. The safe direction is the other one, and it has been taken:
deploy_site'ssubdomainwent from required to optional. - Never change what an argument means. Both sides report success while acting on different meanings, which is worse than an error.
- A new capability that does not fit an existing tool is a new tool. A client that cannot see it simply never calls it, which is the mildest failure available.
- Put what matters today in the result. A refusal explains itself in the reply and names what would make the call succeed, without assuming the caller can see the current schema.
The fourth rule has a cost worth naming. A new argument of a new shape is not just undiscoverable against an old schema, it is unusable: sending functions to a client still holding the earlier schema was rejected by validation with expected array, received string at functions. After the hello change that is one call away from fixed rather than indefinite, which is the whole value of it, and it is still not zero.
The rule, once
A hint describes the handler, a description holds what stays true, a result carries what is true now, and a secret goes to a person rather than a model. Every one of the tools a customer's assistant can call, with what it declares and what it refuses, is listed in the docs.
Drawn from docs/adr/0034-what-the-tools-declare-about-themselves.md in this project's own record.