Accept callables in Tool.from_tool() (#3235)

* Accept callables in Tool.from_tool()

* chore: Update SDK documentation

* Add end-to-end client test for decorated function transform

---------

Co-authored-by: marvin-context-protocol[bot] <225465937+marvin-context-protocol[bot]@users.noreply.github.com>
This commit is contained in:
Jeremiah Lowin 2026-02-19 11:31:13 -05:00 committed by GitHub
commit b894a0b747
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 105 additions and 9 deletions

View file

@ -106,10 +106,10 @@ Schedule this tool for background execution via docket.
#### `from_tool` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/src/fastmcp/tools/tool.py#L364" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
from_tool(cls, tool: Tool) -> TransformedTool
from_tool(cls, tool: Tool | Callable[..., Any]) -> TransformedTool
```
#### `get_span_attributes` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/src/fastmcp/tools/tool.py#L395" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `get_span_attributes` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/src/fastmcp/tools/tool.py#L412" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
get_span_attributes(self) -> dict[str, Any]

View file

@ -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` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/src/fastmcp/tools/tool_transform.py#L977" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `apply_transformations_to_tools` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/src/fastmcp/tools/tool_transform.py#L979" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
apply_transformations_to_tools(tools: dict[str, Tool], transformations: dict[str, ToolTransformConfig]) -> dict[str, Tool]
@ -213,7 +213,7 @@ functions.
#### `from_tool` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/src/fastmcp/tools/tool_transform.py#L369" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/src/fastmcp/tools/tool_transform.py#L923" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `ToolTransformConfig` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/src/fastmcp/tools/tool_transform.py#L925" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
Provides a way to transform a tool.
@ -301,7 +301,7 @@ Provides a way to transform a tool.
**Methods:**
#### `apply` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/src/fastmcp/tools/tool_transform.py#L956" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `apply` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/src/fastmcp/tools/tool_transform.py#L958" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
apply(self, tool: Tool) -> TransformedTool

View file

@ -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",

View file

@ -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

View file

@ -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: