Skip to main content

MCP Tool Annotations: readOnlyHint, destructiveHint and More

Short answer

Tool annotations are an optional annotations object on each tool. It holds four true or false hints, readOnlyHint, destructiveHint, idempotentHint and openWorldHint, plus a display title. Apps use the hints to decide things like whether to ask you before a tool runs. Set them on every tool, because the defaults assume the worst. Treat them as hints, not guarantees: the spec says clients must treat them as untrusted unless they come from a trusted server, so they don't replace authorization or sandboxing.

Tested withMCP spec 2026-07-28Python SDK (mcp) 2.3.0TypeScript SDK (@modelcontextprotocol/server) 2.3.1MCP Inspector 2.11.0Checked 20 Oct 2026

What you'll have at the end​

A small notes server with three tools (list_notes, add_note and delete_note), each with annotations you chose on purpose. This is the entry the MCP Inspector prints for delete_note after you run the test in step 4:

{
"name": "delete_note",
"description": "Permanently delete a note by id. This cannot be undone.",
"inputSchema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"note_id": {
"type": "integer",
"minimum": -9007199254740991,
"maximum": 9007199254740991,
"description": "Id of the note to delete, from list_notes"
}
},
"required": [
"note_id"
]
},
"annotations": {
"title": "Delete note",
"readOnlyHint": false,
"destructiveHint": true,
"idempotentHint": true,
"openWorldHint": false
}
}

Before you start

  • Python: Python 3.10 or later and uv.
  • TypeScript: Node.js 22.19 or later and npm. MCP Inspector 2.11.0 requires Node 22.19 or later.
  • A server to annotate. If you don't have one, build the small server in How to build an MCP server first. The example here is self-contained.

1. Know what each hint means​

The annotation fields come from the ToolAnnotations type in the 2026-07-28 spec. All four hints are booleans, and each has a default you get when you leave it out.

FieldDefaultMeaning
titlenoneA human-readable name for the tool. Not a hint.
readOnlyHintfalseThe tool does not modify its environment.
destructiveHinttrueThe tool may perform destructive updates. false means it only adds.
idempotentHintfalseCalling it again with the same arguments has no further effect.
openWorldHinttrueThe tool may interact with external entities. false means its domain is closed.

Two details trip people up:

  • The defaults are pessimistic. A tool with no annotations is treated as possibly writing, possibly destroying data and possibly reaching the outside world. The MCP team puts it this way: "The spec assumes the worst until told otherwise." If you leave destructiveHint and openWorldHint unset on a tool that only reads a closed data set, clients will still treat it as risky.
  • Two hints only apply to writes. The spec says destructiveHint and idempotentHint are meaningful only when readOnlyHint is false. Setting them on a read-only tool does nothing.

2. Pick the hints for each tool​

Work through what the tool does to the world outside the server, then set the hints to match. The OpenAI Apps SDK reference gives the same logic from the other side: set readOnlyHint to true only for tools that retrieve or compute information without changing anything, and set openWorldHint to false for tools that only affect a bounded target.

What the tool doesreadOnlyHintdestructiveHintidempotentHintopenWorldHint
Lists, looks up or searches your own datatruefalsetruefalse
Adds a record and removes nothingfalsefalsefalsefalse
Deletes or overwrites datafalsetruetrue if a repeat call changes nothingfalse
Posts to a public site or sends a message to someone outside your systemfalsedepends on whether it can be undonefalsetrue

The openWorldHint row follows the OpenAI reference. It says to leave openWorldHint as true for tools that can write to arbitrary URLs, files or resources, and to set it to false for tools that only affect a bounded target, such as "update a task by id" in your own product.

3. Set the annotations in your server​

The example server has three tools that cover the table above. Each tool's annotations sit next to its definition, and each one sets all four hints and a title, even when the value matches the default. Explicit values make the server's intent visible to anyone who reads the code.

notes.py
import json
import logging
import os
from pathlib import Path
from typing import Annotated

from mcp.server import MCPServer
from mcp.types import ToolAnnotations
from pydantic import Field

# Logs go to stderr. Anything written to stdout corrupts the stdio transport.
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("notes")

NOTES_FILE = Path(os.environ.get("NOTES_FILE", Path.home() / ".notes-mcp.json"))

mcp = MCPServer("notes")


def load_notes() -> list[dict]:
if not NOTES_FILE.exists():
return []
return json.loads(NOTES_FILE.read_text())


def save_notes(notes: list[dict]) -> None:
NOTES_FILE.write_text(json.dumps(notes, indent=2))


# Reads only. Nothing changes, so clients can skip the confirmation prompt.
@mcp.tool(
annotations=ToolAnnotations(
title="List notes",
read_only_hint=True,
destructive_hint=False,
idempotent_hint=True,
open_world_hint=False,
)
)
def list_notes() -> str:
"""List every saved note with its id and title."""
notes = load_notes()
if not notes:
return "No notes saved."
return "\n".join(f"{n['id']}: {n['title']}" for n in notes)


# Writes, but only adds. Nothing is removed or overwritten.
@mcp.tool(
annotations=ToolAnnotations(
title="Add note",
read_only_hint=False,
destructive_hint=False,
idempotent_hint=False,
open_world_hint=False,
)
)
def add_note(
title: Annotated[str, Field(description="Short title for the note")],
body: Annotated[str, Field(description="Text of the note")],
) -> str:
"""Save a new note. Use this when the user asks to write something down."""
notes = load_notes()
next_id = max((n["id"] for n in notes), default=0) + 1
notes.append({"id": next_id, "title": title, "body": body})
save_notes(notes)
logger.info("Added note %d", next_id)
return f"Added note {next_id}: '{title}'."


# Deletes data. destructive_hint=True tells clients to ask first.
@mcp.tool(
annotations=ToolAnnotations(
title="Delete note",
read_only_hint=False,
destructive_hint=True,
idempotent_hint=True,
open_world_hint=False,
)
)
def delete_note(
note_id: Annotated[int, Field(description="Id of the note to delete, from list_notes")],
) -> str:
"""Permanently delete a note by id. This cannot be undone."""
notes = load_notes()
remaining = [n for n in notes if n["id"] != note_id]
if len(remaining) == len(notes):
return f"No note with id {note_id}."
save_notes(remaining)
logger.info("Deleted note %d", note_id)
return f"Deleted note {note_id}."


if __name__ == "__main__":
mcp.run(transport="stdio")

Python's ToolAnnotations is in mcp.types. Its keyword arguments use snake_case, such as read_only_hint=True. The SDK sends them to clients as readOnlyHint and the other camelCase names, which you can see in the output in step 4.

The list_notes tool sets readOnlyHint to true, so clients that honor the hint can run it without asking. The delete_note tool sets destructiveHint to true, so clients that honor it can ask first. add_note falls between the two: it writes, but nothing is removed, so destructiveHint is false.

Keep the description and the annotations in step. If a tool says "read only" in its description and its handler writes to a file, the hint is wrong and the description misleads the model. Tool names and descriptions covers the description side.

4. Check what clients receive​

Run the server through MCP Inspector and list its tools. The Inspector sends tools/list the same way an app does. From the python folder of the example, run:

npx @modelcontextprotocol/inspector --cli uv run notes.py --protocol-era modern --method tools/list

For TypeScript, run npm run build in the typescript folder, then replace uv run notes.py with node build/index.js. Look for an annotations block on each tool. The hints should match the table you chose in step 2. If a tool shows no annotations block, the server isn't setting them, and clients fall back to the spec defaults from step 1.

The example folder includes a test.sh that runs this check in both protocol eras and asserts each hint value. It also calls the three tools to make sure they work.

5. See how apps use the hints​

Apps read these hints differently, and none of them documents every case. The table covers what each vendor has documented.

AppWhat it does with the hintsSource
Claude connectorsRead-only tools can run without a per-call confirmation. Destructive tools always prompt. Every tool in a connector directory listing needs a title and a readOnlyHint or destructiveHint.Claude connector review criteria
Claude Agent SDKreadOnlyHint lets Claude run the tool in parallel with other read-only tools. The other hints are informational.Claude Agent SDK custom tools
VS CodeTools marked readOnlyHint run without a confirmation. Every other tool shows a confirmation dialog where the user can edit the input.VS Code MCP developer guide
ChatGPT apps (OpenAI Apps SDK)The reference lists readOnlyHint, destructiveHint and openWorldHint as required. A destructive tool tells the host to ask for explicit approval first.Apps SDK reference

Test each app you target. The MCP team notes that clients vary in how strictly they honor the defaults, and that most clients still treat installing a server as the trust decision.

Common misconceptions​

"readOnlyHint: true makes a tool safe." It makes the tool's behavior easier for clients to read. It doesn't stop the tool from doing anything. The MCP team's example: "A server can claim readOnlyHint: true and delete your files anyway." The Claude Agent SDK docs put it the same way: "Annotations are metadata, not enforcement."

"Annotations stop prompt injection." They don't. The model can still follow instructions hidden in a tool result. Annotations can help a client decide when to ask, but they don't change how the model reads content. For that, see prompt injection.

"Once I set the hints, the server enforces them." The server doesn't enforce anything. If you need a guarantee that a tool can't delete files or reach the internet, use controls outside the tool: the authorization that limits what the token can do, and sandboxing that limits what the process can reach.

"A tool's risk is only its own." It depends on the whole session. The MCP team's example is a search_emails tool that "isn't safe or dangerous on its own" because the risk depends on what other tools the agent has. An annotation on one tool can't describe that combination.

What's new in the spec​

The 2026-07-28 schema defines the four hints and title covered in step 1, and nothing else. In March 2026 the MCP team reported five open proposals for more annotations, including secretHint and unsafeOutputHint. None of them are in the spec, so don't rely on them.

FAQ​

Do I have to set tool annotations?

No. The spec makes them optional. But the defaults assume the worst, and some directories require them. Claude's connector directory asks for a title and a readOnlyHint or destructiveHint on every tool, and the OpenAI Apps SDK reference lists three hints as required.

Does the title annotation replace the tool name?

No. The name is what the model calls, so it stays in the code. The title is a display name for people. The spec uses title first, then annotations.title, then the name.

Can annotations stop a client from calling a tool?

Not on their own. A client may ask you to confirm before a tool runs, but the server still runs whatever it is asked to do. To limit what a tool can do, use authorization scopes and sandboxing.

Should I set openWorldHint to false on a tool that only uses my own database?

Yes, if the tool only works inside a closed system. The OpenAI reference gives "update a task by id" in your own product as an example of a tool that should be false. Leave it true for tools that can write to any URL, file or outside service.