Hand TypedDict tool results to pydantic natively - #3331
Conversation
MCPServer used to mirror a TypedDict return type into a synthesized BaseModel by hand. That mirror gave optional keys a `None` default and dumped them as `null`, so a tool omitting a `NotRequired`/`total=False` key produced structuredContent that violated its own outputSchema and was rejected by the client (#3224); it fed un-stripped `NotRequired`/`Required` (3.10) and `ReadOnly` (3.10-3.12) qualifiers to `create_model`, which raised at registration (#3227); and it dropped the TypedDict's docstring and `Annotated[..., Field(...)]` metadata from the schema. TypedDict returns are now validated and serialized through a `TypeAdapter` over the TypedDict itself, so pydantic's own handling of qualifiers, totality, docstrings and field metadata applies and omitted keys stay absent. Below Python 3.12 pydantic refuses `typing.TypedDict`, so those are rebuilt as an equivalent `typing_extensions.TypedDict` first. The validator is built once at registration, inside the existing "not serializable" fallback, and cached on `FuncMetadata` as `output_adapter`; `output_model` is now the TypedDict class for such tools. Observable schema change for TypedDict tools: optional keys no longer carry `"default": null`, and docstring/Field descriptions and constraints now appear. Fixes #3224 Fixes #3227
📚 Documentation preview
|
| @functools.cached_property | ||
| def output_adapter(self) -> TypeAdapter[Any]: | ||
| """Validates and serializes structured output against `output_model`.""" | ||
| assert self.output_model is not None, "Output model must be set if output schema is defined" |
There was a problem hiding this comment.
Additional findings (outside current diff — PR may have been updated during review):
-
🟡
src/mcp/server/mcpserver/utilities/func_metadata.py— nit: func_metadata's Returns docstring still says "output_model: A Pydantic model for the return type" although output_model is now the TypedDict class itself for TypedDict tools (field type widened to type[Any]) [also at: src/mcp/server/mcpserver/utilities/func_metadata.py:95 - nit: stale assert message in output_adapter — "Output model must be set if output schema is defined" was copied from…]Extended reasoning...
Concrete cost: misleading documentation. A caller reading func_metadata's docstring (src/mcp/server/mcpserver/utilities/func_metadata.py line 260) and treating meta.output_model as a BaseModel subclass (e.g. calling output_model.model_validate or model_json_schema) will get an AttributeError for TypedDict tools, since the diff changed output_model to hold the raw TypedDict class while the docstring was only partially updated (line 250 was fixed, line 260 was not).
Verification: nit — the claim is factually accurate. The diff changed
FuncMetadata.output_modelfromAnnotated[type[BaseModel], WithJsonSchema(None)] | NonetoAnnotated[type[Any], WithJsonSchema(None)] | None(src/mcp/server/mcpserver/utilities/func_metadata.py:89), and for TypedDict returns_create_output_modelnow stores the raw TypedDict class itself (`model = _pydantic_readable_typeddict(type_annot
| def _as_typing_extensions_typeddict(td_type: type[Any]) -> type[Any]: # pragma: lax no cover | ||
| items: dict[str, Any] = {} | ||
| for name, hint in get_type_hints(td_type, include_extras=True).items(): | ||
| key = inspect_annotation(hint, annotation_source=AnnotationSource.TYPED_DICT) | ||
| item: Any = Annotated[(key.type, *key.metadata)] if key.metadata else key.type | ||
| # pydantic's rule: an explicit qualifier wins over class totality. Needed because a stdlib TypedDict | ||
| # this old computes `__required_keys__` without seeing `typing_extensions` qualifiers. | ||
| required = (name in td_type.__required_keys__ or "required" in key.qualifiers) and ( | ||
| "not_required" not in key.qualifiers | ||
| ) | ||
| items[name] = item if required else NotRequired[item] | ||
| # The functional form, spelled so type checkers don't try to evaluate it statically. | ||
| rebuilt = cast("Callable[[str, dict[str, Any]], type[Any]]", TypedDict)(td_type.__name__, items) | ||
| rebuilt.__doc__ = td_type.__doc__ | ||
| return rebuilt |
There was a problem hiding this comment.
🔴 Py<3.12 TypedDict rebuild is shallow: nested stdlib typing.TypedDict field types are passed to pydantic unrebuilt, so pydantic raises PydanticUserError and the tool silently loses structured output on 3.10/3.11 only [additional confirmed claim at this location: The Python <3.12 TypedDict rebuild copies only doc, silently dropping __pydantic_config__, so pydantic config declared on a stdlib TypedDict (e.g. ConfigDict(extra='forbid')) is honored on…]
Extended reasoning...
On Python 3.10/3.11, a tool annotated -> Person where Person is a typing.TypedDict containing another typing.TypedDict field (e.g. address: Address) hits pydantic's 'use typing_extensions.TypedDict on Python < 3.12' PydanticUserError inside the registration try-block in func_metadata; the error is swallowed (logger.info) and the tool is registered with no outputSchema and no structuredContent, while the identical server code on Python 3.12+ publishes a full schema. Clients relying on outputSchema/structured_content see the tool behave differently depending on the server's Python version, with no error surfaced to the tool author (or an unexplained InvalidSignature if structured_output=True). _as_typing_extensions_typeddict could recurse into key.type values that are themselves stdlib TypedDicts (via _pydantic_readable_typeddict) to fix the whole class. Low severity edge case: pre-shim code also failed for nested stdlib TypedDicts on these versions, but it failed loudly at the decorator, and the PR states the shim keeps from typing import TypedDict working everywhere.
Verification: normal. The rebuild shim is shallow. In /home/claude/python-sdk/src/mcp/server/mcpserver/utilities/func_metadata.py, _pydantic_readable_typeddict (line 523-528) rebuilds only the class it is given: if sys.version_info >= (3, 12) or type(td_type).__module__ != "typing": return td_type else _as_typing_extensions_typeddict(td_type). Inside _as_typing_extensions_typeddict (lines 531-543), ea
| def _as_typing_extensions_typeddict(td_type: type[Any]) -> type[Any]: # pragma: lax no cover | ||
| items: dict[str, Any] = {} | ||
| for name, hint in get_type_hints(td_type, include_extras=True).items(): | ||
| key = inspect_annotation(hint, annotation_source=AnnotationSource.TYPED_DICT) |
There was a problem hiding this comment.
🟡 On Python 3.10/3.11 the TypedDict rebuild runs outside the registration-time try, so typing_inspection.ForbiddenQualifier raised by inspect_annotation(..., AnnotationSource.TYPED_DICT) for a qualifier invalid in TypedDicts (Final, ClassVar) escapes @ mcp.tool() as a raw undocumented exception, while the identical code on 3.12+ is caught (pydantic raises PydanticForbiddenQualifier, a PydanticUserError subclass, inside the except block at lines 395-401) and degrades gracefully to unstructured output.
Extended reasoning...
A server running on Python 3.10 or 3.11 has a module with from __future__ import annotations (annotations stored as strings, so stdlib TypedDict class creation performs no runtime check) defining class Config(TypedDict): retries: Final[int] (or ClassVar[int]) and a tool @ mcp.tool()\ndef get_config() -> Config. func_metadata calls _create_output_model at line 386, which calls _pydantic_readable_typeddict -> _as_typing_extensions_typeddict; get_type_hints resolves the string to Final[int] and inspect_annotation at line 534 raises typing_inspection.ForbiddenQualifier because AnnotationSource.TYPED_DICT only allows required/not_required/read_only. This happens BEFORE the try block at lines 390-408 (the only guard) and the only ForbiddenQualifier handler (line 316) covers just the return-annotation inspection, so the raw ForbiddenQualifier propagates through Tool.from_function (src/mcp/server/mcpserver/tools/base.py:95) and crashes server setup with an exception type that is neither InvalidSignature nor documented. On Python 3.12+, no rebuild happens and pydantic's TypeAdapter r
Verification: normal. The structural claim is verifiable directly from src/mcp/server/mcpserver/utilities/func_metadata.py. Line 386 runs _create_output_model(...) BEFORE the registration-time try at lines 390-401; on Python 3.10/3.11 that call reaches _pydantic_readable_typeddict (line 526: if sys.version_info >= (3, 12) or type(td_type).__module__ != "typing": return td_type — the rebuild only runs on
| ``` | ||
|
|
||
| A `TypedDict` is a plain `dict` at runtime, so that is what you build and return. The schema, the validation, and `structured_content` are identical to the `BaseModel` version (minus the descriptions, which `TypedDict` has no place for). | ||
| A `TypedDict` is a plain `dict` at runtime, so that is what you build and return. The schema, the validation, and `structured_content` are identical to the `BaseModel` version: the class docstring and `Annotated[..., Field(description=...)]` carry the descriptions, and a `NotRequired` key you leave out of the dict stays out of `structured_content`. |
There was a problem hiding this comment.
🟡 nit: docs now claim the TypedDict tutorial's schema is "identical to the BaseModel version: the class docstring and Annotated[..., Field(description=...)] carry the descriptions", but the referenced snippet docs_src/structured_output/tutorial003.py has no docstring and no Annotated metadata, and its schema demonstrably lacks the descriptions the BaseModel version has.
Extended reasoning...
A reader comparing the two tutorials sees tutorial002's schema contain "description": "Degrees Celsius." etc. while tutorial003's schema — locked in by the inline snapshot in tests/docs_src/test_structured_output.py::test_typeddict_produces_the_same_schema — has bare {"title": "Temperature", "type": "number"} properties. The page's own convention (tests/docs_src/test_structured_output.py header: "every claim the page makes, proved against the real SDK") is broken: the new sentence asserts identity-with-descriptions that the shown code does not produce. The old wording correctly said "minus the descriptions"; either tutorial003.py needs the docstring/Annotated Field(description=...) added (with the snapshot updated) or the sentence should say descriptions can be added that way rather than that the schemas are identical.
Verification: nit — the candidate's claim is factually accurate. The new sentence at docs/servers/structured-output.md:103 reads: "The schema, the validation, and structured_content are identical to the BaseModel version: the class docstring and Annotated[..., Field(description=...)] carry the descriptions...". But the snippet the page includes just above (docs_src/structured_output/tutorial003.py, unch
MCPServer now validates and serializes
TypedDicttool results through pydantic's own TypedDict support instead of mirroring the TypedDict into a hand-builtBaseModel.Fixes #3224
Fixes #3227
Motivation and Context
The hand-built mirror in
_create_model_from_typeddictwas the common root of a few problems withTypedDictreturn types: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 (structuredContent for a TypedDict return injects nulls for NotRequired keys, violating the tool's own outputSchema #3224). This affected every Python version.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(NotRequired TypedDict return annotation raises PydanticForbiddenQualifier at tool registration on Python 3.10 #3227, plus the unreportedReadOnlyvariant).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.typing.TypedDictbelow Python 3.12, so on 3.10/3.11 a stdlib TypedDict is rebuilt as an equivalenttyping_extensions.TypedDict(per-key required/optional derived the same way pydantic does it). This keepsfrom typing import TypedDictworking as a return type everywhere and can be deleted when 3.11 support is dropped.FuncMetadataasoutput_adapter.FuncMetadata.output_modelis now the TypedDict class for TypedDict tools (still the model class for everything else).This supersedes #3225 — thanks @sainikhiljuluri for the thorough reports and the initial fix, and @gingeekrishna for the
typing_extensions.get_type_hintspointer. I went with the native route rather thanexclude_unsetbecauseexclude_unsetrecurses into nested models (aBaseModelwith defaults inside a TypedDict would lose its defaulted fields) and the mirror would still publishdefault: nulland drop metadata.How Has This Been Tested?
Client(server)round trip; the changed tests fail onmain.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 for TypedDict tools only:
outputSchemano longer carries"default": nullon optional keys; the class docstring becomesdescription, andAnnotated[..., Field(...)]descriptions/constraints/aliases now appear and are enforced.structuredContentinstead ofnull.InvalidSignaturewithstructured_output=True) like other unsupported return types, instead of raising from the decorator.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
Checklist
Additional context
Not included, possible follow-ups: dataclass/plain-class returns still go through the hand-built model (
InitVarfields,slots=True,default_factoryhave similar rough edges), and model construction for those kinds still happens outside the fallbacktry.AI Disclaimer