mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-21 13:04:18 +02:00
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:
parent
a50dcd8705
commit
9aa31d57f2
3 changed files with 42 additions and 16 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue