diff --git a/src/fastmcp/tools/base.py b/src/fastmcp/tools/base.py index b704016fa..223413aaa 100644 --- a/src/fastmcp/tools/base.py +++ b/src/fastmcp/tools/base.py @@ -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)) diff --git a/src/fastmcp/tools/function_parsing.py b/src/fastmcp/tools/function_parsing.py index 804dc6efd..21d10cb79 100644 --- a/src/fastmcp/tools/function_parsing.py +++ b/src/fastmcp/tools/function_parsing.py @@ -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 diff --git a/src/fastmcp/tools/function_tool.py b/src/fastmcp/tools/function_tool.py index 8dfc47f60..cb79b72cc 100644 --- a/src/fastmcp/tools/function_tool.py +++ b/src/fastmcp/tools/function_tool.py @@ -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. diff --git a/tests/server/providers/local_provider_tools/test_local_provider_tools.py b/tests/server/providers/local_provider_tools/test_local_provider_tools.py index 9e1803b41..292365bc4 100644 --- a/tests/server/providers/local_provider_tools/test_local_provider_tools.py +++ b/tests/server/providers/local_provider_tools/test_local_provider_tools.py @@ -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() diff --git a/tests/server/tasks/test_task_return_types.py b/tests/server/tasks/test_task_return_types.py index 3ef352902..f29f1e19b 100644 --- a/tests/server/tasks/test_task_return_types.py +++ b/tests/server/tasks/test_task_return_types.py @@ -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",