Skip to content

Commit 3be803d

Browse files
committed
fix(mcpserver): keep Annotated field constraints on ordinary-class structured output (#3712)
_create_output_model resolved an ordinary class's field hints with get_type_hints(type_annotation), which drops Annotated metadata. A field declared Annotated[int, Field(gt=0)] collapsed to a bare int: the generated output schema lost exclusiveMinimum and convert_result accepted out-of-bound values such as 0 without raising ValidationError. Pass include_extras=True so the Annotated metadata is retained and carried into the synthesized Pydantic model, matching the TypedDict path which already resolves hints with extras. Fixes #3712 Signed-off-by: Pratik Dhanave <i.pratikdhanave@gmail.com>
1 parent af4c6f4 commit 3be803d

2 files changed

Lines changed: 38 additions & 1 deletion

File tree

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -552,7 +552,9 @@ def _create_output_model(original_annotation: Any, type_expr: Any, func_name: st
552552

553553
# Case 5: Other class types (dataclasses, regular classes with annotations)
554554
else:
555-
type_hints = get_type_hints(type_annotation)
555+
# include_extras keeps `Annotated[...]` metadata (e.g. Field(gt=0)) so field
556+
# constraints survive into the synthesized model instead of being silently dropped.
557+
type_hints = get_type_hints(type_annotation, include_extras=True)
556558
if type_hints:
557559
# Classes with type hints can be converted to Pydantic models
558560
model = _create_model_from_class(type_annotation, type_hints)

‎tests/server/mcpserver/test_func_metadata.py‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -973,6 +973,41 @@ def func_returning_class() -> PersonClass: # pragma: no cover
973973
}
974974

975975

976+
def test_structured_output_ordinary_class_keeps_annotated_field_constraints():
977+
"""Annotated field constraints on an ordinary class must survive into the output model.
978+
979+
Regression test for #3712: ``get_type_hints`` was called without ``include_extras=True``,
980+
so ``Annotated[int, Field(gt=0)]`` collapsed to a bare ``int`` - the constraint vanished
981+
from the schema and output conversion accepted out-of-bound values.
982+
"""
983+
984+
class Result:
985+
value: Annotated[int, Field(gt=0)]
986+
987+
def __init__(self, value: int) -> None:
988+
self.value = value
989+
990+
def produce() -> Result: # pragma: no cover - never called
991+
return Result(0)
992+
993+
meta = func_metadata(produce)
994+
995+
# The constraint must appear in the generated schema.
996+
assert meta.output_schema == {
997+
"type": "object",
998+
"properties": {"value": {"type": "integer", "exclusiveMinimum": 0}},
999+
"required": ["value"],
1000+
"title": "Result",
1001+
}
1002+
1003+
# A valid value round-trips.
1004+
assert meta.convert_result(Result(1)).structured_content == {"value": 1}
1005+
1006+
# An out-of-bound value is rejected rather than silently accepted.
1007+
with pytest.raises(ValidationError):
1008+
meta.convert_result(Result(0))
1009+
1010+
9761011
def test_unstructured_output_unannotated_class():
9771012
# Test with class that has no annotations
9781013
class UnannotatedClass:

0 commit comments

Comments
 (0)