From 2b313bc3ff1984cd2cfa7159f21a6af2b633d4eb Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Thu, 26 Jun 2025 22:02:04 -0400 Subject: [PATCH] Update tool transformation for ToolResult compatibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/fastmcp/client/client.py | 9 ++-- src/fastmcp/server/proxy.py | 9 ++-- src/fastmcp/server/server.py | 13 +++-- src/fastmcp/tools/tool.py | 78 +++++++++++++++++++---------- src/fastmcp/tools/tool_manager.py | 8 ++- src/fastmcp/tools/tool_transform.py | 23 ++++++--- 6 files changed, 86 insertions(+), 54 deletions(-) diff --git a/src/fastmcp/client/client.py b/src/fastmcp/client/client.py index 89599906d..4512f3d5e 100644 --- a/src/fastmcp/client/client.py +++ b/src/fastmcp/client/client.py @@ -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. diff --git a/src/fastmcp/server/proxy.py b/src/fastmcp/server/proxy.py index f80c9c38e..d196c2b7c 100644 --- a/src/fastmcp/server/proxy.py +++ b/src/fastmcp/server/proxy.py @@ -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): diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index b33ec1f4c..bfbd50678 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -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}") diff --git a/src/fastmcp/tools/tool.py b/src/fastmcp/tools/tool.py index 8510714e7..560b4df00 100644 --- a/src/fastmcp/tools/tool.py +++ b/src/fastmcp/tools/tool.py @@ -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 == "": 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}") diff --git a/src/fastmcp/tools/tool_manager.py b/src/fastmcp/tools/tool_manager.py index ee577a450..29bb956c3 100644 --- a/src/fastmcp/tools/tool_manager.py +++ b/src/fastmcp/tools/tool_manager.py @@ -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. diff --git a/src/fastmcp/tools/tool_transform.py b/src/fastmcp/tools/tool_transform.py index 75fa2baaa..38d074430 100644 --- a/src/fastmcp/tools/tool_transform.py +++ b/src/fastmcp/tools/tool_transform.py @@ -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)