Guide · MCP Protocol
MCP tool annotations
MCP tool annotations (also called hints) tell clients what a tool does to the world — whether it's safe to call without confirmation, whether it might destroy data, whether calling it twice is safe, and whether it affects systems outside your server. Clients use these hints to decide which tools to auto-approve and which to show a confirmation dialog. Well-annotated tools improve user experience and reduce unnecessary friction in automated workflows.
TL;DR
Add annotations via the annotations field on the tool definition object. The four behavioral hints are: readOnlyHint: true (no writes, safe to auto-call), destructiveHint: true (may delete or irreversibly modify data), idempotentHint: true (repeated calls produce the same result), and openWorldHint: true (has side effects outside your server). Set title for a human-readable display name. Annotations are hints — they do not enforce behavior. The client decides whether to trust them. Default values: readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true.
Why annotations matter
Without annotations, every tool call is ambiguous to the client — a tool named search_files looks the same as delete_files from the protocol's perspective. Clients that implement confirmation dialogs must either confirm every tool call (maximum friction) or auto-approve everything (maximum risk). Annotations let you declare the behavioral contract so clients can enforce appropriate policies.
Practically, this affects:
- Agentic workflows — an LLM agent running autonomously can call
readOnlyHinttools in a loop without user intervention; it pauses fordestructiveHinttools - Retry logic — clients can safely retry
idempotentHinttools on network failure; they cannot safely retry non-idempotent mutations - Audit logging — clients log
destructiveHinttool calls with higher severity for security review - UI display — the
titleannotation provides a user-facing label that is better than a snake_case function name
Adding annotations in the SDK
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
const server = new McpServer({ name: 'my-server', version: '1.0.0' });
// Safe read-only tool
server.tool(
'search_files',
'Search for files matching a pattern',
{ pattern: z.string() },
{
title: 'Search Files',
readOnlyHint: true, // reads only, no writes
openWorldHint: false, // no external side effects
},
async ({ pattern }) => {
const results = await globFiles('.', pattern);
return { content: [{ type: 'text', text: results.join('\n') }] };
}
);
// Destructive tool
server.tool(
'delete_file',
'Permanently delete a file from the filesystem',
{ path: z.string() },
{
title: 'Delete File',
destructiveHint: true, // irreversible
idempotentHint: false, // second call may error (file already gone)
openWorldHint: false, // local filesystem only
},
async ({ path: filePath }) => {
await fs.unlink(filePath);
return { content: [{ type: 'text', text: `Deleted: ${filePath}` }] };
}
);
Annotation reference
| Annotation | Type | Default | Meaning |
|---|---|---|---|
title |
string |
— | Human-readable display name for UI (e.g. "Search Files" instead of search_files) |
readOnlyHint |
boolean |
false |
Tool does not modify any state. Safe to auto-call, safe to retry, no confirmation needed. |
destructiveHint |
boolean |
true |
Tool may perform irreversible actions (delete, overwrite, send). Client should require confirmation. |
idempotentHint |
boolean |
false |
Calling N times with the same arguments produces the same result as calling once. Safe to retry on failure. |
openWorldHint |
boolean |
true |
Tool has side effects outside your server (sends email, calls external API, writes to external service). |
Note the default for destructiveHint is true — if you don't annotate a tool, clients assume it might be destructive. Annotating your non-destructive tools explicitly is valuable even when you don't care about the destructive ones.
Annotation combinations in practice
Common tool archetypes and their correct annotation sets:
| Tool type | readOnly | destructive | idempotent | openWorld |
|---|---|---|---|---|
| File/DB read query | ✓ true | false | ✓ true | false |
| Search / list | ✓ true | false | ✓ true | false |
| HTTP GET to external API | ✓ true | false | ✓ true | ✓ true |
| Upsert / idempotent write | false | false | ✓ true | false |
| Append / create (non-destructive) | false | false | false | false |
| Delete / overwrite | false | ✓ true | false | false |
| Send email / send Slack message | false | ✓ true | false | ✓ true |
| Trigger CI/CD deploy | false | ✓ true | false | ✓ true |
How clients use annotations
Annotation handling is client-specific. Common patterns in production MCP clients:
- Claude Desktop — shows a permission dialog for any tool call (first time per tool per server) but may auto-approve subsequent calls to
readOnlyHinttools - Cursor / Windsurf — use annotations to label tools in the UI as "Safe" vs "Requires confirmation"
- Custom LLM agents — check
readOnlyHintbefore including a tool in an auto-execution loop; alldestructiveHinttools require a separate approval step - Retry middleware — retries tool calls on network failure only if
idempotentHint: true
Annotations are advisory — a client may ignore them. Never rely on annotations as a security control. If a tool must only be called by certain users, enforce that with authentication and RBAC, not annotations.
Progress notifications and cancellation
Annotations work alongside progress notifications. A long-running destructive tool should both declare destructiveHint: true and emit progress updates so users can see what's happening and cancel if needed.
server.tool(
'migrate_database',
'Run all pending database migrations',
{ dryRun: z.boolean().default(false) },
{
title: 'Run Database Migrations',
destructiveHint: true,
idempotentHint: false,
openWorldHint: false,
},
async ({ dryRun }, context) => {
const migrations = await getPendingMigrations();
for (let i = 0; i < migrations.length; i++) {
// Check for client-side cancellation
if (context.signal?.aborted) {
return {
content: [{ type: 'text', text: 'Migration cancelled by client.' }],
isError: true,
};
}
// Send progress notification
await context.sendProgress({
progress: i,
total: migrations.length,
description: `${dryRun ? '[DRY RUN] ' : ''}Running: ${migrations[i].name}`,
});
if (!dryRun) {
await migrations[i].run();
}
}
return {
content: [{
type: 'text',
text: `${dryRun ? 'Dry run complete' : 'Migrations complete'}: ${migrations.length} migrations processed.`,
}],
};
}
);
Annotations are not a security boundary
A malicious client can ignore annotations entirely. They communicate intent to well-behaved clients — they do not enforce access control. For tools that are genuinely dangerous (deleting production data, sending external communications, triggering deploys), implement explicit guards:
- Require authenticated sessions with the right role (RBAC)
- Validate inputs strictly before executing destructive operations
- Log all calls to destructive tools with user identity for audit
- Return
isError: truewith a clear message when a destructive tool is called outside allowed conditions
Further reading
- MCP tool design — naming, argument schemas, and return shapes
- MCP server resources API — expose structured data to LLM clients
- MCP server prompts API — reusable prompt templates
- MCP server authentication — securing tool and resource access
- MCP server RBAC — role-based access control
- MCP server input validation — Zod schemas and boundary checks
- MCP server error handling — isError vs protocol errors
- MCP server JSON-RPC — protocol messages and lifecycle
- AliveMCP — uptime monitoring for HTTP-deployed MCP servers