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:

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

AnnotationTypeDefaultMeaning
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 typereadOnlydestructiveidempotentopenWorld
File/DB read query✓ truefalse✓ truefalse
Search / list✓ truefalse✓ truefalse
HTTP GET to external API✓ truefalse✓ true✓ true
Upsert / idempotent writefalsefalse✓ truefalse
Append / create (non-destructive)falsefalsefalsefalse
Delete / overwritefalse✓ truefalsefalse
Send email / send Slack messagefalse✓ truefalse✓ true
Trigger CI/CD deployfalse✓ truefalse✓ true

How clients use annotations

Annotation handling is client-specific. Common patterns in production MCP clients:

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:

Further reading