Skip to content
Chapter 3 of 8 · Tools over MCPBehavioural tool annotations

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:

python
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:

json
{
  "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
  }
}
FieldTypeDefault when absentWhat it assertsWho reads it
titlestringtool nameA human-readable label for UIHost UI, sometimes forwarded to the model
readOnlyHintbooleanfalseThe tool does not modify its environmentHost: whether to gate at all
destructiveHintbooleantrueUpdates may be destructive rather than purely additiveHost: whether to gate harder
idempotentHintbooleanfalseRepeated calls with the same arguments have no additional effectHost: whether a retry is safe
openWorldHintbooleantrueThe tool touches an open external world rather than a closed oneHost: 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.

python
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())
text
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: False

The 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:

ToolAnnotations declaredConfirmation observedRetry on timeout observedResult written to the transcript
list_indexesreadOnlyHint: truenoneretried once, silentlyfull output
reindex_documentwrites, idempotentHint: truenoneretried twice on a 30 s timeoutfull output
wipe_indexwrites, destructiveHint: truemodal, "Allow once / Always allow / Deny"not retriedfull output
purge_tenantno annotations object at allmodal, same as abovenot retriedfull 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:

Flowchart: tool selected → readOnlyHint?; readOnlyHint? → true; true → run, no dialog; readOnlyHint? → false; false → destructiveHint?; destructiveHint? → true; true → modal confirmation; destructiveHint? → false; false → inline notice or nothing; modal confirmation → idempotentHint?; inline notice or nothing → idempotentHint?; idempotentHint? → true; true → retry on timeout; idempotentHint? → false; false → surface the failure.

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.

ToolreadOnlydestructiveidempotentopenWorldWhy
list_indexestrue——falseReads internal state; the other three are ignored when read-only
search_webtrue——trueReads, but the corpus is the internet and results are unverifiable
append_notefalsefalsefalsefalseWrites, adds only, and calling twice appends twice
set_document_titlefalsefalsetruefalseOverwrites one field; calling twice leaves the same state
wipe_indexfalsetruefalsefalseRemoves data and cannot be safely repeated mid-rebuild
send_invoice_emailfalsetruefalsetrueLeaves 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

Back to top