Hand TypedDict tool results to pydantic natively

Merged
MA
2mo ago
+230-85

MCPServer now validates and serializes TypedDict tool results through pydantic's own TypedDict support instead of mirroring the TypedDict into a hand-built BaseModel.

Fixes #3224
Fixes #3227

Motivation and Context

The hand-built mirror in _create_model_from_typeddict was the common root of a few problems with TypedDict return types:

  • Optional keys (NotRequired, total=False) got a None default and were dumped as null, so a tool that left one out produced structuredContent that violated its own outputSchema ({"type": "integer", "default": null}) and the client rejected the call (#3224). This affected every Python version.
  • The field types came from typing.get_type_hints, which leaves NotRequired/Required in place on 3.10 and ReadOnly in place on 3.10–3.12; create_model rejects bare qualifiers, so registration raised PydanticForbiddenQualifier (#3227, plus the unreported ReadOnly variant).
  • The TypedDict's docstring and any Annotated[..., Field(...)] metadata never reached the schema, and constraints weren't enforced.

Nested and wrapped TypedDicts (-> list[Person], a TypedDict inside a model) already went through pydantic natively and had none of these issues, so this makes the top-level case consistent with them.

What changes:

  • TypedDict returns are handled by a TypeAdapter over the TypedDict itself; _create_model_from_typeddict is gone.
  • pydantic refuses typing.TypedDict below Python 3.12, so on 3.10/3.11 a stdlib TypedDict return type is rebuilt as an equivalent typing_extensions.TypedDict when the validator is built (per-key required/optional derived the way pydantic does it; docstring, __module__/__qualname__, __pydantic_config__ and ReadOnly carried over). output_model stays the class the user declared. Only the top-level class is rebuilt: a stdlib TypedDict nested inside one (or config inherited from a stdlib base) still needs typing_extensions below 3.12 and otherwise falls back to unstructured output with an INFO log; wrapped forms (list[StdTD], StdTD | None, ...) raise pydantic's "use typing_extensions.TypedDict" at registration exactly as they do on main. Delete when 3.11 support is dropped.
  • FuncMetadata derives a private validator (and output_schema, unless one is given) from output_model when it is constructed; func_metadata() constructs it inside the existing "not serializable for structured output" fallback, so unsupported return types still degrade (or raise InvalidSignature with structured_output=True). The fields are read live: code that clears or assigns output_schema/output_model on a registered tool's fn_metadata keeps working, and the validator is rebuilt if output_model is reassigned. FuncMetadata.output_model is the TypedDict class for TypedDict tools (still the model class for everything else); its annotation widens to type[Any] | None.
  • Results are validated with by_name=True as well as by alias, so a return type declaring Field(alias=...) accepts the Python-side keys a tool naturally returns while structuredContent carries the aliases the schema advertises.

This supersedes #3225 — thanks @sainikhiljuluri for the thorough reports and the initial fix, and @gingeekrishna for the typing_extensions.get_type_hints pointer. I went with the native route rather than exclude_unset because exclude_unset recurses into nested models (a BaseModel with defaults inside a TypedDict would lose its defaulted fields) and the mirror would still publish default: null and drop metadata.

How Has This Been Tested?

  • New/updated unit tests for qualifiers, Annotated metadata, aliases and omitted keys (using stdlib TypedDict so the 3.10/3.11 legs go through the rebuild), hand-built and post-registration-mutated FuncMetadata, an unresolvable key annotation, plus an in-memory Client(server) round trip; the changed tests fail on main.
  • Drove a stdio server with TypedDict tools (both typing and typing_extensions spellings, NotRequired/Required/ReadOnly, nested model with defaults, passthrough CallToolResult) through mcp.Client on 3.14 and on 3.10, and compared against main.

Breaking Changes

No code changes needed. Observable differences, mostly for TypedDict tools:

  • outputSchema no longer carries "default": null on optional keys; the class docstring becomes description; Annotated[..., Field(...)] descriptions/constraints/aliases and __pydantic_config__/@with_config (e.g. extra='forbid', alias_generator) now appear and are enforced.
  • Omitted optional keys are absent from structuredContent instead of null.
  • A TypedDict pydantic can't build a schema for (un-schema-able value type, unresolvable key annotation) now falls back to unstructured output (or InvalidSignature with structured_output=True) like other unsupported return types, instead of raising from the decorator. One 3.10-only wrinkle: a quoted forward reference inside a builtin generic on a TypedDict key (children: list["Node"]) is something pydantic can't resolve on 3.10, so it takes that fallback too; from __future__ import annotations, List["Node"] or quoting the whole annotation work.
  • For any structured return type, a dict keyed by field names now validates where the type declares aliases (previously only alias keys did).
  • A ReadOnly key triggers pydantic's own UserWarning ("Pydantic will not protect items from any mutation") once at registration. I left that visible rather than filtering pydantic's message in library code; happy to revisit.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

Additional context

Not included, possible follow-ups: dataclass/plain-class returns still go through the hand-built model (InitVar fields, slots=True, default_factory have similar rough edges); the synthesized models (wrapped {"result": ...}, dict[str, T], dataclass/plain class) are still constructed outside the fallback try, so an un-schema-able member type there still raises at registration; recursive return types publish a root $ref schema that 2025-11-25 sessions reject (#3337, pre-existing for BaseModel, now also TypedDict); schema is generated in validation mode while content is serialized (#3100).

AI Disclaimer