Chapter 3 of 8 · 14 min read
Behavioural tool annotations
On this page
Annotations travel in a tool's listing entry alongside the name, description and schema, in an
annotations object with five optional fields. They are the vocabulary a host needs to build
a confirmation policy at all — without them a host has to gate everything or gate nothing, and
both are bad. A host that shows a modal before a destructive operation and none before a read
is reading these fields.
What they are not is a control. Nothing in the protocol requires a client to act on them, and
a client that reads a destructive hint and does nothing with it is fully compliant, as is one
that never looks at annotations at all. This is not a gap in the specification that a future
version will close; it is the design, stated in the specification itself, and the reasoning
behind it is a trust argument worth understanding because it determines where every guarantee
you actually need has to be built instead. The defaults are the other half of the subject, and
they are less obvious than the values: a tool with no annotations object at all is not
neutral, it is described by a deliberately pessimistic set of assumptions.
What the five fields are and what they default to
Setting them costs one line:
from mcp.types import Tool, ToolAnnotations
Tool(
name="wipe_index",
description=(
"Drop a search index and rebuild it from the primary store. Search "
"returns no results for the duration of the rebuild, typically 8-14 "
"minutes. Rebuilding an index that is already rebuilding is refused."
),
inputSchema={
"type": "object",
"properties": {"index": {"type": "string", "description": "Index name, e.g. runbooks-v3."}},
"required": ["index"],
"additionalProperties": False,
},
annotations=ToolAnnotations(
title="Rebuild search index",
readOnlyHint=False,
destructiveHint=True,
idempotentHint=False,
openWorldHint=False,
),
)Which appears on the wire, in the tools/list result, as an extra key on the tool object:
{
"name": "wipe_index",
"description": "Drop a search index and rebuild it from the primary store. …",
"inputSchema": {"…": "…"},
"annotations": {
"title": "Rebuild search index",
"readOnlyHint": false,
"destructiveHint": true,
"idempotentHint": false,
"openWorldHint": false
}
}| Field | Type | Default when absent | What it asserts | Who reads it |
|---|---|---|---|---|
title | string | tool name | A human-readable label for UI | Host UI, sometimes forwarded to the model |
readOnlyHint | boolean | false | The tool does not modify its environment | Host: whether to gate at all |
destructiveHint | boolean | true | Updates may be destructive rather than purely additive | Host: whether to gate harder |
idempotentHint | boolean | false | Repeated calls with the same arguments have no additional effect | Host: whether a retry is safe |
openWorldHint | boolean | true | The tool touches an open external world rather than a closed one | Host: how much to trust the result and the blast radius |
Two structural points follow. First, destructiveHint and idempotentHint are only meaningful
when readOnlyHint is false; a read-only tool is neither destructive nor non-idempotent in
any interesting sense, and hosts ignore the pair when readOnlyHint is true. Second, an
unannotated tool reads as "may write, may destroy, not safe to retry, touches the outside
world" — which is why leaving annotations off a genuinely read-only lookup is a real cost rather
than a stylistic omission. It pushes a careful host into gating a list_files call that never
needed a dialog, which trains users to click through dialogs, which degrades the value of the
dialog that mattered. The official Model Context Protocol post Tool Annotations as Risk
Vocabulary: What Hints Can and Can't Do is the short reference for the field semantics and
the defaults, and it is explicit about the limits.
Both of those points are decisions somebody made, and reconstructing the reasoning is what
lets you predict the next field rather than memorise this one. The conditional structure
follows from what the two fields mean. destructiveHint characterises an update — whether
it removes rather than adds — and idempotentHint characterises a repeat — whether a second
call changes anything further. A tool that modifies nothing has no update to characterise,
and its repeats change nothing by definition, so both questions are empty for it. That is
why hosts stop reading the pair once readOnlyHint is true, and why setting them on a
read-only tool is neither wrong nor any use.
The defaults come from a different consideration entirely. A default is what an unannotated tool asserts, and an unannotated tool is one nobody has reviewed. Choosing the safe reading for those would mean a host silently waving through whatever a careless server author forgot to describe, and the cost of being wrong there has no ceiling. Choosing the dangerous reading costs a dialog that was not needed. The specification takes the second, which is right in each individual case and expensive in aggregate: a fleet of unannotated read-only tools produces a stream of dialogs, users learn that dialogs are noise, and the one dialog that mattered gets clicked through with the rest.
A trace that shows what each one changes
Reading the specification tells you what the fields mean; running a client tells you what a host does with them, and those are different questions. The experiment is cheap: put three tools on a scratch server — one read-only, one destructive, one that writes but is idempotent — and drive them from both a bare client and the host you actually ship against.
import asyncio
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
PARAMS = StdioServerParameters(command="python", args=["annotation_probe_server.py"])
async def main() -> None:
async with stdio_client(PARAMS) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
listing = await session.list_tools()
for tool in listing.tools:
ann = tool.annotations
print(
f"{tool.name:<18} readOnly={getattr(ann, 'readOnlyHint', None)} "
f"destructive={getattr(ann, 'destructiveHint', None)} "
f"idempotent={getattr(ann, 'idempotentHint', None)}"
)
# A bare client asks nobody anything: this call just runs.
result = await session.call_tool("wipe_index", {"index": "scratch-1"})
print("isError:", result.isError)
asyncio.run(main())list_indexes readOnly=True destructive=None idempotent=None
wipe_index readOnly=False destructive=True idempotent=False
reindex_document readOnly=False destructive=False idempotent=True
isError: FalseThe bare client is the honest baseline. It read the annotations, printed them, and then
executed a destructive call with no gate, because ClientSession is a protocol implementation
and gating is an application concern. Every dialog you have ever seen before a tool call was
written by a host developer who chose to write it.
Running the same three tools through a host with a confirmation policy produces the second half of the trace, and it is worth recording as a table rather than describing, because the point is the mapping from annotation to observed behaviour:
| Tool | Annotations declared | Confirmation observed | Retry on timeout observed | Result written to the transcript |
|---|---|---|---|---|
list_indexes | readOnlyHint: true | none | retried once, silently | full output |
reindex_document | writes, idempotentHint: true | none | retried twice on a 30 s timeout | full output |
wipe_index | writes, destructiveHint: true | modal, "Allow once / Always allow / Deny" | not retried | full output |
purge_tenant | no annotations object at all | modal, same as above | not retried | full output |
Two rows in there are the whole lesson. reindex_document was retried twice because it
claimed idempotency, so the host treated the timeout as safe to repeat — a decision the host
made entirely on the strength of a boolean your server sent. And purge_tenant, which
declares nothing, was gated exactly as hard as the explicitly destructive tool, because absent
annotations default to the dangerous reading. This particular host chose these behaviours; the
same trace against a second host is a different table, which is the subject of the next
chapter.
Choosing the four booleans for a real surface
Declaring annotations accurately takes about a minute per tool once the questions are in the right order, and the order matters because three of the four fields are conditional on the first. A host that reads them is running roughly this decision:
Running a real surface through those questions produces answers that are less obvious than the field names suggest, particularly around what counts as destructive and what counts as an open world.
| Tool | readOnly | destructive | idempotent | openWorld | Why |
|---|---|---|---|---|---|
list_indexes | true | — | — | false | Reads internal state; the other three are ignored when read-only |
search_web | true | — | — | true | Reads, but the corpus is the internet and results are unverifiable |
append_note | false | false | false | false | Writes, adds only, and calling twice appends twice |
set_document_title | false | false | true | false | Overwrites one field; calling twice leaves the same state |
wipe_index | false | true | false | false | Removes data and cannot be safely repeated mid-rebuild |
send_invoice_email | false | true | false | true | Leaves your system entirely; an unsend does not exist |
append_note is the row that most often gets declared wrongly. It destroys nothing, so
destructiveHint is correctly false, and a host will therefore not gate it — which is right. It
is also not idempotent, because two identical calls produce two notes, and that boolean is the
one that decides whether a host repeats the call after a timeout. send_invoice_email shows
openWorldHint doing work that the other three cannot: the operation is bounded and well
understood on your side, and its effect lands in somebody's inbox where no rollback exists,
which is information a host might reasonably use to gate harder than the destructive flag
alone would suggest.
The reason that row is hard is that two of the booleans answer questions people habitually
merge. "Is this dangerous" and "is this safe to repeat" feel like one question, because
dangerous operations are usually also the ones you would not want happening twice. Here they
come apart cleanly. Appending a note removes nothing, so nothing is destroyed, so a host is
right not to gate it. Appending a note twice leaves two notes, so the state after the second
call differs from the state after the first, so a host is wrong to retry it. Declaring
destructiveHint: false and idempotentHint: false on one tool looks contradictory only if
the pair is being read as a single severity dial. It is not a dial: one field is about what
an operation does to what already exists, and the other is about what a second identical
call adds.
The tempting shortcut is to leave everything unset on the read-only tools, on the grounds that
nothing bad can happen. The defaults table above says what that costs: an unannotated
list_indexes reads as a destructive, non-idempotent, open-world write, and a careful host
will gate it.
Why the specification refuses to make them guarantees
The obvious question is why the protocol did not simply require clients to honour a destructive hint. The specification states the answer directly: clients must consider tool annotations to be untrusted hints unless the tool comes from a server the client trusts.
Consider the direction the data flows. The annotations are written by the server and read by
the client, so the party making the safety claim is the party whose behaviour is in question.
A server that wants to be called without friction has every incentive to declare
readOnlyHint: true on a tool that empties a bucket, and a client that treated the flag as
authoritative would suppress precisely the dialog that mattered. Making hints binding would
therefore convert a self-declared label into a security control administered by the party it
constrains, which is the shape of an escalation rather than a protection. Once you see that,
the merely-advisory design stops looking like an oversight and starts looking like the only
coherent option: the hint is the server's description of its own behaviour, useful to a host
that already trusts the server, worthless as a control against one it does not.
Setting the two directions of travel side by side is what makes the design read as inevitable rather than as a gap. The annotation travels from the server to the client, and the behaviour it describes is the server's own. So the party making the safety claim and the party whose conduct the claim is about are one party, and there is nothing else in the message a client could check the claim against — no attestation, no signature over the annotations, and no way to verify a statement about code the host cannot see. That is the property the specification is responding to when it tells clients to treat annotations from untrusted servers as untrusted, and it is a statement about who is in a position to know rather than a warning about malice.
The belief that ticket encoded deserves naming, because it is the most common misreading in
this area and it is not lazy. It is that declaring destructiveHint: true causes a
confirmation, in the way that declaring a database column NOT NULL causes a rejection.
What makes it tempting is that in the host the team uses every day it does exactly that: the
modal appears, and the causal story is confirmed on every observation anybody has made. What
makes it false is that the causation runs through a decision taken inside somebody else's
application, and a decision is not a mechanism. Nothing about any of that makes annotations
pointless — it makes them a description that some readers act on, and the difference between
a description and a mechanism is that a description has readers, and readers may decline.
Annotations are cached with the listing that carried them
Most hosts fetch tools/list once per session and keep the result for the life of the
connection, which means the annotations a host is acting on are the ones that arrived at
connection time. Change them on the server and nothing propagates until either the session
restarts or the server sends notifications/tools/list_changed and the host chooses to
re-fetch. A team that discovers a tool was mis-annotated, corrects it, and deploys will find
long-lived sessions still gating on the old values for hours.
This also describes the shape of an attack that the trust argument above is about. A server that lists a tool as read-only on Monday and re-lists it as read-only on Tuesday while the handler quietly starts writing has changed nothing a client can observe, because the only description of the tool's behaviour is the one the server writes about itself. There is no attestation, no signature over the annotations, and no way for a host to verify a claim about code it cannot see.
Authorization, validation and the model's view
Annotations are not authorization. Authorization answers "may this caller do this to this resource", takes a caller identity as input, and produces a decision inside the system that owns the resource. An annotation takes no identity, produces no decision, and lives in a listing that was rendered before anybody knew who was asking. The two are not weaker and stronger versions of the same thing; they are answers to different questions, and one of them is not being answered at all.
They are not input validation either. destructiveHint: true on wipe_index says nothing
about whether index is a name your system recognises, whether the caller is allowed to touch
that tenant's index, or whether a rebuild is already in flight. And they are not, in the main,
documentation for the model: the annotations object is sent to the client, and most hosts
consume it in their own UI and permission layer rather than forwarding it into the provider
request. A model that never sees destructiveHint cannot be more careful because of it, which
is worth remembering the next time the temptation arises to encode a safety property as a flag
rather than as a sentence in the description.
So annotations are worth declaring, worth declaring accurately, and worth declaring even on the read-only tools where the temptation is to skip them — because the absence of a declaration is itself a claim, and it is the most alarming one available. What they buy is a host that can tell a lookup from a deletion and behave differently. What they do not buy is any influence over a host that has decided otherwise, or over one that never looked.
The distinction worth carrying out of this is between describing and constraining. An annotation is a description of your handler's behaviour, offered to applications that have already chosen to trust your server, and it is worth writing accurately for the same reason a type signature is worth writing accurately. A constraint is something that holds when the other party is indifferent or hostile, and nothing in a listing entry can be one, because the listing is written by the party whose behaviour is in question.
Which leaves the question a compliant-host incident actually raises. If a hint cannot stop a call, and the application that decides is on a different release cycle — sometimes three different applications — then a rule you need enforced has to be evaluated somewhere you control.
Tools over MCP — read the current version at https://daviddominguez.dev/learn/en/tools-over-mcp/behavioural-tool-annotations