diff --git a/src/mcp/server/mcpserver/utilities/func_metadata.py b/src/mcp/server/mcpserver/utilities/func_metadata.py index e5721488f5..c1ed42a0a6 100644 --- a/src/mcp/server/mcpserver/utilities/func_metadata.py +++ b/src/mcp/server/mcpserver/utilities/func_metadata.py @@ -552,7 +552,9 @@ def _create_output_model(original_annotation: Any, type_expr: Any, func_name: st # Case 5: Other class types (dataclasses, regular classes with annotations) else: - type_hints = get_type_hints(type_annotation) + # include_extras keeps `Annotated[...]` metadata (e.g. Field(gt=0)) so field + # constraints survive into the synthesized model instead of being silently dropped. + type_hints = get_type_hints(type_annotation, include_extras=True) if type_hints: # Classes with type hints can be converted to Pydantic models model = _create_model_from_class(type_annotation, type_hints) diff --git a/tests/server/mcpserver/test_func_metadata.py b/tests/server/mcpserver/test_func_metadata.py index f929fb3954..7de210ea9a 100644 --- a/tests/server/mcpserver/test_func_metadata.py +++ b/tests/server/mcpserver/test_func_metadata.py @@ -973,6 +973,41 @@ def func_returning_class() -> PersonClass: # pragma: no cover } +def test_structured_output_ordinary_class_keeps_annotated_field_constraints(): + """Annotated field constraints on an ordinary class must survive into the output model. + + Regression test for #3712: ``get_type_hints`` was called without ``include_extras=True``, + so ``Annotated[int, Field(gt=0)]`` collapsed to a bare ``int`` - the constraint vanished + from the schema and output conversion accepted out-of-bound values. + """ + + class Result: + value: Annotated[int, Field(gt=0)] + + def __init__(self, value: int) -> None: + self.value = value + + def produce() -> Result: # pragma: no cover - never called + return Result(0) + + meta = func_metadata(produce) + + # The constraint must appear in the generated schema. + assert meta.output_schema == { + "type": "object", + "properties": {"value": {"type": "integer", "exclusiveMinimum": 0}}, + "required": ["value"], + "title": "Result", + } + + # A valid value round-trips. + assert meta.convert_result(Result(1)).structured_content == {"value": 1} + + # An out-of-bound value is rejected rather than silently accepted. + with pytest.raises(ValidationError): + meta.convert_result(Result(0)) + + def test_unstructured_output_unannotated_class(): # Test with class that has no annotations class UnannotatedClass: