mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-20 04:24:17 +02:00
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:
parent
21d4ce092c
commit
2b313bc3ff
6 changed files with 86 additions and 54 deletions
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue