MCP Tool Annotations: readOnlyHint, destructiveHint and More
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.
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.
| Field | Default | Meaning |
|---|---|---|
title | none | A human-readable name for the tool. Not a hint. |
readOnlyHint | false | The tool does not modify its environment. |
destructiveHint | true | The tool may perform destructive updates. false means it only adds. |
idempotentHint | false | Calling it again with the same arguments has no further effect. |
openWorldHint | true | The 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
destructiveHintandopenWorldHintunset 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
destructiveHintandidempotentHintare meaningful only whenreadOnlyHintisfalse. 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 does | readOnlyHint | destructiveHint | idempotentHint | openWorldHint |
|---|---|---|---|---|
| Lists, looks up or searches your own data | true | false | true | false |
| Adds a record and removes nothing | false | false | false | false |
| Deletes or overwrites data | false | true | true if a repeat call changes nothing | false |
| Posts to a public site or sends a message to someone outside your system | false | depends on whether it can be undone | false | true |
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.
- Python
- TypeScript
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.
#!/usr/bin/env node
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { McpServer } from "@modelcontextprotocol/server";
import { serveStdio } from "@modelcontextprotocol/server/stdio";
import { z } from "zod";
const NOTES_FILE =
process.env.NOTES_FILE ?? join(homedir(), ".notes-mcp.json");
interface Note {
id: number;
title: string;
body: string;
}
function loadNotes(): Note[] {
if (!existsSync(NOTES_FILE)) return [];
return JSON.parse(readFileSync(NOTES_FILE, "utf8")) as Note[];
}
function saveNotes(notes: Note[]): void {
writeFileSync(NOTES_FILE, JSON.stringify(notes, null, 2));
}
function text(value: string) {
return { content: [{ type: "text" as const, text: value }] };
}
// serveStdio calls this whenever it needs a server instance,
// so it must return a new McpServer every time.
function createServer() {
const server = new McpServer({ name: "notes", version: "1.0.0" });
// Reads only. Nothing changes, so clients can skip the confirmation prompt.
server.registerTool(
"list_notes",
{
description: "List every saved note with its id and title.",
annotations: {
title: "List notes",
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
},
async () => {
const notes = loadNotes();
if (notes.length === 0) return text("No notes saved.");
return text(notes.map((n) => `${n.id}: ${n.title}`).join("\n"));
},
);
// Writes, but only adds. Nothing is removed or overwritten.
server.registerTool(
"add_note",
{
description:
"Save a new note. Use this when the user asks to write something down.",
inputSchema: z.object({
title: z.string().describe("Short title for the note"),
body: z.string().describe("Text of the note"),
}),
annotations: {
title: "Add note",
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
openWorldHint: false,
},
},
async ({ title, body }) => {
const notes = loadNotes();
const nextId = notes.reduce((max, n) => Math.max(max, n.id), 0) + 1;
notes.push({ id: nextId, title, body });
saveNotes(notes);
console.error(`Added note ${nextId}`);
return text(`Added note ${nextId}: '${title}'.`);
},
);
// Deletes data. destructiveHint: true tells clients to ask first.
server.registerTool(
"delete_note",
{
description: "Permanently delete a note by id. This cannot be undone.",
inputSchema: z.object({
note_id: z
.number()
.int()
.describe("Id of the note to delete, from list_notes"),
}),
annotations: {
title: "Delete note",
readOnlyHint: false,
destructiveHint: true,
idempotentHint: true,
openWorldHint: false,
},
},
async ({ note_id }) => {
const notes = loadNotes();
const remaining = notes.filter((n) => n.id !== note_id);
if (remaining.length === notes.length) {
return text(`No note with id ${note_id}.`);
}
saveNotes(remaining);
console.error(`Deleted note ${note_id}`);
return text(`Deleted note ${note_id}.`);
},
);
return server;
}
// Logs go to stderr. Anything written to stdout corrupts the stdio transport.
serveStdio(createServer, {
onerror: (error) => console.error("Notes server error:", error),
});
console.error("Notes MCP server running on stdio");
In TypeScript, pass the hints in the annotations field of the registerTool config. The names are already camelCase, and they match the wire format exactly.
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.
| App | What it does with the hints | Source |
|---|---|---|
| Claude connectors | Read-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 SDK | readOnlyHint lets Claude run the tool in parallel with other read-only tools. The other hints are informational. | Claude Agent SDK custom tools |
| VS Code | Tools 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.
- Tool names and descriptionsWrite names and descriptions that lead the model to the right tool.
- Tool input schemasDescribe parameters so the model fills them in correctly.
- Tool errorsReturn errors the model can recover from.
- MCP security best practicesThe threats to plan for, and the checklist to work through.
- Sandbox local serversLimit what a local server can reach, whatever its annotations say.