11import functools
22import inspect
33import json
4+ import sys
45from collections .abc import Awaitable , Callable , Sequence
56from itertools import chain
67from types import GenericAlias
7- from typing import Annotated , Any , Union , cast , get_args , get_origin , get_type_hints
8+ from typing import Annotated , Any , Union , cast , get_args , get_origin
89
910import anyio
1011import anyio .to_thread
1112import pydantic_core
1213from mcp_types import CallToolResult , ContentBlock , InputRequiredResult , TextContent
13- from pydantic import BaseModel , ConfigDict , Field , PydanticUserError , 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+ )
1424from pydantic .fields import FieldInfo
1525from pydantic .json_schema import GenerateJsonSchema , JsonSchemaWarningKind
16- from typing_extensions import is_typeddict
26+ from typing_extensions import NotRequired , ReadOnly , TypedDict , get_type_hints , is_typeddict
1727from typing_inspection .introspection import (
1828 UNKNOWN ,
1929 AnnotationSource ,
@@ -83,10 +93,27 @@ def model_dump_one_level(self) -> dict[str, Any]:
8393
8494
8595class 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+
86101 arg_model : Annotated [type [ArgModelBase ], WithJsonSchema (None )]
87102 output_schema : dict [str , Any ] | None = None
88- output_model : Annotated [type [BaseModel ], WithJsonSchema (None )] | None = None
103+ output_model : Annotated [type [Any ], WithJsonSchema (None )] | None = None
89104 wrap_output : bool = False
105+ _adapter : tuple [type [Any ], TypeAdapter [Any ]] | None = PrivateAttr (default = None )
106+
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 ]
90117
91118 def validate_arguments (self , arguments_to_validate : dict [str , Any ]) -> dict [str , Any ]:
92119 """Validate raw arguments into a one-level kwargs dict (no function call).
@@ -142,23 +169,29 @@ def convert_result(self, result: Any) -> CallToolResult | InputRequiredResult:
142169 """
143170 if isinstance (result , InputRequiredResult ):
144171 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
145174 if isinstance (result , CallToolResult ):
146- if self .output_schema is not None :
147- assert self .output_model is not None , "Output model must be set if output schema is defined"
148- self .output_model .model_validate (result .structured_content )
175+ if output_model is not None :
176+ self ._output_adapter (output_model ).validate_python (result .structured_content )
149177 return result
150178
151179 unstructured_content = _convert_to_content (result )
152180
153- if self . output_schema is None :
181+ if output_model is None :
154182 return CallToolResult (content = unstructured_content )
155183
156184 if self .wrap_output :
157185 result = {"result" : result }
158186
159- assert self .output_model is not None , "Output model must be set if output schema is defined"
160- validated = self .output_model .model_validate (result )
161- structured_content = validated .model_dump (mode = "json" , by_alias = True )
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 )
190+ if isinstance (validated , BaseModel ):
191+ # Dump via the instance so a returned subclass keeps its own fields.
192+ structured_content = validated .model_dump (mode = "json" , by_alias = True )
193+ else :
194+ structured_content = adapter .dump_python (validated , mode = "json" , by_alias = True )
162195
163196 return CallToolResult (content = unstructured_content , structured_content = structured_content )
164197
@@ -238,7 +271,7 @@ def func_metadata(
238271 - BaseModel subclasses (used directly)
239272 - Primitive types (str, int, float, bool, bytes, None) - wrapped in a
240273 model with a 'result' field
241- - TypedDict - converted to a Pydantic model with same fields
274+ - TypedDict - used directly
242275 - Dataclasses and other annotated classes - converted to Pydantic models
243276 - Generic types (list, dict, Union, etc.) - wrapped in a model with a 'result' field
244277 - Content blocks (TextContent, EmbeddedResource, ...), Image and Audio, bare or inside a
@@ -248,7 +281,9 @@ def func_metadata(
248281 Returns:
249282 A FuncMetadata object containing:
250283 - arg_model: A Pydantic model representing the function's arguments
251- - 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
252287 - wrap_output: Whether the function result needs to be wrapped in `{"result": ...}` for structured output.
253288 """
254289 try :
@@ -374,30 +409,44 @@ def func_metadata(
374409 # structured_output=True still forces one.
375410 return FuncMetadata (arg_model = arguments_model )
376411
377- output_model , output_schema , wrap_output = _try_create_model_and_schema (
378- original_annotation , return_type_expr , func .__name__
379- )
412+ output_model , wrap_output = _create_output_model (original_annotation , return_type_expr , func .__name__ )
380413
381- if output_model is None and structured_output is True :
414+ if output_model is not None :
415+ try :
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 )
419+ except (
420+ PydanticUserError ,
421+ ForbiddenQualifier ,
422+ NameError ,
423+ TypeError ,
424+ ValueError ,
425+ pydantic_core .SchemaError ,
426+ pydantic_core .ValidationError ,
427+ ) as e :
428+ # These are expected errors when a type can't be converted to a Pydantic schema
429+ # PydanticUserError: When Pydantic can't handle the type (e.g. PydanticInvalidForJsonSchema);
430+ # subclasses TypeError on pydantic <2.13 and RuntimeError on pydantic >=2.13
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
435+ # SchemaError: When Pydantic can't build a schema
436+ # ValidationError: When validation fails
437+ logger .info (f"Cannot create schema for type { return_type_expr } in { func .__name__ } : { type (e ).__name__ } : { e } " )
438+
439+ if structured_output is True :
382440 # Model creation failed or produced warnings - no structured output
383441 raise InvalidSignature (
384442 f"Function { func .__name__ } : return type { return_type_expr } is not serializable for structured output"
385443 )
386444
387- return FuncMetadata (
388- arg_model = arguments_model ,
389- output_schema = output_schema ,
390- output_model = output_model ,
391- wrap_output = wrap_output ,
392- )
445+ return FuncMetadata (arg_model = arguments_model )
393446
394447
395- def _try_create_model_and_schema (
396- original_annotation : Any ,
397- type_expr : Any ,
398- func_name : str ,
399- ) -> tuple [type [BaseModel ] | None , dict [str , Any ] | None , bool ]:
400- """Try to create a model and schema for the given annotation without warnings.
448+ def _create_output_model (original_annotation : Any , type_expr : Any , func_name : str ) -> tuple [type [Any ] | None , bool ]:
449+ """Pick the type structured output is validated against for the given return annotation.
401450
402451 Args:
403452 original_annotation: The original return annotation (may be wrapped in `Annotated`).
@@ -406,11 +455,11 @@ def _try_create_model_and_schema(
406455 func_name: The name of the function.
407456
408457 Returns:
409- tuple of (model or None, schema or None, wrap_output)
410- Model and schema are None if warnings occur or creation fails .
458+ tuple of (model or None, wrap_output)
459+ Model is None if the type cannot carry structured output .
411460 wrap_output is True if the result needs to be wrapped in {"result": ...}
412461 """
413- model = None
462+ model : type [ Any ] | None = None
414463 wrap_output = False
415464
416465 # First handle special case: None
@@ -446,9 +495,9 @@ def _try_create_model_and_schema(
446495 if issubclass (type_annotation , BaseModel ):
447496 model = type_annotation
448497
449- # Case 2: TypedDicts:
498+ # Case 2: TypedDicts (pydantic reads qualifiers, totality, docstring and `Annotated` metadata natively)
450499 elif is_typeddict (type_annotation ):
451- model = _create_model_from_typeddict ( type_annotation )
500+ model = type_annotation
452501
453502 # Case 3: Primitive types that need wrapping
454503 elif type_annotation in (str , int , float , bool , bytes , type (None )):
@@ -470,30 +519,7 @@ def _try_create_model_and_schema(
470519 model = _create_wrapped_model (func_name , original_annotation )
471520 wrap_output = True
472521
473- if model :
474- # If we successfully created a model, try to get its schema
475- # Use StrictJsonSchema to raise exceptions instead of warnings
476- try :
477- schema = model .model_json_schema (schema_generator = StrictJsonSchema )
478- except (
479- PydanticUserError ,
480- TypeError ,
481- ValueError ,
482- pydantic_core .SchemaError ,
483- pydantic_core .ValidationError ,
484- ) as e :
485- # These are expected errors when a type can't be converted to a Pydantic schema
486- # PydanticUserError: When Pydantic can't handle the type (e.g. PydanticInvalidForJsonSchema);
487- # subclasses TypeError on pydantic <2.13 and RuntimeError on pydantic >=2.13
488- # ValueError: When there are issues with the type definition (including our custom warnings)
489- # SchemaError: When Pydantic can't build a schema
490- # ValidationError: When validation fails
491- logger .info (f"Cannot create schema for type { type_expr } in { func_name } : { type (e ).__name__ } : { e } " )
492- return None , None , False
493-
494- return model , schema , wrap_output
495-
496- return None , None , False
522+ return model , wrap_output
497523
498524
499525_no_default = object ()
@@ -523,25 +549,35 @@ def _create_model_from_class(cls: type[Any], type_hints: dict[str, Any]) -> type
523549 return create_model (cls .__name__ , __config__ = ConfigDict (from_attributes = True ), ** model_fields )
524550
525551
526- def _create_model_from_typeddict (td_type : type [Any ]) -> type [BaseModel ]:
527- """Create a Pydantic model from a TypedDict.
528-
529- The created model will have the same name and fields as the TypedDict.
530- """
531- type_hints = get_type_hints (td_type )
532- required_keys = getattr (td_type , "__required_keys__" , set (type_hints .keys ()))
533-
534- model_fields : dict [str , Any ] = {}
535- for field_name , field_type in type_hints .items ():
536- if field_name not in required_keys :
537- # For optional TypedDict fields, set default=None
538- # This makes them not required in the Pydantic model
539- # The model should use exclude_unset=True when dumping to get TypedDict semantics
540- model_fields [field_name ] = (field_type , None )
541- else :
542- model_fields [field_name ] = field_type
543-
544- return create_model (td_type .__name__ , ** model_fields )
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
560+
561+
562+ def _as_typing_extensions_typeddict (td_type : type [Any ]) -> type [Any ]: # pragma: lax no cover
563+ items : dict [str , Any ] = {}
564+ for name , hint in get_type_hints (td_type , include_extras = True ).items ():
565+ key = inspect_annotation (hint , annotation_source = AnnotationSource .TYPED_DICT )
566+ item : Any = Annotated [(key .type , * key .metadata )] if key .metadata else key .type
567+ if "read_only" in key .qualifiers :
568+ item = ReadOnly [item ]
569+ # pydantic's rule: an explicit qualifier wins over class totality. Needed because a stdlib TypedDict
570+ # this old computes `__required_keys__` without seeing `typing_extensions` qualifiers.
571+ required = (name in td_type .__required_keys__ or "required" in key .qualifiers ) and (
572+ "not_required" not in key .qualifiers
573+ )
574+ items [name ] = item if required else NotRequired [item ]
575+ # The functional form, spelled so type checkers don't try to evaluate it statically.
576+ rebuilt = cast ("Callable[[str, dict[str, Any]], type[Any]]" , TypedDict )(td_type .__name__ , items )
577+ for attr in ("__doc__" , "__module__" , "__qualname__" , "__pydantic_config__" ):
578+ if hasattr (td_type , attr ):
579+ setattr (rebuilt , attr , getattr (td_type , attr ))
580+ return rebuilt
545581
546582
547583def _create_wrapped_model (func_name : str , annotation : Any ) -> type [BaseModel ]:
0 commit comments