mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-22 13:34:17 +02:00
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:
parent
3f2090f10a
commit
b894a0b747
5 changed files with 105 additions and 9 deletions
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue