Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/mcp/server/mcpserver/utilities/func_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
35 changes: 35 additions & 0 deletions tests/server/mcpserver/test_func_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading