Add helpers for converting FunctionTool and TransformedTool to SamplingTool (#3062)

Co-authored-by: Bill Easton <strawgate@users.noreply.github.com>
Co-authored-by: Jeremiah Lowin <jlowin@users.noreply.github.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>
Co-authored-by: marvin-context-protocol[bot] <225465937+marvin-context-protocol[bot]@users.noreply.github.com>
This commit is contained in:
Bill Easton 2026-02-11 19:43:03 -06:00 committed by GitHub
commit 359575b12f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 442 additions and 24 deletions

View file

@ -6,6 +6,23 @@ This document tracks major features in FastMCP v3.0 for release notes preparatio
## 3.0.0rc1
### SamplingTool Conversion Helpers
Server tools (FunctionTool and TransformedTool) can now be passed directly to sampling methods via `SamplingTool.from_callable_tool()` ([#3062](https://github.com/jlowin/fastmcp/pull/3062)). Previously, tools defined with `@mcp.tool` had to be recreated as functions for use in `ctx.sample()`. Now `ctx.sample()` and `ctx.sample_step()` accept these tool instances directly.
```python
@mcp.tool
def search(query: str) -> str:
"""Search the web."""
return do_search(query)
# Use tool directly in sampling
result = await ctx.sample(
"Research Python frameworks",
tools=[search] # FunctionTool works directly!
)
```
### Concurrent Tool Execution in Sampling
When an LLM returns multiple tool calls in a single sampling response, they can now be executed concurrently ([#3022](https://github.com/jlowin/fastmcp/pull/3022)). Default behavior remains sequential; opt in with `tool_concurrency`. Tools can declare `sequential=True` to force sequential execution even when concurrency is enabled.
@ -109,7 +126,6 @@ The `_deprecated_settings` attribute and `.settings` property are also removed.
### Breaking: `ui=` Renamed to `app=`
The MCP Apps decorator parameter has been renamed from `ui=ToolUI(...)` / `ui=ResourceUI(...)` to `app=AppConfig(...)` ([#3117](https://github.com/jlowin/fastmcp/pull/3117)). `ToolUI` and `ResourceUI` are consolidated into a single `AppConfig` class. Wire format is unchanged. See the MCP Apps section under beta2 for full details.
## 3.0.0beta2
### CLI: `fastmcp list` and `fastmcp call`

View file

@ -10,7 +10,7 @@ Sampling types and helper functions for FastMCP servers.
## Functions
### `determine_handler_mode` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/sampling/run.py#L130" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `determine_handler_mode` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/sampling/run.py#L132" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
determine_handler_mode(context: Context, needs_tools: bool) -> bool
@ -30,7 +30,7 @@ Determine whether to use fallback handler or client for sampling.
- `ValueError`: If client lacks required capability and no fallback configured.
### `call_sampling_handler` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/sampling/run.py#L189" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `call_sampling_handler` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/sampling/run.py#L191" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
call_sampling_handler(context: Context, messages: list[SamplingMessage]) -> CreateMessageResult | CreateMessageResultWithTools
@ -44,7 +44,7 @@ sampling_handler is set via determine_handler_mode(). The checks below are
safeguards against internal misuse.
### `execute_tools` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/sampling/run.py#L240" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `execute_tools` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/sampling/run.py#L242" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
execute_tools(tool_calls: list[ToolUseContent], tool_map: dict[str, SamplingTool], mask_error_details: bool = False, tool_concurrency: int | None = None) -> list[ToolResultContent]
@ -71,7 +71,7 @@ regardless of this setting.
- List of tool result content blocks in the same order as tool_calls.
### `prepare_messages` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/sampling/run.py#L350" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `prepare_messages` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/sampling/run.py#L352" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
prepare_messages(messages: str | Sequence[str | SamplingMessage]) -> list[SamplingMessage]
@ -81,17 +81,28 @@ prepare_messages(messages: str | Sequence[str | SamplingMessage]) -> list[Sampli
Convert various message formats to a list of SamplingMessage objects.
### `prepare_tools` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/sampling/run.py#L369" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `prepare_tools` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/sampling/run.py#L371" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
prepare_tools(tools: Sequence[SamplingTool | Callable[..., Any]] | None) -> list[SamplingTool] | None
prepare_tools(tools: Sequence[SamplingTool | FunctionTool | TransformedTool | Callable[..., Any]] | None) -> list[SamplingTool] | None
```
Convert tools to SamplingTool objects.
Accepts SamplingTool instances, FunctionTool instances, TransformedTool instances,
or plain callable functions. FunctionTool and TransformedTool are converted using
from_callable_tool(), while plain functions use from_function().
### `extract_tool_calls` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/sampling/run.py#L388" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
**Args:**
- `tools`: Sequence of tools to prepare. Can be SamplingTool, FunctionTool,
TransformedTool, or plain callable functions.
**Returns:**
- List of SamplingTool instances, or None if tools is None.
### `extract_tool_calls` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/sampling/run.py#L407" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
extract_tool_calls(response: CreateMessageResult | CreateMessageResultWithTools) -> list[ToolUseContent]
@ -101,7 +112,7 @@ extract_tool_calls(response: CreateMessageResult | CreateMessageResultWithTools)
Extract tool calls from a response.
### `create_final_response_tool` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/sampling/run.py#L400" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `create_final_response_tool` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/sampling/run.py#L419" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
create_final_response_tool(result_type: type) -> SamplingTool
@ -114,7 +125,7 @@ This tool is used to capture structured responses from the LLM.
The tool's schema is derived from the result_type.
### `sample_step_impl` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/sampling/run.py#L436" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `sample_step_impl` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/sampling/run.py#L455" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
sample_step_impl(context: Context, messages: str | Sequence[str | SamplingMessage]) -> SampleStep
@ -127,7 +138,7 @@ Make a single LLM sampling call. This is a stateless function that makes
exactly one LLM call and optionally executes any requested tools.
### `sample_impl` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/sampling/run.py#L552" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `sample_impl` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/sampling/run.py#L572" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
sample_impl(context: Context, messages: str | Sequence[str | SamplingMessage]) -> SamplingResult[ResultT]
@ -143,7 +154,7 @@ provides a final text response.
## Classes
### `SamplingResult` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/sampling/run.py#L52" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `SamplingResult` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/sampling/run.py#L54" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
Result of a sampling operation.
@ -154,7 +165,7 @@ Result of a sampling operation.
- `history`: All messages exchanged during sampling.
### `SampleStep` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/sampling/run.py#L67" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `SampleStep` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/sampling/run.py#L69" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
Result of a single sampling call.
@ -164,7 +175,7 @@ Represents what the LLM returned in this step plus the message history.
**Methods:**
#### `is_tool_use` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/sampling/run.py#L77" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `is_tool_use` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/sampling/run.py#L79" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
is_tool_use(self) -> bool
@ -173,7 +184,7 @@ is_tool_use(self) -> bool
True if the LLM is requesting tool execution.
#### `text` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/sampling/run.py#L84" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `text` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/sampling/run.py#L86" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
text(self) -> str | None
@ -182,7 +193,7 @@ text(self) -> str | None
Extract text from the response, if available.
#### `tool_calls` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/sampling/run.py#L97" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `tool_calls` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/sampling/run.py#L99" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
tool_calls(self) -> list[ToolUseContent]

View file

@ -10,7 +10,7 @@ SamplingTool for use during LLM sampling requests.
## Classes
### `SamplingTool` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/sampling/sampling_tool.py#L16" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `SamplingTool` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/sampling/sampling_tool.py#L20" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
A tool that can be used during LLM sampling.
@ -37,7 +37,7 @@ Create a SamplingTool explicitly when you need custom name/description:
**Methods:**
#### `run` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/sampling/sampling_tool.py#L47" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `run` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/sampling/sampling_tool.py#L51" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
run(self, arguments: dict[str, Any] | None = None) -> Any
@ -52,7 +52,7 @@ Execute the tool with the given arguments.
- The result of executing the tool function.
#### `from_function` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/sampling/sampling_tool.py#L77" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `from_function` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/sampling/sampling_tool.py#L81" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
from_function(cls, fn: Callable[..., Any]) -> SamplingTool
@ -78,3 +78,24 @@ concurrently. Defaults to False.
**Raises:**
- `ValueError`: If the function is a lambda without a name override.
#### `from_callable_tool` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/sampling/sampling_tool.py#L123" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
from_callable_tool(cls, tool: FunctionTool | TransformedTool) -> SamplingTool
```
Create a SamplingTool from a FunctionTool or TransformedTool.
Reuses existing server tools in sampling contexts. For TransformedTool,
the tool's .run() method is used to ensure proper argument transformation,
and the ToolResult is automatically unwrapped.
**Args:**
- `tool`: A FunctionTool or TransformedTool to convert.
- `name`: Optional name override. Defaults to tool.name.
- `description`: Optional description override. Defaults to tool.description.
**Raises:**
- `TypeError`: If the tool is not a FunctionTool or TransformedTool.

View file

@ -32,6 +32,8 @@ from typing_extensions import TypeVar
from fastmcp import settings
from fastmcp.exceptions import ToolError
from fastmcp.server.sampling.sampling_tool import SamplingTool
from fastmcp.tools.function_tool import FunctionTool
from fastmcp.tools.tool_transform import TransformedTool
from fastmcp.utilities.async_utils import gather
from fastmcp.utilities.json_schema import compress_schema
from fastmcp.utilities.logging import get_logger
@ -367,9 +369,22 @@ def prepare_messages(
def prepare_tools(
tools: Sequence[SamplingTool | Callable[..., Any]] | None,
tools: Sequence[SamplingTool | FunctionTool | TransformedTool | Callable[..., Any]]
| None,
) -> list[SamplingTool] | None:
"""Convert tools to SamplingTool objects."""
"""Convert tools to SamplingTool objects.
Accepts SamplingTool instances, FunctionTool instances, TransformedTool instances,
or plain callable functions. FunctionTool and TransformedTool are converted using
from_callable_tool(), while plain functions use from_function().
Args:
tools: Sequence of tools to prepare. Can be SamplingTool, FunctionTool,
TransformedTool, or plain callable functions.
Returns:
List of SamplingTool instances, or None if tools is None.
"""
if tools is None:
return None
@ -377,10 +392,14 @@ def prepare_tools(
for t in tools:
if isinstance(t, SamplingTool):
sampling_tools.append(t)
elif isinstance(t, (FunctionTool, TransformedTool)):
sampling_tools.append(SamplingTool.from_callable_tool(t))
elif callable(t):
sampling_tools.append(SamplingTool.from_function(t))
else:
raise TypeError(f"Expected SamplingTool or callable, got {type(t)}")
raise TypeError(
f"Expected SamplingTool, FunctionTool, TransformedTool, or callable, got {type(t)}"
)
return sampling_tools if sampling_tools else None
@ -441,7 +460,8 @@ async def sample_step_impl(
temperature: float | None = None,
max_tokens: int | None = None,
model_preferences: ModelPreferences | str | list[str] | None = None,
tools: Sequence[SamplingTool | Callable[..., Any]] | None = None,
tools: Sequence[SamplingTool | FunctionTool | TransformedTool | Callable[..., Any]]
| None = None,
tool_choice: ToolChoiceOption | str | None = None,
auto_execute_tools: bool = True,
mask_error_details: bool | None = None,
@ -557,7 +577,8 @@ async def sample_impl(
temperature: float | None = None,
max_tokens: int | None = None,
model_preferences: ModelPreferences | str | list[str] | None = None,
tools: Sequence[SamplingTool | Callable[..., Any]] | None = None,
tools: Sequence[SamplingTool | FunctionTool | TransformedTool | Callable[..., Any]]
| None = None,
result_type: type[ResultT] | None = None,
mask_error_details: bool | None = None,
tool_concurrency: int | None = None,

View file

@ -6,10 +6,14 @@ import inspect
from collections.abc import Callable
from typing import Any
from mcp.types import TextContent
from mcp.types import Tool as SDKTool
from pydantic import ConfigDict
from fastmcp.tools.function_parsing import ParsedFunction
from fastmcp.tools.function_tool import FunctionTool
from fastmcp.tools.tool import ToolResult
from fastmcp.tools.tool_transform import TransformedTool
from fastmcp.utilities.types import FastMCPBaseModel
@ -114,3 +118,66 @@ class SamplingTool(FastMCPBaseModel):
fn=parsed.fn,
sequential=sequential,
)
@classmethod
def from_callable_tool(
cls,
tool: FunctionTool | TransformedTool,
*,
name: str | None = None,
description: str | None = None,
) -> SamplingTool:
"""Create a SamplingTool from a FunctionTool or TransformedTool.
Reuses existing server tools in sampling contexts. For TransformedTool,
the tool's .run() method is used to ensure proper argument transformation,
and the ToolResult is automatically unwrapped.
Args:
tool: A FunctionTool or TransformedTool to convert.
name: Optional name override. Defaults to tool.name.
description: Optional description override. Defaults to tool.description.
Raises:
TypeError: If the tool is not a FunctionTool or TransformedTool.
"""
# Validate that the tool is a supported type
if not isinstance(tool, (FunctionTool, TransformedTool)):
raise TypeError(
f"Expected FunctionTool or TransformedTool, got {type(tool).__name__}. "
"Only callable tools can be converted to SamplingTools."
)
# Both FunctionTool and TransformedTool need .run() to ensure proper
# result processing (serializers, output_schema, wrap-result flags)
async def wrapper(**kwargs: Any) -> Any:
result = await tool.run(kwargs)
# Unwrap ToolResult - extract the actual value
if isinstance(result, ToolResult):
# If there's structured_content, use that
if result.structured_content is not None:
# Check tool's schema - this is the source of truth
if tool.output_schema and tool.output_schema.get(
"x-fastmcp-wrap-result"
):
# Tool wraps results: {"result": value} -> value
return result.structured_content.get("result")
else:
# No wrapping: use structured_content directly
return result.structured_content
# Otherwise, extract from text content
if result.content and len(result.content) > 0:
first_content = result.content[0]
if isinstance(first_content, TextContent):
return first_content.text
return result
fn = wrapper
# Extract the callable function, name, description, and parameters
return cls(
name=name or tool.name,
description=description or tool.description,
parameters=tool.parameters,
fn=fn,
)

View file

@ -0,0 +1,111 @@
"""Tests for prepare_tools helper function."""
import pytest
from fastmcp.server.sampling.run import prepare_tools
from fastmcp.server.sampling.sampling_tool import SamplingTool
from fastmcp.tools.function_tool import FunctionTool
from fastmcp.tools.tool_transform import ArgTransform, TransformedTool
class TestPrepareTools:
"""Tests for prepare_tools()."""
def test_prepare_tools_with_none(self):
"""Test that None returns None."""
result = prepare_tools(None)
assert result is None
def test_prepare_tools_with_sampling_tool(self):
"""Test that SamplingTool instances pass through."""
def search(query: str) -> str:
return f"Results: {query}"
sampling_tool = SamplingTool.from_function(search)
result = prepare_tools([sampling_tool])
assert result is not None
assert len(result) == 1
assert result[0] is sampling_tool
def test_prepare_tools_with_function(self):
"""Test that plain functions are converted."""
def search(query: str) -> str:
"""Search function."""
return f"Results: {query}"
result = prepare_tools([search])
assert result is not None
assert len(result) == 1
assert isinstance(result[0], SamplingTool)
assert result[0].name == "search"
def test_prepare_tools_with_function_tool(self):
"""Test that FunctionTool instances are converted."""
def search(query: str) -> str:
"""Search the web."""
return f"Results: {query}"
function_tool = FunctionTool.from_function(search)
result = prepare_tools([function_tool])
assert result is not None
assert len(result) == 1
assert isinstance(result[0], SamplingTool)
assert result[0].name == "search"
assert result[0].description == "Search the web."
def test_prepare_tools_with_transformed_tool(self):
"""Test that TransformedTool instances are converted."""
def original(query: str) -> str:
"""Original tool."""
return f"Results: {query}"
function_tool = FunctionTool.from_function(original)
transformed_tool = TransformedTool.from_tool(
function_tool,
name="search_v2",
transform_args={"query": ArgTransform(name="q")},
)
result = prepare_tools([transformed_tool])
assert result is not None
assert len(result) == 1
assert isinstance(result[0], SamplingTool)
assert result[0].name == "search_v2"
assert "q" in result[0].parameters.get("properties", {})
def test_prepare_tools_with_mixed_types(self):
"""Test that mixed tool types are all converted."""
def plain_fn(x: int) -> int:
return x * 2
def fn_for_tool(y: int) -> int:
return y * 3
function_tool = FunctionTool.from_function(fn_for_tool)
sampling_tool = SamplingTool.from_function(lambda z: z * 4, name="lambda_tool")
result = prepare_tools([plain_fn, function_tool, sampling_tool])
assert result is not None
assert len(result) == 3
assert all(isinstance(t, SamplingTool) for t in result)
def test_prepare_tools_with_invalid_type(self):
"""Test that invalid types raise TypeError."""
with pytest.raises(TypeError, match="Expected SamplingTool, FunctionTool"):
prepare_tools(["not a tool"]) # type: ignore[arg-type]
def test_prepare_tools_empty_list(self):
"""Test that empty list returns None."""
result = prepare_tools([])
assert result is None

View file

@ -3,6 +3,8 @@
import pytest
from fastmcp.server.sampling import SamplingTool
from fastmcp.tools.function_tool import FunctionTool
from fastmcp.tools.tool_transform import ArgTransform, TransformedTool
class TestSamplingToolFromFunction:
@ -119,3 +121,172 @@ class TestSamplingToolSDKConversion:
assert sdk_tool.name == "search"
assert sdk_tool.description == "Search the web."
assert "query" in sdk_tool.inputSchema.get("properties", {})
class TestSamplingToolFromCallableTool:
"""Tests for SamplingTool.from_callable_tool()."""
def test_from_function_tool(self):
"""Test converting a FunctionTool to SamplingTool."""
def search(query: str) -> str:
"""Search the web."""
return f"Results for: {query}"
function_tool = FunctionTool.from_function(search)
sampling_tool = SamplingTool.from_callable_tool(function_tool)
assert sampling_tool.name == "search"
assert sampling_tool.description == "Search the web."
assert "query" in sampling_tool.parameters.get("properties", {})
# fn is now a wrapper that calls tool.run() for proper result processing
assert callable(sampling_tool.fn)
def test_from_function_tool_with_overrides(self):
"""Test converting FunctionTool with name/description overrides."""
def search(query: str) -> str:
"""Search the web."""
return f"Results for: {query}"
function_tool = FunctionTool.from_function(search)
sampling_tool = SamplingTool.from_callable_tool(
function_tool,
name="web_search",
description="Search the internet",
)
assert sampling_tool.name == "web_search"
assert sampling_tool.description == "Search the internet"
def test_from_transformed_tool(self):
"""Test converting a TransformedTool to SamplingTool."""
def original(query: str, limit: int) -> str:
"""Original tool."""
return f"Results for: {query} (limit: {limit})"
function_tool = FunctionTool.from_function(original)
transformed_tool = TransformedTool.from_tool(
function_tool,
name="search_transformed",
transform_args={"query": ArgTransform(name="q")},
)
sampling_tool = SamplingTool.from_callable_tool(transformed_tool)
assert sampling_tool.name == "search_transformed"
assert sampling_tool.description == "Original tool."
# The transformed tool should have 'q' instead of 'query'
assert "q" in sampling_tool.parameters.get("properties", {})
assert "limit" in sampling_tool.parameters.get("properties", {})
async def test_from_function_tool_execution(self):
"""Test that converted FunctionTool executes correctly."""
def add(a: int, b: int) -> int:
"""Add two numbers."""
return a + b
function_tool = FunctionTool.from_function(add)
sampling_tool = SamplingTool.from_callable_tool(function_tool)
result = await sampling_tool.run({"a": 2, "b": 3})
assert result == 5
async def test_from_transformed_tool_execution(self):
"""Test that converted TransformedTool executes correctly."""
def multiply(x: int, y: int) -> int:
"""Multiply two numbers."""
return x * y
function_tool = FunctionTool.from_function(multiply)
transformed_tool = TransformedTool.from_tool(
function_tool,
transform_args={"x": ArgTransform(name="a"), "y": ArgTransform(name="b")},
)
sampling_tool = SamplingTool.from_callable_tool(transformed_tool)
# Use the transformed parameter names
result = await sampling_tool.run({"a": 3, "b": 4})
# Result should be unwrapped from ToolResult
assert result == 12
def test_from_invalid_tool_type(self):
"""Test that from_callable_tool rejects non-tool objects."""
class NotATool:
pass
with pytest.raises(
TypeError,
match="Expected FunctionTool or TransformedTool",
):
SamplingTool.from_callable_tool(NotATool()) # type: ignore[arg-type]
def test_from_plain_function_fails(self):
"""Test that plain functions are rejected by from_callable_tool."""
def my_function():
pass
with pytest.raises(TypeError, match="Expected FunctionTool or TransformedTool"):
SamplingTool.from_callable_tool(my_function) # type: ignore[arg-type]
async def test_from_function_tool_with_output_schema(self):
"""Test that FunctionTool with output_schema is handled correctly."""
def search(query: str) -> dict:
"""Search for something."""
return {"results": ["item1", "item2"], "count": 2}
# Create FunctionTool with x-fastmcp-wrap-result
function_tool = FunctionTool.from_function(
search,
output_schema={
"type": "object",
"properties": {
"results": {"type": "array"},
"count": {"type": "integer"},
},
"x-fastmcp-wrap-result": True,
},
)
sampling_tool = SamplingTool.from_callable_tool(function_tool)
# Run the tool - should unwrap the {"result": {...}} wrapper
result = await sampling_tool.run({"query": "test"})
# Should get the unwrapped dict, not ToolResult
assert isinstance(result, dict)
assert result == {"results": ["item1", "item2"], "count": 2}
async def test_from_function_tool_without_wrap_result(self):
"""Test that FunctionTool without x-fastmcp-wrap-result is handled correctly."""
def get_data() -> dict:
"""Get some data."""
return {"status": "ok", "value": 42}
# Create FunctionTool with output_schema but no wrap-result flag
function_tool = FunctionTool.from_function(
get_data,
output_schema={
"type": "object",
"properties": {
"status": {"type": "string"},
"value": {"type": "integer"},
},
},
)
sampling_tool = SamplingTool.from_callable_tool(function_tool)
# Run the tool - should return structured_content directly
result = await sampling_tool.run({})
assert isinstance(result, dict)
assert result == {"status": "ok", "value": 42}