Repository navigation
Description
What I noticed
Across examples/, 2 tool registrations out of 119 declare ToolAnnotations,
and both are the same calc tool:
examples/stories/tools/server.pyexamples/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:
- Annotate a side-effecting example where the values are genuinely
informative rather than default. - Leave the examples alone and say something in the docs about what the
hints are and are not for. - 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
vasikmansuri commentedon Oct 22, 2026
Confirmed on
main(2af9e30): the onlyToolAnnotationsinexamples/are the twocalcregistrations inexamples/stories/tools/.examples/mcpserver/text_me.pyregisters@mcp.tool(name="textme", ...)with no annotations, even though it sends a real SMS withhttpx2.Client().post(...).MCPServer.tool()already takesannotations=, so annotating it needs no API change.Approach I'd take: annotate
text_me.pywithToolAnnotations(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.pycould getread_only_hint=True. Would that be the direction you want?gl1tchdust commentedon Oct 22, 2026
Thanks for confirming it.
screenshot.pyis a good catch, and I would pairopen_world_hint=Falsewith theread_only_hint=Trueyou suggest: it readsthe local display through
pyautogui.screenshot()and touches nothingexternal, so both fields carry information and
read_only_hint=Trueisnon-default there.
On
text_me.pyI am less sure, and it is why I did not open a PR. Your fourvalues against the spec defaults:
read_only_hintFalseFalsedestructive_hintFalseTrueidempotent_hintFalseFalseopen_world_hintTrueTrueThree match the defaults, so they tell a spec-compliant client nothing it does
not already assume. The fourth moves
destructive_hintfromtruetofalse, which means annotatingtextmethis way leaves a client lesscautious 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=Falseis the honest reading. The problem isthat the vocabulary has no field for "irreversible effect outside the system",
and
destructiveHintis about destroying existing state, not aboutreversibility. So a faithful annotation on
textmeends up communicating lesscaution than silence does.
Which suggests the split is:
screenshot.pyqualifies,calcalready does.textme, the one-line comment you propose may be worth more than theannotations 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_hintreading if a maintainer sees it differently, since that isthe part the whole thing turns on.
vasikmansuri commentedon Oct 22, 2026
Good point: on
textmethe annotations would signal less caution than leaving them off, so agreed: a comment there, andread_only_hint=True, open_world_hint=Falseonscreenshot.py. Happy for whoever a maintainer assigns to write it.aaryagodbole commentedon Nov 9, 2026
I went through the rest of
examples/(on77001c9) for a tool where the hints are informative, not just default, andexamples/stories/stickynotes/seems like the strongest candidate. Its three tools between them use every field:add_notedestructive_hint=False,open_world_hint=Falseremove_noteidempotent_hint=True,open_world_hint=FalseFalse); destructive is already the defaultremove_allopen_world_hint=FalseThat avoids the
textmeproblem: nothing relaxes caution on a tool with real-world effects, and the story is self-contained.stickynotes/server_lowlevel.pywould need the same values so the pair stays in sync.mcp_simple_tool'sfetch(read_only_hint=True) is the other clear one.If a maintainer wants this, I'm happy to do the
stickynotes+screenshot.pychange with thetextmecomment discussed above.AI disclosure: AI-assisted.
talha-81 commentedon Nov 10, 2026
I'd like to pick this up following the approach discussed above: add a short comment to the
text_me.pyexample explaining why its defaults are left as-is, and annotate thescreenshot.pyexample withToolAnnotations(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 withFixes #3737.gl1tchdust commentedon Nov 10, 2026
Reporter here. Two things from the registry-wide survey I ran after filing this, in case they help settle which example to pick.
On
stickynotesvsscreenshot.py. @aaryagodbole's point thatstickynotesexercises every field is right, and I think it argues for both rather than either, because they teach different things.stickynotesshows the full vocabulary on a closed, in-memory system where every field is answerable.screenshot.pyshows 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
destructiveHintreads astrue. That makes @aaryagodbole's experience the common case rather than a quirk of one client.docs/servers/tools.md:155currently saysdestructive_hintandidempotent_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 onlyread_only_hint=Trueserialises nodestructiveHintat all, and any client that does not first apply theread_only_hint == falseprecondition sees the documented default oftrue.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=Trueon 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 inexamples/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.