All posts

Testing MCP Tool Annotations: Validate readOnlyHint, destructiveHint, and openWorldHint for ChatGPT and Claude (May 2026)

Abe Wheeler
MCP Apps MCP App Testing MCP App Framework ChatGPT Apps ChatGPT App Testing ChatGPT App Framework Claude Connectors Claude Connector Testing Claude Connector Framework Tool Annotations
Set and test MCP tool annotations for ChatGPT Apps and Claude Connectors.

Set and test MCP tool annotations for ChatGPT Apps and Claude Connectors.

Incorrect tool annotations are the number one reason MCP App submissions get rejected from both the ChatGPT App Store and the Claude Connectors Directory. Anthropic reports that missing annotations alone cause 30% of directory rejections. OpenAI calls incorrect or missing annotations “a common cause of rejection” and requires detailed justification for each annotation at submission time.

The fix is straightforward: set four boolean properties on each tool, then write tests to make sure they stay correct as your codebase changes. This guide covers what each annotation means, how to pick the right values for common tool patterns, how ChatGPT and Claude use annotations differently, and how to write automated tests that catch annotation errors before you submit.

TL;DR: Every MCP tool needs readOnlyHint, destructiveHint, idempotentHint, and openWorldHint annotations. These tell hosts like ChatGPT and Claude whether a tool reads data, modifies data, is safe to retry, or talks to external systems. The defaults are conservative (destructiveHint: true, openWorldHint: true), so omitting annotations makes hosts treat every tool as destructive and external, which adds confirmation friction for users. Write integration tests with the mcp fixture to verify annotations in CI.

The Four Annotation Properties

The MCP specification defines four annotation properties on ToolAnnotations. All are booleans, all are optional, and all have defaults that assume the worst case.

readOnlyHint

Default: false

Set to true when the tool does not modify its environment. Search tools, list tools, get-by-ID tools, and query tools are read-only. If a tool only retrieves data and never creates, updates, deletes, or sends anything, it’s read-only.

annotations: {
  readOnlyHint: true,
}

When readOnlyHint is true, the other write-related annotations (destructiveHint, idempotentHint) don’t apply. The MCP spec says those properties are “meaningful only when readOnlyHint == false.”

The default is false, which means hosts assume your tool writes data unless you tell them otherwise. If you forget to set readOnlyHint: true on a search tool, ChatGPT will show an unnecessary confirmation prompt every time a user searches.

destructiveHint

Default: true

Set to true when the tool performs destructive updates: deleting records, overwriting data, removing access, revoking tokens. Set to false when the tool writes data but only adds or creates new things without destroying existing data.

// A tool that creates a new record (additive, not destructive)
annotations: {
  readOnlyHint: false,
  destructiveHint: false,
}

// A tool that deletes a record (destructive)
annotations: {
  readOnlyHint: false,
  destructiveHint: true,
}

This property only matters when readOnlyHint is false. The default is true, so if you mark a tool as non-read-only but forget destructiveHint, the host assumes the tool destroys data. For a tool that only creates new records, this means users get a confirmation prompt they shouldn’t need.

idempotentHint

Default: false

Set to true when calling the tool multiple times with the same arguments produces no additional effect. An update that sets a user’s name to “Alice” is idempotent because calling it ten times still results in the name “Alice.” Sending a message is not idempotent because calling it ten times sends ten messages.

// Idempotent: setting a value
annotations: {
  readOnlyHint: false,
  destructiveHint: false,
  idempotentHint: true,
}

// Not idempotent: appending or sending
annotations: {
  readOnlyHint: false,
  destructiveHint: false,
  idempotentHint: false,
}

Like destructiveHint, this only matters when readOnlyHint is false. Hosts can use idempotency information to decide whether retrying a failed tool call is safe. If a network error occurs mid-call and the tool is idempotent, the host can retry without worrying about duplicate side effects.

openWorldHint

Default: true

Set to true when the tool interacts with external systems, public platforms, or the open internet. Web search tools, tools that post to social media, tools that call third-party APIs, and tools that send emails are all open-world. Set to false when the tool only operates within your own closed system, like a memory store or an internal database.

// Open world: sends email via external service
annotations: {
  readOnlyHint: false,
  destructiveHint: false,
  openWorldHint: true,
}

// Closed world: reads from internal database
annotations: {
  readOnlyHint: true,
  openWorldHint: false,
}

The default is true, so hosts assume your tool talks to external systems unless you say otherwise. ChatGPT specifically requires openWorldHint on every tool and checks it during submission review.

Why Defaults Matter

The MCP spec chose conservative defaults for safety: if a server omits annotations, hosts treat the tool as destructive, non-idempotent, non-read-only, and open-world. This is the safest assumption because it triggers the most confirmation friction.

But conservative defaults also mean that omitting annotations on a simple read-only search tool makes the host treat it as if it could delete data and post to the internet. Users see unnecessary confirmation prompts, the experience feels clunky, and reviewers reject the submission because the annotations don’t match the tool’s behavior.

Here’s a summary:

PropertyDefaultWhat the default assumes
readOnlyHintfalseTool writes data
destructiveHinttrueTool destroys data
idempotentHintfalseEach call has additional effects
openWorldHinttrueTool talks to external systems

Set every property explicitly. Don’t rely on defaults.

Annotation Examples for Common Tool Patterns

Different tool types need different annotation combinations. Here are examples for the patterns you’re most likely to build.

Search and query tools

Tools that search, filter, or query data without changing anything.

// search_products, search_tickets, query_logs
annotations: {
  readOnlyHint: true,
  destructiveHint: false,
  idempotentHint: true,
  openWorldHint: false,
}

Get-by-ID tools

Tools that retrieve a single record by identifier.

// get_order, get_user_profile, get_invoice
annotations: {
  readOnlyHint: true,
  destructiveHint: false,
  idempotentHint: true,
  openWorldHint: false,
}

List tools

Tools that return collections of records.

// list_users, list_channels, list_repositories
annotations: {
  readOnlyHint: true,
  destructiveHint: false,
  idempotentHint: true,
  openWorldHint: false,
}

Create tools

Tools that add new records without affecting existing ones.

// create_ticket, add_comment, create_project
annotations: {
  readOnlyHint: false,
  destructiveHint: false,
  idempotentHint: false,
  openWorldHint: false,
}

Update tools

Tools that modify existing records. Most update operations are idempotent because setting a field to the same value twice has no additional effect.

// update_profile, set_status, rename_channel
annotations: {
  readOnlyHint: false,
  destructiveHint: false,
  idempotentHint: true,
  openWorldHint: false,
}

Delete tools

Tools that remove data. Always destructive.

// delete_account, remove_member, archive_project
annotations: {
  readOnlyHint: false,
  destructiveHint: true,
  idempotentHint: true,
  openWorldHint: false,
}

Deleting is usually idempotent: deleting a record that’s already deleted has no additional effect.

Send/post tools

Tools that send messages, emails, or create content on external platforms.

// send_email, post_to_slack, send_sms
annotations: {
  readOnlyHint: false,
  destructiveHint: false,
  idempotentHint: false,
  openWorldHint: true,
}

Sending is not idempotent because each call sends another message. It’s open-world because it reaches external systems.

Web search tools

Tools that search the internet or query external APIs for information.

// web_search, fetch_url, lookup_weather
annotations: {
  readOnlyHint: true,
  destructiveHint: false,
  idempotentHint: true,
  openWorldHint: true,
}

Read-only because they don’t modify data, but open-world because they reach external systems.

File upload tools

Tools that upload files to external storage.

// upload_to_s3, attach_file, upload_image
annotations: {
  readOnlyHint: false,
  destructiveHint: false,
  idempotentHint: false,
  openWorldHint: true,
}

Toggle tools

Tools that flip a boolean setting (enable/disable).

// toggle_notifications, enable_feature, mute_channel
annotations: {
  readOnlyHint: false,
  destructiveHint: false,
  idempotentHint: true,
  openWorldHint: false,
}

Idempotent because toggling to the same state has no additional effect.

How ChatGPT and Claude Use Annotations

ChatGPT and Claude both read tool annotations, but they use them in slightly different ways.

ChatGPT

ChatGPT requires readOnlyHint, destructiveHint, and openWorldHint on every tool. OpenAI’s submission guidelines say that tools with destructiveHint: true or openWorldHint: true must trigger confirmation prompts so “clients can enforce guardrails, approvals, confirmations, or prompts before execution.” OpenAI also requires a detailed justification for each annotation at submission time.

In practice: if your tool is marked readOnlyHint: true, ChatGPT may call it without asking the user first. If it’s marked destructive or open-world, ChatGPT shows a confirmation step.

Claude

Claude requires at least readOnlyHint or destructiveHint on every tool submitted to the Connectors Directory. Missing annotations cause 30% of directory rejections. Claude uses annotations to decide when to ask for user confirmation before calling a tool. Read-only tools can be called with less friction, while destructive tools prompt for confirmation.

The practical difference

ChatGPT is stricter about openWorldHint specifically. If your tool makes any call to an external service, ChatGPT wants openWorldHint: true. Claude focuses more on the read/write distinction (readOnlyHint vs destructiveHint) and is less strict about openWorldHint, though including it is still good practice.

If you’re submitting to both platforms, set all four annotations on every tool. This satisfies both sets of requirements and gives both hosts the information they need to make smart confirmation decisions.

Setting Annotations in a sunpeak Project

In a sunpeak project, annotations go in your tool config file:

import type { AppToolConfig } from 'sunpeak/mcp';

export const tool: AppToolConfig = {
  resource: 'order-status',
  title: 'Get Order Status',
  description: 'Look up the current status of an order by order ID.',
  annotations: {
    readOnlyHint: true,
    destructiveHint: false,
    idempotentHint: true,
    openWorldHint: false,
  },
};

For a tool that modifies data:

export const tool: AppToolConfig = {
  resource: 'send-notification',
  title: 'Send Notification',
  description: 'Send a push notification to a user by user ID.',
  annotations: {
    readOnlyHint: false,
    destructiveHint: false,
    idempotentHint: false,
    openWorldHint: true,
  },
};

If you’re building a plain MCP server without sunpeak, annotations go in your tool definition wherever your framework exposes them. The MCP protocol sends annotations as part of the tools/list response, so they’re available to any host that supports the protocol.

Testing Annotations Automatically

Annotation errors are easy to prevent with automated tests. Write integration tests that inspect your tool list and verify every tool has the right annotations.

Test that every tool has annotations

The minimum viable test: make sure no tool ships without annotations at all.

import { test, expect } from 'sunpeak/test';

test('every tool has annotations defined', async ({ mcp }) => {
  const { tools } = await mcp.listTools();
  for (const tool of tools) {
    expect(
      tool.annotations,
      `${tool.name} is missing annotations`
    ).toBeDefined();
  }
});

Test that all four properties are set explicitly

Don’t rely on defaults. Verify that every tool sets each annotation property.

test('every tool sets all annotation properties', async ({ mcp }) => {
  const { tools } = await mcp.listTools();
  const requiredProps = [
    'readOnlyHint',
    'destructiveHint',
    'idempotentHint',
    'openWorldHint',
  ];

  for (const tool of tools) {
    for (const prop of requiredProps) {
      expect(
        tool.annotations?.[prop],
        `${tool.name} is missing ${prop}`
      ).toBeDefined();
    }
  }
});

Test that read-only tools are annotated correctly

Tools with names that imply read-only behavior should be marked as such.

test('read-only tools have readOnlyHint: true', async ({ mcp }) => {
  const { tools } = await mcp.listTools();
  const readPrefixes = ['get_', 'search_', 'list_', 'query_', 'fetch_'];
  const readTools = tools.filter((t) =>
    readPrefixes.some((prefix) => t.name.startsWith(prefix))
  );

  for (const tool of readTools) {
    expect(
      tool.annotations?.readOnlyHint,
      `${tool.name} looks read-only but readOnlyHint is not true`
    ).toBe(true);
    expect(
      tool.annotations?.destructiveHint,
      `${tool.name} is read-only but destructiveHint is true`
    ).toBe(false);
  }
});

Test that destructive tools are annotated correctly

Tools that delete or remove should be marked destructive.

test('destructive tools have destructiveHint: true', async ({ mcp }) => {
  const { tools } = await mcp.listTools();
  const destructivePrefixes = ['delete_', 'remove_', 'revoke_', 'archive_'];
  const destructiveTools = tools.filter((t) =>
    destructivePrefixes.some((prefix) => t.name.startsWith(prefix))
  );

  for (const tool of destructiveTools) {
    expect(
      tool.annotations?.readOnlyHint,
      `${tool.name} is destructive but readOnlyHint is true`
    ).toBe(false);
    expect(
      tool.annotations?.destructiveHint,
      `${tool.name} should be marked destructive`
    ).toBe(true);
  }
});

Test annotation consistency

Catch logical contradictions like a tool that claims to be both read-only and destructive.

test('no tool is both readOnly and destructive', async ({ mcp }) => {
  const { tools } = await mcp.listTools();
  for (const tool of tools) {
    if (tool.annotations?.readOnlyHint === true) {
      expect(
        tool.annotations?.destructiveHint,
        `${tool.name} cannot be both read-only and destructive`
      ).not.toBe(true);
    }
  }
});

test('no tool is readOnly with idempotentHint explicitly false', async ({ mcp }) => {
  const { tools } = await mcp.listTools();
  for (const tool of tools) {
    if (tool.annotations?.readOnlyHint === true) {
      // idempotentHint is meaningless for read-only tools, but if set,
      // it should be true (reads are naturally idempotent)
      if (tool.annotations?.idempotentHint !== undefined) {
        expect(
          tool.annotations.idempotentHint,
          `${tool.name} is read-only so idempotentHint should be true if set`
        ).toBe(true);
      }
    }
  }
});

Run in CI/CD

Add annotation tests to your CI/CD pipeline so they run on every push. Annotation tests are free (no API credits, no external services) and fast, so there’s no reason not to run them alongside your other integration tests.

pnpm test

If someone adds a new tool without annotations, or changes a read-only tool to write data without updating the annotations, the test fails before the code merges.

Common Annotation Mistakes

Forgetting annotations entirely

The most common mistake. If you add a tool and don’t set annotations, the host uses the conservative defaults (readOnlyHint: false, destructiveHint: true, openWorldHint: true). Your read-only search tool now triggers confirmation prompts and gets flagged during submission review.

Fix: set all four properties on every tool. The automated test above catches this.

Marking create tools as destructive

A tool that creates a new ticket or adds a comment is not destructive. It adds data without destroying existing data. destructiveHint should be false for create and add operations.

Fix: reserve destructiveHint: true for tools that delete, overwrite, or remove data.

Forgetting openWorldHint on tools that call external APIs

A tool that sends data to a third-party API (Stripe, Twilio, GitHub) is open-world, even if it only reads from that API. If your tool calls api.stripe.com to retrieve a customer record, it’s still interacting with an external system.

Fix: set openWorldHint: true on any tool that makes HTTP requests to services outside your own infrastructure.

Setting readOnlyHint and destructiveHint on the same tool

A tool cannot be both read-only and destructive. If readOnlyHint is true, then destructiveHint and idempotentHint don’t apply per the MCP spec. Setting both creates a logical contradiction that reviewers will catch.

Fix: if a tool reads data, set readOnlyHint: true and destructiveHint: false. The consistency test above catches this.

Not updating annotations after changing tool behavior

You start with a read-only get_user tool, then add a side effect (logging the access, updating “last viewed” timestamp). The tool now modifies its environment, but the annotations still say readOnlyHint: true.

Fix: update annotations whenever you change what a tool does. Automated tests that check annotations against naming conventions help, but you also need to review annotations during code review.

Annotation Decision Flowchart

When you’re not sure which annotations to set, work through these questions:

  1. Does this tool change anything? If no: readOnlyHint: true, destructiveHint: false. Skip to question 4.

  2. Does it delete, overwrite, or remove existing data? If yes: destructiveHint: true. If it only creates or adds: destructiveHint: false.

  3. Is calling it twice with the same arguments safe? If the second call has no additional effect (like setting a name): idempotentHint: true. If each call has a cumulative effect (like sending a message): idempotentHint: false.

  4. Does it talk to anything outside your own system? If it makes HTTP requests to third-party services, public APIs, or the open internet: openWorldHint: true. If it only touches your own database or internal services: openWorldHint: false.

Where Annotations Fit in the Testing Pyramid

Annotation tests sit at the integration test level. They use the mcp fixture to connect to your MCP server and inspect tool metadata through the protocol, which makes them fast and free (no browser, no API credits, no external services).

In the full MCP App testing pyramid:

  1. Unit tests test component rendering and handler logic
  2. Integration tests test MCP protocol behavior, including annotations
  3. E2E tests test full app rendering in simulated hosts
  4. Evals test whether real models call the right tools

Annotation tests belong in step 2. They verify that your tool metadata is correct at the protocol level, before you ever test the UI or submit to an app store. Since annotations are the #1 rejection reason on both platforms, catching annotation errors early saves you weeks of review cycles.

sunpeak scaffolds annotation tests when you run npx sunpeak test init, so you start with a working test suite that covers the basics. Add tool-specific assertions as you build out your MCP App, ChatGPT App, or Claude Connector.

Get Started

Documentation →
npx sunpeak new

Further Reading

Frequently Asked Questions

What are MCP tool annotations?

MCP tool annotations are metadata properties on your MCP tools that tell host applications (ChatGPT, Claude) whether a tool reads data, modifies data, is idempotent, or interacts with external systems. The four annotation properties are readOnlyHint, destructiveHint, idempotentHint, and openWorldHint. Both ChatGPT and Claude require correct annotations for app store and directory submissions, and incorrect annotations are the #1 cause of rejections on both platforms.

What is readOnlyHint in MCP tool annotations?

readOnlyHint is a boolean annotation that, when set to true, tells the host that a tool does not modify its environment. Use it for tools that only retrieve, search, list, or query data. The default is false, so you must explicitly set readOnlyHint to true on read-only tools. ChatGPT and Claude both use this annotation to decide whether to show a confirmation prompt before calling the tool.

What is the difference between destructiveHint and readOnlyHint?

readOnlyHint indicates a tool does not change anything. destructiveHint indicates a tool performs destructive updates like deleting or overwriting data. destructiveHint is only meaningful when readOnlyHint is false. A tool that creates a new record but does not destroy existing data should set readOnlyHint to false and destructiveHint to false. A tool that deletes data should set readOnlyHint to false and destructiveHint to true.

What is openWorldHint in MCP tool annotations?

openWorldHint indicates whether a tool interacts with external systems, public platforms, or the open internet. Set openWorldHint to true for tools that make API calls to third-party services, post to social media, send emails, or search the web. Set it to false for tools that only interact with your own closed system. The default is true, so tools that only work within a closed system should explicitly set openWorldHint to false. ChatGPT requires openWorldHint on all tools.

What is idempotentHint in MCP tool annotations?

idempotentHint indicates whether calling the tool repeatedly with the same arguments has no additional effect. Set it to true for update operations where calling the tool twice with the same data produces the same result (like setting a user name). Set it to false for operations where each call has a cumulative effect (like sending a message or incrementing a counter). The default is false, and it is only meaningful when readOnlyHint is false.

Why do MCP Apps get rejected for wrong tool annotations?

Incorrect or missing tool annotations are the #1 cause of rejection on both the ChatGPT App Store and the Claude Connectors Directory. Anthropic reports that missing annotations cause 30% of Claude directory rejections. OpenAI requires developers to double-check readOnlyHint, destructiveHint, and openWorldHint and provide detailed justification for each annotation at submission time. Hosts use annotations to enforce safety guardrails like confirmation prompts, so wrong annotations create security risks.

How do I test MCP tool annotations automatically?

Write integration tests using the mcp fixture from sunpeak/test to call mcp.listTools() and assert that every tool has correct annotations. Test that all tools have annotations defined, that read-only tools have readOnlyHint set to true, that write tools have destructiveHint set correctly, and that tools interacting with external services have openWorldHint set to true. Run these tests in CI/CD so annotation regressions are caught before submission.

How do ChatGPT and Claude use tool annotations differently?

ChatGPT requires all three annotations (readOnlyHint, destructiveHint, openWorldHint) on every tool and uses them to trigger confirmation prompts before destructive or open-world actions. Claude requires at least readOnlyHint or destructiveHint and uses annotations to decide when to ask for user confirmation versus calling tools automatically. Both platforms reject submissions with missing or incorrect annotations, but ChatGPT is stricter about requiring openWorldHint specifically.

What are the default values for MCP tool annotations?

readOnlyHint defaults to false (assumes the tool writes data). destructiveHint defaults to true (assumes the tool is destructive). idempotentHint defaults to false (assumes each call has additional effects). openWorldHint defaults to true (assumes the tool interacts with external systems). These conservative defaults mean that if you omit annotations entirely, the host treats your tool as destructive and open-world, which triggers maximum confirmation friction for users.