Skip to content

Examples demonstrate ToolAnnotations only on a tool where they change nothing #3737

Open

Description

@gl1tchdust

What I noticed

Across examples/, 2 tool registrations out of 119 declare ToolAnnotations,
and both are the same calc tool:

  • examples/stories/tools/server.py
  • examples/stories/tools/server_lowlevel.py
annotations=ToolAnnotations(read_only_hint=True, idempotent_hint=True),

calc is pure arithmetic. The annotation is correct, and it is also the case
where it cannot affect anything a client decides.

Meanwhile examples/mcpserver/text_me.py posts to api.surgemsg.com and sends
a real SMS to a configured number. It declares nothing, so a client sees
annotations: None and has no signal that approving this call has an effect
in the world and costs money. examples/mcpserver/screenshot.py and the
other side-effecting examples are the same.

So the examples show how to annotate a tool that cannot do anything, and
nothing shows the case where the annotation is load bearing.

The part that argues against the obvious fix

I started out assuming "add annotations to text_me.py" and then checked the
values against the spec defaults:

Field Correct for textme Spec default
read_only_hint False False
destructive_hint False, it is additive True
idempotent_hint False, each call sends another message False
open_world_hint True True

Only destructive_hint differs, and declaring it False would relax the
default rather than tighten it. For a tool that sends real messages, leaving
it undeclared arguably leaves a client more cautious, not less. So the
mechanical fix is not obviously an improvement, which is why I am reporting
the gap rather than proposing a patch.

If there is a direction you would prefer, I am happy to write it:

  1. Annotate a side-effecting example where the values are genuinely
    informative rather than default.
  2. Leave the examples alone and say something in the docs about what the
    hints are and are not for.
  3. Treat this as not worth the churn.

How I found it

I wrote a static scanner that compares an MCP tool's declared annotations
against what its handler does, and ran it over 289 tools across this SDK,
FastMCP and the reference servers. 25 of the 289 declare any annotations at
all. textme surfaced as a handler posting to an external API while
declaring nothing.

No PR, since CONTRIBUTING asks for the issue first.

AI assistance disclosure: I used Claude Code while investigating this and
writing this issue. I read the scanner output and the example source myself
and can speak to the reasoning.

Activity

  1. vasikmansuri commented on Oct 22, 2026

    @vasikmansuri

    Confirmed on main (2af9e30): the only ToolAnnotations in examples/ are the two calc registrations in examples/stories/tools/. examples/mcpserver/text_me.py registers @mcp.tool(name="textme", ...) with no annotations, even though it sends a real SMS with httpx2.Client().post(...). MCPServer.tool() already takes annotations=, so annotating it needs no API change.

    Approach I'd take: annotate text_me.py with ToolAnnotations(read_only_hint=False, destructive_hint=False, idempotent_hint=False, open_world_hint=True), plus a one-line comment on why (it has an external, non-repeatable side effect), so the examples show both ends: a pure tool (calc) and one with a real-world effect. screenshot.py could get read_only_hint=True. Would that be the direction you want?

  2. gl1tchdust commented on Oct 22, 2026

    @gl1tchdust
    Author

    Thanks for confirming it. screenshot.py is a good catch, and I would pair
    open_world_hint=False with the read_only_hint=True you suggest: it reads
    the local display through pyautogui.screenshot() and touches nothing
    external, so both fields carry information and read_only_hint=True is
    non-default there.

    On text_me.py I am less sure, and it is why I did not open a PR. Your four
    values against the spec defaults:

    Field Your value Spec default
    read_only_hint False False
    destructive_hint False True
    idempotent_hint False False
    open_world_hint True True

    Three match the defaults, so they tell a spec-compliant client nothing it does
    not already assume. The fourth moves destructive_hint from true to
    false, which means annotating textme this way leaves a client less
    cautious about a tool that sends a real SMS than leaving it undeclared.

    I do not think your values are wrong. An SMS is additive rather than
    destructive, so destructive_hint=False is the honest reading. The problem is
    that the vocabulary has no field for "irreversible effect outside the system",
    and destructiveHint is about destroying existing state, not about
    reversibility. So a faithful annotation on textme ends up communicating less
    caution than silence does.

    Which suggests the split is:

    • Annotate the examples where a value is non-default and informative.
      screenshot.py qualifies, calc already does.
    • For textme, the one-line comment you propose may be worth more than the
      annotations themselves, since the comment can say the thing the hints
      cannot.

    No strong feelings on who writes it. And I am happy to be wrong on the
    destructive_hint reading if a maintainer sees it differently, since that is
    the part the whole thing turns on.

  3. vasikmansuri commented on Oct 22, 2026

    @vasikmansuri

    Good point: on textme the annotations would signal less caution than leaving them off, so agreed: a comment there, and read_only_hint=True, open_world_hint=False on screenshot.py. Happy for whoever a maintainer assigns to write it.

  4. aaryagodbole commented on Nov 9, 2026

    @aaryagodbole

    I went through the rest of examples/ (on 77001c9) for a tool where the hints are informative, not just default, and examples/stories/stickynotes/ seems like the strongest candidate. Its three tools between them use every field:

    Tool Hints worth declaring Why
    add_note destructive_hint=False, open_world_hint=False additive, in-memory board
    remove_note idempotent_hint=True, open_world_hint=False removing an id twice has the same result as once (second call returns False); destructive is already the default
    remove_all open_world_hint=False destructive, not idempotent: both defaults, and it already elicits "This cannot be undone"

    That avoids the textme problem: nothing relaxes caution on a tool with real-world effects, and the story is self-contained. stickynotes/server_lowlevel.py would need the same values so the pair stays in sync. mcp_simple_tool's fetch (read_only_hint=True) is the other clear one.

    If a maintainer wants this, I'm happy to do the stickynotes + screenshot.py change with the textme comment discussed above.

    AI disclosure: AI-assisted.

  5. talha-81 commented on Nov 10, 2026

    @talha-81

    I'd like to pick this up following the approach discussed above: add a short comment to the text_me.py example explaining why its defaults are left as-is, and annotate the screenshot.py example with ToolAnnotations(read_only_hint=True, open_world_hint=False) so the examples show annotations on a tool where they actually change client behaviour. Examples-only, no library changes. If a maintainer is happy to assign this, I'll open a PR with Fixes #3737.

  6. gl1tchdust commented on Nov 10, 2026

    @gl1tchdust
    Author

    Reporter here. Two things from the registry-wide survey I ran after filing this, in case they help settle which example to pick.

    On stickynotes vs screenshot.py. @aaryagodbole's point that stickynotes exercises every field is right, and I think it argues for both rather than either, because they teach different things. stickynotes shows the full vocabulary on a closed, in-memory system where every field is answerable. screenshot.py shows the case this issue was actually about: a tool whose honest annotation is non-default, so a client's decision changes. One example where the fields are exhaustive, one where they are load-bearing.

    On the docs point, with a number behind it. After filing this I scanned every server in the official registry: 14,138 tools across the Python and TypeScript SDKs. 34.9% of Python tools and 32.1% of TypeScript ones declare any annotation at all. Method and limits are in RESEARCH.md, including the places the scan is wrong.

    So roughly two thirds of real tools ship with all four hints absent, and an absent destructiveHint reads as true. That makes @aaryagodbole's experience the common case rather than a quirk of one client.

    docs/servers/tools.md:155 currently says destructive_hint and idempotent_hint "would say nothing" on a read-only tool. That is correct against the spec, and it is also the sentence that leads people to omit them. A reader who declares only read_only_hint=True serialises no destructiveHint at all, and any client that does not first apply the read_only_hint == false precondition sees the documented default of true.

    I would not frame that as accommodating non-conforming clients, though. The spec says clients MUST treat annotations as untrusted, so a client is entitled to be conservative about a field it was never given. The honest version is that read_only_hint=True on its own is a weaker signal than it looks.

    On text_me.py, nothing has changed my reading from 22 Oct: the vocabulary has no field for an irreversible effect outside the system, so a faithful annotation there signals less caution than silence does. A comment saying so is worth more than annotations, and it is the only place in examples/ that could teach where the vocabulary runs out.

    I reported this one and would like to write it, so I am flagging that per CONTRIBUTING rather than letting it go by default. Equally happy to be overruled, or for a maintainer to take it in-house, which the same file says is usually faster. I will not open a PR unless the issue is assigned.

    AI disclosure: the registry survey is my own work, published with its method and its limits. This comment was drafted with Claude Code; the numbers and the reasoning are mine.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    documentationImprovements or additions to documentation

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions