Skip to content

Dataclass structured outputs mishandle slots and InitVar fields #3711

Open

Description

@inanna-malick

Initial Checks

Release line

2.x (current stable)

Description

Two standard dataclass configurations fail when used as structured tool outputs. With @dataclass(slots=True), schema derivation silently falls back to unstructured output: func_metadata(produce).output_schema is None. With an InitVar[str] constructor parameter, metadata construction instead raises PydanticForbiddenQualifier because the constructor-only annotation is treated as a model field.

I expected the schema and structured result to contain the dataclass's instance fields. A constructor-only InitVar should not become an output property. The ordinary dataclass control passes, and Pydantic's own TypeAdapter serializes both variants to {"value": "ok"} in the same environments. These cases matter when an existing dataclass is reused as a tool return value.

Example Code

from dataclasses import InitVar, dataclass
from mcp.server.mcpserver.utilities.func_metadata import func_metadata

@dataclass(slots=True)
class Slotted:
    value: str

def slotted() -> Slotted:
    return Slotted("ok")

print(func_metadata(slotted).output_schema)  # None

@dataclass
class ConstructorOnly:
    value: str
    token: InitVar[str]

def constructor_only() -> ConstructorOnly:
    return ConstructorOnly("ok", "constructor-only")

func_metadata(constructor_only)  # PydanticForbiddenQualifier

Two deterministic, intentionally failing tests are on this test-only branch; no production changes. Run either case independently, for example:

uv run --frozen pytest tests/server/mcpserver/test_func_metadata.py::test_slotted_dataclass_structured_output_has_instance_fields -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.

Activity

  1. added
    bugSomething isn't working
    v2Affects the v2 line (2.x on main)
    v1Affects the v1.x maintenance line
    on Oct 14, 2026
  2. nosmile99 commented on Oct 16, 2026

    @nosmile99

    Reproduced both cases on main and confirmed the failure modes, then verified a fix locally against your repro branch (inanna-malick/python-sdk@repro/dataclass-output-fields):

    Root cause — both live in _create_model_from_class (src/mcp/server/mcpserver/utilities/func_metadata.py):

    1. Slotted dataclasses: getattr(cls, field_name, _no_default) returns the slot member descriptor, which the code mistakes for a default value. create_model then fails and schema derivation silently falls back to unstructured output (output_schema=None).
    2. InitVar: get_type_hints keeps token: InitVar[str] in the hints and it is fed straight into create_model, which pydantic rejects with PydanticForbiddenQualifier (raised outside the FuncMetadata try block, so it propagates).

    Fix (verified locally): in _create_model_from_class,

    • skip hints where isinstance(field_type, dataclasses.InitVar) — constructor-only, not instance state, so never an output property;
    • read dataclass defaults from dataclasses.fields() instead of attribute access (slot descriptors are not defaults); non-dataclass classes keep the old getattr path, with member descriptors additionally treated as "no default".

    Results: both repro tests now pass (test_slotted_dataclass_structured_output_has_instance_fields, test_dataclass_initvar_is_excluded_from_structured_output — confirmed failing before the fix), the existing test_structured_output_dataclass control still passes, and the full tests/server/mcpserver/test_func_metadata.py module is 60/60 green; broader tests/server/mcpserver/ is 643 passed with 1 unrelated environmental failure (test_file_resources.py::test_permission_error, fails when run as root). ruff check and ruff format --check are clean on the touched file.

    I have the patch ready as a branch. Happy to open a PR if the issue gets assigned (per the repo gate, unassigned PRs are auto-closed) — just say the word.

    Disclosure: this investigation and fix verification were AI-assisted.

  3. inanna-malick commented on Oct 16, 2026

    @inanna-malick
    Author

    Thanks @nosmile99 for reproducing both cases and checking the broader suite. I'm happy for you to take the fix forward, subject to maintainer assignment; I'm not reserving it for myself.

    Could you link your patch branch here? Reading dataclass fields instead of treating slot descriptors as defaults sounds like the right direction. Alongside the two original repros, inherited fields and default_factory fields would be useful checks.

    AI-assisted follow-up.

  4. nosmile99 commented on Oct 16, 2026

    @nosmile99

    Patch branch is up: https://github.com/nosmile99/python-sdk/tree/fix/dataclass-structured-output

    The fix is in _create_model_from_class (src/mcp/server/mcpserver/utilities/func_metadata.py). For dataclasses, defaults are now read from dataclasses.fields() instead of attribute access — that fixes both reported cases at once: slot descriptors are no longer mistaken for defaults, and InitVar/ClassVar annotations are excluded since they never appear as dataclass fields. Non-dataclass slotted classes get the member-descriptor guard too, so they no longer silently fall back to unstructured output.

    Verification:

    • Both of your repro tests pass (I included them in the branch, attributed to your repro branch), confirmed failing on main before the fix
    • Your requested extras: inherited fields, plain defaults, and default_factory defaults all land correctly in the schema (inherited fields required, defaults optional)
    • Full tests/server/mcpserver/test_func_metadata.py module: 63/63 green; broader tests/server/mcpserver/: 646 passed, 1 unrelated environmental failure (test_file_resources.py::test_permission_error, fails as root on a clean tree too)
    • ruff check and ruff format --check clean on both touched files

    Happy to open the PR whenever a maintainer assigns the issue — the repo gate auto-closes unassigned outside PRs, so I will hold it until then.

    Disclosure: AI-assisted, as before.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingv1Affects the v1.x maintenance linev2Affects the v2 line (2.x on main)

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions