Hand TypedDict tool results to pydantic natively
MCPServer now validates and serializes TypedDict tool results through pydantic's own TypedDict support instead of mirroring the TypedDict into a hand-built BaseModel.
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 aNonedefault and were dumped asnull, so a tool that left one out producedstructuredContentthat violated its ownoutputSchema({"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 leavesNotRequired/Requiredin place on 3.10 andReadOnlyin place on 3.10–3.12;create_modelrejects bare qualifiers, so registration raisedPydanticForbiddenQualifier(#3227, plus the unreportedReadOnlyvariant). - 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:
TypedDictreturns are handled by aTypeAdapterover the TypedDict itself;_create_model_from_typeddictis gone.- pydantic refuses
typing.TypedDictbelow Python 3.12, so on 3.10/3.11 a stdlib TypedDict return type is rebuilt as an equivalenttyping_extensions.TypedDictwhen the validator is built (per-key required/optional derived the way pydantic does it; docstring,__module__/__qualname__,__pydantic_config__andReadOnlycarried over).output_modelstays 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 needstyping_extensionsbelow 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 onmain. Delete when 3.11 support is dropped. FuncMetadataderives a private validator (andoutput_schema, unless one is given) fromoutput_modelwhen it is constructed;func_metadata()constructs it inside the existing "not serializable for structured output" fallback, so unsupported return types still degrade (or raiseInvalidSignaturewithstructured_output=True). The fields are read live: code that clears or assignsoutput_schema/output_modelon a registered tool'sfn_metadatakeeps working, and the validator is rebuilt ifoutput_modelis reassigned.FuncMetadata.output_modelis the TypedDict class for TypedDict tools (still the model class for everything else); its annotation widens totype[Any] | None.- Results are validated with
by_name=Trueas well as by alias, so a return type declaringField(alias=...)accepts the Python-side keys a tool naturally returns whilestructuredContentcarries 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,
Annotatedmetadata, aliases and omitted keys (using stdlibTypedDictso the 3.10/3.11 legs go through the rebuild), hand-built and post-registration-mutatedFuncMetadata, an unresolvable key annotation, plus an in-memoryClient(server)round trip; the changed tests fail onmain. - Drove a stdio server with TypedDict tools (both
typingandtyping_extensionsspellings,NotRequired/Required/ReadOnly, nested model with defaults, passthroughCallToolResult) throughmcp.Clienton 3.14 and on 3.10, and compared againstmain.
Breaking Changes
No code changes needed. Observable differences, mostly for TypedDict tools:
outputSchemano longer carries"default": nullon optional keys; the class docstring becomesdescription;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
structuredContentinstead ofnull. - A TypedDict pydantic can't build a schema for (un-schema-able value type, unresolvable key annotation) now falls back to unstructured output (or
InvalidSignaturewithstructured_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
ReadOnlykey triggers pydantic's ownUserWarning("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).