fix: suppress output schema for ToolResult subclass annotations (#3548)

* fix: suppress output schema for ToolResult subclass annotations

* use issubclass_safe/is_class_member_of_type for ToolResult subclass checks

* use parsed_fn.return_type for ToolResult check in transform fallback
This commit is contained in:
Jeremiah Lowin 2026-03-18 15:22:37 -04:00 committed by GitHub
commit 9aa31d57f2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 42 additions and 16 deletions

View file

@ -26,6 +26,7 @@ from fastmcp.utilities.types import (
Image,
create_function_without_params,
get_cached_typeadapter,
is_class_member_of_type,
replace_type,
)
@ -212,6 +213,11 @@ class ParsedFunction:
if _PREFAB_TYPES and _contains_prefab_type(output_type):
output_type = _UnserializableType
# ToolResult subclasses should suppress schema generation just
# like ToolResult itself — replace_type only does exact matching.
if is_class_member_of_type(output_type, ToolResult):
output_type = _UnserializableType
# there are a variety of types that we don't want to attempt to
# serialize because they are either used by FastMCP internally,
# or are MCP content types that explicitly don't form structured

View file

@ -26,6 +26,7 @@ from fastmcp.utilities.types import (
NotSet,
NotSetT,
get_cached_typeadapter,
issubclass_safe,
)
logger = get_logger(__name__)
@ -313,16 +314,7 @@ class TransformedTool(Tool):
# If transform function returns ToolResult, respect our output_schema setting
if isinstance(result, ToolResult):
if self.output_schema is None:
# Check if this is from a custom function that returns ToolResult
return_annotation = inspect.signature(self.fn).return_annotation
if return_annotation is ToolResult:
# Custom function returns ToolResult - preserve its content
return result
else:
# Forwarded call with no explicit schema - preserve parent's structured content
# The parent tool may have generated structured content via its own fallback logic
return result
return result
elif self.output_schema.get(
"type"
) != "object" and not self.output_schema.get("x-fastmcp-wrap-result"):
@ -496,11 +488,11 @@ class TransformedTool(Tool):
# parsed fn is not none here
final_output_schema = cast(ParsedFunction, parsed_fn).output_schema
if final_output_schema is None:
# Check if function returns ToolResult - if so, don't fall back to parent
return_annotation = inspect.signature(
transform_fn
).return_annotation
if return_annotation is ToolResult:
# Check if function returns ToolResult (or subclass) - if so, don't fall back to parent.
# Use parsed_fn.return_type (resolved via get_type_hints) instead of
# inspect.signature, which returns strings under `from __future__ import annotations`.
return_type = cast(ParsedFunction, parsed_fn).return_type
if issubclass_safe(return_type, ToolResult):
final_output_schema = None
else:
final_output_schema = tool.output_schema

View file

@ -7,7 +7,7 @@ from mcp.types import AudioContent, EmbeddedResource, ImageContent, TextContent
from pydantic import AnyUrl, BaseModel, Field, TypeAdapter
from typing_extensions import TypedDict
from fastmcp.tools.base import Tool
from fastmcp.tools.base import Tool, ToolResult
from fastmcp.utilities.json_schema import compress_schema
from fastmcp.utilities.types import Audio, File, Image
@ -123,6 +123,34 @@ class TestToolFromFunctionOutputSchema:
# Image, Audio, File types don't generate output schemas since they're converted to content directly
assert tool.output_schema is None
async def test_tool_result_return_annotation_no_output_schema(self):
def func() -> ToolResult:
return ToolResult(content="hello")
tool = Tool.from_function(func)
assert tool.output_schema is None
async def test_tool_result_subclass_return_annotation_no_output_schema(self):
class MyToolResult(ToolResult):
def __init__(self, data: str):
super().__init__(structured_content={"content": data})
def func() -> MyToolResult:
return MyToolResult("hello")
tool = Tool.from_function(func)
assert tool.output_schema is None
async def test_optional_tool_result_subclass_no_output_schema(self):
class MyToolResult(ToolResult):
pass
def func() -> MyToolResult | None:
return None
tool = Tool.from_function(func)
assert tool.output_schema is None
async def test_dataclass_return_annotation(self):
@dataclass
class Person: