diff --git a/docs/development/v3-notes/v3-features.mdx b/docs/development/v3-notes/v3-features.mdx
index 67474880d6..aca82b92af 100644
--- a/docs/development/v3-notes/v3-features.mdx
+++ b/docs/development/v3-notes/v3-features.mdx
@@ -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`
diff --git a/docs/python-sdk/fastmcp-server-sampling-run.mdx b/docs/python-sdk/fastmcp-server-sampling-run.mdx
index a28542f289..657b9dd307 100644
--- a/docs/python-sdk/fastmcp-server-sampling-run.mdx
+++ b/docs/python-sdk/fastmcp-server-sampling-run.mdx
@@ -10,7 +10,7 @@ Sampling types and helper functions for FastMCP servers.
## Functions
-### `determine_handler_mode`
+### `determine_handler_mode`
```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`
+### `call_sampling_handler`
```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`
+### `execute_tools`
```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`
+### `prepare_messages`
```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`
+### `prepare_tools`
```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`
+**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`
```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`
+### `create_final_response_tool`
```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`
+### `sample_step_impl`
```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`
+### `sample_impl`
```python
sample_impl(context: Context, messages: str | Sequence[str | SamplingMessage]) -> SamplingResult[ResultT]
@@ -143,7 +154,7 @@ provides a final text response.
## Classes
-### `SamplingResult`
+### `SamplingResult`
Result of a sampling operation.
@@ -154,7 +165,7 @@ Result of a sampling operation.
- `history`: All messages exchanged during sampling.
-### `SampleStep`
+### `SampleStep`
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`
+#### `is_tool_use`
```python
is_tool_use(self) -> bool
@@ -173,7 +184,7 @@ is_tool_use(self) -> bool
True if the LLM is requesting tool execution.
-#### `text`
+#### `text`
```python
text(self) -> str | None
@@ -182,7 +193,7 @@ text(self) -> str | None
Extract text from the response, if available.
-#### `tool_calls`
+#### `tool_calls`
```python
tool_calls(self) -> list[ToolUseContent]
diff --git a/docs/python-sdk/fastmcp-server-sampling-sampling_tool.mdx b/docs/python-sdk/fastmcp-server-sampling-sampling_tool.mdx
index 15941b0dc5..c1650aa4f2 100644
--- a/docs/python-sdk/fastmcp-server-sampling-sampling_tool.mdx
+++ b/docs/python-sdk/fastmcp-server-sampling-sampling_tool.mdx
@@ -10,7 +10,7 @@ SamplingTool for use during LLM sampling requests.
## Classes
-### `SamplingTool`
+### `SamplingTool`
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`
+#### `run`
```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`
+#### `from_function`
```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`
+
+```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.
+
diff --git a/src/fastmcp/server/sampling/run.py b/src/fastmcp/server/sampling/run.py
index c9aa94a765..6ece2c30e4 100644
--- a/src/fastmcp/server/sampling/run.py
+++ b/src/fastmcp/server/sampling/run.py
@@ -32,6 +32,8 @@
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,
diff --git a/src/fastmcp/server/sampling/sampling_tool.py b/src/fastmcp/server/sampling/sampling_tool.py
index 877be71c53..1781eb6517 100644
--- a/src/fastmcp/server/sampling/sampling_tool.py
+++ b/src/fastmcp/server/sampling/sampling_tool.py
@@ -6,10 +6,14 @@
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 @@ def from_function(
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,
+ )
diff --git a/tests/server/sampling/test_prepare_tools.py b/tests/server/sampling/test_prepare_tools.py
new file mode 100644
index 0000000000..c08a27ae10
--- /dev/null
+++ b/tests/server/sampling/test_prepare_tools.py
@@ -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
diff --git a/tests/server/sampling/test_sampling_tool.py b/tests/server/sampling/test_sampling_tool.py
index 8c2dba7a8e..a95b393acb 100644
--- a/tests/server/sampling/test_sampling_tool.py
+++ b/tests/server/sampling/test_sampling_tool.py
@@ -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 @@ def search(query: str) -> str:
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}