MCP Tool Annotations and Behavioral Hints
MCP tool annotations are metadata fields on a tool definition that describe how the tool behaves, separate from what the tool does. A tool's name, description, and input schema tell an agent what a tool is for. Annotations tell the agent (and the human watching over it) whether calling that tool is safe, reversible, and predictable. If you are building or auditing MCP servers in 2026, annotations are the cheapest safety lever you have, and most servers still ship without them.
This article covers the annotation fields defined in the Model Context Protocol spec, how to set them correctly on real tools, how clients like Claude Code and Claude Desktop use them today, and where they fall short so you do not over-trust them.
What MCP tool annotations actually are
Every MCP tool has a required name, description, and inputSchema. Annotations are an optional annotations object attached alongside those fields. The spec defines four standard hints:
readOnlyHint: the tool does not modify its environment.destructiveHint: the tool may perform destructive updates (only meaningful whenreadOnlyHintis false).idempotentHint: calling the tool repeatedly with the same arguments has no additional effect beyond the first call.openWorldHint: the tool interacts with an unpredictable "open world" (the public internet, a live filesystem, an external API) rather than a closed, predictable one.
There is also a title annotation, a human-friendly display name distinct from the machine-readable name, useful when your tool names are things like db_query_v2 but you want the UI to show "Query Database."
None of these fields change what the tool does. They are hints for the client and the human in the loop. The spec is explicit that they are untrusted unless the server is known and trusted, which is the single most important thing to understand before you rely on them for anything security-critical.
Why annotations exist
Before annotations, an MCP client had exactly one signal for how risky a tool call was: the free-text description. A description like "Deletes a file" is easy for a human to read but hard for a client to act on programmatically. Should the client auto-approve this call or ask for confirmation? Should it retry on timeout? Should it warn before batching ten calls in a row?
Annotations give the client a structured, machine-checkable answer to those questions. A client can implement a policy like "auto-approve anything with readOnlyHint: true, but always confirm destructiveHint: true calls" without parsing natural language. That is the whole point: annotations move tool-risk assessment out of prose and into typed metadata that a permission system, an audit log, or a human-approval UI can consume directly.
This matters more as agents chain more tool calls autonomously. A single-tool workflow where a human reviews every call does not need this. A multi-step agent loop that might call fifteen tools in a row across three MCP servers absolutely does.
Defining annotations in a TypeScript server
Using the official TypeScript SDK (@modelcontextprotocol/sdk), annotations are set when you register a tool:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
const server = new McpServer({
name: "inventory-tools",
version: "1.0.0",
});
server.registerTool(
"get_stock_level",
{
title: "Get Stock Level",
description: "Returns the current stock count for a SKU.",
inputSchema: { sku: z.string() },
annotations: {
readOnlyHint: true,
idempotentHint: true,
openWorldHint: false,
},
},
async ({ sku }) => {
const level = await lookupStock(sku);
return {
content: [{ type: "text", text: `Stock for ${sku}: ${level}` }],
};
}
);
server.registerTool(
"delete_stale_listings",
{
title: "Delete Stale Listings",
description: "Permanently removes listings not updated in 90+ days.",
inputSchema: { olderThanDays: z.number().default(90) },
annotations: {
readOnlyHint: false,
destructiveHint: true,
idempotentHint: false,
openWorldHint: false,
},
},
async ({ olderThanDays }) => {
const removed = await purgeStaleListings(olderThanDays);
return {
content: [{ type: "text", text: `Removed ${removed} listings.` }],
};
}
);Notice the difference between the two. get_stock_level is read-only and idempotent: calling it a hundred times in a row is harmless, and a client can safely cache or auto-approve it. delete_stale_listings is neither: two calls in a row could delete different things depending on what has changed, and the operation cannot be undone. Marking idempotentHint: false here is deliberate. It would be wrong to mark it true just because passing the same olderThanDays twice "does the same kind of thing" , the actual rows deleted differ each time because state changed between calls.
Defining annotations in a Python server
The Python SDK (mcp) exposes the same fields through ToolAnnotations:
from mcp.server.fastmcp import FastMCP
from mcp.types import ToolAnnotations
mcp = FastMCP("inventory-tools")
@mcp.tool(
annotations=ToolAnnotations(
readOnlyHint=True,
idempotentHint=True,
openWorldHint=False,
)
)
def get_stock_level(sku: str) -> str:
"""Returns the current stock count for a SKU."""
level = lookup_stock(sku)
return f"Stock for {sku}: {level}"
@mcp.tool(
annotations=ToolAnnotations(
readOnlyHint=False,
destructiveHint=True,
idempotentHint=False,
openWorldHint=False,
)
)
def delete_stale_listings(older_than_days: int = 90) -> str:
"""Permanently removes listings not updated in N days. Irreversible."""
removed = purge_stale_listings(older_than_days)
return f"Removed {removed} listings."The pattern is identical across SDKs because the annotations live in the protocol, not in any one language binding. If you write servers in Go, Rust, or Java against the spec directly, you are setting the same four booleans plus title inside the tool's annotations object in the JSON-RPC tools/list response.
Reading the four hints correctly
Getting these right requires thinking about the tool from the caller's perspective, not the implementer's.
`readOnlyHint`: set true only if the tool cannot change any state an outside observer would notice. A tool that writes to an internal cache for performance but has no externally visible side effect is a judgment call; most servers still mark it read-only if the cache is purely an implementation detail. A tool that sends an email, even a "read receipt" email, is not read-only.
`destructiveHint`: only evaluated when readOnlyHint is false. It answers "can this remove or overwrite something the user would want back?" A tool that appends rows to a log is not destructive. A tool that overwrites a file, cancels an order, or hard-deletes a record is. When in doubt, mark it destructive: the cost of an unnecessary confirmation prompt is far lower than the cost of a silent, unconfirmed deletion.
`idempotentHint`: answers "if the client retries this call after a timeout, is that safe?" This is the hint most often set wrong. A POST /orders style "create a new order" call is not idempotent, calling it twice creates two orders. A PUT /orders/123 {status: "shipped"} style "set status to shipped" call is idempotent, calling it twice leaves the same end state. If your tool wraps a network call that might legitimately need a retry on failure, get this one right, because clients may use it to decide whether an automatic retry is safe without asking the human first.
`openWorldHint`: answers "does this tool's behavior depend on something outside your control?" A calculator tool is closed-world: given the same input, it always returns the same output. A web search tool, a stock price tool, or a "read the latest file in this directory" tool is open-world: results can differ between calls even with identical arguments, and the tool can fail in ways the server author cannot fully enumerate. This hint helps a client set expectations about non-determinism and about caching tool results.
How clients use annotations today
Annotation support is still uneven across MCP clients, and this is the part worth being honest about. Some clients surface destructiveHint and readOnlyHint in their permission-approval UI, showing a stronger warning or requiring explicit confirmation before a destructive call executes. Others use readOnlyHint to decide whether a tool call can run without interrupting the user at all, similar to how a read-only Bash command might be auto-approved while a write command prompts.
Fewer clients currently act on idempotentHint for retry logic or on openWorldHint for caching decisions, though both are natural places for that logic to live as tooling matures. When you are building an MCP server, set all four hints correctly regardless of what a given client does with them today. You are describing the tool's actual behavior once, and every client that adds support later benefits without you touching the server again.
The trust boundary: annotations are hints, not guarantees
The spec is direct about this, and it is worth repeating because it changes how you should build on top of annotations: a malicious or careless server can set readOnlyHint: true on a tool that deletes data. The client has no way to verify the annotation matches the tool's real implementation, because the implementation is opaque to the client, it runs on the server.
The practical implications:
- Do not use annotations as the sole gate for auto-executing high-risk operations against servers you have not vetted. Treat annotations from an unknown or third-party MCP server as advisory, and keep a human-in-the-loop confirmation step for anything that looks destructive regardless of what the server claims.
- For first-party servers you write and control, annotations are trustworthy because you are the one asserting them, and you have direct incentive to get them right. This is the strongest use case: annotate your own internal tools accurately, and any client (yours or a teammate's) that connects gets a safer default experience for free.
- Treat annotations as one signal among several, alongside tool description review, scoped credentials, and server provenance, not as a replacement for any of them.
A worked example: annotating a database MCP server
Say you are exposing a Postgres-backed MCP server with four tools. Here is how the annotations should look and why:
run_select_query
readOnlyHint: true
destructiveHint: false
idempotentHint: true
openWorldHint: false
insert_row
readOnlyHint: false
destructiveHint: false
idempotentHint: false
openWorldHint: false
update_row_by_id
readOnlyHint: false
destructiveHint: false
idempotentHint: true
openWorldHint: false
drop_table
readOnlyHint: false
destructiveHint: true
idempotentHint: true
openWorldHint: falserun_select_query is read-only and idempotent by nature of SQL SELECT. insert_row is not idempotent, running it twice creates two rows. update_row_by_id is idempotent because setting the same column values on the same row twice leaves the database in the same state. drop_table is destructive but technically idempotent, dropping an already-dropped table is a no-op (or a harmless error), so idempotentHint: true is defensible even though the operation is catastrophic the first time. This is exactly the case where destructiveHint earns its keep: idempotency alone would tell a client "safe to retry," but destructiveness tells it "confirm with a human before running this at all."
None of these tools got openWorldHint: true because a well-configured Postgres connection behaves predictably: the same query against the same data returns the same result, and failures are enumerable (connection error, constraint violation, permission denied) rather than the open-ended failure modes of, say, scraping a live website.
Common mistakes to avoid
Marking everything readOnlyHint: false "to be safe" defeats the purpose. If a client de-prioritizes or always confirms non-read-only tools, an overly conservative server trains users to click through confirmations without reading them, which is worse than no annotations at all.
Marking a tool idempotentHint: true because "it usually doesn't matter if you call it twice" is a common trap. Usually is not always, and the hint exists precisely for the automatic-retry case where "usually" fails silently.
Forgetting destructiveHint on tools that overwrite files, cancel subscriptions, or send irreversible external communications (an email, a webhook, an SMS) is the highest-impact mistake, since this is the annotation clients are most likely to act on today by inserting a confirmation step.
Setting annotations once and never revisiting them as the tool's implementation changes. If update_row_by_id later gets a code path that also triggers a downstream webhook with side effects, the annotations need to be reviewed again, not left as they were when the tool was simpler.
FAQ
What is the difference between a tool's description and its annotations? The description is free-text meant for the model and a human reader to understand what the tool does and how to call it correctly. Annotations are structured, typed metadata meant for the client application to make programmatic decisions, like whether to auto-approve a call or warn before executing it. A good tool needs both: a clear description for correct usage and accurate annotations for safe usage.
Are MCP tool annotations required? No, all annotation fields are optional. A tool with no annotations object is valid and will work, but the client has no structured signal about its risk profile and will typically fall back to treating it as unknown or, depending on the client's default policy, as potentially unsafe. Adding accurate annotations is low effort relative to the safety benefit, so there is little reason to skip it for production servers.
Can a client trust `readOnlyHint` from any MCP server it connects to? Only as much as it trusts the server itself. The spec explicitly warns that annotations are hints and should be treated as untrusted unless the server is known and trusted, because nothing stops a server from mislabeling a destructive tool as read-only. Vet third-party servers before connecting them, and keep confirmation prompts in place for high-risk operations even when a server claims they are safe.
Does `idempotentHint` mean the tool has no side effects? No. Idempotent means repeated calls with the same arguments produce the same end state, not that the tool has zero effect. update_row_by_id has a side effect (it changes a row) but is idempotent because calling it twice with identical arguments leaves the row in the same state as calling it once. Contrast with readOnlyHint, which does mean no side effects at all.
How do annotations interact with MCP's separate consent and authorization mechanisms? They are complementary layers, not substitutes for each other. OAuth-based authorization controls whether a client can call a server's tools at all. Human-in-the-loop consent, which the spec requires for tool invocations, controls whether a specific call is approved. Annotations feed into how that consent step is presented, for example by triggering a stronger warning for destructiveHint: true tools, but they do not replace the consent step itself.
Should I annotate internal, single-user MCP servers the same way as public ones? Yes, and arguably it matters more, not less. A server used only by you might tempt you to skip annotations since you already "know" which tools are dangerous. But the moment you add a second agent, share the server with a teammate, or start chaining it into a longer autonomous workflow, accurate annotations are what let the client's confirmation logic protect you from your own agent's mistakes instead of relying on you remembering every tool's risk profile from memory.
BootcampA 30-day guided bootcamp: build, harden and ship a production autonomous agent from scratch.
AI AgentsUnderstand how AI agents really work: the loop, the tools, the memory, and why most agent projects fail.