Skip to content

Commit 053ffda

Browse files
committed
Derive the output validator on FuncMetadata construction; review fixes
FuncMetadata now derives its structured-output validator (and the schema, when none is given) from `output_model` when it is constructed, keeping it in a private attribute instead of a public cached property guarded by an assert. The fields are still read live and the validator is rebuilt if `output_model` is reassigned, so code that clears or sets `output_schema`/`output_model` on a registered tool keeps working. `func_metadata()` simply constructs the metadata inside the existing "not serializable" fallback. Results are validated with `by_name=True` as well as by alias, so a TypedDict or model that declares `Field(alias=...)` accepts the Python-side keys a tool returns while structured content still carries the aliases the schema advertises. The `typing.TypedDict` rebuild for Python < 3.12 now runs inside that validator build, so `output_model` stays the declared class and rebuild failures take the same fallback as pydantic's own; it also carries over `__module__`, `__qualname__`, `__pydantic_config__` and `ReadOnly`, and an unresolvable key annotation (`NameError`) degrades like it does on 3.12+.
1 parent 1e90f40 commit 053ffda

4 files changed

Lines changed: 116 additions & 42 deletions

File tree

docs/servers/structured-output.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ Not every shape deserves a class. A `TypedDict` produces the same schema:
100100
--8<-- "docs_src/structured_output/tutorial003.py"
101101
```
102102

103-
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`.
103+
A `TypedDict` is a plain `dict` at runtime, so that is what you build and return. The schema, the validation, and `structured_content` follow the same rules as the `BaseModel` version: add a class docstring or `Annotated[..., Field(description=...)]` and they become the descriptions, and a `NotRequired` key you leave out of the dict stays out of `structured_content`.
104104

105105
## A dataclass
106106

src/mcp/server/mcpserver/utilities/func_metadata.py

Lines changed: 62 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,19 @@
1111
import anyio.to_thread
1212
import pydantic_core
1313
from mcp_types import CallToolResult, ContentBlock, InputRequiredResult, TextContent
14-
from pydantic import BaseModel, ConfigDict, Field, PydanticUserError, TypeAdapter, WithJsonSchema, create_model
14+
from pydantic import (
15+
BaseModel,
16+
ConfigDict,
17+
Field,
18+
PrivateAttr,
19+
PydanticUserError,
20+
TypeAdapter,
21+
WithJsonSchema,
22+
create_model,
23+
)
1524
from pydantic.fields import FieldInfo
1625
from pydantic.json_schema import GenerateJsonSchema, JsonSchemaWarningKind
17-
from typing_extensions import NotRequired, TypedDict, get_type_hints, is_typeddict
26+
from typing_extensions import NotRequired, ReadOnly, TypedDict, get_type_hints, is_typeddict
1827
from typing_inspection.introspection import (
1928
UNKNOWN,
2029
AnnotationSource,
@@ -84,16 +93,27 @@ def model_dump_one_level(self) -> dict[str, Any]:
8493

8594

8695
class FuncMetadata(BaseModel):
96+
"""A tool function's argument model plus, for structured output, the published `output_schema` and the
97+
`output_model` results are validated against. Constructing one with an `output_model` and no schema derives
98+
the schema (and raises if pydantic can't); the fields are read live, so clearing or reassigning them later
99+
takes effect on the next call."""
100+
87101
arg_model: Annotated[type[ArgModelBase], WithJsonSchema(None)]
88102
output_schema: dict[str, Any] | None = None
89103
output_model: Annotated[type[Any], WithJsonSchema(None)] | None = None
90104
wrap_output: bool = False
105+
_adapter: tuple[type[Any], TypeAdapter[Any]] | None = PrivateAttr(default=None)
91106

92-
@functools.cached_property
93-
def output_adapter(self) -> TypeAdapter[Any]:
94-
"""Validates and serializes structured output against `output_model`."""
95-
assert self.output_model is not None, "Output model must be set if output schema is defined"
96-
return TypeAdapter(self.output_model)
107+
def model_post_init(self, context: Any, /) -> None:
108+
if self.output_model is not None and self.output_schema is None:
109+
# StrictJsonSchema raises instead of warning, so an unserializable return type fails construction.
110+
self.output_schema = self._output_adapter(self.output_model).json_schema(schema_generator=StrictJsonSchema)
111+
112+
def _output_adapter(self, output_model: type[Any]) -> TypeAdapter[Any]:
113+
"""The validator/serializer for `output_model`, built once and rebuilt only if the field is reassigned."""
114+
if self._adapter is None or self._adapter[0] is not output_model:
115+
self._adapter = (output_model, TypeAdapter(_pydantic_readable_typeddict(output_model)))
116+
return self._adapter[1]
97117

98118
def validate_arguments(self, arguments_to_validate: dict[str, Any]) -> dict[str, Any]:
99119
"""Validate raw arguments into a one-level kwargs dict (no function call).
@@ -149,25 +169,29 @@ def convert_result(self, result: Any) -> CallToolResult | InputRequiredResult:
149169
"""
150170
if isinstance(result, InputRequiredResult):
151171
return result
172+
# A schema published without a model (hand-built metadata) is advertised but not validated here.
173+
output_model = self.output_model if self.output_schema is not None else None
152174
if isinstance(result, CallToolResult):
153-
if self.output_schema is not None:
154-
self.output_adapter.validate_python(result.structured_content)
175+
if output_model is not None:
176+
self._output_adapter(output_model).validate_python(result.structured_content)
155177
return result
156178

157179
unstructured_content = _convert_to_content(result)
158180

159-
if self.output_schema is None:
181+
if output_model is None:
160182
return CallToolResult(content=unstructured_content)
161183

162184
if self.wrap_output:
163185
result = {"result": result}
164186

165-
validated = self.output_adapter.validate_python(result)
187+
# The tool hands back Python-side names; the wire (and outputSchema) use aliases.
188+
adapter = self._output_adapter(output_model)
189+
validated = adapter.validate_python(result, by_alias=True, by_name=True)
166190
if isinstance(validated, BaseModel):
167191
# Dump via the instance so a returned subclass keeps its own fields.
168192
structured_content = validated.model_dump(mode="json", by_alias=True)
169193
else:
170-
structured_content = self.output_adapter.dump_python(validated, mode="json", by_alias=True)
194+
structured_content = adapter.dump_python(validated, mode="json", by_alias=True)
171195

172196
return CallToolResult(content=unstructured_content, structured_content=structured_content)
173197

@@ -257,7 +281,9 @@ def func_metadata(
257281
Returns:
258282
A FuncMetadata object containing:
259283
- arg_model: A Pydantic model representing the function's arguments
260-
- output_model: A Pydantic model for the return type if the output is structured
284+
- output_schema: The published JSON schema for structured output, or None if the output is unstructured
285+
- output_model: The type structured output is validated against: the declared BaseModel or TypedDict,
286+
or a synthesized model for wrapped, `dict[str, T]` and annotated-class returns
261287
- wrap_output: Whether the function result needs to be wrapped in `{"result": ...}` for structured output.
262288
"""
263289
try:
@@ -386,14 +412,14 @@ def func_metadata(
386412
output_model, wrap_output = _create_output_model(original_annotation, return_type_expr, func.__name__)
387413

388414
if output_model is not None:
389-
meta = FuncMetadata(arg_model=arguments_model, output_model=output_model, wrap_output=wrap_output)
390415
try:
391-
# Building the validator here surfaces unsupported types at registration rather than on the
392-
# first call. StrictJsonSchema raises instead of emitting warnings.
393-
meta.output_schema = meta.output_adapter.json_schema(schema_generator=StrictJsonSchema)
394-
return meta
416+
# FuncMetadata builds the validator and schema on construction, so an unsupported return type
417+
# surfaces here, at registration, rather than on the first call.
418+
return FuncMetadata(arg_model=arguments_model, output_model=output_model, wrap_output=wrap_output)
395419
except (
396420
PydanticUserError,
421+
ForbiddenQualifier,
422+
NameError,
397423
TypeError,
398424
ValueError,
399425
pydantic_core.SchemaError,
@@ -402,7 +428,10 @@ def func_metadata(
402428
# These are expected errors when a type can't be converted to a Pydantic schema
403429
# PydanticUserError: When Pydantic can't handle the type (e.g. PydanticInvalidForJsonSchema);
404430
# subclasses TypeError on pydantic <2.13 and RuntimeError on pydantic >=2.13
405-
# ValueError: When there are issues with the type definition (including our custom warnings)
431+
# ForbiddenQualifier, NameError: an invalid qualifier or unresolvable annotation on a TypedDict key,
432+
# met while rebuilding a stdlib TypedDict below 3.12 (pydantic reports both as PydanticUserError)
433+
# ValueError: When there are issues with the type definition (including our custom warnings);
434+
# arrives wrapped in a ValidationError when raised during FuncMetadata construction
406435
# SchemaError: When Pydantic can't build a schema
407436
# ValidationError: When validation fails
408437
logger.info(f"Cannot create schema for type {return_type_expr} in {func.__name__}: {type(e).__name__}: {e}")
@@ -468,7 +497,7 @@ def _create_output_model(original_annotation: Any, type_expr: Any, func_name: st
468497

469498
# Case 2: TypedDicts (pydantic reads qualifiers, totality, docstring and `Annotated` metadata natively)
470499
elif is_typeddict(type_annotation):
471-
model = _pydantic_readable_typeddict(type_annotation)
500+
model = type_annotation
472501

473502
# Case 3: Primitive types that need wrapping
474503
elif type_annotation in (str, int, float, bool, bytes, type(None)):
@@ -520,19 +549,23 @@ def _create_model_from_class(cls: type[Any], type_hints: dict[str, Any]) -> type
520549
return create_model(cls.__name__, __config__=ConfigDict(from_attributes=True), **model_fields)
521550

522551

523-
def _pydantic_readable_typeddict(td_type: type[Any]) -> type[Any]:
524-
"""pydantic refuses `typing.TypedDict` below Python 3.12 (it needs `__orig_bases__`); rebuild those as an
525-
equivalent `typing_extensions.TypedDict` so tool authors don't have to know. Delete once 3.11 support goes."""
526-
if sys.version_info >= (3, 12) or type(td_type).__module__ != "typing":
527-
return td_type
528-
return _as_typing_extensions_typeddict(td_type) # pragma: lax no cover
552+
def _pydantic_readable_typeddict(output_model: type[Any]) -> type[Any]:
553+
"""pydantic refuses `typing.TypedDict` below Python 3.12 (it needs `__orig_bases__`); rebuild such a return
554+
type as an equivalent `typing_extensions.TypedDict` so tool authors don't have to know. Only the class itself
555+
(its keys, docstring and own config) is rebuilt: stdlib TypedDicts nested inside it, or config inherited from
556+
one, still need `typing_extensions` there. Delete with 3.11 support."""
557+
if sys.version_info >= (3, 12) or not is_typeddict(output_model) or type(output_model).__module__ != "typing":
558+
return output_model
559+
return _as_typing_extensions_typeddict(output_model) # pragma: lax no cover
529560

530561

531562
def _as_typing_extensions_typeddict(td_type: type[Any]) -> type[Any]: # pragma: lax no cover
532563
items: dict[str, Any] = {}
533564
for name, hint in get_type_hints(td_type, include_extras=True).items():
534565
key = inspect_annotation(hint, annotation_source=AnnotationSource.TYPED_DICT)
535566
item: Any = Annotated[(key.type, *key.metadata)] if key.metadata else key.type
567+
if "read_only" in key.qualifiers:
568+
item = ReadOnly[item]
536569
# pydantic's rule: an explicit qualifier wins over class totality. Needed because a stdlib TypedDict
537570
# this old computes `__required_keys__` without seeing `typing_extensions` qualifiers.
538571
required = (name in td_type.__required_keys__ or "required" in key.qualifiers) and (
@@ -541,7 +574,9 @@ def _as_typing_extensions_typeddict(td_type: type[Any]) -> type[Any]: # pragma:
541574
items[name] = item if required else NotRequired[item]
542575
# The functional form, spelled so type checkers don't try to evaluate it statically.
543576
rebuilt = cast("Callable[[str, dict[str, Any]], type[Any]]", TypedDict)(td_type.__name__, items)
544-
rebuilt.__doc__ = td_type.__doc__
577+
for attr in ("__doc__", "__module__", "__qualname__", "__pydantic_config__"):
578+
if hasattr(td_type, attr):
579+
setattr(rebuilt, attr, getattr(td_type, attr))
545580
return rebuilt
546581

547582

tests/server/mcpserver/test_func_metadata.py

Lines changed: 51 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,21 @@
55
# pyright: reportUnknownLambdaType=false
66
from collections.abc import Callable
77
from dataclasses import dataclass
8-
from typing import Annotated, Any, Final, NamedTuple, TypedDict
8+
from typing import TYPE_CHECKING, Annotated, Any, Final, NamedTuple, TypedDict
99

1010
import annotated_types
1111
import pytest
12-
import typing_extensions
1312
from dirty_equals import IsPartialDict
1413
from mcp_types import CallToolResult, ContentBlock, EmbeddedResource, InputRequiredResult, TextContent
15-
from pydantic import BaseModel, Field
14+
from pydantic import BaseModel, Field, ValidationError
1615
from typing_extensions import NotRequired, ReadOnly, Required
1716

1817
from mcp.server.mcpserver import Audio, Image
1918
from mcp.server.mcpserver.exceptions import InvalidSignature
20-
from mcp.server.mcpserver.utilities.func_metadata import func_metadata
19+
from mcp.server.mcpserver.utilities.func_metadata import ArgModelBase, FuncMetadata, func_metadata
20+
21+
if TYPE_CHECKING:
22+
from decimal import Decimal
2123

2224

2325
class SomeInputModelA(BaseModel):
@@ -821,13 +823,14 @@ def func_returning_typeddict_required() -> PersonTypedDictRequired: # pragma: n
821823

822824

823825
def test_structured_output_typeddict_qualifiers_and_metadata():
824-
"""PEP 655/705 qualifiers register on every supported Python and decide `required`; the docstring
825-
and `Annotated` field metadata reach the schema like they do for a BaseModel."""
826+
"""PEP 655/705 qualifiers register on every supported Python and decide `required`; the docstring and
827+
`Annotated` field metadata reach the schema like they do for a BaseModel, and an alias names the wire key.
828+
A stdlib TypedDict, so below 3.12 all of this goes through the typing_extensions rebuild."""
826829

827-
class Forecast(typing_extensions.TypedDict, total=False):
830+
class Forecast(TypedDict, total=False):
828831
"""Tomorrow's weather."""
829832

830-
city: Required[Annotated[str, Field(description="City name")]]
833+
city: Required[Annotated[str, Field(alias="cityName", description="City name")]]
831834
high: ReadOnly[Required[float]]
832835
low: float
833836
summary: NotRequired[Annotated[str, Field(max_length=80)]]
@@ -839,21 +842,58 @@ def forecast() -> Forecast:
839842
meta = func_metadata(forecast)
840843
result = meta.convert_result(forecast())
841844
assert isinstance(result, CallToolResult)
842-
assert result.structured_content == {"city": "Berlin", "high": 21.5}
845+
assert result.structured_content == {"cityName": "Berlin", "high": 21.5}
843846
assert meta.output_schema == {
844847
"type": "object",
845848
"title": "Forecast",
846849
"description": "Tomorrow's weather.",
847850
"properties": {
848-
"city": {"title": "City", "type": "string", "description": "City name"},
851+
"cityName": {"title": "Cityname", "type": "string", "description": "City name"},
849852
"high": {"title": "High", "type": "number"},
850853
"low": {"title": "Low", "type": "number"},
851854
"summary": {"title": "Summary", "type": "string", "maxLength": 80},
852855
},
853-
"required": ["city", "high"],
856+
"required": ["cityName", "high"],
854857
}
855858

856859

860+
def test_func_metadata_built_by_hand_keeps_a_given_output_schema():
861+
"""A hand-built FuncMetadata publishes the schema it was given but still validates results against output_model."""
862+
meta = FuncMetadata(arg_model=ArgModelBase, output_model=SomeInputModelB.InnerModel, output_schema={"x": 1})
863+
assert meta.output_schema == {"x": 1}
864+
with pytest.raises(ValidationError):
865+
meta.convert_result({"x": "not an int"})
866+
867+
868+
def test_func_metadata_output_fields_are_read_live():
869+
"""Code in the wild switches structured output off or on by assigning the fields after registration."""
870+
871+
def total() -> int:
872+
return 3
873+
874+
meta = func_metadata(total)
875+
meta.output_schema = None
876+
off = meta.convert_result(total())
877+
meta.output_schema, meta.output_model, meta.wrap_output = {"type": "object"}, SomeInputModelB.InnerModel, False
878+
on = meta.convert_result({"x": 3})
879+
assert isinstance(off, CallToolResult) and isinstance(on, CallToolResult)
880+
assert (off.structured_content, on.structured_content) == (None, {"x": 3})
881+
882+
883+
def test_structured_output_typeddict_with_unresolvable_annotation_is_unstructured():
884+
"""An annotation only importable under TYPE_CHECKING degrades the same way on every supported Python."""
885+
886+
class Report(TypedDict):
887+
total: "Decimal"
888+
889+
def report() -> Report: # pragma: no cover
890+
raise NotImplementedError
891+
892+
assert func_metadata(report).output_schema is None
893+
with pytest.raises(InvalidSignature):
894+
func_metadata(report, structured_output=True)
895+
896+
857897
def test_structured_output_ordinary_class():
858898
"""Test structured output with ordinary annotated classes"""
859899

tests/server/mcpserver/test_server.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import base64
22
from pathlib import Path
33
from types import SimpleNamespace
4-
from typing import Any, TypedDict
4+
from typing import Any
55
from unittest.mock import AsyncMock, MagicMock, patch
66

77
import anyio
@@ -44,7 +44,7 @@
4444
from pydantic import BaseModel
4545
from starlette.applications import Starlette
4646
from starlette.routing import Mount, Route
47-
from typing_extensions import NotRequired
47+
from typing_extensions import NotRequired, TypedDict
4848

4949
from mcp.client import Client
5050
from mcp.server.context import ServerRequestContext
@@ -716,7 +716,6 @@ async def test_remove_tool_and_call(self):
716716
assert "Unknown tool" in content.text
717717

718718

719-
@pytest.mark.anyio
720719
async def test_typeddict_tool_omitting_optional_keys_passes_client_validation():
721720
"""The client validates structured content against the tool's output schema, so a `NotRequired`
722721
key the tool leaves out must be absent from `structured_content` rather than null."""

0 commit comments

Comments
 (0)