Repository navigation
MCP: ToolAnnotations silently accepts misspelled hints #142
Description
Activity
Draft for the upstream report
For https://github.com/modelcontextprotocol/python-sdk. Not yet filed; hand-amend
before posting.Framing note: it is deliberately anchored to
modelcontextprotocol/python-sdk#3067 (same root cause,
different model) and it explicitly disclaims the ask that got
modelcontextprotocol/python-sdk#2937 closed, so it does
not read as another request to enforce hints.Title:
ToolAnnotations silently ignores misspelled hint names (extra=ignore default in MCPModel)
Version:
mcp2.1.1,mcp-types2.1.1This is modelcontextprotocol/python-sdk#3067 one layer
down: the sameextra="ignore"default, but in the protocol types rather than in
FastMCP's argument models. PR
modelcontextprotocol/python-sdk#3068 fixes it for
ArgModelBaseonly, so this path is unaffected by that change.from mcp.types import ToolAnnotations ToolAnnotations(readOnlyHnt=True).model_dump(exclude_none=True, by_alias=True) # {} <- no error, no warning, hint silently gone
The valid fields are
title,read_only_hint,destructive_hint,
idempotent_hint,open_world_hint(mcp_types/_types.py:1365).MCPModel
(mcp_types/_types.py:45) sets only:model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
so pydantic's default
extra="ignore"applies and unknown keys are discarded.
This is shared by all 189 protocol models in that module.populate_by_namealso
means bothreadOnlyHintandread_only_hintare accepted, which widens the
surface a typo can land on.To be clear about scope: this is not a request to enforce or verify hints,
which was proposed and reasonably declined in
modelcontextprotocol/python-sdk#2937. Whether a tool
marked read-only really is read-only is the server author's problem. The narrower
complaint is thatreadOnlyHntis not a field ofToolAnnotations, will never be
one, and is nonetheless accepted in silence, so the author never finds out their
annotation was dropped.Why the current default is defensible on one path and not the other: ignoring
unknown fields is correct when deserializing a peer's message, since that is
what makes protocol forward-compatibility work. It is not what you want when a
server author constructs an annotation in Python. Both share one config today,
so the lenient wire behaviour is applied to the authoring path as well.Impact: a read-only tool that fails to be marked read-only is treated as
mutating by clients. No error at build time, no test failure, no runtime symptom;
the mistake surfaces only as a client-side behaviour difference.Possible directions, least to most intrusive:
- Document the behaviour on
ToolAnnotations, given the hint set is fixed and
spec-defined. - Provide a strict construction path (
extra="forbid"viamodel_validate, or a
keyword-only helper) for models a server builds rather than receives. - Apply
extra="forbid"to server-constructed models while leaving inbound
parsing lenient, consistent with wherever #3067 lands.
Happy to open a PR once the direction on #3067 is settled.
Lower-friction alternative: #3067 is labelled
needs decision, so commenting
there to ask that the decision coverMCPModeltoo would also work, instead of a
second issue.- Document the behaviour on
Mitigated in #133: Operation carries explicitly typed hint fields and the adapter builds ToolAnnotations by keyword, so a misspelled hint raises TypeError. A test asserts every hint maps to a declared field.
The upstream report to https://github.com/modelcontextprotocol/python-sdk is not filed yet. The draft stays in the comment above for whoever picks it up; nothing on our side depends on it.
ToolAnnotationsaccepts a misspelled hint without error and silently discardsit, producing a tool whose annotations are quietly missing.
Verified against
mcp2.1.1 /mcp-types2.1.1:Mechanism. Corrected 2026-08-28: the original report cited
extra="allow"atmcp/types/_types.py:1033, which does not match the version we use. There is noexplicit
extrasetting at all. The base classMCPModel(
mcp_types/_types.py:45) sets only:so pydantic's default
extra="ignore"applies and unknown keys are droppedrather than stored. This is shared by all 189 protocol models in that module.
populate_by_namealso means bothreadOnlyHintandread_only_hintareaccepted, which widens the surface a typo can land on.
The valid fields are
title,read_only_hint,destructive_hint,idempotent_hint,open_world_hint(mcp_types/_types.py:1365). A wrong nameproduces a tool whose annotations are missing, with no warning at build time, no
test failure, and no runtime symptom. The visible consequence is client-side: a
read-only tool that is not marked read-only gets treated as mutating.
Our mitigation (in the shared-core refactor for #133):
Operationcarriesfour explicitly typed
Optional[bool]fields rather than an open dict, and theadapter constructs
ToolAnnotationsby keyword, not**dict, so a wrong name isa
TypeError. A test asserts every hint maps to a declared field.Upstream state, searched 2026-08-28. No existing issue covers this:
cause one layer up:
ArgModelBasesilently ignores unknown tool-callarguments. Open, labelled
needs decision.touches only
func_metadata.py, soToolAnnotationsis unaffected by it.enforce hints and was closed: annotations are hints, and the SDK cannot know
a tool's real behaviour. Our report has to be explicit that it is not asking
for enforcement, only that a non-field should not be silently discarded.
Remaining action: file the upstream report (draft in the comment below).
Keep our guard test regardless.