From 9785b1e5bcbcb5d08d25000b582cb0d4aaef98c3 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Tue, 3 Feb 2026 01:21:33 +0000 Subject: [PATCH 01/10] Add helpers for converting FunctionTool and TransformedTool to SamplingTool Co-authored-by: Bill Easton --- src/fastmcp/server/sampling/run.py | 26 ++++- src/fastmcp/server/sampling/sampling_tool.py | 94 +++++++++++++++ tests/server/sampling/test_prepare_tools.py | 111 ++++++++++++++++++ tests/server/sampling/test_sampling_tool.py | 114 +++++++++++++++++++ 4 files changed, 342 insertions(+), 3 deletions(-) create mode 100644 tests/server/sampling/test_prepare_tools.py diff --git a/src/fastmcp/server/sampling/run.py b/src/fastmcp/server/sampling/run.py index 7299689162..54b1e57c3b 100644 --- a/src/fastmcp/server/sampling/run.py +++ b/src/fastmcp/server/sampling/run.py @@ -334,20 +334,40 @@ def prepare_messages( def prepare_tools( - tools: Sequence[SamplingTool | Callable[..., Any]] | None, + tools: Sequence[SamplingTool | Callable[..., Any] | 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 + # Import here to avoid circular dependencies and check for tool types + from fastmcp.tools.function_tool import FunctionTool + from fastmcp.tools.tool_transform import TransformedTool + sampling_tools: list[SamplingTool] = [] 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 diff --git a/src/fastmcp/server/sampling/sampling_tool.py b/src/fastmcp/server/sampling/sampling_tool.py index 106c55fc60..8643c7cea2 100644 --- a/src/fastmcp/server/sampling/sampling_tool.py +++ b/src/fastmcp/server/sampling/sampling_tool.py @@ -107,3 +107,97 @@ def from_function( parameters=parsed.input_schema, fn=parsed.fn, ) + + @classmethod + def from_callable_tool( + cls, + tool: Any, + *, + name: str | None = None, + description: str | None = None, + ) -> SamplingTool: + """Create a SamplingTool from a FunctionTool or TransformedTool. + + This helper enables reusing existing server tools in sampling contexts + without duplication. Both FunctionTool and TransformedTool have callable + .fn attributes that can be directly used for sampling. + + For TransformedTool instances, the tool's .run() method is used instead + of .fn to ensure proper argument transformation and execution. The result + is automatically unwrapped from ToolResult if needed. + + Args: + tool: A FunctionTool or TransformedTool with a callable .fn attribute. + name: Optional name override. Defaults to tool.name. + description: Optional description override. Defaults to tool.description. + + Returns: + A SamplingTool that wraps the tool's functionality. + + Raises: + AttributeError: If the tool doesn't have required attributes (.fn, .name, etc.). + + Examples: + Convert a FunctionTool to SamplingTool: + + @mcp.tool + def search(query: str) -> str: + return do_search(query) + + sampling_tool = SamplingTool.from_callable_tool(search) + + Use in sampling context: + + result = await ctx.sample( + "Research Python", + tools=[SamplingTool.from_callable_tool(search)] + ) + """ + # Import here to avoid circular dependencies + from fastmcp.tools.function_tool import FunctionTool + from fastmcp.tools.tool import ToolResult + from fastmcp.tools.tool_transform import 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." + ) + + # For TransformedTool, we need to use .run() and unwrap ToolResult + # because .fn might be a forwarding function that returns ToolResult + if isinstance(tool, TransformedTool): + + 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: + # Handle wrapped results + if ( + isinstance(result.structured_content, dict) + and "result" in result.structured_content + ): + return result.structured_content["result"] + return result.structured_content + # Otherwise, extract from text content + if result.content and len(result.content) > 0: + first_content = result.content[0] + if hasattr(first_content, "text"): + return first_content.text + return result + + fn = wrapper + else: + # FunctionTool.fn can be used directly + fn = tool.fn + + # 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..0008bdd4c5 --- /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"]) + + 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..0f02172757 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,115 @@ 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", {}) + assert sampling_tool.fn is function_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()) + + 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) From bcd1a53c1ba7494410bd2b4bb71084cf249b0b80 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Tue, 3 Feb 2026 07:51:32 +0000 Subject: [PATCH 02/10] Remove unnecessary inline imports and fix Any type hints There is no circular dependency between tools and sampling modules. Moved FunctionTool/TransformedTool imports to top-level and replaced Any type hints with proper FunctionTool | TransformedTool unions. Co-authored-by: Bill Easton --- src/fastmcp/server/sampling/run.py | 15 ++++++++------- src/fastmcp/server/sampling/sampling_tool.py | 12 +++++------- 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/src/fastmcp/server/sampling/run.py b/src/fastmcp/server/sampling/run.py index 54b1e57c3b..bf0c9ed5b0 100644 --- a/src/fastmcp/server/sampling/run.py +++ b/src/fastmcp/server/sampling/run.py @@ -31,6 +31,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.json_schema import compress_schema from fastmcp.utilities.logging import get_logger from fastmcp.utilities.types import get_cached_typeadapter @@ -334,7 +336,8 @@ def prepare_messages( def prepare_tools( - tools: Sequence[SamplingTool | Callable[..., Any] | Any] | None, + tools: Sequence[SamplingTool | FunctionTool | TransformedTool | Callable[..., Any]] + | None, ) -> list[SamplingTool] | None: """Convert tools to SamplingTool objects. @@ -352,10 +355,6 @@ def prepare_tools( if tools is None: return None - # Import here to avoid circular dependencies and check for tool types - from fastmcp.tools.function_tool import FunctionTool - from fastmcp.tools.tool_transform import TransformedTool - sampling_tools: list[SamplingTool] = [] for t in tools: if isinstance(t, SamplingTool): @@ -428,7 +427,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, @@ -540,7 +540,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, ) -> SamplingResult[ResultT]: diff --git a/src/fastmcp/server/sampling/sampling_tool.py b/src/fastmcp/server/sampling/sampling_tool.py index 8643c7cea2..6b2a0760b7 100644 --- a/src/fastmcp/server/sampling/sampling_tool.py +++ b/src/fastmcp/server/sampling/sampling_tool.py @@ -10,6 +10,9 @@ 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 @@ -111,7 +114,7 @@ def from_function( @classmethod def from_callable_tool( cls, - tool: Any, + tool: FunctionTool | TransformedTool, *, name: str | None = None, description: str | None = None, @@ -135,7 +138,7 @@ def from_callable_tool( A SamplingTool that wraps the tool's functionality. Raises: - AttributeError: If the tool doesn't have required attributes (.fn, .name, etc.). + TypeError: If the tool is not a FunctionTool or TransformedTool. Examples: Convert a FunctionTool to SamplingTool: @@ -153,11 +156,6 @@ def search(query: str) -> str: tools=[SamplingTool.from_callable_tool(search)] ) """ - # Import here to avoid circular dependencies - from fastmcp.tools.function_tool import FunctionTool - from fastmcp.tools.tool import ToolResult - from fastmcp.tools.tool_transform import TransformedTool - # Validate that the tool is a supported type if not isinstance(tool, (FunctionTool, TransformedTool)): raise TypeError( From cb83f003498927d2a7a4c0586ab09d0315efc218 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Sun, 8 Feb 2026 23:44:53 +0000 Subject: [PATCH 03/10] Check x-fastmcp-wrap-result before unwrapping ToolResult Only unwrap the 'result' key from structured_content when the tool's output schema has x-fastmcp-wrap-result set. Otherwise, return structured_content directly. This mirrors the client's logic and prevents data loss for legitimate schemas with a 'result' field. Co-authored-by: Bill Easton --- src/fastmcp/server/sampling/sampling_tool.py | 14 ++++++++------ tests/server/sampling/test_prepare_tools.py | 2 +- tests/server/sampling/test_sampling_tool.py | 4 ++-- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/src/fastmcp/server/sampling/sampling_tool.py b/src/fastmcp/server/sampling/sampling_tool.py index 6b2a0760b7..38a1b03786 100644 --- a/src/fastmcp/server/sampling/sampling_tool.py +++ b/src/fastmcp/server/sampling/sampling_tool.py @@ -173,13 +173,15 @@ async def wrapper(**kwargs: Any) -> Any: if isinstance(result, ToolResult): # If there's structured_content, use that if result.structured_content is not None: - # Handle wrapped results - if ( - isinstance(result.structured_content, dict) - and "result" in result.structured_content + # Check tool's schema - this is the source of truth + if tool.output_schema and tool.output_schema.get( + "x-fastmcp-wrap-result" ): - return result.structured_content["result"] - return result.structured_content + # 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] diff --git a/tests/server/sampling/test_prepare_tools.py b/tests/server/sampling/test_prepare_tools.py index 0008bdd4c5..c08a27ae10 100644 --- a/tests/server/sampling/test_prepare_tools.py +++ b/tests/server/sampling/test_prepare_tools.py @@ -103,7 +103,7 @@ 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"]) + prepare_tools(["not a tool"]) # type: ignore[arg-type] def test_prepare_tools_empty_list(self): """Test that empty list returns None.""" diff --git a/tests/server/sampling/test_sampling_tool.py b/tests/server/sampling/test_sampling_tool.py index 0f02172757..40c15b9545 100644 --- a/tests/server/sampling/test_sampling_tool.py +++ b/tests/server/sampling/test_sampling_tool.py @@ -223,7 +223,7 @@ class NotATool: TypeError, match="Expected FunctionTool or TransformedTool", ): - SamplingTool.from_callable_tool(NotATool()) + 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.""" @@ -232,4 +232,4 @@ def my_function(): pass with pytest.raises(TypeError, match="Expected FunctionTool or TransformedTool"): - SamplingTool.from_callable_tool(my_function) + SamplingTool.from_callable_tool(my_function) # type: ignore[arg-type] From d79ca6818bdb3cffabd58c21869bb4684f4b4ac2 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Mon, 9 Feb 2026 20:54:49 -0500 Subject: [PATCH 04/10] fix: use isinstance instead of hasattr, direct indexing for wrap-result --- src/fastmcp/server/sampling/sampling_tool.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/fastmcp/server/sampling/sampling_tool.py b/src/fastmcp/server/sampling/sampling_tool.py index 38a1b03786..772ff75d6b 100644 --- a/src/fastmcp/server/sampling/sampling_tool.py +++ b/src/fastmcp/server/sampling/sampling_tool.py @@ -6,6 +6,7 @@ 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 @@ -178,14 +179,14 @@ async def wrapper(**kwargs: Any) -> Any: "x-fastmcp-wrap-result" ): # Tool wraps results: {"result": value} -> value - return result.structured_content.get("result") + return result.structured_content["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 hasattr(first_content, "text"): + if isinstance(first_content, TextContent): return first_content.text return result From d464a2cafd7a71b0d8c8c762f1943ad6b70abcda Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Mon, 9 Feb 2026 21:03:04 -0500 Subject: [PATCH 05/10] fix: use .get() for wrap-result unwrapping --- src/fastmcp/server/sampling/sampling_tool.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/fastmcp/server/sampling/sampling_tool.py b/src/fastmcp/server/sampling/sampling_tool.py index dec7eb1c75..405dd25879 100644 --- a/src/fastmcp/server/sampling/sampling_tool.py +++ b/src/fastmcp/server/sampling/sampling_tool.py @@ -186,7 +186,7 @@ async def wrapper(**kwargs: Any) -> Any: "x-fastmcp-wrap-result" ): # Tool wraps results: {"result": value} -> value - return result.structured_content["result"] + return result.structured_content.get("result") else: # No wrapping: use structured_content directly return result.structured_content From 126e624a203290ebf17b3cacce8e62eccfae20c4 Mon Sep 17 00:00:00 2001 From: "marvin-context-protocol[bot]" <225465937+marvin-context-protocol[bot]@users.noreply.github.com> Date: Tue, 10 Feb 2026 02:03:39 +0000 Subject: [PATCH 06/10] chore: Update SDK documentation --- .../fastmcp-server-sampling-run.mdx | 41 +++++++++------ .../fastmcp-server-sampling-sampling_tool.mdx | 51 +++++++++++++++++-- 2 files changed, 74 insertions(+), 18 deletions(-) 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..b92ba007a7 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,48 @@ 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. + +This helper enables reusing existing server tools in sampling contexts +without duplication. Both FunctionTool and TransformedTool have callable +.fn attributes that can be directly used for sampling. + +For TransformedTool instances, the tool's .run() method is used instead +of .fn to ensure proper argument transformation and execution. The result +is automatically unwrapped from ToolResult if needed. + +**Args:** +- `tool`: A FunctionTool or TransformedTool with a callable .fn attribute. +- `name`: Optional name override. Defaults to tool.name. +- `description`: Optional description override. Defaults to tool.description. + +**Returns:** +- A SamplingTool that wraps the tool's functionality. + +**Raises:** +- `TypeError`: If the tool is not a FunctionTool or TransformedTool. + +**Examples:** + +Convert a FunctionTool to SamplingTool: + + @mcp.tool + def search(query: str) -> str: + return do_search(query) + + sampling_tool = SamplingTool.from_callable_tool(search) + +Use in sampling context: + + result = await ctx.sample( + "Research Python", + tools=[SamplingTool.from_callable_tool(search)] + ) + From facc043502a4333dfa05a601dd4c742f8cc0c5dc Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Mon, 9 Feb 2026 21:14:01 -0500 Subject: [PATCH 07/10] fix: clean up from_callable_tool docstring --- src/fastmcp/server/sampling/sampling_tool.py | 31 +++----------------- 1 file changed, 4 insertions(+), 27 deletions(-) diff --git a/src/fastmcp/server/sampling/sampling_tool.py b/src/fastmcp/server/sampling/sampling_tool.py index 405dd25879..04890debae 100644 --- a/src/fastmcp/server/sampling/sampling_tool.py +++ b/src/fastmcp/server/sampling/sampling_tool.py @@ -129,40 +129,17 @@ def from_callable_tool( ) -> SamplingTool: """Create a SamplingTool from a FunctionTool or TransformedTool. - This helper enables reusing existing server tools in sampling contexts - without duplication. Both FunctionTool and TransformedTool have callable - .fn attributes that can be directly used for sampling. - - For TransformedTool instances, the tool's .run() method is used instead - of .fn to ensure proper argument transformation and execution. The result - is automatically unwrapped from ToolResult if needed. + 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 with a callable .fn attribute. + tool: A FunctionTool or TransformedTool to convert. name: Optional name override. Defaults to tool.name. description: Optional description override. Defaults to tool.description. - Returns: - A SamplingTool that wraps the tool's functionality. - Raises: TypeError: If the tool is not a FunctionTool or TransformedTool. - - Examples: - Convert a FunctionTool to SamplingTool: - - @mcp.tool - def search(query: str) -> str: - return do_search(query) - - sampling_tool = SamplingTool.from_callable_tool(search) - - Use in sampling context: - - result = await ctx.sample( - "Research Python", - tools=[SamplingTool.from_callable_tool(search)] - ) """ # Validate that the tool is a supported type if not isinstance(tool, (FunctionTool, TransformedTool)): From e30c8a6ae724652aac3a0134c1966c1049e0dc78 Mon Sep 17 00:00:00 2001 From: "marvin-context-protocol[bot]" <225465937+marvin-context-protocol[bot]@users.noreply.github.com> Date: Tue, 10 Feb 2026 02:14:40 +0000 Subject: [PATCH 08/10] chore: Update SDK documentation --- .../fastmcp-server-sampling-sampling_tool.mdx | 32 +++---------------- 1 file changed, 4 insertions(+), 28 deletions(-) diff --git a/docs/python-sdk/fastmcp-server-sampling-sampling_tool.mdx b/docs/python-sdk/fastmcp-server-sampling-sampling_tool.mdx index b92ba007a7..c1650aa4f2 100644 --- a/docs/python-sdk/fastmcp-server-sampling-sampling_tool.mdx +++ b/docs/python-sdk/fastmcp-server-sampling-sampling_tool.mdx @@ -87,39 +87,15 @@ from_callable_tool(cls, tool: FunctionTool | TransformedTool) -> SamplingTool Create a SamplingTool from a FunctionTool or TransformedTool. -This helper enables reusing existing server tools in sampling contexts -without duplication. Both FunctionTool and TransformedTool have callable -.fn attributes that can be directly used for sampling. - -For TransformedTool instances, the tool's .run() method is used instead -of .fn to ensure proper argument transformation and execution. The result -is automatically unwrapped from ToolResult if needed. +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 with a callable .fn attribute. +- `tool`: A FunctionTool or TransformedTool to convert. - `name`: Optional name override. Defaults to tool.name. - `description`: Optional description override. Defaults to tool.description. -**Returns:** -- A SamplingTool that wraps the tool's functionality. - **Raises:** - `TypeError`: If the tool is not a FunctionTool or TransformedTool. -**Examples:** - -Convert a FunctionTool to SamplingTool: - - @mcp.tool - def search(query: str) -> str: - return do_search(query) - - sampling_tool = SamplingTool.from_callable_tool(search) - -Use in sampling context: - - result = await ctx.sample( - "Research Python", - tools=[SamplingTool.from_callable_tool(search)] - ) - From 0ab64e17b38de0851ca1d3656e106985f23434c5 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Wed, 11 Feb 2026 15:33:21 +0000 Subject: [PATCH 09/10] Add SamplingTool conversion helpers to v3-features rc1 section Co-authored-by: Jeremiah Lowin --- docs/development/v3-notes/v3-features.mdx | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/docs/development/v3-notes/v3-features.mdx b/docs/development/v3-notes/v3-features.mdx index 2a286961fe..12801c3d61 100644 --- a/docs/development/v3-notes/v3-features.mdx +++ b/docs/development/v3-notes/v3-features.mdx @@ -4,6 +4,25 @@ title: v3.0 Feature Tracking This document tracks major features in FastMCP v3.0 for release notes preparation. +## 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! +) +``` + ## 3.0.0beta2 ### CLI: `fastmcp list` and `fastmcp call` From 3b8a821aeef1099fde49f15777186d6c09106422 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Wed, 11 Feb 2026 21:59:34 +0000 Subject: [PATCH 10/10] Use tool.run() for FunctionTool to respect output_schema - Changed from_callable_tool() to use tool.run() for both FunctionTool and TransformedTool - This ensures output_schema, serializers, and x-fastmcp-wrap-result flags are respected - Added unified ToolResult unwrapping logic for both tool types - Added tests for output_schema handling with and without wrap-result - Updated existing test to check callable instead of function identity Co-authored-by: Bill Easton --- src/fastmcp/server/sampling/sampling_tool.py | 55 +++++++++--------- tests/server/sampling/test_sampling_tool.py | 59 +++++++++++++++++++- 2 files changed, 83 insertions(+), 31 deletions(-) diff --git a/src/fastmcp/server/sampling/sampling_tool.py b/src/fastmcp/server/sampling/sampling_tool.py index 04890debae..1781eb6517 100644 --- a/src/fastmcp/server/sampling/sampling_tool.py +++ b/src/fastmcp/server/sampling/sampling_tool.py @@ -148,36 +148,31 @@ def from_callable_tool( "Only callable tools can be converted to SamplingTools." ) - # For TransformedTool, we need to use .run() and unwrap ToolResult - # because .fn might be a forwarding function that returns ToolResult - if isinstance(tool, TransformedTool): - - 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 - else: - # FunctionTool.fn can be used directly - fn = tool.fn + # 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( diff --git a/tests/server/sampling/test_sampling_tool.py b/tests/server/sampling/test_sampling_tool.py index 40c15b9545..a95b393acb 100644 --- a/tests/server/sampling/test_sampling_tool.py +++ b/tests/server/sampling/test_sampling_tool.py @@ -139,7 +139,8 @@ def search(query: str) -> str: assert sampling_tool.name == "search" assert sampling_tool.description == "Search the web." assert "query" in sampling_tool.parameters.get("properties", {}) - assert sampling_tool.fn is function_tool.fn + # 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.""" @@ -233,3 +234,59 @@ def my_function(): 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}