Skip to content

MCP: ToolAnnotations silently accepts misspelled hints #142

Description

@LukasGold

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.

Activity

  1. LukasGold commented on Aug 28, 2026

    @LukasGold
    ContributorAuthor

    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: mcp 2.1.1, mcp-types 2.1.1

    This is modelcontextprotocol/python-sdk#3067 one layer
    down: the same extra="ignore" default, but in the protocol types rather than in
    FastMCP's argument models. PR
    modelcontextprotocol/python-sdk#3068 fixes it for
    ArgModelBase only, 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_name also
    means both readOnlyHint and read_only_hint are 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 that readOnlyHnt is not a field of ToolAnnotations, 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" via model_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 cover MCPModel too would also work, instead of a
    second issue.

  2. LukasGold commented on Aug 31, 2026

    @LukasGold
    ContributorAuthor

    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.

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

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions