mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-24 06:24:18 +02:00
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:
parent
a307e9c3cc
commit
359575b12f
7 changed files with 442 additions and 24 deletions
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
111
tests/server/sampling/test_prepare_tools.py
Normal file
111
tests/server/sampling/test_prepare_tools.py
Normal 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
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue