The most alarming tool on a well-annotated MCP server is usually the one carrying the fewest annotations. That is the design, not an accident: silence here means assume the worst, and declaring nothing is how a careful server author says stop and ask.
Annotations are four optional booleans plus a title, so a client sitting between a model and a person can decide per call whether to run it or interrupt somebody.
The four hints
On the wire they are readOnlyHint, destructiveHint, idempotentHint and openWorldHint. SDKs spell them in their own idiom.
readOnlyHint — the tool performs no state changes. This is the hint that makes automation defensible: a call that cannot change anything is one you can afford to get wrong.
destructiveHint — whether a tool that does change things may perform destructive updates. False means additive only: it writes, but does not remove or overwrite what was there. Only meaningful for tools that are not read-only.
idempotentHint — repeated identical calls have no additional effect on the environment. Note the wording: a claim about the environment, not about what the second call returns.
openWorldHint — whether the tool's domain of interaction is open or closed. A web search is open-world; what comes back depends on entities the server does not control. Reading from a server's own store is closed.
A fifth field in the same object is not a boolean. Title is a display string a client can show instead of the programmatic name — arguably the most load-bearing of the lot, because a person reads it in the approval prompt.
Unspecified is not neutral
The defaults are the part people miss. Leaving a hint off is not "no comment" — each has a documented default, and they are pessimistic on purpose.
- read-only unspecified — clients assume it is not read-only
- destructive unspecified — clients should assume true
- idempotent unspecified — assume false
- open-world unspecified — assume true
An unannotated tool therefore declares: writes, may destroy, unsafe to retry, unbounded domain. Annotating is an act of narrowing — telling clients they may relax, and you had better be right.
What a client does with them
A client that keeps a person in the loop has one interesting decision per call: ask, or don't. Annotations are its only structured input, so approval policies get built out of them.
- read-only and idempotent — run it, show the result
- not read-only, destructive false — run it or ask, depending on the deployment
- destructive true or unstated — always ask, and show the title if there is one
- non-idempotent — never auto-retry a timeout, because a repeat could be a second write
That last rule is learned the hard way: a client that retries a timed-out call is fine against an idempotent tool and gambling against everything else. Human-in-the-loop review is easier when the tools say which calls are cheap.
Seventy-two tools, annotated honestly
RelayLink's MCP server is small enough to read end to end.
Sixty-three declare openWorldHint false; nine declare it true. Since true is the default, the false is the active statement: those sixty-three touch nothing outside RelayLink's own data. Seven of the nine that admit otherwise are exactly the ones that put a message in somebody's mailbox — confirm_send, invite_contact, accept_contact, revoke_api_key, open_support_request, reply_to_support and send_feedback. confirm_intent declares it for a different reason: it performs whichever call was staged, so the honest hint is the widest one any of them could need rather than the one its own row-level effect suggests. The ninth is project_work_update, and it is neither of those: nothing it does sends mail, but accepting, declining or progressing a shared task changes what the person who assigned it — or anybody else on the project — sees the moment they next look, which is exactly the world outside RelayLink's own accounting that the hint exists to flag. Creating, changing or leaving a group is not among them: those verbs change who a future send can reach, but they mail nobody by themselves. revoke_api_key is the one worth pausing over, because its row-level effect looks entirely internal: revoking a key also mails the account's own address, since a key going off is the shape of both housekeeping and a lockout, and the mailbox is the one channel a compromised session does not control. A closed-world hint there would have described the row and hidden the mail.
Note which tools are not on that list. The whole notebook — all eleven of its tools, which save, search, revise and organise somebody's own material — is closed-world, because none of it reaches anybody. notebook_share is the near miss and stays closed-world honestly: it opens a draft and stops, and the thing that puts a message in a mailbox is the confirm_send after it. Reading a shared project is closed-world for the same reason reading the notebook is: project_list, project_open, project_brief, project_work_find and project_work_open only ever describe what is already there. Documents are closed-world the same way: a saved or uploaded file sits in RelayLink's own store until somebody attaches it to a briefing, and it is the confirm_send that follows which actually delivers it — so none of the attachment tools, attachment_manage included, ever needed the open-world hint.
Twenty-nine declare read-only and idempotent — whats_new, check_inbox, search_messages, list_drafts, thread_status, list_threads, thread_ledger, list_contacts, list_forwarded, list_groups, whoami, usage_status, memory_search, list_api_keys, support_status, list_tags, the six notebook reads (notebook_find, notebook_open, notebook_context, notebook_run_routine, notebook_handoffs, notebook_fidelity_check), five project reads (project_list, project_open, project_brief, project_work_find, project_work_open), and two document reads (attachment_list, attachment_read). list_groups belongs there for the same reason list_contacts does: it names what the caller already owns or is on, and a Separate group's own members are never in that answer at all. The auto-approve candidates: nothing changes, and repeating costs nothing. list_api_keys belongs there without qualification because it returns no usable credential — a name, twelve characters with an ellipsis through the middle, and the dates. notebook_open belongs there because reading is reading: opening a task does not complete it and opening a hand-off does not take it away from another assistant, which is a property of the code rather than of the hint. project_brief belongs there for the strictest reason of all — it is built entirely from rows other calls already wrote, so there is nothing left for it to change even if it wanted to. usage_status is the same: it counts what the account holds against what its plan allows, and counting spends nothing.
Thirty decline read-only without claiming to destroy anything — get_package, get_thread, read_forwarded, notebook_share_fact, track_item, resolve_item, remind_me, save_handoff, resume_handoff, acknowledge_package, draft_package, rename_contact, set_my_name, set_preferences, invite_contact, accept_contact, create_group, open_support_request, reply_to_support, send_feedback, tag_thread, tag_contact, four notebook writes (notebook_save, notebook_update, notebook_organize, notebook_share), two project writes (project_work_update, project_draft), attachment_create_text and attachment_upload_link. Every one genuinely writes, and none takes away anything a person could not simply put back — the two that come nearest, notebook_organize and attachment_upload_link, are named below: get_package inserts a read receipt, one of the two events that later let the sender's thread_status report a package as seen; read_forwarded marks a forwarded message read, which reaches nobody but its own account and is the reason it cannot claim read-only either; draft_package inserts a draft row; accept_contact turns a pending row into an accepted one. notebook_update is the interesting member — it replaces the current text and is still not destructive, because the previous version stays in the entry's history and a stale write is refused with its text kept rather than dropped. project_draft earns its place the same way twice over: the call it names never writes anything shared at all — it only opens an intent confirm_intent later applies — and even the change it eventually produces is additive by construction, because retiring a work item is a flag rather than a deletion. attachment_create_text is the same shape as draft_package: it inserts a new document version, and a repeated idempotency key returns the one already saved rather than writing a second time or touching the first. That is what "additive updates only" means, and claiming read-only for any of them would have been a small, convenient lie. attachment_upload_link is the newest member and the only one that was ever something else: it used to hand back a fixed portal URL, which read nothing and wrote nothing, and calling it read-only was true. Repurposed to mint a single-use upload link, it now reserves a pending document slot on every call that names a filename — a write by the same test that seats attachment_create_text here, and the annotation had to move with the behaviour rather than stay pinned to what the tool used to do. It is also the exception: replaying its idempotencyKey replaces the link the first call handed out, and the old link stops working. The hint still says not destructive, and deliberately — the reservation and anything already uploaded are untouched, the replacement arrives in the same reply, and retiring a link that leaked or never arrived is what the replay is for.
notebook_organize is the one that deletes and declines the destructive hint, which needs saying out loud. Deleting an entry there is a lifecycle flag with a thirty-day recovery window and a restore on the same tool, so the honest reading is "puts something away", not "takes it away". Had deletion been immediate, the hint would have had to say so.
Thirteen declare destructive true — confirm_send, cancel_draft, update_group, leave_group, block_contact, unblock_contact, withdraw_invite, revoke_api_key, sign_out_everywhere, confirm_intent, memory_add, memory_forget and attachment_manage. Three of those read as harmless and are not, which is the test the hint actually asks: unblock_contact deletes the pair rather than restoring some earlier state, withdraw_invite deletes a request that was waiting, and memory_add — which sounds purely additive — deletes a row whenever it is given replaces, because correcting a fact means the wrong version goes. All three take something away. update_group earns it the same way — dissolving a group and taking a member off are both real removals, folded into one tool with adding rather than split across three. sign_out_everywhere ends every browser session at once, which is a removal whatever else it is; confirm_intent declares it because it runs whatever was staged, and the hint has to describe the worst of those rather than the average. None of the seven project tools that touch shared state joins this list: the one that comes closest, project_draft, never writes on its own, and confirm_intent — already on the list — is what carries the risk of whatever it opened. attachment_manage joins it even though two of its own actions, archiving and restoring, are exactly as reversible as notebook_organize's: the same tool also ends a recipient's access and can retire a document for everyone, and a hint describes the worst a tool can do, not the mildest.
Idempotency splits the list unevenly, and four of the seventeen exceptions are exceptions in reply only. draft_package, confirm_send, memory_add, project_draft and attachment_upload_link are genuinely not repeatable — a second draft is a second row, a send is a send, a memory added twice is two rows saying the same thing about somebody, a second project_draft call opens a second intent alongside the first rather than returning the one already waiting, and a second attachment_upload_link call reserves a second slot or, given the same key, replaces the first call's link so that one stops working. But accept_contact, unblock_contact, withdraw_invite and create_group leave the world exactly where the first call left it and return an error the second time, because there is no longer a pending request or a block to act on, or because the name is already taken. They decline the hint anyway. The mirror image sits one line up: cancel_draft and rename_contact do declare it, on the same facts, because cancelling twice or setting the same nickname twice is the ordinary shape of a person repeating themselves rather than a mistake worth an error posture. Four booleans cannot express the difference, and the honest move is to pick the reading that costs a prompt rather than the one that costs a surprise.
One wrinkle errs safe in the other direction. confirm_send behaves idempotently on retry — repeat it against an already-sent draft and it returns the original identifiers, and nothing goes out twice — yet declares no idempotentHint. Under-claiming costs a redundant prompt; over-claiming costs a message nobody can recall.
Three titles are written for a person — "Send package (irreversible)", "Revoke an API key (irreversible)" and "Manage a document (some actions irreversible)". A title is the one annotation a human reads, so it goes on the calls somebody should be asked about by name rather than by function.
Hints, not enforcement
None of this is a security control, and the official SDK documentation says so: the properties are hints, not guaranteed to describe behaviour faithfully, and clients should not make tool-use decisions based on annotations from untrusted servers. Nothing stops a hostile server stamping readOnlyHint true on a tool that deletes everything.
So a server enforces its own rules in code. RelayLink's send safety is not the word irreversible in a title; there is no single-call send at all. The server requires a draft created earlier by the same account, checks it belongs to the caller, and re-checks permission at confirm time. Strip every annotation off and that gate is untouched. The gap between a label and an enforced rule is most of what makes a server safe to connect, and why the approval step is the product.
Annotating a server you wrote
Two rules follow. Prefer under-claiming: an unnecessary prompt costs an interruption, a false read-only costs whatever the tool can reach. And write the title for the person, not the model — it appears when someone has to decide.
The vocabulary is coarser than reality. Four booleans cannot express "writes a row nobody would miss" or "idempotent in effect but not in reply", and both turn up inside a seventy-two-tool server. When it cannot say the true thing, decline the flattering annotation rather than stretching it. If you are sizing up what a connected agent can do to you, start from the checklist rather than the hints.