fix: materialize generators before result conversion, handle bytes gracefully (#3830)

- Detect async/sync generators after tool execution and materialize
  into lists before the result conversion pipeline processes them
- Generator materialization runs inside timeout scope so slow generators
  respect the configured timeout
- Handle bytes return types: UTF-8 bytes as text, non-UTF-8 as base64
- Suppress output_schema for bytes return types (can't be structured JSON)
- Catch UnicodeDecodeError alongside PydanticSerializationError in
  convert_result for robustness

Fixes #3829

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bill Easton 2026-04-11 10:23:51 -05:00 committed by GitHub
commit eec52b1f02
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 51 additions and 4 deletions

View file

@ -290,6 +290,10 @@ class Tool(FastMCPComponent):
content = _convert_to_content(raw_value, serializer=self.serializer)
# Bytes can't be represented as structured JSON content
if isinstance(raw_value, bytes):
return ToolResult(content=content)
# Skip structured content for ContentBlock types only if no output_schema
# (if output_schema exists, MCP SDK requires structured_content)
if self.output_schema is None and (
@ -303,7 +307,7 @@ class Tool(FastMCPComponent):
try:
structured = pydantic_core.to_jsonable_python(raw_value)
except pydantic_core.PydanticSerializationError:
except (pydantic_core.PydanticSerializationError, UnicodeDecodeError):
return ToolResult(content=content)
if self.output_schema is None:
@ -492,6 +496,14 @@ def _convert_to_single_content_block(
if isinstance(item, str):
return TextContent(type="text", text=item)
if isinstance(item, bytes):
try:
return TextContent(type="text", text=item.decode("utf-8"))
except UnicodeDecodeError:
import base64
return TextContent(type="text", text=base64.b64encode(item).decode("ascii"))
return TextContent(type="text", text=_serialize_with_fallback(item, serializer))

View file

@ -39,6 +39,16 @@ except ImportError:
_PREFAB_TYPES = ()
def _contains_bytes_type(tp: Any) -> bool:
"""Check if *tp* is or contains bytes, recursing through unions and Annotated."""
if tp is bytes:
return True
origin = get_origin(tp)
if origin is Union or origin is types.UnionType or origin is Annotated:
return any(_contains_bytes_type(a) for a in get_args(tp))
return False
def _contains_prefab_type(tp: Any) -> bool:
"""Check if *tp* is or contains a prefab type, recursing through unions and Annotated."""
if isinstance(tp, type) and issubclass(tp, _PREFAB_TYPES):
@ -205,6 +215,10 @@ class ParsedFunction:
original_output_type = output_type
if output_type not in (inspect._empty, None, Any, ...):
# bytes can't be represented as structured JSON output — skip schema
if _contains_bytes_type(output_type):
output_type = _UnserializableType
# Prefab component subclasses (Column, Card, etc.) shouldn't
# produce output schemas — replace_type only does exact matching,
# so we handle subclass matching explicitly here. We also need

View file

@ -255,6 +255,9 @@ class FunctionTool(Tool):
# Handle sync wrappers that return awaitables
if inspect.isawaitable(result):
result = await result
# Materialize generators inside timeout scope so slow
# generators don't run past the configured timeout
result = await self._materialize_generator(result)
except TimeoutError:
logger.warning(
f"Tool '{self.name}' timed out after {self.timeout}s. "
@ -277,9 +280,24 @@ class FunctionTool(Tool):
)
if inspect.isawaitable(result):
result = await result
result = await self._materialize_generator(result)
return self.convert_result(result)
@staticmethod
async def _materialize_generator(result: Any) -> Any:
"""Consume generators/async generators into lists.
Without this, async generators pass through as objects (repr string),
and sync generators get consumed during text serialization but are
exhausted by the time structured content is built.
"""
if inspect.isasyncgen(result):
return [item async for item in result]
if inspect.isgenerator(result):
return list(result)
return result
def register_with_docket(self, docket: Docket) -> None:
"""Register this tool with docket for background execution.

View file

@ -67,7 +67,9 @@ class TestToolReturnTypes:
return b"Hello, world!"
result = await mcp.call_tool("bytes_tool", {})
assert result.structured_content == {"result": "Hello, world!"}
# bytes can't be represented as structured JSON, so no structured_content
assert result.structured_content is None
assert result.content[0].text == "Hello, world!" # ty:ignore[unresolved-attribute]
async def test_uuid(self):
mcp = FastMCP()

View file

@ -258,8 +258,9 @@ async def binary_type_server():
[
(
"return_bytes",
str,
lambda r: "Hello bytes!" in r.data or "SGVsbG8gYnl0ZXMh" in r.data,
type(None),
lambda r: r.data is None
and any("Hello bytes!" in c.text for c in r.content),
),
(
"return_uuid",