From 3be803dd0bb1914270aed1ccd6d9a22f462219d9 Mon Sep 17 00:00:00 2001 From: Pratik Dhanave Date: Wed, 4 Nov 2026 23:24:22 +0530 Subject: [PATCH] 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 --- .../mcpserver/utilities/func_metadata.py | 4 ++- tests/server/mcpserver/test_func_metadata.py | 35 +++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) 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: