Skip to content

FastMCP crashes when tool return type uses Python 3.10+ A | B | C union syntax #2591

Closed as not planned

Description

@Swcmb

Initial Checks

Description

FastMCP crashes on startup when a tool function's return type annotation uses Python 3.10+ union syntax (A | B | C, i.e. types.UnionType). The error originates from _create_wrapped_model in func_metadata.py, which passes the union annotation directly to Pydantic's create_model() as a bare keyword value, causing PydanticUserError.

Example Code

from mcp.server.fastmcp import FastMCP

mcp = FastMCP("test")

@mcp.tool()
async def my_tool(flag: bool) -> dict | list | str:  # <-- triggers the bug
    if flag:
        return {"key": "value"}
    return ["item"]

Python & MCP Python SDK

- mcp (Python): latest (>=1.0.0,<2.0.0)
- pydantic: >=2.10 (observed on 2.10.x)
- Python: >=3.10

Activity

  1. Swcmb commented on May 14, 2026

    @Swcmb
    Author

    Discovery Process

    While developing a local MCP tool server with FastMCP, the server immediately crashed on startup with a PydanticUserError. The error message pointed to the result = dict | list | str field in a dynamically generated Pydantic model. Tracing the call stack backward:

    1. The error came from pydantic's create_model() in _create_wrapped_model.
    2. _create_wrapped_model was called from _try_create_model_and_schema in func_metadata.py.
    3. It was triggered because the scripts() tool's return type dict | list | str (a types.UnionType) fell outside both the GenericAlias and plain type branches, landing in the generic else branch that wraps the annotation as-is.

    Comparing with Union[dict, list, str] (which is a GenericAlias and works fine) confirmed that only the Python 3.10+ | syntax triggers this path. The fix was then implemented and verified locally.

    Error Trace

    pydantic.errors.PydanticUserError: A non-annotated attribute was detected:
      `result = dict | list | str`.
    All model fields require a type annotation; if `result` is not meant to be a field,
    you may be able to resolve this error by annotating it as a `ClassVar` or
    updating `model_config['ignored_types']`.
    

    Root Cause

    The call chain is:

    1. inspect.signature(func, eval_str=True) resolves dict | list | str into a types.UnionType object (Python 3.10+).
    2. In func_metadata.py:_try_create_model_and_schema, the UnionType is not a GenericAlias nor a plain type, so it falls into the else branch at line ~406.
    3. _create_wrapped_model(model_name, result=annotation) is called.
    4. Inside _create_wrapped_model, create_model(model_name, result=annotation) is called — Pydantic's create_model interprets result=dict | list | str as a bare default value (not a type annotation), because UnionType is not recognized as a field annotation in this context.

    Proposed Fix

    In _create_wrapped_model (file: src/mcp/server/fastmcp/utilities/func_metadata.py), change the create_model call to use the (type, default) tuple form, which explicitly tells Pydantic that the first element is a type annotation:

    # Line ~483
    - return create_model(model_name, result=annotation)
    + return create_model(model_name, result=(annotation, ...))

    ... (Ellipsis) means the field is required. This syntax works correctly with types.UnionType, GenericAlias, and all other annotation forms.

    Workaround

    If you cannot patch the library, avoid using Python 3.10+ union syntax in tool return types:

    # Instead of:
    async def my_tool(flag: bool) -> dict | list | str:
    
    # Use typing.Union:
    from typing import Union
    async def my_tool(flag: bool) -> Union[dict, list, str]:

    Union[dict, list, str] is a GenericAlias and is handled correctly by the existing code path.


    Reported by: Ianwusb — discovered the bug while using FastMCP in a personal project, traced the error through the call stack, and identified the root cause with assistance from DeepSeek-V4-Pro analysis.

  2. jshaofa-ui commented on May 14, 2026

    @jshaofa-ui
  3. jshaofa-ui commented on May 14, 2026

    @jshaofa-ui
  4. added
    triageQueued for automated analysis — bot will process and remove this label
    on May 31, 2026
  5. ghost added
    bugSomething isn't working
    on Jun 1, 2026
  6. ghost added
    needs reproneeds additional information to be able to reproduce bug
    on Jun 1, 2026
  7. ghost removed
    triageQueued for automated analysis — bot will process and remove this label
    on Jun 1, 2026
  8. Bartok9 commented on Jun 18, 2026

    @Bartok9

    Verification on current main (734746a): this no longer reproduces. The original repro registers cleanly and produces valid structured output.

    from mcp.server.mcpserver import MCPServer  # FastMCP was renamed in #1951 (65c614e)
    mcp = MCPServer("test")
    
    @mcp.tool()
    async def my_tool(flag: bool) -> dict | list | str:
        return {"key": "value"} if flag else ["item"]
    # -> registers without PydanticUserError

    Direct check of func_metadata(my_tool, structured_output=True).output_schema:

    {'properties': {'result': {'anyOf': [{'additionalProperties': True, 'type': 'object'},
                                         {'items': {}, 'type': 'array'},
                                         {'type': 'string'}],
                               'title': 'Result'}},
     'required': ['result'], 'title': 'my_toolOutput', 'type': 'object'}

    Why it's fixed: dict | list | str is a types.UnionType, which is neither a types.GenericAlias nor a type, so in _try_create_model_and_schema (func_metadata.py) it now falls through to the catch-all branch that calls _create_wrapped_model(...) with wrap_output=True. The bare union is never passed to create_model() as a field value, so the PydanticUserError from the original report no longer occurs. The schema-generation try/except (added in #2434, 5cbd259, "catch PydanticUserError when generating output schema") additionally guards the StrictJsonSchema path.

    The three prior fix attempts (#2592, #2599, #2669) were all closed during backlog cleanup rather than for an approach problem, and the underlying behavior is now correct on main. Suggest this can be closed as resolved (or the needs repro label dropped) — happy to open a focused regression test PR asserting the union output_schema shape if maintainers want a guard against regressions, since I don't see an existing test covering the A | B | C return-annotation case.

  9. maxisbey commented on Aug 10, 2026

    @maxisbey
    Contributor

    The create_model failure is specific to pydantic 2.10.x, below the SDK's supported floor (pydantic>=2.11.0 on the 1.x line, >=2.12.0 on main); on supported pydantic a -> dict | list | str return annotation registers normally, so the fix is upgrading pydantic in the environment rather than an SDK change. Closing this as part of a wider backlog cleanup following the v2 launch. Feel free to reopen if this is still relevant.

    AI Disclaimer

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 workingneeds reproneeds additional information to be able to reproduce bug

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions