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}