1111import anyio .to_thread
1212import pydantic_core
1313from 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+ )
1524from pydantic .fields import FieldInfo
1625from 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
1827from typing_inspection .introspection import (
1928 UNKNOWN ,
2029 AnnotationSource ,
@@ -84,16 +93,27 @@ def model_dump_one_level(self) -> dict[str, Any]:
8493
8594
8695class 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
531562def _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
0 commit comments