mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-22 21:44:18 +02:00
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:
parent
c946664a16
commit
eec52b1f02
5 changed files with 51 additions and 4 deletions
|
|
@ -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))
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue