Update tool transformation for ToolResult compatibility

- TransformedTool.run() now returns ToolResult instead of list[ContentBlock]
- forward() and forward_raw() return ToolResult from parent tools
- Transform functions can return ToolResult for full control or any value for auto-wrapping
- Maintains backward compatibility with existing transform functions

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Jeremiah Lowin 2025-06-26 22:02:04 -04:00
commit 2b313bc3ff
6 changed files with 86 additions and 54 deletions

View file

@ -676,7 +676,7 @@ class Client(Generic[ClientTransportT]):
arguments: dict[str, Any] | None = None,
timeout: datetime.timedelta | float | int | None = None,
progress_handler: ProgressHandler | None = None,
) -> list[ContentBlock] | dict[str, Any] | type:
) -> list[ContentBlock] | dict[str, Any] | Any:
"""Call a tool on the server.
Unlike call_tool_mcp, this method raises a ToolError if the tool call results in an error.
@ -688,10 +688,11 @@ class Client(Generic[ClientTransportT]):
progress_handler (ProgressHandler | None, optional): The progress handler to use for the tool call. Defaults to None.
Returns:
list[ContentBlock] | dict[str, Any]:
list[ContentBlock] | dict[str, Any] | Any:
The content returned by the tool. If the tool returns structured
outputs, they are returned as a dictionary; otherwise, a list of
content blocks is returned. Note: to receive both structured and
outputs, they are returned as a dataclass (if an output schema
is available) or a dictionary; otherwise, a list of content
blocks is returned. Note: to receive both structured and
unstructured outputs, use call_tool_mcp instead and access the
raw result object.

View file

@ -23,7 +23,7 @@ from fastmcp.resources import Resource, ResourceTemplate
from fastmcp.resources.resource_manager import ResourceManager
from fastmcp.server.context import Context
from fastmcp.server.server import FastMCP
from fastmcp.tools.tool import Tool
from fastmcp.tools.tool import Tool, ToolResult
from fastmcp.tools.tool_manager import ToolManager
from fastmcp.utilities.logging import get_logger
@ -232,7 +232,7 @@ class ProxyTool(Tool):
self,
arguments: dict[str, Any],
context: Context | None = None,
) -> list[ContentBlock]:
) -> ToolResult:
"""Executes the tool by making a call through the client."""
# This is where the remote execution logic lives.
async with self._client:
@ -242,7 +242,10 @@ class ProxyTool(Tool):
)
if result.isError:
raise ToolError(cast(mcp.types.TextContent, result.content[0]).text)
return result.content
return ToolResult(
content=result.content,
structured_output=result.structuredContent,
)
class ProxyResource(Resource):

View file

@ -58,7 +58,7 @@ from fastmcp.server.low_level import LowLevelServer
from fastmcp.server.middleware import Middleware, MiddlewareContext
from fastmcp.settings import Settings
from fastmcp.tools import ToolManager
from fastmcp.tools.tool import FunctionTool, Tool
from fastmcp.tools.tool import FunctionTool, Tool, ToolResult
from fastmcp.utilities.cache import TimedCache
from fastmcp.utilities.components import FastMCPComponent
from fastmcp.utilities.logging import get_logger
@ -593,7 +593,7 @@ class FastMCP(Generic[LifespanResultT]):
async def _mcp_call_tool(
self, key: str, arguments: dict[str, Any]
) -> list[ContentBlock]:
) -> list[ContentBlock] | tuple[list[ContentBlock], dict[str, Any]]:
"""
Handle MCP 'callTool' requests.
@ -610,22 +610,21 @@ class FastMCP(Generic[LifespanResultT]):
async with fastmcp.server.context.Context(fastmcp=self):
try:
return await self._call_tool(key, arguments)
result = await self._call_tool(key, arguments)
return result.to_mcp_result()
except DisabledError:
raise NotFoundError(f"Unknown tool: {key}")
except NotFoundError:
raise NotFoundError(f"Unknown tool: {key}")
async def _call_tool(
self, key: str, arguments: dict[str, Any]
) -> list[ContentBlock]:
async def _call_tool(self, key: str, arguments: dict[str, Any]) -> ToolResult:
"""
Applies this server's middleware and delegates the filtered call to the manager.
"""
async def _handler(
context: MiddlewareContext[mcp.types.CallToolRequestParams],
) -> list[ContentBlock]:
) -> ToolResult:
tool = await self._tool_manager.get_tool(context.message.name)
if not self._should_enable_component(tool):
raise NotFoundError(f"Unknown tool: {context.message.name!r}")

View file

@ -5,7 +5,6 @@ from collections.abc import Callable
from dataclasses import dataclass
from typing import TYPE_CHECKING, Annotated, Any
import mcp.types
import pydantic_core
from mcp.types import ContentBlock, TextContent, ToolAnnotations
from mcp.types import Tool as MCPTool
@ -24,7 +23,6 @@ from fastmcp.utilities.types import (
StructuredOutput,
find_kwarg_by_type,
get_cached_typeadapter,
replace_type,
)
if TYPE_CHECKING:
@ -37,6 +35,19 @@ def default_serializer(data: Any) -> str:
return pydantic_core.to_json(data, fallback=str, indent=2).decode()
@dataclass
class ToolResult:
content: list[ContentBlock]
structured_output: dict[str, Any] | None = None
def to_mcp_result(
self,
) -> list[ContentBlock] | tuple[list[ContentBlock], dict[str, Any]]:
if self.structured_output is None:
return self.content
return self.content, self.structured_output
class Tool(FastMCPComponent):
"""Internal tool registration info."""
@ -106,9 +117,7 @@ class Tool(FastMCPComponent):
enabled=enabled,
)
async def run(
self, arguments: dict[str, Any]
) -> list[ContentBlock] | tuple[list[ContentBlock], dict[str, Any]]:
async def run(self, arguments: dict[str, Any]) -> ToolResult:
"""
Run the tool with arguments.
@ -150,6 +159,18 @@ class Tool(FastMCPComponent):
class FunctionTool(Tool):
fn: Callable[..., Any]
wrap_primitive_output: bool = Field(
default=False,
description="""Whether to wrap the function's return value in a {"value": result} object.
This is automatically set to True when a function has a primitive return type
annotation (int, str, bool, etc.) and FastMCP auto-generates an object schema
with a single "value" property to enable structured output support.
When True, the function's raw return value gets wrapped as {"value": raw_result}
in the structured output, allowing clients to receive properly typed objects
even for primitive return types.""",
)
@classmethod
def from_function(
@ -171,8 +192,18 @@ class FunctionTool(Tool):
if name is None and parsed_fn.name == "<lambda>":
raise ValueError("You must provide a name for lambda functions")
wrap_primitive_output = False
if isinstance(output_schema, NotSetT):
output_schema = parsed_fn.output_schema
# convert primitive types to object with a single "value" property
if output_schema and output_schema.get("type") != "object":
wrap_primitive_output = True
output_schema = {
"type": "object",
"properties": {"value": output_schema | {"title": "Value"}},
"required": ["value"],
"title": "Result",
}
return cls(
fn=parsed_fn.fn,
@ -184,11 +215,10 @@ class FunctionTool(Tool):
tags=tags or set(),
serializer=serializer,
enabled=enabled if enabled is not None else True,
wrap_primitive_output=wrap_primitive_output,
)
async def run(
self, arguments: dict[str, Any]
) -> list[ContentBlock] | tuple[list[ContentBlock], dict[str, Any]]:
async def run(self, arguments: dict[str, Any]) -> ToolResult:
"""Run the tool with arguments."""
from fastmcp.server.context import Context
@ -205,18 +235,20 @@ class FunctionTool(Tool):
unstructured_result = _convert_to_content(result, serializer=self.serializer)
structured_result = None
structured_output = None
if isinstance(result, StructuredOutput):
structured_result = result.to_structured_output()
structured_output = result.to_structured_output()
elif self.output_schema is not None:
structured_result = pydantic_core.to_jsonable_python(result, fallback=str)
raw_result = pydantic_core.to_jsonable_python(result, fallback=str)
if self.wrap_primitive_output:
structured_output = {"value": raw_result}
else:
structured_output = raw_result
# return only the unstructured result if there is no structured output
if structured_result is None:
return unstructured_result
# return both the unstructured and structured results if there is structured output
return (unstructured_result, structured_result)
return ToolResult(
content=unstructured_result,
structured_output=structured_output,
)
@dataclass
@ -284,17 +316,9 @@ class ParsedFunction:
output_schema = None
output_type = inspect.signature(fn).return_annotation
if output_type is not inspect._empty:
if output_type not in (inspect._empty, Image, Audio, File, StructuredOutput):
try:
replaced_output_type = replace_type(
output_type,
{
Image: mcp.types.ImageContent,
Audio: mcp.types.AudioContent,
File: mcp.types.EmbeddedResource,
},
)
output_type_adapter = get_cached_typeadapter(replaced_output_type)
output_type_adapter = get_cached_typeadapter(output_type)
output_schema = output_type_adapter.json_schema()
except PydanticSchemaGenerationError:
logger.debug(f"Unable to generate schema for type {output_type!r}")

View file

@ -4,12 +4,12 @@ import warnings
from collections.abc import Callable
from typing import TYPE_CHECKING, Any
from mcp.types import ContentBlock, ToolAnnotations
from mcp.types import ToolAnnotations
from fastmcp import settings
from fastmcp.exceptions import NotFoundError, ToolError
from fastmcp.settings import DuplicateBehavior
from fastmcp.tools.tool import Tool
from fastmcp.tools.tool import Tool, ToolResult
from fastmcp.utilities.logging import get_logger
if TYPE_CHECKING:
@ -169,9 +169,7 @@ class ToolManager:
else:
raise NotFoundError(f"Tool {key!r} not found")
async def call_tool(
self, key: str, arguments: dict[str, Any]
) -> list[ContentBlock]:
async def call_tool(self, key: str, arguments: dict[str, Any]) -> ToolResult:
"""
Internal API for servers: Finds and calls a tool, respecting the
filtered protocol path.

View file

@ -6,10 +6,10 @@ from contextvars import ContextVar
from dataclasses import dataclass
from typing import Any, Literal
from mcp.types import ContentBlock, ToolAnnotations
from mcp.types import ToolAnnotations
from pydantic import ConfigDict
from fastmcp.tools.tool import ParsedFunction, Tool
from fastmcp.tools.tool import ParsedFunction, Tool, ToolResult
from fastmcp.utilities.logging import get_logger
from fastmcp.utilities.types import NotSet, NotSetT, get_cached_typeadapter
@ -22,7 +22,7 @@ _current_tool: ContextVar[TransformedTool | None] = ContextVar(
)
async def forward(**kwargs) -> Any:
async def forward(**kwargs) -> ToolResult:
"""Forward to parent tool with argument transformation applied.
This function can only be called from within a transformed tool's custom
@ -38,7 +38,7 @@ async def forward(**kwargs) -> Any:
**kwargs: Arguments to forward to the parent tool (using transformed names).
Returns:
The result from the parent tool execution.
The ToolResult from the parent tool execution.
Raises:
RuntimeError: If called outside a transformed tool context.
@ -219,7 +219,7 @@ class TransformedTool(Tool):
forwarding_fn: Callable[..., Any] # Always present, handles arg transformation
transform_args: dict[str, ArgTransform]
async def run(self, arguments: dict[str, Any]) -> list[ContentBlock]:
async def run(self, arguments: dict[str, Any]) -> ToolResult:
"""Run the tool with context set for forward() functions.
This method executes the tool's function while setting up the context
@ -230,8 +230,7 @@ class TransformedTool(Tool):
arguments: Dictionary of arguments to pass to the tool's function.
Returns:
List of content objects (text, image, or embedded resources) representing
the tool's output.
ToolResult object containing content and optional structured output.
"""
from fastmcp.tools.tool import _convert_to_content
@ -269,7 +268,15 @@ class TransformedTool(Tool):
token = _current_tool.set(self)
try:
result = await self.fn(**arguments)
return _convert_to_content(result, serializer=self.serializer)
# If transform function returns ToolResult, use it directly
if isinstance(result, ToolResult):
return result
# Otherwise convert to content and create basic ToolResult
from fastmcp.tools.tool import _convert_to_content
unstructured_result = _convert_to_content(result, serializer=self.serializer)
return ToolResult(content=unstructured_result)
finally:
_current_tool.reset(token)