ToolAnnotations accepts a misspelled hint without error and silently discards
it, producing a tool whose annotations are quietly missing.
Verified against mcp 2.1.1 / mcp-types 2.1.1:
from mcp.types import ToolAnnotations
ToolAnnotations(readOnlyHnt=True).model_dump(exclude_none=True, by_alias=True)
# {} <- no error, no warning, hint silently gone
Mechanism. Corrected 2026-08-28: the original report cited extra="allow" at
mcp/types/_types.py:1033, which does not match the version we use. There is no
explicit extra setting at all. The base class 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 dropped
rather than stored. This is shared by all 189 protocol models in that module.
populate_by_name also means both readOnlyHint and read_only_hint are
accepted, 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 name
produces 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): Operation carries
four explicitly typed Optional[bool] fields rather than an open dict, and the
adapter constructs ToolAnnotations by keyword, not **dict, so a wrong name is
a TypeError. A test asserts every hint maps to a declared field.
Upstream state, searched 2026-08-28. No existing issue covers this:
Remaining action: file the upstream report (draft in the comment below).
Keep our guard test regardless.
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.