You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Ordinary-class structured outputs discard Annotated field constraints and accept invalid values #3712
An ordinary class's Annotated field constraints disappear when its return annotation is converted into a structured output model. A field declared Annotated[int, Field(gt=0)] produces a schema with only type: integer, and an instance containing zero is accepted as structured_content={"value": 0}.
I expected the generated property to retain exclusiveMinimum: 0 and output conversion to reject zero. The reproduction first proves that Pydantic validates the exact field annotation and rejects that value, then fails because SDK conversion does not raise ValidationError. A BaseModel with the same constraint rejects zero, and the existing ordinary-class output test still passes.
This is a class-field path, separate from the string-keyed dictionary return annotation metadata loss tracked in #2935. The relevant class path calls get_type_hints(type_annotation) without retaining extras before synthesizing the model. A tool author relying on the declared bound therefore gets neither the advertised constraint nor its validation.
uv run --frozen pytest tests/server/mcpserver/test_func_metadata.py::test_ordinary_class_output_rejects_values_outside_annotated_field_bounds -q
Python & MCP Python SDK
Python 3.14.7; Pydantic 2.12.5; MCP 2.3.0 (tag v2.3.0, 2118f14f8a19bc158d8a1cf90af58d85d187f849). Also reproduced using main source at 754254c08fe1c3eda18b69da3d336106fe8d72d6.
AI-assisted investigation and test reproduction, with a human in the loop.
Looks like the cause is L540 in func_metadata.py at 754254c. The Case 4 path (dataclasses and plain annotated classes) calls get_type_hints(type_annotation) without include_extras=True, so Annotated is stripped before _create_model_from_class builds the model. The TypedDict rebuild at L595 already passes include_extras=True.
Dataclasses are affected too. On MCP 2.3.0, pydantic 2.14.1, Python 3.13, a @dataclass with value: Annotated[int, Field(gt=0)] gives {'title': 'Value', 'type': 'integer'} and convert_result accepts {'value': 0}.
Workarounds until it is fixed:
Return a TypedDict. The schema keeps exclusiveMinimum: 0, and {"value": 0} raises greater_than.
A BaseModel return keeps the schema constraint, but the returned instance is not re-validated, so model_construct(value=0) still goes out as {'value': 0}. Normal construction fails inside the tool body, which is usually what you want.
Changing L540 to get_type_hints(type_annotation, include_extras=True) fixed the plain class and the dataclass cases locally, with TypedDict unchanged. I have not run the repo's test suite against it.
The likely cause is in the line your description already names. get_type_hints(cls) strips Annotated metadata unless include_extras=True is passed, and the stripped metadata is exactly the Field(gt=0) bound that disappears. So the class path is not losing the constraint during model synthesis, it is losing it before synthesis ever sees it.
Two cheap checks confirm it without touching the fix:
Compare get_type_hints(cls) against get_type_hints(cls, include_extras=True) for the repro class. If the first omits the Field and the second keeps it, that is the whole bug, and the change is the flag plus a test.
Check whether the class path reaches create_model or TypeAdapter with the already-stripped hints. If it does, the fix is the flag. If it goes through TypeAdapter directly and still loses the bound, then something downstream is dropping it and the flag is necessary but not sufficient.
Two hazards while you are in there. include_extras=True changes what other consumers of those hints see, so anything that later introspects the same class expecting bare types will now receive Annotated objects and has to unwrap them. And ClassVar and InitVar behave differently under __dataclass_fields__ and fields(), so if the synthesized model iterates class fields it is worth asserting both markers are handled rather than assuming symmetric treatment.
On the test, comparing schemas for the two paths is more durable than asserting on one. Emit the JSON Schema for the ordinary class and for the equivalent BaseModel and assert both contain exclusiveMinimum: 0, then assert both raise on zero. That way the two paths cannot drift apart again silently, which is what made this one survive: the existing ordinary class test passed while the constraint was already gone.
Thanks @sattyamjjain for confirming that dataclasses are affected too, and for the BaseModel clarification: normal construction validates, but an already-constructed instance isn't necessarily revalidated. The include_extras=True change matches the source-level cause.
@dhvani7014 I'm happy for someone else to take the fix forward, subject to maintainer assignment; I'm not reserving it for myself. Please coordinate here if either of you has a patch underway so work isn't duplicated. Regression coverage should check both the advertised constraint and rejection of invalid results for ordinary classes and dataclasses, with valid results still accepted.
Thanks @inanna-malick and thanks @sattyamjjain for confirming the dataclass case. I reproduced this on main and confirmed the cause: L540 in func_metadata.py calls get_type_hints(type_annotation) without include_extras=True, so the Annotated constraint is stripped.
Passing include_extras=True fixes both ordinary classes and dataclasses. I added regression tests for each, checking that the schema keeps exclusiveMinimum: 0, that 0 is rejected with ValidationError, and that valid values are still accepted. The tests fail without the change and pass with it, and the full suite, ruff and pyright pass locally.
Activity
dhvani7014 commentedon Oct 15, 2026
Hi, I would like to work on this if it's not already taken.
sattyamjjain commentedon Oct 15, 2026
Looks like the cause is L540 in func_metadata.py at 754254c. The Case 4 path (dataclasses and plain annotated classes) calls
get_type_hints(type_annotation)withoutinclude_extras=True, soAnnotatedis stripped before_create_model_from_classbuilds the model. The TypedDict rebuild at L595 already passesinclude_extras=True.python-sdk/src/mcp/server/mcpserver/utilities/func_metadata.py
Line 540 in 754254c
Dataclasses are affected too. On MCP 2.3.0, pydantic 2.14.1, Python 3.13, a
@dataclasswithvalue: Annotated[int, Field(gt=0)]gives{'title': 'Value', 'type': 'integer'}andconvert_resultaccepts{'value': 0}.Workarounds until it is fixed:
exclusiveMinimum: 0,and{"value": 0}raisesgreater_than.model_construct(value=0)still goes out as{'value': 0}. Normal construction fails inside the tool body, which is usually what you want.Changing L540 to
get_type_hints(type_annotation, include_extras=True)fixed the plain class and the dataclass cases locally, with TypedDict unchanged. I have not run the repo's test suite against it.Ben-Home commentedon Oct 16, 2026
The likely cause is in the line your description already names.
get_type_hints(cls)stripsAnnotatedmetadata unlessinclude_extras=Trueis passed, and the stripped metadata is exactly theField(gt=0)bound that disappears. So the class path is not losing the constraint during model synthesis, it is losing it before synthesis ever sees it.Two cheap checks confirm it without touching the fix:
Compare
get_type_hints(cls)againstget_type_hints(cls, include_extras=True)for the repro class. If the first omits theFieldand the second keeps it, that is the whole bug, and the change is the flag plus a test.Check whether the class path reaches
create_modelorTypeAdapterwith the already-stripped hints. If it does, the fix is the flag. If it goes throughTypeAdapterdirectly and still loses the bound, then something downstream is dropping it and the flag is necessary but not sufficient.Two hazards while you are in there.
include_extras=Truechanges what other consumers of those hints see, so anything that later introspects the same class expecting bare types will now receiveAnnotatedobjects and has to unwrap them. AndClassVarandInitVarbehave differently under__dataclass_fields__andfields(), so if the synthesized model iterates class fields it is worth asserting both markers are handled rather than assuming symmetric treatment.On the test, comparing schemas for the two paths is more durable than asserting on one. Emit the JSON Schema for the ordinary class and for the equivalent
BaseModeland assert both containexclusiveMinimum: 0, then assert both raise on zero. That way the two paths cannot drift apart again silently, which is what made this one survive: the existing ordinary class test passed while the constraint was already gone.inanna-malick commentedon Oct 16, 2026
Thanks @sattyamjjain for confirming that dataclasses are affected too, and for the BaseModel clarification: normal construction validates, but an already-constructed instance isn't necessarily revalidated. The include_extras=True change matches the source-level cause.
@dhvani7014 I'm happy for someone else to take the fix forward, subject to maintainer assignment; I'm not reserving it for myself. Please coordinate here if either of you has a patch underway so work isn't duplicated. Regression coverage should check both the advertised constraint and rejection of invalid results for ordinary classes and dataclasses, with valid results still accepted.
AI-assisted follow-up.
dhvani7014 commentedon Oct 18, 2026
Thanks @inanna-malick and thanks @sattyamjjain for confirming the dataclass case. I reproduced this on main and confirmed the cause: L540 in func_metadata.py calls get_type_hints(type_annotation) without include_extras=True, so the Annotated constraint is stripped.
Passing include_extras=True fixes both ordinary classes and dataclasses. I added regression tests for each, checking that the schema keeps exclusiveMinimum: 0, that 0 is rejected with ValidationError, and that valid values are still accepted. The tests fail without the change and pass with it, and the full suite, ruff and pyright pass locally.
The patch is ready on my fork: https://github.com/dhvani7014/python-sdk/tree/fix-annotated-constraints-class-output. I’ll wait for maintainer assignment before opening a PR. Disclosure: AI-assisted investigation and test writing, with me running and reviewing everything.