Skip to content

Add MCP tool annotations so read-only tools aren't shown as destructive - #34

Merged
aterga merged 2 commits into
mainfrom
claude/mcp-spec-compliance-9202ka
Jul 5, 2026
Merged

aterga merged 2 commits into
mainfrom
claude/mcp-spec-compliance-9202ka

Conversation

@aterga

@aterga aterga commented Jul 5, 2026

Copy link
Copy Markdown
Collaborator

What & why

Clients like ChatGPT were labeling read-only tools (e.g. get_candid, a pure metadata read) as PUBLIC WRITE / DESTRUCTIVE / OPEN WORLD. That's because we set no MCP tool annotations, and the hints default to the unsafe interpretation when omitted:

Hint Default when absent
readOnlyHint false (assume it writes)
destructiveHint true (assume destructive)
openWorldHint true

So every tool rendered as write/destructive. This PR adds annotations(...) to all 19 #[tool]s (rmcp 1.7.0's macro attribute — no logic changes), classified against what each tool actually does (query vs update call, which method, HTTP read, etc.).

Classification

Read-only (read_only_hint = true): get_candid, discover_canisters, find_canister, lookup_canister, list_ic_skills, get_ic_skill, list_accounts, cycles_balance, get_principal, canister_status.

Additive / reversible writes (read_only = false, destructive = false): create_canister, top_up_canister, start_canister, stop_canister (last two idempotent = true).

Destructive / irreversible writes (destructive = true): install_code (reinstall wipes state), update_canister_settings (controller replacement can lock you out), uninstall_code, delete_canister.

call_canister is dual-mode (is_query) and calls arbitrary methods on arbitrary canisters, so it's conservatively read_only = false, destructive = true — correctly not read-only.

open_world_hint is true for tools that reach the live IC / arbitrary canisters / web registries; false for the two skills tools (one curated registry).

Notes / judgment calls

  • canister_status reads status (changes nothing → read_only = true, so it's recognized as safe), but on the IC it's an update call gated by require_write. Its description now states it needs a full (non-read-only) II session, so a read-only session's rejection isn't surprising.
  • get_principal / cycles_balance are read_only = true; on a cache miss they trigger mcp_prepare_delegation (an update to II) under the hood, but that only mints an ephemeral, self-scoped credential — not user/canister state — so read-only is correct. (Unlike canister_status, these are not require_write-gated.)
  • These are hints, not enforcement — clients may still prompt — but ChatGPT/Claude/etc. use them for labeling and auto-run vs. confirm decisions.
  • Not included: outputSchema (the separate "recommended" badge). Our tools return free text, so an output schema isn't applicable without converting tools to structured output; deferred.

Classification was produced by reading the code and adversarially verified (the only correction that pass made: update_canister_settings → destructive = true, since controller replacement is an irreversible lock-out).

Testing

🤖 Generated with Claude Code

https://claude.ai/code/session_014T9N8USDfNK5yzznPGg7Ym


Generated by Claude Code

Clients (ChatGPT etc.) were showing read-only tools like get_candid as
"PUBLIC WRITE / DESTRUCTIVE" because we set no MCP tool annotations, and
the hints default to the unsafe interpretation when omitted (readOnlyHint
false, destructiveHint true, openWorldHint true).

Add annotations() to all 19 #[tool]s, classified against what each tool
actually does:

- Read-only (read_only_hint=true): get_candid, discover_canisters,
  find_canister, lookup_canister, list_ic_skills, get_ic_skill,
  list_accounts, cycles_balance, get_principal, canister_status.
- Additive/reversible writes (read_only=false, destructive=false):
  create_canister, top_up_canister, start_canister, stop_canister.
- Destructive/irreversible writes (destructive=true): install_code
  (reinstall wipes), update_canister_settings (controller replacement can
  lock out), uninstall_code, delete_canister.
- call_canister is dual-mode (is_query) and calls arbitrary methods, so it
  is conservatively read_only=false, destructive=true.

Notes:
- canister_status reads (changes nothing) so read_only=true, but on the IC
  it is an update call gated by require_write; its description now says it
  needs a full (non-read-only) session.
- open_world_hint=true for tools that reach the live IC / arbitrary
  canisters / web registries; false for the two skills tools (one curated
  registry).

Add a test asserting every tool carries annotations and spot-checking the
read/write classification via ToolRouter::list_all().

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014T9N8USDfNK5yzznPGg7Ym

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds MCP tool annotations(...) metadata to the IC MCP server’s tool definitions so clients can correctly label tools as read-only vs. write/destructive, rather than defaulting to unsafe interpretations when hints are omitted.

Changes:

  • Annotates all 19 #[tool] endpoints with MCP tool annotation hints (read-only / destructive / idempotent / open-world, plus titles).
  • Updates several tool descriptions to clarify behavior/requirements (notably around read-only vs. update-call semantics).
  • Adds a unit test asserting all tools have annotations and spot-checking the intended read/write/destructive classification.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/main.rs Resolved
Comment thread src/main.rs Resolved
Comment thread src/main.rs Resolved
Comment thread src/main.rs Resolved
Comment thread src/main.rs Resolved
Comment thread src/main.rs Resolved
Comment thread src/main.rs Resolved
Comment thread src/main.rs Resolved
Comment thread src/main.rs Resolved
Comment thread src/main.rs Resolved
Per MCP spec, destructiveHint is ignored when readOnlyHint=true, so
omitting it is spec-correct. But a naive client that reads destructiveHint
without first checking readOnlyHint would fall back to the true default and
still mislabel a read-only tool as destructive — the exact cross-client
robustness this change is about. Set destructive_hint=false explicitly on
all ten read-only tools as belt-and-suspenders, and assert it in the test.

Addresses the Copilot review on #34.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014T9N8USDfNK5yzznPGg7Ym
@aterga
aterga marked this pull request as ready for review July 5, 2026 15:17
Copilot AI review requested due to automatic review settings July 5, 2026 15:17

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 1 out of 1 changed files in this pull request and generated no new comments.

@aterga
aterga merged commit f1709e3 into main Jul 5, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants