Skip to content

func_metadata raises uncaught PydanticSchemaGenerationError for Iterator/AsyncIterator tool return annotations instead of the unstructured fallback #3573

Description

@BlueX888

Initial Checks

Release line

2.x (current stable)

Description

Registering a tool whose function is annotated -> Iterator[...] or -> AsyncIterator[...] — the PEP 484 spelling for generator functions — raises a raw pydantic.errors.PydanticSchemaGenerationError at registration time, instead of either falling back to an unstructured tool (structured_output=None, the default) or raising the SDK's InvalidSignature (structured_output=True). The same happens via @server.tool() / Tool.from_function(), so a properly typed generator tool cannot be registered at all.

Actual output of the script in "Example Code" (error text truncated at 80 chars by the script):

func_metadata(search): UNCAUGHT pydantic.errors.PydanticSchemaGenerationError: Unable to generate pydantic-core schema for typing.Iterator[str]. Set `arbitrary
func_metadata(search, structured_output=True): UNCAUGHT pydantic.errors.PydanticSchemaGenerationError: Unable to generate pydantic-core schema for typing.Iterator[str]. Set `arbitrary
Tool.from_function(search): UNCAUGHT pydantic.errors.PydanticSchemaGenerationError: Unable to generate pydantic-core schema for typing.Iterator[str]. Set `arbitrary

Full traceback (captured with the collections.abc spelling of the same annotation, so the error names it accordingly):

  File "src/mcp/server/mcpserver/utilities/func_metadata.py", line 444, in func_metadata
    output_model, wrap_output = _create_output_model(original_annotation, return_type_expr, func.__name__)
  File "src/mcp/server/mcpserver/utilities/func_metadata.py", line 550, in _create_output_model
    model = _create_wrapped_model(func_name, original_annotation)
  File "src/mcp/server/mcpserver/utilities/func_metadata.py", line 621, in _create_wrapped_model
    return create_model(model_name, result=annotation)
pydantic.errors.PydanticSchemaGenerationError: Unable to generate pydantic-core schema for collections.abc.Iterator[str]. Set `arbitrary_types_allowed=True` in the model_config to ignore this error or implement `__get_pydantic_core_schema__` on your type to fully support it.

What I expected is what already happens for other unserializable return types, pinned by test_structured_output_unserializable_type_error (tests/server/mcpserver/test_func_metadata.py:1233, passes on main) and documented in docs/servers/structured-output.md: with structured_output=None, registration succeeds and output_schema is None (fallback to text); with structured_output=True, InvalidSignature: Function search: return type ... is not serializable for structured output. For contrast, Iterable[str] and Generator[str, None, None] both register successfully through the same wrapped-model path — only the PEP 484-recommended spellings for generators crash.

Root cause: _create_output_model(...) is called at src/mcp/server/mcpserver/utilities/func_metadata.py:444, outside the try/except at lines 446–470 whose except tuple (PydanticUserError, pydantic_core.SchemaError, ...) exists exactly so that "an unsupported return type surfaces here, at registration" as a clean failure. _create_output_model → _create_wrapped_model → create_model(model_name, result=annotation) (line 621) builds a schema itself, and its PydanticSchemaGenerationError (a PydanticUserError subclass) escapes uncaught. Moving the line 444 call inside the existing try/except looks like it would restore both documented behaviours; I'd be happy to be assigned and open a PR with that approach.

Related: the guard was added in #2434 (for #1131), but it wraps only the FuncMetadata construction, not this call. #1060 reports the same error class for a different type (Image, 1.x fastmcp) and looks unrelated to this code path.

AI disclosure: this issue and its reproduction were prepared with AI assistance.

Example Code

from typing import Iterator

from mcp.server.mcpserver.tools import Tool
from mcp.server.mcpserver.utilities.func_metadata import func_metadata


def search(n: int) -> Iterator[str]:
    yield from ["a"] * n


for label, call in [
    ("func_metadata(search)", lambda: func_metadata(search)),
    ("func_metadata(search, structured_output=True)", lambda: func_metadata(search, structured_output=True)),
    ("Tool.from_function(search)", lambda: Tool.from_function(search)),
]:
    try:
        call()
        print(f"{label}: OK")
    except Exception as e:
        print(f"{label}: UNCAUGHT {type(e).__module__}.{type(e).__name__}: {str(e)[:80]}")
# AsyncIterator[str] return annotations behave identically

Python & MCP Python SDK

mcp: main @ 6affe5c0d3588fd1705713b3703dc68015cfe3eb
     func_metadata.py is identical in v2.2.0 (latest 2.x release);
     the same crash reproduces on a fresh `pip install mcp==2.2.0`
Python 3.13.15
pydantic 2.12.5
macOS 26.6.2 (arm64)

Activity

JingHao-Leon commented on Sep 23, 2026

@JingHao-Leon

Confirmed on mcp 2.2.0 — all three registration shapes raise the raw pydantic.errors.PydanticSchemaGenerationError for collections.abc.Iterator[str] / AsyncIterator[str] returns (func_metadata(search), func_metadata(search, structured_output=True), and the Tool.from_function path).

The traceback pins exactly where the intended fallback is defeated. In mcp/server/mcpserver/utilities/func_metadata.py:

line 444, in func_metadata          -> output_model, wrap_output = _create_output_model(...)
line 518, in _create_output_model   -> model = _create_wrapped_model(func_name, original_annotation)
line 621, in _create_wrapped_model  -> create_model(... result: Iterator[str] ...)  # raises

Two things make this a straightforward fix:

  1. The raise happens at L444, outside the guarded region. The try/except at L447-469 exists precisely to turn unsupported return types into a graceful outcome, and PydanticSchemaGenerationError (a PydanticUserError subclass on pydantic 2.13) is already covered conceptually by its caught types — but _create_output_model runs before the try, so _create_wrapped_model's create_model call escapes unprotected. Both intended behaviors are defeated by the same escape: the unstructured fallback (return FuncMetadata(arg_model=arguments_model), L477) and, for structured_output=True, the proper InvalidSignature (L471-475).

  2. Iterator returns aren't recognized as content-iterators. The early-out at L437 (if structured_output is None and _returns_content(return_type_expr)) returns an unstructured FuncMetadata for annotations the model reads as content — but it doesn't match the PEP 484 generator spellings Iterator[...]/AsyncIterator[...], so they fall through into schema construction instead.

Wrapping the _create_output_model call in the existing except handling would restore both documented behaviors with one change; optionally treating Iterator/AsyncIterator origins in _returns_content would give generator tools the same unstructured default as content-block returns.

added
v2Affects the v2 line (2.x on main)
v1Affects the v1.x maintenance line
on Sep 23, 2026

BlueX888 commented on Sep 23, 2026

@BlueX888
Author

Reproduced this. Root cause: src/mcp/server/mcpserver/utilities/func_metadata.py:444 calls _create_output_model outside the try/except at lines 446-470 that reports unserializable return types at registration, so the PydanticSchemaGenerationError it raises for Iterator/AsyncIterator escapes func_metadata uncaught.

Minimal reproduction:

from typing import Iterator

from mcp.server.mcpserver.tools import Tool
from mcp.server.mcpserver.utilities.func_metadata import func_metadata


def search(n: int) -> Iterator[str]:
    yield from ["a"] * n


for label, call in [
    ("func_metadata(search)", lambda: func_metadata(search)),
    ("func_metadata(search, structured_output=True)", lambda: func_metadata(search, structured_output=True)),
    ("Tool.from_function(search)", lambda: Tool.from_function(search)),
]:
    try:
        call()
        print(f"{label}: OK")
    except Exception as e:
        print(f"{label}: UNCAUGHT {type(e).__module__}.{type(e).__name__}: {str(e)[:80]}")
# AsyncIterator[str] return annotations behave identically

Observed output:

func_metadata(search): UNCAUGHT pydantic.errors.PydanticSchemaGenerationError: Unable to generate pydantic-core schema for typing.Iterator[str]. Set `arbitrary
func_metadata(search, structured_output=True): UNCAUGHT pydantic.errors.PydanticSchemaGenerationError: Unable to generate pydantic-core schema for typing.Iterator[str]. Set `arbitrary
Tool.from_function(search): UNCAUGHT pydantic.errors.PydanticSchemaGenerationError: Unable to generate pydantic-core schema for typing.Iterator[str]. Set `arbitrary

Fix is ready on BlueX888:fix/prep-func-metadata-iterator-return-uncaught-pydantic-error (commit 2f4ce1ae). Moves the _create_output_model call inside that existing try/except, so Iterator/AsyncIterator returns follow the documented contract: no output schema by default, InvalidSignature with structured_output=True.
Verified with: uv run --frozen pytest tests/server/mcpserver/test_func_metadata.py

Happy to open the PR once you confirm the approach or assign this to me.

0xamlab commented on Sep 25, 2026

@0xamlab

Confirmed on mcp 2.2.0 with the v2 API (MCPServer): both the default path and structured_output=True raise the raw PydanticSchemaGenerationError at registration for a -> Iterator[str] tool — the generator tool simply cannot be registered at all.

Minimal repro:

from mcp.server.mcpserver import MCPServer
from typing import Iterator

server = MCPServer("repro")

@server.tool()
def search(query: str) -> Iterator[str]:
    """Search things."""
    yield "a"

→ PydanticSchemaGenerationError: Unable to generate pydantic-core schema for typing.Iterator[str]. Same for structured_output=True.

The contract says the default should fall back to an unstructured tool (structured_output=None), and structured_output=True should raise the SDK's own InvalidSignature — a raw pydantic error escapes both. Root-cause candidate: func_metadata's return-type schema generation treats Iterator/AsyncIterator as a schema-able output type instead of short-circuiting to the generator/unstructured path first.

Happy to open a PR with a failing test first (both spellings must register without raising; structured_output=True must surface InvalidSignature) plus the narrow fix, if a maintainer assigns the issue — the repo's bot auto-closes non-assignee PRs, so I'm holding the branch until then.

Affiliation: we ship a 140-tool MCP server and hit this exact class of annotation edge case in our own tool registry.

Rainmemery commented on Sep 26, 2026

@Rainmemery

Confirmed on Windows 11 / Python 3.13 / pydantic 2.12.5 against main (f1b6589) — all four spellings crash at registration, with both the default and structured_output=True:

from typing import Iterator
from mcp.server.mcpserver.utilities.func_metadata import func_metadata

def search(n: int) -> Iterator[str]:
    yield from ["a"] * n

func_metadata(search)   # pydantic.errors.PydanticSchemaGenerationError

The escape point matches @BlueX888's analysis: the _create_output_model() call (func_metadata.py:444 on main) sits outside the try at :446 that routes expected schema failures to the unstructured fallback, so the PydanticSchemaGenerationError raised by create_model(..., result=Iterator[str]) inside _create_wrapped_model() escapes func_metadata entirely.

The fix that keeps the existing semantics is to route that call through the same failure path: on PydanticSchemaGenerationError, degrade to the unstructured fallback (output_model=None), which also makes structured_output=True raise InvalidSignature via the existing check. Two notes from testing this locally:

  • Both spellings need covering: typing.Iterator[str] (typing._GenericAlias) and collections.abc.Iterator[str] (types.GenericAlias) take different branches in _create_output_model but end at the same create_model crash.
  • Generator[str, None, None] is unaffected: pydantic models it as a sequence, so it builds the wrapped {"result": [...]} schema today and keeps doing so after the fix.

I have this working locally with regression tests for all four spellings plus the Generator contrast — a PR is up at #3587 linking this issue.

Disclosure: this was developed with AI assistance; I have reviewed the diff and run the tests locally.

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

    v1Affects 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