Skip to content

Tool return type handling: generators, None, bytes, non-dict output_schema #3829

@strawgate

Description

@strawgate

The tool result conversion pipeline in base.py and function_tool.py mishandles several return value patterns, causing crashes or silent data loss.

1. Async generator tools return repr string instead of data

When a tool is an async generator (async def tool() -> AsyncIterator[str]: yield ...), FunctionTool.run() never consumes the generator. The async generator object itself is passed to _convert_to_content, which calls str() on it, producing "<async_generator object ... at 0x...>" as the tool output. No error is raised.

@mcp.tool
async def stream_data(count: int) -> AsyncIterator[str]:
    for i in range(count):
        yield f"item {i}"

result = await client.call_tool("stream_data", {"count": 3})
print(result.data)     # None
print(result.content)  # "<async_generator object ...>"

The fix: detect inspect.isasyncgen(result) after calling the function, and consume into a list.

2. Sync generator text/structured content inconsistency

Sync generators are consumed correctly for text content (Pydantic iterates them) but the resulting list produces inconsistent structured content — result.data is None while text content has the values.

@mcp.tool
def gen_tool() -> list:
    return (x for x in [1, 2, 3])

r = await client.call_tool_mcp("gen_tool", {})
print(r.content[0].text)       # "[1,2,3]"
print(r.structuredContent)     # {"result": []}  — empty!

The generator is consumed during text serialization and exhausted by the time structured content is built. Fix: materialize generators before the dual processing pipeline.

3. Non-UTF8 bytes crash serialization

Tools returning bytes with non-UTF8 content crash with PydanticSerializationError: invalid utf-8 sequence. The tool returns an error response instead of encoding the binary data.

@mcp.tool()
def get_binary() -> bytes:
    return bytes(range(256))
# PydanticSerializationError: invalid utf-8 sequence of 1 bytes from index 128

Fix: catch PydanticSerializationError in default_serializer for bytes and fall back to base64 encoding, or handle bytes specially in _convert_to_content.

4. None return with typed annotation crashes client

When a tool declares -> str but returns None at runtime, the server wraps None into structuredContent as {"result": null}. The MCP SDK client rejects this because null doesn't match the string output schema.

@mcp.tool()
def lookup(key: str) -> str:
    return {"name": "Alice"}.get(key)  # None for missing keys
# Client: RuntimeError: Invalid structured content returned by tool

5. Custom output_schema with non-dict return crashes

When a tool specifies a custom output_schema dict but returns a non-dict value (string, int, list), convert_result raises ValueError: structured_content must be a dict or None at runtime. The auto-generated output_schema auto-wraps via x-fastmcp-wrap-result, but custom schemas don't.

@mcp.tool(output_schema={"type": "object", "properties": {"msg": {"type": "string"}}})
def broken() -> str:
    return "hello"
# ValueError: structured_content must be a dict or None

🤖 Generated with Claude Code

Metadata

Metadata

Assignees

No one assigned

    Labels

    too-longExcessively verbose or unedited LLM output. Condense before triage.

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions