Build-it

How to mark MCP tools read-only or destructive

MCP tool annotations (readOnlyHint, destructiveHint, and two more) tell a client which tools are safe to auto-run. Build-it, tested on Node 25 with the SDK.

Mark

Head of Marketing, MCPOrbit

Published
Updated
· Updated
Read time
· 8 min read
A delete_note tool card lists four annotation hints, an arrow carries them into a host policy box, and three outcome pills read auto-run, notify, and confirm, with the confirm pill lit as the chosen route.

To tell a Model Context Protocol (MCP) client which of your tools are safe to run on their own, add annotations to each tool: readOnlyHint, destructiveHint, idempotentHint, and openWorldHint. The client reads those hints from the tool list and decides when to run a tool silently and when to stop and ask the user first.

This matters because an agent calling your tools makes a choice on every call: run it, or check with a human. With no signal, a careful host asks about everything, which is tedious, or asks about nothing, which is dangerous. Annotations give the host that signal up front, in the tool descriptor, before any call runs.

What are MCP tool annotations?

Tool annotations are optional metadata on a tool descriptor that describe how the tool behaves before anyone calls it. They shipped in the 2025-03-26 MCP spec revision and are stable in the current spec. There are five fields: a title for display, plus four behavior hints.

  • `readOnlyHint`: the tool does not modify anything.
  • `destructiveHint`: the tool may make changes that are hard or impossible to undo. Only meaningful when `readOnlyHint` is false.
  • `idempotentHint`: calling it again with the same arguments adds no further effect.
  • `openWorldHint`: the tool may reach an external system such as the web, email, or another API, not just a closed local dataset.

Why the defaults matter more than the fields

Here is the part that trips people up. Every hint is optional, and each has a default. When you leave a hint out, the client fills in that default. The defaults are deliberately cautious: readOnlyHint is false, destructiveHint is true, idempotentHint is false, and openWorldHint is true.

So a tool with no annotations at all reads as: it writes, its writes are destructive, repeats add effects, and it reaches outside. That is the safest assumption, and it is why an unannotated tool gets a confirmation prompt. Two things follow. First, annotating a safe tool is what earns it auto-run. Second, the destructiveHint default of true only bites when readOnlyHint is false, so a read-only tool is never treated as destructive.

Turn that into a small policy. resolveAnnotations fills the spec defaults, and decide maps the result to one of three actions.

// policy.mjs: turn tool annotations into a confirmation decision.
// Annotations are optional hints. When one is missing, the spec defines a
// default, and the safe reading is the cautious one. Fill defaults first.
export function resolveAnnotations(tool) {
  const a = tool.annotations ?? {};
  return {
    readOnlyHint: a.readOnlyHint ?? false,      // assume it can write
    destructiveHint: a.destructiveHint ?? true, // assume writes can destroy
    idempotentHint: a.idempotentHint ?? false,  // assume repeats add effects
    openWorldHint: a.openWorldHint ?? true,     // assume it reaches outside
  };
}

// Return "auto", "notify", or "confirm" for a tool.
export function decide(tool) {
  const { readOnlyHint, destructiveHint, openWorldHint } = resolveAnnotations(tool);

  // Read-only and closed-world: nothing to undo, no outside reach. Auto-run.
  if (readOnlyHint && !openWorldHint) return "auto";

  // destructiveHint only means anything when the tool is not read-only.
  if (!readOnlyHint && destructiveHint) return "confirm";

  // Writes, but reversible: run it, tell the user what happened.
  return "notify";
}

Annotate the tools on the server

Build a small notes server with four tools that span the space: a read-only search, a reversible upsert, a destructive delete, and an email send that reaches outside. Each tool passes an annotations object to registerTool.

// server.mjs: an MCP server whose tools declare behavior with annotations.
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";

export function buildServer() {
  const server = new McpServer({ name: "notes-server", version: "1.0.0" });

  // Read-only, closed-world: safe to call without asking the user.
  server.registerTool(
    "search_notes",
    {
      title: "Search notes",
      description: "Full-text search over the local notes index.",
      inputSchema: { query: z.string() },
      annotations: {
        title: "Search notes",
        readOnlyHint: true,
        openWorldHint: false,
      },
    },
    async ({ query }) => ({
      content: [{ type: "text", text: `3 notes match "${query}"` }],
    })
  );

  // Writes, but not destructive, and safe to repeat: create-or-replace by id.
  server.registerTool(
    "upsert_note",
    {
      title: "Save note",
      description: "Create or overwrite a note by id.",
      inputSchema: { id: z.string(), body: z.string() },
      annotations: {
        title: "Save note",
        readOnlyHint: false,
        destructiveHint: false,
        idempotentHint: true,
        openWorldHint: false,
      },
    },
    async ({ id }) => ({
      content: [{ type: "text", text: `Saved note ${id}` }],
    })
  );

  // Irreversible local change: destructive, so a client should confirm first.
  server.registerTool(
    "delete_note",
    {
      title: "Delete note",
      description: "Permanently delete a note by id.",
      inputSchema: { id: z.string() },
      annotations: {
        title: "Delete note",
        readOnlyHint: false,
        destructiveHint: true,
        idempotentHint: true,
        openWorldHint: false,
      },
    },
    async ({ id }) => ({
      content: [{ type: "text", text: `Deleted note ${id}` }],
    })
  );

  // Reaches an external system: not read-only, and open-world.
  server.registerTool(
    "send_email",
    {
      title: "Send email",
      description: "Send an email to an external address.",
      inputSchema: { to: z.string(), subject: z.string() },
      annotations: {
        title: "Send email",
        readOnlyHint: false,
        destructiveHint: true,
        idempotentHint: false,
        openWorldHint: true,
      },
    },
    async ({ to }) => ({
      content: [{ type: "text", text: `Email sent to ${to}` }],
    })
  );

  return server;
}

Read the four annotation blocks top to bottom. search_notes is read-only and closed-world. upsert_note writes but is not destructive and is idempotent, since saving the same note twice lands you in the same place. delete_note is destructive. send_email is destructive and open-world, because it acts on something you cannot recall.

Read the annotations on the client and decide

The client lists the tools once, reads each tool's annotations, and applies the policy. We connect client and server in-process with the SDK's in-memory transport, so the whole demo is one command with no network.

// run.mjs: connect a client to the server in-process, read every tool's
// annotations, and apply the confirmation policy. One command, no network.
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { buildServer } from "./server.mjs";
import { decide, resolveAnnotations } from "./policy.mjs";

const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
const server = buildServer();
await server.connect(serverTransport);

const client = new Client({ name: "notes-host", version: "1.0.0" });
await client.connect(clientTransport);

const { tools } = await client.listTools();

console.log("tool           read  destr  idem  open   -> action");
console.log("-------------- ----- ------ ----- -----  ---------");
for (const tool of tools) {
  const a = resolveAnnotations(tool);
  const action = decide(tool);
  const flag = (b) => (b ? "yes" : "no ");
  console.log(
    tool.name.padEnd(14),
    flag(a.readOnlyHint).padEnd(5),
    flag(a.destructiveHint).padEnd(6),
    flag(a.idempotentHint).padEnd(5),
    flag(a.openWorldHint).padEnd(5),
    "->",
    action
  );
}

// Prove the policy gates a real call. A destructive tool needs a yes first.
async function callWithPolicy(name, args, userSaysYes) {
  const tool = tools.find((t) => t.name === name);
  const action = decide(tool);
  if (action === "confirm" && !userSaysYes) {
    return `BLOCKED: ${name} needs confirmation`;
  }
  const res = await client.callTool({ name, arguments: args });
  return `${action.toUpperCase()}: ${res.content[0].text}`;
}

console.log("");
console.log(await callWithPolicy("search_notes", { query: "mcp" }, false));
console.log(await callWithPolicy("delete_note", { id: "42" }, false));
console.log(await callWithPolicy("delete_note", { id: "42" }, true));

await client.close();
await server.close();

Run it and watch the policy gate a real call

Save the three files, install the two pinned dependencies, and run. Tested end to end on Node v25.8.1.

npm init -y
npm i @modelcontextprotocol/[email protected] [email protected]
node run.mjs
tool           read  destr  idem  open   -> action
-------------- ----- ------ ----- -----  ---------
search_notes   yes   yes    no    no    -> auto
upsert_note    no    no     yes   no    -> notify
delete_note    no    yes    yes   no    -> confirm
send_email     no    yes    no    yes   -> confirm

AUTO: 3 notes match "mcp"
BLOCKED: delete_note needs confirmation
CONFIRM: Deleted note 42

Read the table. search_notes auto-runs. delete_note and send_email require confirmation, so the client blocks the first delete and only runs it after the user says yes. upsert_note runs with a notice. Notice that search_notes shows destr yes, its default value, but the policy ignores that because the tool is read-only, which is exactly the rule from earlier.

Frequently asked questions

Frequently asked questions

What are MCP tool annotations?
They are optional metadata on an MCP tool that describe its behavior before it runs: readOnlyHint, destructiveHint, idempotentHint, and openWorldHint, plus a display title. A client reads them from the tool list to decide whether to auto-run a tool or ask the user first.
What is the difference between readOnlyHint and destructiveHint?
readOnlyHint says the tool changes nothing. destructiveHint says the tool may make changes that are hard to undo, and it only applies when readOnlyHint is false. A read-only tool is never treated as destructive.
What happens if I do not set any tool annotations?
The client fills cautious defaults: readOnlyHint false, destructiveHint true, idempotentHint false, and openWorldHint true. An unannotated tool is treated as a destructive, open-world write, so it usually triggers a confirmation prompt.
Can an MCP client trust annotations for security?
No. Annotations are hints, not guarantees. A buggy or hostile server can mislabel a tool. Use annotations to shape the user experience, and enforce real permissions where you control execution.
Which MCP spec version added tool annotations?
The 2025-03-26 spec revision added them, and they are stable in the current spec. They are supported by @modelcontextprotocol/sdk 1.30.0, so you can set them today with registerTool.

About the author

Mark

Head of Marketing, MCPOrbit

Mark leads marketing at MCPOrbit and writes the build-it MCP tutorials, code tested end to end before it ships.

Share this post

MCPOrbit

Test an MCP server in 60 seconds.

Download MCPOrbit for free. No signup, no telemetry. Hear about a server and test it before the curiosity wears off.

macOS 14+ · Apple Silicon & Intel · No account needed