diff --git a/docs/python-sdk/fastmcp-tools-tool.mdx b/docs/python-sdk/fastmcp-tools-tool.mdx index a5e3b8409..1fe8e4c9d 100644 --- a/docs/python-sdk/fastmcp-tools-tool.mdx +++ b/docs/python-sdk/fastmcp-tools-tool.mdx @@ -106,10 +106,10 @@ Schedule this tool for background execution via docket. #### `from_tool` ```python -from_tool(cls, tool: Tool) -> TransformedTool +from_tool(cls, tool: Tool | Callable[..., Any]) -> TransformedTool ``` -#### `get_span_attributes` +#### `get_span_attributes` ```python get_span_attributes(self) -> dict[str, Any] diff --git a/docs/python-sdk/fastmcp-tools-tool_transform.mdx b/docs/python-sdk/fastmcp-tools-tool_transform.mdx index 30fd08d5c..84a1b66cf 100644 --- a/docs/python-sdk/fastmcp-tools-tool_transform.mdx +++ b/docs/python-sdk/fastmcp-tools-tool_transform.mdx @@ -62,7 +62,7 @@ y=2)` will call the parent tool with `x=1` and `y=2`. - `RuntimeError`: If called outside a transformed tool context. -### `apply_transformations_to_tools` +### `apply_transformations_to_tools` ```python apply_transformations_to_tools(tools: dict[str, Tool], transformations: dict[str, ToolTransformConfig]) -> dict[str, Tool] @@ -213,7 +213,7 @@ functions. #### `from_tool` ```python -from_tool(cls, tool: Tool, name: str | None = None, version: str | NotSetT | None = NotSet, title: str | NotSetT | None = NotSet, description: str | NotSetT | None = NotSet, tags: set[str] | None = None, transform_fn: Callable[..., Any] | None = None, transform_args: dict[str, ArgTransform] | None = None, annotations: ToolAnnotations | NotSetT | None = NotSet, output_schema: dict[str, Any] | NotSetT | None = NotSet, serializer: Callable[[Any], str] | NotSetT | None = NotSet, meta: dict[str, Any] | NotSetT | None = NotSet) -> TransformedTool +from_tool(cls, tool: Tool | Callable[..., Any], name: str | None = None, version: str | NotSetT | None = NotSet, title: str | NotSetT | None = NotSet, description: str | NotSetT | None = NotSet, tags: set[str] | None = None, transform_fn: Callable[..., Any] | None = None, transform_args: dict[str, ArgTransform] | None = None, annotations: ToolAnnotations | NotSetT | None = NotSet, output_schema: dict[str, Any] | NotSetT | None = NotSet, serializer: Callable[[Any], str] | NotSetT | None = NotSet, meta: dict[str, Any] | NotSetT | None = NotSet) -> TransformedTool ``` Create a transformed tool from a parent tool. @@ -293,7 +293,7 @@ async def custom_output(**kwargs) -> ToolResult: ``` -### `ToolTransformConfig` +### `ToolTransformConfig` Provides a way to transform a tool. @@ -301,7 +301,7 @@ Provides a way to transform a tool. **Methods:** -#### `apply` +#### `apply` ```python apply(self, tool: Tool) -> TransformedTool diff --git a/src/fastmcp/tools/tool.py b/src/fastmcp/tools/tool.py index bdacdac58..3c99db465 100644 --- a/src/fastmcp/tools/tool.py +++ b/src/fastmcp/tools/tool.py @@ -363,7 +363,7 @@ class Tool(FastMCPComponent): @classmethod def from_tool( cls, - tool: Tool, + tool: Tool | Callable[..., Any], *, name: str | None = None, title: str | NotSetT | None = NotSet, @@ -378,6 +378,8 @@ class Tool(FastMCPComponent): ) -> TransformedTool: from fastmcp.tools.tool_transform import TransformedTool + tool = cls._ensure_tool(tool) + return TransformedTool.from_tool( tool=tool, transform_fn=transform_fn, @@ -392,6 +394,21 @@ class Tool(FastMCPComponent): meta=meta, ) + @classmethod + def _ensure_tool(cls, tool: Tool | Callable[..., Any]) -> Tool: + """Coerce a callable into a Tool, respecting @tool decorator metadata.""" + if isinstance(tool, Tool): + return tool + + from fastmcp.decorators import get_fastmcp_meta + from fastmcp.tools.function_tool import FunctionTool, ToolMeta + + fmeta = get_fastmcp_meta(tool) + if isinstance(fmeta, ToolMeta): + return FunctionTool.from_function(tool, metadata=fmeta) + + return cls.from_function(tool) + def get_span_attributes(self) -> dict[str, Any]: return super().get_span_attributes() | { "fastmcp.component.type": "tool", diff --git a/src/fastmcp/tools/tool_transform.py b/src/fastmcp/tools/tool_transform.py index f22010750..9fc63e6e3 100644 --- a/src/fastmcp/tools/tool_transform.py +++ b/src/fastmcp/tools/tool_transform.py @@ -368,7 +368,7 @@ class TransformedTool(Tool): @classmethod def from_tool( cls, - tool: Tool, + tool: Tool | Callable[..., Any], name: str | None = None, version: str | NotSetT | None = NotSet, title: str | NotSetT | None = NotSet, @@ -456,6 +456,8 @@ class TransformedTool(Tool): ) ``` """ + tool = Tool._ensure_tool(tool) + if ( serializer is not NotSet and serializer is not None diff --git a/tests/tools/tool_transform/test_tool_transform.py b/tests/tools/tool_transform/test_tool_transform.py index 47ab1853b..f6167a8ec 100644 --- a/tests/tools/tool_transform/test_tool_transform.py +++ b/tests/tools/tool_transform/test_tool_transform.py @@ -9,7 +9,7 @@ from pydantic import BaseModel, Field from fastmcp import FastMCP from fastmcp.client.client import Client -from fastmcp.tools import Tool, forward, forward_raw +from fastmcp.tools import Tool, forward, forward_raw, tool from fastmcp.tools.function_tool import FunctionTool from fastmcp.tools.tool import ToolResult from fastmcp.tools.tool_transform import ( @@ -41,6 +41,60 @@ def test_tool_from_tool_no_change(add_tool): assert new_tool.description == add_tool.description +def test_from_tool_accepts_decorated_function(): + @tool + def search(q: str, limit: int = 10) -> list[str]: + """Search for items.""" + return [f"Result {i} for {q}" for i in range(limit)] + + transformed = Tool.from_tool( + search, + name="find_items", + transform_args={"q": ArgTransform(name="query")}, + ) + assert isinstance(transformed, TransformedTool) + assert transformed.name == "find_items" + assert "query" in transformed.parameters["properties"] + assert "q" not in transformed.parameters["properties"] + + +def test_from_tool_accepts_plain_function(): + def search(q: str, limit: int = 10) -> list[str]: + return [f"Result {i} for {q}" for i in range(limit)] + + transformed = Tool.from_tool( + search, + name="find_items", + transform_args={"q": ArgTransform(name="query")}, + ) + assert isinstance(transformed, TransformedTool) + assert transformed.name == "find_items" + assert "query" in transformed.parameters["properties"] + + +def test_from_tool_decorated_function_preserves_metadata(): + @tool(description="Custom description") + def search(q: str) -> list[str]: + """Original description.""" + return [] + + transformed = Tool.from_tool(search) + assert transformed.parent_tool.description == "Custom description" + + +async def test_from_tool_decorated_function_runs(add_tool): + @tool + def add(x: int, y: int = 10) -> int: + return x + y + + transformed = Tool.from_tool( + add, + transform_args={"x": ArgTransform(name="a")}, + ) + result = await transformed.run(arguments={"a": 3, "y": 5}) + assert result.structured_content == {"result": 8} + + async def test_renamed_arg_description_is_maintained(add_tool): new_tool = Tool.from_tool( add_tool, transform_args={"old_x": ArgTransform(name="new_x")} @@ -492,6 +546,29 @@ def test_function_with_kwargs_can_add_params(add_tool): assert "new_x" in tool.parameters["properties"] +async def test_from_tool_decorated_function_via_client(): + @tool + def search(q: str, limit: int = 10) -> list[str]: + """Search for items.""" + return [f"Result {i} for {q}" for i in range(limit)] + + better_search = Tool.from_tool( + search, + name="find_items", + transform_args={ + "q": ArgTransform(name="query", description="The search terms"), + }, + ) + + mcp = FastMCP("Server") + mcp.add_tool(better_search) + + async with Client(mcp) as client: + result = await client.call_tool("find_items", {"query": "hello", "limit": 3}) + assert isinstance(result.content[0], TextContent) + assert "Result 0 for hello" in result.content[0].text + + class TestProxy: @pytest.fixture def mcp_server(self) -> FastMCP: