Short answer: to add tool annotations in the official TypeScript SDK, pass an annotations object inside the config of registerTool; in Python FastMCP, pass annotations={...} to the @mcp.tool decorator. You only declare the hints that differ from the defaults, because the defaults — readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true — are deliberately cautious. So a safe read-only tool declares two booleans to shed the confirmation dialog, a write tool usually declares nothing, and you should think hard before ever touching idempotentHint.
Here is the whole thing in one screen.
TypeScript (official SDK)#
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
const server = new McpServer({ name: "files", version: "1.0.0" });
server.registerTool(
"search_docs",
{
title: "Search Docs",
description: "Full-text search over the local docs index.",
inputSchema: z.object({ query: z.string() }),
annotations: {
readOnlyHint: true, // reads, never writes -> flip from default false
openWorldHint: false, // stays inside a closed corpus -> flip from default true
},
},
async ({ query }) => ({
content: [{ type: "text", text: await runSearch(query) }],
})
);
That is the complete change: the annotations object as the third key of the config. The two hints you set move this tool from "assume the worst" to "read-only and bounded," which is what lets a trusted client skip the are-you-sure dialog.
Python (FastMCP)#
from fastmcp import FastMCP
mcp = FastMCP("files")
@mcp.tool(annotations={"readOnlyHint": True, "openWorldHint": False})
def search_docs(query: str) -> str:
"""Full-text search over the local docs index."""
return run_search(query)
FastMCP has accepted the annotations argument since 2.2.7. Pass a plain dict as above, or import ToolAnnotations from mcp.types if you want the type checker's help. On the lower-level mcp SDK you set annotations=types.ToolAnnotations(...) on the types.Tool you return from your list_tools handler — more boilerplate, same four fields.
Declare the difference, not the obvious#
The one rule that saves you work: only set the hints that differ from the defaults. The defaults are pessimistic by design — a tool that says nothing is treated as write-capable, destructive, non-idempotent, and open-world, so silence gets the most guarded handling, not the least. (If that framing is new, the companion piece — MCP Tool Annotations, Explained — walks through why the defaults run the way they do.)
In practice that means:
- A read-only, closed-domain tool (a search, a lookup, a calculator) is the big win: set
readOnlyHint: trueandopenWorldHint: falseand it stops triggering a confirmation on every call. - A write tool (create, update, delete) usually needs nothing — the defaults already describe it as destructive and worth a prompt. Add a clear
descriptioninstead; that does more for the model than any boolean. - A read-only tool that hits the network (a web search, a third-party API read) keeps
readOnlyHint: truebut leavesopenWorldHintat its defaulttrue— it reads nothing local yet reaches an unbounded set of entities, and that is its own risk the client should see.
Two gotchas that bite in review#
destructiveHint only means anything when readOnlyHint is false. It is a sub-qualifier of writes — destructive (overwrite, delete) versus additive (append). Setting it on a read-only tool is noise; clients ignore it there. Don't let it into your read-only tools in a copy-paste.
idempotentHint: true is the one to fear. It is a gift to a client's retry logic — a timed-out call it can safely replay without inventing a dedupe scheme. But a timeout is ambiguous: the call may have already succeeded and only the ack got lost. If you marked a create_charge tool idempotent because it returns the same value, and it isn't idempotent in effect, the clean retry just billed the card twice. The hint can tell a client whether to bother asking before a retry. It cannot make the retry safe. That guarantee comes from a real idempotency key you mint and honor at the tool boundary — never from a boolean.
Verify it actually shipped#
Annotations are easy to write and easy to silently drop. Confirm they serialize: start the server, call tools/list from any client (or the MCP Inspector), and read the annotations block back off the tool's JSON, right next to name and inputSchema. If it's there, a trusted client can use it; if it's missing, you probably nested it one level off.
One last thing to keep straight, because it is the mistake that turns a convenience into an incident: these are hints for a server you already trust, consumed to shape a dialog. They are not a permission model. An untrusted server can stamp readOnlyHint: true on a tool that quietly deletes your working directory — which is why the real boundary stays in sandboxing, egress rules, and auth, and why a poisoned third-party server is never stopped by a boolean it got to set. Declare your own tools honestly so good clients can be humane. Trust other people's annotations exactly as far as you trust the server that sent them.



