From 9aa31d57f2e544c342b794a4d4706eb8d39bdbcd Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Wed, 18 Mar 2026 15:22:37 -0400 Subject: [PATCH] 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 --- src/fastmcp/tools/function_parsing.py | 6 ++++++ src/fastmcp/tools/tool_transform.py | 22 ++++++------------- tests/tools/tool/test_output_schema.py | 30 +++++++++++++++++++++++++- 3 files changed, 42 insertions(+), 16 deletions(-) diff --git a/src/fastmcp/tools/function_parsing.py b/src/fastmcp/tools/function_parsing.py index 10ee7f089..804dc6efd 100644 --- a/src/fastmcp/tools/function_parsing.py +++ b/src/fastmcp/tools/function_parsing.py @@ -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 diff --git a/src/fastmcp/tools/tool_transform.py b/src/fastmcp/tools/tool_transform.py index e750a1103..ca030408b 100644 --- a/src/fastmcp/tools/tool_transform.py +++ b/src/fastmcp/tools/tool_transform.py @@ -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 diff --git a/tests/tools/tool/test_output_schema.py b/tests/tools/tool/test_output_schema.py index 8625d6886..11d850a7f 100644 --- a/tests/tools/tool/test_output_schema.py +++ b/tests/tools/tool/test_output_schema.py @@ -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: