From e056a3946e01829c3bc0a19777133c2e52f10c36 Mon Sep 17 00:00:00 2001
From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>
Date: Sun, 26 Jul 2026 14:34:21 -0400
Subject: [PATCH 01/15] Remove server-initiated sampling and roots from the
server API
Deletes fastmcp/server/sampling/, Context.sample/sample_step/list_roots, and
FastMCP(sampling_handler=). The proxy's handshake-era relay now reaches the
front session through the SDK directly.
---
docs/servers/sampling.mdx | 591 ------------
examples/sampling/README.md | 62 --
examples/sampling/server_fallback.py | 88 --
examples/sampling/structured_output.py | 110 ---
examples/sampling/text.py | 78 --
examples/sampling/tool_use.py | 125 ---
fastmcp_slim/fastmcp/server/context.py | 283 +-----
.../fastmcp/server/providers/proxy.py | 33 +-
.../fastmcp/server/sampling/__init__.py | 10 -
fastmcp_slim/fastmcp/server/sampling/run.py | 836 -----------------
.../fastmcp/server/sampling/sampling_tool.py | 204 ----
fastmcp_slim/fastmcp/server/server.py | 12 +-
tests/client/test_sampling_result_types.py | 681 --------------
tests/client/test_sampling_tool_loop.py | 811 ----------------
tests/server/sampling/__init__.py | 0
tests/server/sampling/test_prepare_tools.py | 111 ---
tests/server/sampling/test_sampling_tool.py | 440 ---------
.../server/telemetry/test_sampling_tracing.py | 881 ------------------
18 files changed, 33 insertions(+), 5323 deletions(-)
delete mode 100644 docs/servers/sampling.mdx
delete mode 100644 examples/sampling/README.md
delete mode 100644 examples/sampling/server_fallback.py
delete mode 100644 examples/sampling/structured_output.py
delete mode 100644 examples/sampling/text.py
delete mode 100644 examples/sampling/tool_use.py
delete mode 100644 fastmcp_slim/fastmcp/server/sampling/__init__.py
delete mode 100644 fastmcp_slim/fastmcp/server/sampling/run.py
delete mode 100644 fastmcp_slim/fastmcp/server/sampling/sampling_tool.py
delete mode 100644 tests/client/test_sampling_result_types.py
delete mode 100644 tests/client/test_sampling_tool_loop.py
delete mode 100644 tests/server/sampling/__init__.py
delete mode 100644 tests/server/sampling/test_prepare_tools.py
delete mode 100644 tests/server/sampling/test_sampling_tool.py
delete mode 100644 tests/server/telemetry/test_sampling_tracing.py
diff --git a/docs/servers/sampling.mdx b/docs/servers/sampling.mdx
deleted file mode 100644
index 4dd2886d9..000000000
--- a/docs/servers/sampling.mdx
+++ /dev/null
@@ -1,591 +0,0 @@
----
-title: Sampling
-sidebarTitle: Sampling
-description: Request LLM text generation from the client or a configured provider through the MCP context.
-icon: robot
----
-
-import { VersionBadge } from "/snippets/version-badge.mdx"
-
-
-
-
-**Sampling is deprecated and will be removed in a future FastMCP release.**
-
-`ctx.sample()` and `ctx.sample_step()` rely on server-initiated `createMessage`
-requests, which MCP removed as of the 2026-07-28 protocol (SEP-2577). They work
-only on session-based (handshake-era) connections; on a 2026-07-28 connection
-they raise a clear error rather than reaching the client.
-
-**Migration:** call an LLM directly from your server using your own API key and
-provider SDK instead of borrowing the client's model. There is no drop-in
-replacement on modern connections — this architectural shift is the intended
-answer.
-
-
-
-This page covers `ctx.sample()`, which requests generation over the handshake-era back-channel. The modern protocol's guard mechanism can structurally carry a sampling request in its `input_requests` map, but SEP-2577 deprecated server-initiated sampling as a pattern rather than only the `ctx.sample()` spelling of it, so that is not a supported migration path. Use the guard mechanism for [elicitation](/servers/elicitation#elicitation-on-the-modern-protocol) and roots, and call an LLM directly from your server for generation.
-
-
-LLM sampling allows your MCP tools to request text generation from an LLM during execution. This enables tools to leverage AI capabilities for analysis, generation, reasoning, and more—without the client needing to orchestrate multiple calls.
-
-By default, sampling requests are routed to the client's LLM. You can also configure a fallback handler to use a specific provider (like OpenAI) when the client doesn't support sampling, or to always use your own LLM regardless of client capabilities.
-
-## Overview
-
-The simplest use of sampling is passing a prompt string to `ctx.sample()`. The method sends the prompt to the LLM, waits for the complete response, and returns a `SamplingResult`. You can access the generated text through the `.text` attribute.
-
-```python
-from fastmcp import FastMCP, Context
-
-mcp = FastMCP()
-
-@mcp.tool
-async def summarize(content: str, ctx: Context) -> str:
- """Generate a summary of the provided content."""
- result = await ctx.sample(f"Please summarize this:\n\n{content}")
- return result.text or ""
-```
-
-The `SamplingResult` also provides `.result` (identical to `.text` for plain text responses) and `.history` containing the full message exchange—useful if you need to continue the conversation or debug the interaction.
-
-### System Prompts
-
-System prompts let you establish the LLM's role and behavioral guidelines before it processes your request. This is useful for controlling tone, enforcing constraints, or providing context that shouldn't clutter the user-facing prompt.
-
-````python
-from fastmcp import FastMCP, Context
-
-mcp = FastMCP()
-
-@mcp.tool
-async def generate_code(concept: str, ctx: Context) -> str:
- """Generate a Python code example for a concept."""
- result = await ctx.sample(
- messages=f"Write a Python example demonstrating '{concept}'.",
- system_prompt=(
- "You are an expert Python programmer. "
- "Provide concise, working code without explanations."
- ),
- temperature=0.7,
- max_tokens=300
- )
- return f"```python\n{result.text}\n```"
-````
-
-The `temperature` parameter controls randomness—higher values (up to 1.0) produce more varied outputs, while lower values make responses more deterministic. The `max_tokens` parameter limits response length.
-
-### Model Preferences
-
-Model preferences let you hint at which LLM the client should use for a request. You can pass a single model name or a list of preferences in priority order. These are hints rather than requirements—the actual model used depends on what the client has available.
-
-```python
-from fastmcp import FastMCP, Context
-
-mcp = FastMCP()
-
-@mcp.tool
-async def technical_analysis(data: str, ctx: Context) -> str:
- """Analyze data using a reasoning-focused model."""
- result = await ctx.sample(
- messages=f"Analyze this data:\n\n{data}",
- model_preferences=["claude-opus-4-5", "gpt-5-2"],
- temperature=0.2,
- )
- return result.text or ""
-```
-
-Use model preferences when different tasks benefit from different model characteristics. Creative writing might prefer faster models with higher temperature, while complex analysis might benefit from larger reasoning-focused models.
-
-### Multi-Turn Conversations
-
-For requests that need conversational context, construct a list of `SamplingMessage` objects representing the conversation history. Each message has a `role` ("user" or "assistant") and `content` (a `TextContent` object).
-
-```python
-from mcp_types import SamplingMessage, TextContent
-from fastmcp import FastMCP, Context
-
-mcp = FastMCP()
-
-@mcp.tool
-async def contextual_analysis(query: str, data: str, ctx: Context) -> str:
- """Analyze data with conversational context."""
- messages = [
- SamplingMessage(
- role="user",
- content=TextContent(type="text", text=f"Here's my data: {data}"),
- ),
- SamplingMessage(
- role="assistant",
- content=TextContent(type="text", text="I see the data. What would you like to know?"),
- ),
- SamplingMessage(
- role="user",
- content=TextContent(type="text", text=query),
- ),
- ]
- result = await ctx.sample(messages=messages)
- return result.text or ""
-```
-
-The LLM receives the full conversation thread and responds with awareness of the preceding context.
-
-### Fallback Handlers
-
-Client support for sampling is optional—some clients may not implement it. To ensure your tools work regardless of client capabilities, configure a `sampling_handler` that sends requests directly to an LLM provider.
-
-FastMCP provides built-in handlers for [OpenAI and Anthropic APIs](/clients/sampling#built-in-handlers). These handlers support the full sampling API including tools, automatically converting your Python functions to each provider's format.
-
-
-Install handlers with `pip install 'fastmcp[openai]'` or `pip install 'fastmcp[anthropic]'`.
-
-
-```python
-from fastmcp import FastMCP
-from fastmcp.client.sampling.handlers.openai import OpenAISamplingHandler
-
-server = FastMCP(
- name="My Server",
- sampling_handler=OpenAISamplingHandler(default_model="gpt-4o-mini"),
- sampling_handler_behavior="fallback",
-)
-```
-
-The `sampling_handler_behavior` parameter controls when the handler is used:
-
-- **`"fallback"`** (default): Use the handler only when the client doesn't support sampling. This lets capable clients use their own LLM while ensuring your tools still work with clients that lack sampling support.
-- **`"always"`**: Always use the handler, bypassing the client entirely. Use this when you need guaranteed control over which LLM processes requests—for cost control, compliance requirements, or when specific model characteristics are essential.
-
-## Structured Output
-
-
-
-When you need validated, typed data instead of free-form text, use the `result_type` parameter. FastMCP ensures the LLM returns data matching your type, handling validation and retries automatically.
-
-The `result_type` parameter accepts Pydantic models, dataclasses, and basic types like `int`, `list[str]`, or `dict[str, int]`. When you specify a result type, FastMCP automatically creates a `final_response` tool that the LLM calls to provide its response. If validation fails, the error is sent back to the LLM for retry.
-
-```python
-from pydantic import BaseModel
-from fastmcp import FastMCP, Context
-
-mcp = FastMCP()
-
-class SentimentResult(BaseModel):
- sentiment: str
- confidence: float
- reasoning: str
-
-@mcp.tool
-async def analyze_sentiment(text: str, ctx: Context) -> SentimentResult:
- """Analyze text sentiment with structured output."""
- result = await ctx.sample(
- messages=f"Analyze the sentiment of: {text}",
- result_type=SentimentResult,
- )
- return result.result # A validated SentimentResult object
-```
-
-When you call this tool, the LLM returns a structured response that FastMCP validates against your Pydantic model. You access the validated object through `result.result`, while `result.text` contains the JSON representation.
-
-### Structured Output with Tools
-
-Combine structured output with tools for agentic workflows that return validated data. The LLM uses your tools to gather information, then returns a response matching your type.
-
-```python
-from pydantic import BaseModel
-from fastmcp import FastMCP, Context
-
-mcp = FastMCP()
-
-def search(query: str) -> str:
- """Search the web for information."""
- return f"Results for: {query}"
-
-def fetch_url(url: str) -> str:
- """Fetch content from a URL."""
- return f"Content from: {url}"
-
-class ResearchResult(BaseModel):
- summary: str
- sources: list[str]
- confidence: float
-
-@mcp.tool
-async def research(topic: str, ctx: Context) -> ResearchResult:
- """Research a topic and return structured findings."""
- result = await ctx.sample(
- messages=f"Research: {topic}",
- tools=[search, fetch_url],
- result_type=ResearchResult,
- )
- return result.result
-```
-
-
-Structured output with automatic validation only applies to `sample()`. With `sample_step()`, you must manage structured output yourself.
-
-
-## Tool Use
-
-
-
-Sampling with tools enables agentic workflows where the LLM can call functions to gather information before responding. This implements [SEP-1577](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1577), allowing the LLM to autonomously orchestrate multi-step operations.
-
-Pass Python functions to the `tools` parameter, and FastMCP handles the execution loop automatically—calling tools, returning results to the LLM, and continuing until the LLM provides a final response.
-
-### Defining Tools
-
-Define regular Python functions with type hints and docstrings. FastMCP extracts the function's name, docstring, and parameter types to create tool schemas that the LLM can understand.
-
-```python
-from fastmcp import FastMCP, Context
-
-def search(query: str) -> str:
- """Search the web for information."""
- return f"Results for: {query}"
-
-def get_time() -> str:
- """Get the current time."""
- from datetime import datetime
- return datetime.now().strftime("%H:%M:%S")
-
-mcp = FastMCP()
-
-@mcp.tool
-async def research(question: str, ctx: Context) -> str:
- """Answer questions using available tools."""
- result = await ctx.sample(
- messages=question,
- tools=[search, get_time],
- )
- return result.text or ""
-```
-
-The LLM sees each function's signature and docstring, using this information to decide when and how to call them. Tool errors are caught and sent back to the LLM, allowing it to recover gracefully. An internal safety limit prevents infinite loops.
-
-### Custom Tool Definitions
-
-For custom names or descriptions, use `SamplingTool.from_function()`:
-
-```python
-from fastmcp.server.sampling import SamplingTool
-
-tool = SamplingTool.from_function(
- my_func,
- name="custom_name",
- description="Custom description"
-)
-
-result = await ctx.sample(messages="...", tools=[tool])
-```
-
-### Error Handling
-
-By default, when a sampling tool raises an exception, the error message (including details) is sent back to the LLM so it can attempt recovery. To prevent sensitive information from leaking to the LLM, use the `mask_error_details` parameter:
-
-```python
-result = await ctx.sample(
- messages=question,
- tools=[search],
- mask_error_details=True, # Generic error messages only
-)
-```
-
-When `mask_error_details=True`, tool errors become generic messages like `"Error executing tool 'search'"` instead of exposing stack traces or internal details.
-
-To intentionally provide specific error messages to the LLM regardless of masking, raise `ToolError`:
-
-```python
-from fastmcp.exceptions import ToolError
-
-def search(query: str) -> str:
- """Search for information."""
- if not query.strip():
- raise ToolError("Search query cannot be empty")
- return f"Results for: {query}"
-```
-
-`ToolError` messages always pass through to the LLM, making it the escape hatch for errors you want the LLM to see and handle.
-
-### Concurrent Tool Execution
-
-By default, tools execute sequentially — one at a time, in order. When your tools are independent (no shared state between them), you can execute them in parallel with `tool_concurrency`:
-
-```python
-result = await ctx.sample(
- messages="Research these three topics",
- tools=[search, fetch_url],
- tool_concurrency=0, # Unlimited parallel execution
-)
-```
-
-The `tool_concurrency` parameter controls how many tools run at once:
-
-- **`None`** (default): Sequential execution
-- **`0`**: Unlimited parallel execution
-- **`N > 0`**: Execute at most N tools concurrently
-
-For tools that must not run concurrently (file writes, shared state mutations, etc.), mark them as `sequential` when creating the `SamplingTool`:
-
-```python
-from fastmcp.server.sampling import SamplingTool
-
-db_writer = SamplingTool.from_function(
- write_to_db,
- sequential=True, # Forces all tools in the batch to run sequentially
-)
-
-result = await ctx.sample(
- messages="Process this data",
- tools=[search, db_writer],
- tool_concurrency=0, # Would be parallel, but db_writer forces sequential
-)
-```
-
-
-When any tool in a batch has `sequential=True`, the entire batch executes sequentially regardless of `tool_concurrency`. This is a conservative guarantee — if one tool needs ordering, all tools in that batch respect it.
-
-
-### Client Requirements
-
-
-Sampling with tools requires the client to advertise the `sampling.tools` capability. FastMCP clients do this automatically. For external clients that don't support tool-enabled sampling, configure a fallback handler with `sampling_handler_behavior="always"`.
-
-
-## Advanced Control
-
-
-
-While `sample()` handles the tool execution loop automatically, some scenarios require fine-grained control over each step. The `sample_step()` method makes a single LLM call and returns a `SampleStep` containing the response and updated history.
-
-Unlike `sample()`, `sample_step()` is stateless—it doesn't remember previous calls. You control the conversation by passing the full message history each time. The returned `step.history` includes all messages up through the current response, making it easy to continue the loop.
-
-Use `sample_step()` when you need to:
-
-- Inspect tool calls before they execute
-- Implement custom termination conditions
-- Add logging, metrics, or checkpointing between steps
-- Build custom agentic loops with domain-specific logic
-
-### Basic Loop
-
-By default, `sample_step()` executes any tool calls and includes the results in the history. Call it in a loop, passing the updated history each time, until a stop condition is met.
-
-```python
-from mcp_types import SamplingMessage
-from fastmcp import FastMCP, Context
-
-mcp = FastMCP()
-
-def search(query: str) -> str:
- return f"Results for: {query}"
-
-def get_time() -> str:
- return "12:00 PM"
-
-@mcp.tool
-async def controlled_agent(question: str, ctx: Context) -> str:
- """Agent with manual loop control."""
- messages: list[str | SamplingMessage] = [question]
-
- while True:
- step = await ctx.sample_step(
- messages=messages,
- tools=[search, get_time],
- )
-
- if step.is_tool_use:
- # Tools already executed (execute_tools=True by default)
- for call in step.tool_calls:
- print(f"Called tool: {call.name}")
-
- if not step.is_tool_use:
- return step.text or ""
-
- messages = step.history
-```
-
-### SampleStep Properties
-
-Each `SampleStep` provides information about what the LLM returned:
-
-| Property | Description |
-|----------|-------------|
-| `step.is_tool_use` | True if the LLM requested tool calls |
-| `step.tool_calls` | List of tool calls requested (if any) |
-| `step.text` | The text content (if any) |
-| `step.history` | All messages exchanged so far |
-
-The contents of `step.history` depend on `execute_tools`:
-- **`execute_tools=True`** (default): Includes tool results, ready for the next iteration
-- **`execute_tools=False`**: Includes the assistant's tool request, but you add results yourself
-
-### Manual Tool Execution
-
-Set `execute_tools=False` to handle tool execution yourself. When disabled, `step.history` contains the user message and the assistant's response with tool calls—but no tool results. You execute the tools and append the results as a user message.
-
-```python
-from mcp_types import SamplingMessage, ToolResultContent, TextContent
-from fastmcp import FastMCP, Context
-
-mcp = FastMCP()
-
-@mcp.tool
-async def research(question: str, ctx: Context) -> str:
- """Research with manual tool handling."""
-
- def search(query: str) -> str:
- return f"Results for: {query}"
-
- def get_time() -> str:
- return "12:00 PM"
-
- tools = {"search": search, "get_time": get_time}
- messages: list[SamplingMessage] = [question]
-
- while True:
- step = await ctx.sample_step(
- messages=messages,
- tools=list(tools.values()),
- execute_tools=False,
- )
-
- if not step.is_tool_use:
- return step.text or ""
-
- # Execute tools and collect results
- tool_results = []
- for call in step.tool_calls:
- fn = tools[call.name]
- result = fn(**call.input)
- tool_results.append(
- ToolResultContent(
- type="tool_result",
- tool_use_id=call.id,
- content=[TextContent(type="text", text=result)],
- )
- )
-
- messages = list(step.history)
- messages.append(SamplingMessage(role="user", content=tool_results))
-```
-
-To report an error to the LLM, set `is_error=True` on the tool result:
-
-```python
-tool_result = ToolResultContent(
- type="tool_result",
- tool_use_id=call.id,
- content=[TextContent(type="text", text="Permission denied")],
- is_error=True,
-)
-```
-
-## Method Reference
-
-
-
- Request text generation from the LLM, running to completion automatically.
-
-
-
- The prompt to send. Can be a simple string or a list of messages for multi-turn conversations.
-
-
-
- Instructions that establish the LLM's role and behavior.
-
-
-
- Controls randomness (0.0 = deterministic, 1.0 = creative).
-
-
-
- Maximum tokens to generate.
-
-
-
- Hints for which model the client should use.
-
-
-
- Functions the LLM can call during sampling.
-
-
-
- A type for validated structured output. Supports Pydantic models, dataclasses, and basic types like `int`, `list[str]`, or `dict[str, int]`.
-
-
-
- If True, mask detailed error messages from tool execution. When None (default), uses the global `settings.mask_error_details` value. Tools can raise `ToolError` to bypass masking and provide specific error messages to the LLM.
-
-
-
- Controls parallel execution of tools. `None` (default) for sequential, `0` for unlimited parallel, or a positive integer for bounded concurrency. If any tool has `sequential=True`, all tools execute sequentially regardless.
-
-
-
-
-
-
- - `.text`: The raw text response (or JSON for structured output)
- - `.result`: The typed result—same as `.text` for plain text, or a validated Pydantic object for structured output
- - `.history`: All messages exchanged during sampling
-
-
-
-
-
-
-
- Make a single LLM sampling call. Use this for fine-grained control over the sampling loop.
-
-
-
- The prompt or conversation history.
-
-
-
- Instructions that establish the LLM's role and behavior.
-
-
-
- Controls randomness (0.0 = deterministic, 1.0 = creative).
-
-
-
- Maximum tokens to generate.
-
-
-
- Functions the LLM can call during sampling.
-
-
-
- Controls tool usage: `"auto"`, `"required"`, or `"none"`.
-
-
-
- If True, execute tool calls and append results to history. If False, return immediately with tool calls available for manual execution.
-
-
-
- If True, mask detailed error messages from tool execution.
-
-
-
- Controls parallel execution of tools. `None` (default) for sequential, `0` for unlimited parallel, or a positive integer for bounded concurrency.
-
-
-
-
-
- - `.response`: The raw LLM response
- - `.history`: Messages including input, assistant response, and tool results
- - `.is_tool_use`: True if the LLM requested tool execution
- - `.tool_calls`: List of tool calls (if any)
- - `.text`: The text content (if any)
-
-
-
-
diff --git a/examples/sampling/README.md b/examples/sampling/README.md
deleted file mode 100644
index d23a595ec..000000000
--- a/examples/sampling/README.md
+++ /dev/null
@@ -1,62 +0,0 @@
-# Sampling Examples
-
-These examples demonstrate FastMCP's sampling API, which allows server tools to request LLM completions from the client.
-
-## Prerequisites
-
-```bash
-pip install 'fastmcp[anthropic]'
-export ANTHROPIC_API_KEY=your-key
-```
-
-Or run directly with `uv`:
-
-```bash
-uv run examples/sampling/text.py
-```
-
-## Examples
-
-### Simple Text Sampling (`text.py`)
-
-Basic sampling flow where a server tool requests an LLM completion:
-
-```bash
-uv run examples/sampling/text.py
-```
-
-### Structured Output (`structured_output.py`)
-
-Uses `result_type` to get validated Pydantic models from the LLM:
-
-```bash
-uv run examples/sampling/structured_output.py
-```
-
-### Tool Use (`tool_use.py`)
-
-Gives the LLM tools to use during sampling (calculator, time, dice):
-
-```bash
-uv run examples/sampling/tool_use.py
-```
-
-### Server Fallback (`server_fallback.py`)
-
-Configures a fallback sampling handler on the server, enabling sampling even when clients don't support it:
-
-```bash
-uv run examples/sampling/server_fallback.py
-```
-
-## Using OpenAI Instead
-
-To use OpenAI instead of Anthropic, change the handler:
-
-```python
-from fastmcp.client.sampling.handlers.openai import OpenAISamplingHandler
-
-handler = OpenAISamplingHandler(default_model="gpt-4o-mini")
-```
-
-And install with `pip install 'fastmcp[openai]'`.
diff --git a/examples/sampling/server_fallback.py b/examples/sampling/server_fallback.py
deleted file mode 100644
index 1c3fb10af..000000000
--- a/examples/sampling/server_fallback.py
+++ /dev/null
@@ -1,88 +0,0 @@
-# /// script
-# dependencies = ["anthropic", "fastmcp", "rich"]
-# ///
-"""
-Server-Side Fallback Handler
-
-Demonstrates configuring a sampling handler on the server. This ensures
-sampling works even when the client doesn't provide a handler.
-
-The server runs as an HTTP server that can be connected to by any MCP client.
-
-Run:
- uv run examples/sampling/server_fallback.py
-
-Then connect with any MCP client (e.g., Claude Desktop) or test with:
- curl http://localhost:8000/mcp/
-"""
-
-import asyncio
-
-from rich.console import Console
-from rich.panel import Panel
-
-from fastmcp import FastMCP
-from fastmcp.client.sampling.handlers.anthropic import AnthropicSamplingHandler
-from fastmcp.server.context import Context
-
-console = Console()
-
-
-# Create server with a fallback sampling handler
-# This handler is used when the client doesn't support sampling
-mcp = FastMCP(
- "Server with Fallback Handler",
- sampling_handler=AnthropicSamplingHandler(default_model="claude-sonnet-4-5"),
- sampling_handler_behavior="fallback", # Use only if client lacks sampling
-)
-
-
-@mcp.tool
-async def summarize(text: str, ctx: Context) -> str:
- """Summarize the given text."""
- console.print(f"[bold cyan]SERVER[/] Summarizing text ({len(text)} chars)...")
-
- result = await ctx.sample(
- messages=f"Summarize this text in 1-2 sentences:\n\n{text}",
- system_prompt="You are a concise summarizer.",
- max_tokens=150,
- )
-
- console.print("[bold cyan]SERVER[/] Summary complete")
- return result.text or ""
-
-
-@mcp.tool
-async def translate(text: str, target_language: str, ctx: Context) -> str:
- """Translate text to the target language."""
- console.print(f"[bold cyan]SERVER[/] Translating to {target_language}...")
-
- result = await ctx.sample(
- messages=f"Translate to {target_language}:\n\n{text}",
- system_prompt=f"You are a translator. Output only the {target_language} translation.",
- max_tokens=500,
- )
-
- console.print("[bold cyan]SERVER[/] Translation complete")
- return result.text or ""
-
-
-async def main():
- console.print(
- Panel.fit(
- "[bold]Server-Side Fallback Handler Demo[/]\n\n"
- "This server has a built-in Anthropic handler that activates\n"
- "when clients don't provide their own sampling support.",
- subtitle="server_fallback.py",
- )
- )
- console.print()
- console.print("[bold yellow]Starting HTTP server on http://localhost:8000[/]")
- console.print("Connect with an MCP client or press Ctrl+C to stop")
- console.print()
-
- await mcp.run_http_async(host="localhost", port=8000)
-
-
-if __name__ == "__main__":
- asyncio.run(main())
diff --git a/examples/sampling/structured_output.py b/examples/sampling/structured_output.py
deleted file mode 100644
index fa7afe7ad..000000000
--- a/examples/sampling/structured_output.py
+++ /dev/null
@@ -1,110 +0,0 @@
-# /// script
-# dependencies = ["anthropic", "fastmcp", "rich"]
-# ///
-"""
-Structured Output Sampling
-
-Demonstrates using `result_type` to get validated Pydantic models from an LLM.
-The server exposes a sentiment analysis tool that returns structured data.
-
-Run:
- uv run examples/sampling/structured_output.py
-"""
-
-import asyncio
-
-from pydantic import BaseModel
-from rich.console import Console
-from rich.panel import Panel
-from rich.table import Table
-
-from fastmcp import Client, Context, FastMCP
-from fastmcp.client.sampling import SamplingMessage, SamplingParams
-from fastmcp.client.sampling.handlers.anthropic import AnthropicSamplingHandler
-
-console = Console()
-
-
-class LoggingAnthropicHandler(AnthropicSamplingHandler):
- async def __call__(
- self, messages: list[SamplingMessage], params: SamplingParams, context
- ): # type: ignore[override]
- console.print(" [bold blue]SAMPLING[/] Calling Claude API...")
- result = await super().__call__(messages, params, context)
- console.print(" [bold blue]SAMPLING[/] Response received")
- return result
-
-
-# Define a structured output model
-class SentimentAnalysis(BaseModel):
- sentiment: str # "positive", "negative", or "neutral"
- confidence: float # 0.0 to 1.0
- keywords: list[str] # Keywords that influenced the analysis
- explanation: str # Brief explanation of the analysis
-
-
-# Create the MCP server
-mcp = FastMCP("Sentiment Analyzer")
-
-
-@mcp.tool
-async def analyze_sentiment(text: str, ctx: Context) -> dict:
- """Analyze the sentiment of the given text."""
- console.print(" [bold cyan]SERVER[/] Analyzing sentiment...")
-
- result = await ctx.sample(
- messages=f"Analyze the sentiment of this text:\n\n{text}",
- system_prompt="You are a sentiment analysis expert. Analyze text carefully.",
- result_type=SentimentAnalysis,
- )
-
- console.print(" [bold cyan]SERVER[/] Analysis complete")
- return result.result.model_dump() # type: ignore[attr-defined]
-
-
-async def main():
- console.print(
- Panel.fit("[bold]MCP Sampling Flow Demo[/]", subtitle="structured_output.py")
- )
- console.print()
-
- handler = LoggingAnthropicHandler(default_model="claude-sonnet-4-5")
-
- async with Client(mcp, sampling_handler=handler) as client:
- texts = [
- "I absolutely love this product! It exceeded all my expectations.",
- "The service was okay, nothing special but got the job done.",
- "This is the worst experience I've ever had. Never again.",
- ]
-
- for text in texts:
- console.print(f"[bold green]CLIENT[/] Analyzing: [italic]{text[:50]}...[/]")
- console.print()
-
- result = await client.call_tool("analyze_sentiment", {"text": text})
- data = result.data
-
- # Display results in a table
- table = Table(show_header=False, box=None, padding=(0, 2))
- table.add_column(style="bold")
- table.add_column()
-
- sentiment_color = {
- "positive": "green",
- "negative": "red",
- "neutral": "yellow",
- }.get(
- data["sentiment"],
- "white", # type: ignore[union-attr]
- )
- table.add_row("Sentiment", f"[{sentiment_color}]{data['sentiment']}[/]") # type: ignore[index]
- table.add_row("Confidence", f"{data['confidence']:.0%}") # type: ignore[index]
- table.add_row("Keywords", ", ".join(data["keywords"])) # type: ignore[index]
- table.add_row("Explanation", data["explanation"]) # type: ignore[index]
-
- console.print(Panel(table, border_style=sentiment_color))
- console.print()
-
-
-if __name__ == "__main__":
- asyncio.run(main())
diff --git a/examples/sampling/text.py b/examples/sampling/text.py
deleted file mode 100644
index 6d354e7c2..000000000
--- a/examples/sampling/text.py
+++ /dev/null
@@ -1,78 +0,0 @@
-# /// script
-# dependencies = ["anthropic", "fastmcp", "rich"]
-# ///
-"""
-Simple Text Sampling
-
-Demonstrates the basic MCP sampling flow where a server tool requests
-an LLM completion from the client.
-
-Run:
- uv run examples/sampling/text.py
-"""
-
-import asyncio
-
-from rich.console import Console
-from rich.panel import Panel
-
-from fastmcp import Client, Context, FastMCP
-from fastmcp.client.sampling import SamplingMessage, SamplingParams
-from fastmcp.client.sampling.handlers.anthropic import AnthropicSamplingHandler
-
-console = Console()
-
-
-# Create a wrapper handler that logs when the LLM is called
-class LoggingAnthropicHandler(AnthropicSamplingHandler):
- async def __call__(
- self, messages: list[SamplingMessage], params: SamplingParams, context
- ): # type: ignore[override]
- console.print(" [bold blue]SAMPLING[/] Calling Claude API...")
- result = await super().__call__(messages, params, context)
- console.print(" [bold blue]SAMPLING[/] Response received")
- return result
-
-
-# Create the MCP server
-mcp = FastMCP("Haiku Generator")
-
-
-@mcp.tool
-async def write_haiku(topic: str, ctx: Context) -> str:
- """Write a haiku about any topic."""
- console.print(
- f" [bold cyan]SERVER[/] Tool 'write_haiku' called with topic: {topic}"
- )
-
- result = await ctx.sample(
- messages=f"Write a haiku about: {topic}",
- system_prompt="You are a poet. Write only the haiku, nothing else.",
- max_tokens=100,
- )
-
- console.print(" [bold cyan]SERVER[/] Returning haiku to client")
- return result.text or ""
-
-
-async def main():
- console.print(Panel.fit("[bold]MCP Sampling Flow Demo[/]", subtitle="text.py"))
- console.print()
-
- # Create the sampling handler
- handler = LoggingAnthropicHandler(default_model="claude-sonnet-4-5")
-
- # Connect client to server with the sampling handler
- async with Client(mcp, sampling_handler=handler) as client:
- console.print("[bold green]CLIENT[/] Calling tool 'write_haiku'...")
- console.print()
-
- result = await client.call_tool("write_haiku", {"topic": "Python programming"})
-
- console.print()
- console.print("[bold green]CLIENT[/] Received result:")
- console.print(Panel(result.data, title="Haiku", border_style="green")) # type: ignore[arg-type]
-
-
-if __name__ == "__main__":
- asyncio.run(main())
diff --git a/examples/sampling/tool_use.py b/examples/sampling/tool_use.py
deleted file mode 100644
index e7869a16c..000000000
--- a/examples/sampling/tool_use.py
+++ /dev/null
@@ -1,125 +0,0 @@
-# /// script
-# dependencies = ["anthropic", "fastmcp", "rich"]
-# ///
-"""
-Sampling with Tools
-
-Demonstrates giving an LLM tools to use during sampling. The LLM can call
-helper functions to gather information before responding.
-
-Run:
- uv run examples/sampling/tool_use.py
-"""
-
-import asyncio
-import random
-from datetime import datetime
-
-from pydantic import BaseModel, Field
-from rich.console import Console
-from rich.panel import Panel
-
-from fastmcp import Client, Context, FastMCP
-from fastmcp.client.sampling import SamplingMessage, SamplingParams
-from fastmcp.client.sampling.handlers.anthropic import AnthropicSamplingHandler
-
-console = Console()
-
-
-class LoggingAnthropicHandler(AnthropicSamplingHandler):
- async def __call__(
- self, messages: list[SamplingMessage], params: SamplingParams, context
- ): # type: ignore[override]
- console.print(" [bold blue]SAMPLING[/] Calling Claude API...")
- result = await super().__call__(messages, params, context)
- console.print(" [bold blue]SAMPLING[/] Response received")
- return result
-
-
-# Define tools available to the LLM during sampling
-def add(a: float, b: float) -> str:
- """Add two numbers together."""
- result = a + b
- console.print(f" [bold magenta]TOOL[/] add({a}, {b}) = {result}")
- return str(result)
-
-
-def multiply(a: float, b: float) -> str:
- """Multiply two numbers together."""
- result = a * b
- console.print(f" [bold magenta]TOOL[/] multiply({a}, {b}) = {result}")
- return str(result)
-
-
-def get_current_time() -> str:
- """Get the current date and time."""
- console.print(" [bold magenta]TOOL[/] get_current_time()")
- return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
-
-
-def roll_dice(sides: int = 6) -> str:
- """Roll a die with the specified number of sides."""
- result = random.randint(1, sides)
- console.print(f" [bold magenta]TOOL[/] roll_dice({sides}) = {result}")
- return str(result)
-
-
-# Structured output for the response
-class AssistantResponse(BaseModel):
- answer: str = Field(description="The answer to the user's question")
- tools_used: list[str] = Field(description="List of tools that were used")
- reasoning: str = Field(
- description="Brief explanation of how the answer was determined"
- )
-
-
-# Create the MCP server
-mcp = FastMCP("Smart Assistant")
-
-
-@mcp.tool
-async def ask_assistant(question: str, ctx: Context) -> dict:
- """Ask the assistant a question. It can use tools to help answer."""
- console.print(" [bold cyan]SERVER[/] Processing question...")
-
- result = await ctx.sample(
- messages=question,
- system_prompt="You are a helpful assistant with access to tools. Use them when needed to answer questions accurately.",
- tools=[add, multiply, get_current_time, roll_dice],
- result_type=AssistantResponse,
- )
-
- console.print(" [bold cyan]SERVER[/] Response ready")
- return result.result.model_dump() # type: ignore[attr-defined]
-
-
-async def main():
- console.print(Panel.fit("[bold]MCP Sampling Flow Demo[/]", subtitle="tool_use.py"))
- console.print()
-
- handler = LoggingAnthropicHandler(default_model="claude-sonnet-4-5")
-
- async with Client(mcp, sampling_handler=handler) as client:
- questions = [
- "What is 15 times 7, plus 23?",
- "Roll a 20-sided dice for me",
- "What time is it right now?",
- ]
-
- for question in questions:
- console.print(f"[bold green]CLIENT[/] Question: {question}")
- console.print()
-
- result = await client.call_tool("ask_assistant", {"question": question})
- data = result.data
-
- console.print(f"[bold green]CLIENT[/] Answer: {data['answer']}") # type: ignore[index]
- console.print(
- f" Tools used: {', '.join(data['tools_used']) or 'none'}"
- ) # type: ignore[index]
- console.print(f" Reasoning: {data['reasoning']}") # type: ignore[index]
- console.print()
-
-
-if __name__ == "__main__":
- asyncio.run(main())
diff --git a/fastmcp_slim/fastmcp/server/context.py b/fastmcp_slim/fastmcp/server/context.py
index c0977e7aa..df43d32f4 100644
--- a/fastmcp_slim/fastmcp/server/context.py
+++ b/fastmcp_slim/fastmcp/server/context.py
@@ -3,7 +3,7 @@ from __future__ import annotations
import logging
import warnings
import weakref
-from collections.abc import Callable, Generator, Mapping, Sequence
+from collections.abc import Callable, Generator, Mapping
from contextlib import contextmanager
from contextvars import ContextVar, Token
from dataclasses import dataclass
@@ -16,9 +16,6 @@ from mcp import LoggingLevel, ServerSession
from mcp.server.context import ServerRequestContext
from mcp_types import (
GetPromptResult,
- ModelPreferences,
- Root,
- SamplingMessage,
)
from mcp_types import Prompt as SDKPrompt
from mcp_types import Resource as SDKResource
@@ -42,11 +39,6 @@ from fastmcp.server.elicitation import (
parse_elicit_response_type,
)
from fastmcp.server.low_level import client_supports_extension
-from fastmcp.server.sampling import SampleStep, SamplingResult, SamplingTool
-from fastmcp.server.sampling.run import (
- sample_impl,
- sample_step_impl,
-)
from fastmcp.server.server import FastMCP, StateValue
from fastmcp.server.transforms.visibility import (
Visibility,
@@ -79,30 +71,6 @@ _clamp_logger(logger=to_client_logger, max_level="DEBUG")
T = TypeVar("T", default=Any)
-ResultT = TypeVar("ResultT", default=str)
-
-# Import ToolChoiceOption from sampling module (after other imports)
-from fastmcp.server.sampling.run import ToolChoiceOption # noqa: E402
-
-# Warn-once guard for the sampling deprecation. Server-initiated createMessage
-# was removed from MCP as of 2026-07-28 (SEP-2577); the warning fires a single
-# time per process to flag that ctx.sample/ctx.sample_step are on their way out.
-# A mutable set (mutated in place, never rebound) rather than a `global` boolean
-# so the warn-once state is unambiguously read and written from the module.
-_sample_deprecation_warned: set[bool] = set()
-
-_SAMPLING_DEPRECATION_MESSAGE = (
- "ctx.sample() and ctx.sample_step() are deprecated and will be removed in a "
- "future FastMCP release. They rely on server-initiated createMessage "
- "requests, which were removed from MCP as of 2026-07-28 (SEP-2577), so they "
- "work only on session-based (handshake-era) connections. Call an LLM "
- "directly from your server instead."
-)
-
-_SAMPLING_MODERN_ERROR = (
- "server-initiated sampling is not available on MCP 2026-07-28 connections; "
- "SEP-2577 removed it — call an LLM from your server instead."
-)
_ELICIT_MODERN_ERROR = (
"elicitation via server-initiated requests is unavailable on 2026-07-28 "
@@ -110,22 +78,6 @@ _ELICIT_MODERN_ERROR = (
)
-def _warn_sampling_deprecated() -> None:
- """Emit the sampling deprecation warning once per process.
-
- Gated on ``settings.deprecation_warnings`` like every other FastMCP
- deprecation; fires a single time (module-level flag) rather than per call.
- """
- if _sample_deprecation_warned or not fastmcp.settings.deprecation_warnings:
- return
- _sample_deprecation_warned.add(True)
- warnings.warn(
- _SAMPLING_DEPRECATION_MESSAGE,
- FastMCPDeprecationWarning,
- stacklevel=3,
- )
-
-
_current_context: ContextVar[Context | None] = ContextVar("context", default=None)
@@ -278,9 +230,8 @@ class Context:
def is_background_task(self) -> bool:
"""True when this context is running in a background task (Docket worker).
- When True, certain operations like elicit() and sample() will use
- task-aware implementations that can pause the task and wait for
- client input.
+ When True, certain operations like elicit() will use task-aware
+ implementations that can pause the task and wait for client input.
Example:
```python
@@ -936,13 +887,6 @@ class Context:
extra=extra,
)
- async def list_roots(self) -> list[Root]:
- """List the roots available to the server, as indicated by the client."""
- # Deprecated upstream in SDK v2 but deliberately kept per compat directive;
- # removed with the multi-round-trip follow-up.
- result = await self.session.list_roots() # ty: ignore[deprecated]
- return result.roots
-
async def send_notification(
self, notification: mcp_types.ServerNotification
) -> None:
@@ -1008,227 +952,6 @@ class Context:
return False
return rc.protocol_version in MODERN_PROTOCOL_VERSIONS
- def _server_can_sample(self) -> bool:
- """True when a server-configured sampling handler can serve the request
- without the client back-channel.
-
- FastMCP supports a server-side sampling handler (``FastMCP(sampling_handler=...)``).
- With ``sampling_handler_behavior="always"`` the handler always answers;
- with ``"fallback"`` it answers whenever the client cannot. On modern
- connections the client back-channel is gone, so either configuration lets
- the server answer entirely server-side as long as a handler is set. (For
- ``"always"`` without a handler the sampling implementation raises its own
- clear "no handler configured" error, which is not an era concern.)
- """
- fastmcp = self.fastmcp
- if fastmcp.sampling_handler_behavior == "always":
- return True
- return fastmcp.sampling_handler is not None
-
- async def sample_step(
- self,
- messages: str | Sequence[str | SamplingMessage],
- *,
- system_prompt: str | None = None,
- temperature: float | None = None,
- max_tokens: int | None = None,
- model_preferences: ModelPreferences | str | list[str] | None = None,
- tools: Sequence[SamplingTool | Callable[..., Any]] | None = None,
- tool_choice: ToolChoiceOption | str | None = None,
- execute_tools: bool = True,
- mask_error_details: bool | None = None,
- tool_concurrency: int | None = None,
- ) -> SampleStep:
- """
- Make a single LLM sampling call.
-
- This is a stateless function that makes exactly one LLM call and optionally
- executes any requested tools. Use this for fine-grained control over the
- sampling loop.
-
- Args:
- messages: The message(s) to send. Can be a string, list of strings,
- or list of SamplingMessage objects.
- system_prompt: Optional system prompt for the LLM.
- temperature: Optional sampling temperature.
- max_tokens: Maximum tokens to generate. Defaults to 512.
- model_preferences: Optional model preferences.
- tools: Optional list of tools the LLM can use.
- tool_choice: Tool choice mode ("auto", "required", or "none").
- execute_tools: If True (default), execute tool calls and append results
- to history. If False, return immediately with tool_calls available
- in the step for manual execution.
- mask_error_details: If True, mask detailed error messages from tool
- execution. When None (default), uses the global settings value.
- Tools can raise ToolError to bypass masking.
- tool_concurrency: Controls parallel execution of tools:
- - None (default): Sequential execution (one at a time)
- - 0: Unlimited parallel execution
- - N > 0: Execute at most N tools concurrently
- If any tool has sequential=True, all tools execute sequentially
- regardless of this setting.
-
- Returns:
- SampleStep containing:
- - .response: The raw LLM response
- - .history: Messages including input, assistant response, and tool results
- - .is_tool_use: True if the LLM requested tool execution
- - .tool_calls: List of tool calls (if any)
- - .text: The text content (if any)
-
- Example:
- messages = "Research X"
-
- while True:
- step = await ctx.sample_step(messages, tools=[search])
-
- if not step.is_tool_use:
- print(step.text)
- break
-
- # Continue with tool results
- messages = step.history
- """
- _warn_sampling_deprecated()
- # On modern (2026-07-28) connections the client back-channel is gone
- # (SEP-2577). A server-configured sampling handler can still answer
- # entirely server-side; only raise the era error when nothing can serve
- # the request. When modern, force the handler path (never attempt the
- # dead client) by passing client_available=False.
- client_available = not self._is_modern_protocol()
- if not client_available and not self._server_can_sample():
- raise ToolError(_SAMPLING_MODERN_ERROR)
- return await sample_step_impl(
- self,
- messages=messages,
- system_prompt=system_prompt,
- temperature=temperature,
- max_tokens=max_tokens,
- model_preferences=model_preferences,
- tools=tools,
- tool_choice=tool_choice,
- auto_execute_tools=execute_tools,
- mask_error_details=mask_error_details,
- tool_concurrency=tool_concurrency,
- client_available=client_available,
- )
-
- @overload
- async def sample(
- self,
- messages: str | Sequence[str | SamplingMessage],
- *,
- system_prompt: str | None = None,
- temperature: float | None = None,
- max_tokens: int | None = None,
- model_preferences: ModelPreferences | str | list[str] | None = None,
- tools: Sequence[SamplingTool | Callable[..., Any]] | None = None,
- result_type: type[ResultT],
- mask_error_details: bool | None = None,
- tool_concurrency: int | None = None,
- ) -> SamplingResult[ResultT]:
- """Overload: With result_type, returns SamplingResult[ResultT]."""
-
- @overload
- async def sample(
- self,
- messages: str | Sequence[str | SamplingMessage],
- *,
- system_prompt: str | None = None,
- temperature: float | None = None,
- max_tokens: int | None = None,
- model_preferences: ModelPreferences | str | list[str] | None = None,
- tools: Sequence[SamplingTool | Callable[..., Any]] | None = None,
- result_type: None = None,
- mask_error_details: bool | None = None,
- tool_concurrency: int | None = None,
- ) -> SamplingResult[str]:
- """Overload: Without result_type, returns SamplingResult[str]."""
-
- async def sample(
- self,
- messages: str | Sequence[str | SamplingMessage],
- *,
- system_prompt: str | None = None,
- temperature: float | None = None,
- max_tokens: int | None = None,
- model_preferences: ModelPreferences | str | list[str] | None = None,
- tools: Sequence[SamplingTool | Callable[..., Any]] | None = None,
- result_type: type[ResultT] | None = None,
- mask_error_details: bool | None = None,
- tool_concurrency: int | None = None,
- ) -> SamplingResult[ResultT] | SamplingResult[str]:
- """
- Send a sampling request to the client and await the response.
-
- This method runs to completion automatically. When tools are provided,
- it executes a tool loop: if the LLM returns a tool use request, the tools
- are executed and the results are sent back to the LLM. This continues
- until the LLM provides a final text response.
-
- When result_type is specified, a synthetic `final_response` tool is
- created. The LLM calls this tool to provide the structured response,
- which is validated against the result_type and returned as `.result`.
-
- For fine-grained control over the sampling loop, use sample_step() instead.
-
- Args:
- messages: The message(s) to send. Can be a string, list of strings,
- or list of SamplingMessage objects.
- system_prompt: Optional system prompt for the LLM.
- temperature: Optional sampling temperature.
- max_tokens: Maximum tokens to generate. Defaults to 512.
- model_preferences: Optional model preferences.
- tools: Optional list of tools the LLM can use. Accepts plain
- functions or SamplingTools.
- result_type: Optional type for structured output. When specified,
- a synthetic `final_response` tool is created and the LLM's
- response is validated against this type.
- mask_error_details: If True, mask detailed error messages from tool
- execution. When None (default), uses the global settings value.
- Tools can raise ToolError to bypass masking.
- tool_concurrency: Controls parallel execution of tools:
- - None (default): Sequential execution (one at a time)
- - 0: Unlimited parallel execution
- - N > 0: Execute at most N tools concurrently
- If any tool has sequential=True, all tools execute sequentially
- regardless of this setting.
-
- Returns:
- SamplingResult[T] containing:
- - .text: The text representation (raw text or JSON for structured)
- - .result: The typed result (str for text, parsed object for structured)
- - .history: All messages exchanged during sampling
-
- Deprecated:
- Server-initiated sampling relies on the createMessage back-channel,
- which MCP removed as of 2026-07-28 (SEP-2577). This method works only
- on session-based (handshake-era) connections and will be removed in a
- future FastMCP release. Call an LLM directly from your server instead.
- """
- _warn_sampling_deprecated()
- # On modern (2026-07-28) connections the client back-channel is gone
- # (SEP-2577). A server-configured sampling handler can still answer
- # entirely server-side; only raise the era error when nothing can serve
- # the request. When modern, force the handler path (never attempt the
- # dead client) by passing client_available=False.
- client_available = not self._is_modern_protocol()
- if not client_available and not self._server_can_sample():
- raise ToolError(_SAMPLING_MODERN_ERROR)
- return await sample_impl( # ty: ignore[invalid-return-type]
- self,
- messages=messages,
- system_prompt=system_prompt,
- temperature=temperature,
- max_tokens=max_tokens,
- model_preferences=model_preferences,
- tools=tools,
- result_type=result_type,
- mask_error_details=mask_error_details,
- tool_concurrency=tool_concurrency,
- client_available=client_available,
- )
-
@overload
async def elicit(
self,
diff --git a/fastmcp_slim/fastmcp/server/providers/proxy.py b/fastmcp_slim/fastmcp/server/providers/proxy.py
index 78f1b1f17..60c71e961 100644
--- a/fastmcp_slim/fastmcp/server/providers/proxy.py
+++ b/fastmcp_slim/fastmcp/server/providers/proxy.py
@@ -1207,9 +1207,19 @@ class FastMCPProxy(FastMCP):
async def default_proxy_roots_handler(
context: ServerRequestContext[Any, Any],
) -> RootsList:
- """Forward list roots request from remote server to proxy's connected clients."""
+ """Forward list roots request from remote server to proxy's connected clients.
+
+ A handshake-era backend can still issue `roots/list`, and the proxy is that
+ backend's client, so it relays the request onto its own front session. This
+ reaches the wire through the SDK session rather than a `Context` method:
+ `ctx.list_roots()` is not part of FastMCP's server-authoring API, because
+ SEP-2577 removed server-initiated requests from the modern protocol. The
+ relay exists only for handshake-era interop on both legs.
+ """
ctx = get_context()
- return await ctx.list_roots()
+ # Deprecated upstream in SDK v2; the handshake-era relay is the one caller.
+ result = await ctx.session.list_roots() # ty: ignore[deprecated]
+ return result.roots
async def default_proxy_sampling_handler(
@@ -1217,16 +1227,27 @@ async def default_proxy_sampling_handler(
params: mcp_types.CreateMessageRequestParams,
context: ServerRequestContext[Any, Any],
) -> mcp_types.CreateMessageResult:
- """Forward sampling request from remote server to proxy's connected clients."""
+ """Forward sampling request from remote server to proxy's connected clients.
+
+ Relays through the SDK session for the same reason as
+ `default_proxy_roots_handler`: server-initiated sampling is not part of
+ FastMCP's server-authoring API, and this path only ever runs when both legs
+ of the proxy speak the handshake era.
+ """
ctx = get_context()
- result = await ctx.sample(
- list(messages),
+ # Deprecated upstream in SDK v2; the handshake-era relay is the one caller.
+ result = await ctx.session.create_message( # ty: ignore[deprecated]
+ messages=list(messages),
system_prompt=params.system_prompt,
temperature=params.temperature,
max_tokens=params.max_tokens,
model_preferences=params.model_preferences,
+ related_request_id=ctx.origin_request_id,
)
- content = mcp_types.TextContent(type="text", text=result.text or "")
+ text = (
+ result.content.text if isinstance(result.content, mcp_types.TextContent) else ""
+ )
+ content = mcp_types.TextContent(type="text", text=text)
return mcp_types.CreateMessageResult(
role="assistant",
model="fastmcp-client",
diff --git a/fastmcp_slim/fastmcp/server/sampling/__init__.py b/fastmcp_slim/fastmcp/server/sampling/__init__.py
deleted file mode 100644
index 392326d35..000000000
--- a/fastmcp_slim/fastmcp/server/sampling/__init__.py
+++ /dev/null
@@ -1,10 +0,0 @@
-"""Sampling module for FastMCP servers."""
-
-from fastmcp.server.sampling.run import SampleStep, SamplingResult
-from fastmcp.server.sampling.sampling_tool import SamplingTool
-
-__all__ = [
- "SampleStep",
- "SamplingResult",
- "SamplingTool",
-]
diff --git a/fastmcp_slim/fastmcp/server/sampling/run.py b/fastmcp_slim/fastmcp/server/sampling/run.py
deleted file mode 100644
index ed959ed60..000000000
--- a/fastmcp_slim/fastmcp/server/sampling/run.py
+++ /dev/null
@@ -1,836 +0,0 @@
-"""Sampling types and helper functions for FastMCP servers."""
-
-from __future__ import annotations
-
-import inspect
-import json
-from collections.abc import Callable, Sequence
-from dataclasses import dataclass
-from typing import TYPE_CHECKING, Any, Generic, Literal, cast
-
-import anyio
-from mcp_types import (
- ClientCapabilities,
- CreateMessageResult,
- CreateMessageResultWithTools,
- ModelHint,
- ModelPreferences,
- SamplingCapability,
- SamplingMessage,
- SamplingMessageContentBlock,
- SamplingToolsCapability,
- TextContent,
- ToolChoice,
- ToolResultContent,
- ToolUseContent,
-)
-from mcp_types import CreateMessageRequestParams as SamplingParams
-from mcp_types import Tool as SDKTool
-from opentelemetry.trace import SpanKind, Status, StatusCode
-from pydantic import ValidationError
-from typing_extensions import TypeVar
-
-from fastmcp import settings
-from fastmcp.exceptions import ToolError
-from fastmcp.server.sampling.sampling_tool import SamplingTool
-from fastmcp.telemetry import get_tracer, restore_dropped_attributes
-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
-from fastmcp.utilities.types import get_cached_typeadapter
-
-logger = get_logger(__name__)
-
-if TYPE_CHECKING:
- from fastmcp.server.context import Context
-
-ResultT = TypeVar("ResultT")
-
-# Maximum number of consecutive final_response validation retries (not
-# counting the initial attempt) before aborting. Total attempts = N + 1.
-_MAX_VALIDATION_RETRIES = 3
-
-# Simplified tool choice type - just the mode string instead of the full MCP object
-ToolChoiceOption = Literal["auto", "required", "none"]
-
-# How many times we retry when the LLM returns text instead of calling final_response
-_MAX_TEXT_RESPONSE_RETRIES = 3
-
-
-@dataclass
-class SamplingResult(Generic[ResultT]):
- """Result of a sampling operation.
-
- Attributes:
- text: The text representation of the result (raw text or JSON for structured).
- result: The typed result (str for text, parsed object for structured output).
- history: All messages exchanged during sampling.
- """
-
- text: str | None
- result: ResultT
- history: list[SamplingMessage]
-
-
-@dataclass
-class SampleStep:
- """Result of a single sampling call.
-
- Represents what the LLM returned in this step plus the message history.
- """
-
- response: CreateMessageResult | CreateMessageResultWithTools
- history: list[SamplingMessage]
-
- @property
- def is_tool_use(self) -> bool:
- """True if the LLM is requesting tool execution."""
- if isinstance(self.response, CreateMessageResultWithTools):
- return self.response.stop_reason == "toolUse"
- return False
-
- @property
- def text(self) -> str | None:
- """Extract text from the response, if available."""
- content = self.response.content
- if isinstance(content, list):
- for block in content:
- if isinstance(block, TextContent):
- return block.text
- return None
- elif isinstance(content, TextContent):
- return content.text
- return None
-
- @property
- def tool_calls(self) -> list[ToolUseContent]:
- """Get the list of tool calls from the response."""
- content = self.response.content
- if isinstance(content, list):
- return [c for c in content if isinstance(c, ToolUseContent)]
- elif isinstance(content, ToolUseContent):
- return [content]
- return []
-
-
-def _parse_model_preferences(
- model_preferences: ModelPreferences | str | list[str] | None,
-) -> ModelPreferences | None:
- """Convert model preferences to ModelPreferences object."""
- if model_preferences is None:
- return None
- elif isinstance(model_preferences, ModelPreferences):
- return model_preferences
- elif isinstance(model_preferences, str):
- return ModelPreferences(hints=[ModelHint(name=model_preferences)])
- elif isinstance(model_preferences, list):
- if not all(isinstance(h, str) for h in model_preferences):
- raise ValueError("All elements of model_preferences list must be strings.")
- return ModelPreferences(hints=[ModelHint(name=h) for h in model_preferences])
- else:
- raise ValueError(
- "model_preferences must be one of: ModelPreferences, str, list[str], or None."
- )
-
-
-# --- Standalone functions for sample_step() ---
-
-
-def determine_handler_mode(
- context: Context, needs_tools: bool, *, client_available: bool = True
-) -> bool:
- """Determine whether to use fallback handler or client for sampling.
-
- Args:
- context: The MCP context.
- needs_tools: Whether the sampling request requires tool support.
- client_available: Whether the client back-channel can be reached at all.
- On modern (2026-07-28) connections the server-initiated createMessage
- back-channel was removed (SEP-2577), so the client can never serve a
- sampling request; pass False there to force the server-side handler
- path (``"fallback"`` behaves like ``"always"`` when a handler exists).
-
- Returns:
- True if fallback handler should be used, False to use client.
-
- Raises:
- ValueError: If client lacks required capability and no fallback configured.
- """
- fastmcp = context.fastmcp
- session = context.session
-
- # Check what capabilities the client has. On connections without a
- # back-channel the client can never serve the request regardless of the
- # capabilities it advertised, so treat both as unavailable.
- has_sampling = client_available and session.check_client_capability(
- capability=ClientCapabilities(sampling=SamplingCapability())
- )
- has_tools_capability = client_available and session.check_client_capability(
- capability=ClientCapabilities(
- sampling=SamplingCapability(tools=SamplingToolsCapability())
- )
- )
-
- if fastmcp.sampling_handler_behavior == "always":
- if fastmcp.sampling_handler is None:
- raise ValueError(
- "sampling_handler_behavior is 'always' but no handler configured"
- )
- return True
- elif fastmcp.sampling_handler_behavior == "fallback":
- client_sufficient = has_sampling and (not needs_tools or has_tools_capability)
- if not client_sufficient:
- if fastmcp.sampling_handler is None:
- if needs_tools and has_sampling and not has_tools_capability:
- raise ValueError(
- "Client does not support sampling with tools. "
- "The client must advertise the sampling.tools capability."
- )
- raise ValueError("Client does not support sampling")
- return True
- elif fastmcp.sampling_handler_behavior is not None:
- raise ValueError(
- f"Invalid sampling_handler_behavior: {fastmcp.sampling_handler_behavior!r}. "
- "Must be 'always', 'fallback', or None."
- )
- elif not has_sampling:
- raise ValueError("Client does not support sampling")
- elif needs_tools and not has_tools_capability:
- raise ValueError(
- "Client does not support sampling with tools. "
- "The client must advertise the sampling.tools capability."
- )
-
- return False
-
-
-async def call_sampling_handler(
- context: Context,
- messages: list[SamplingMessage],
- *,
- system_prompt: str | None,
- temperature: float | None,
- max_tokens: int,
- model_preferences: ModelPreferences | str | list[str] | None,
- sdk_tools: list[SDKTool] | None,
- tool_choice: ToolChoice | None,
-) -> CreateMessageResult | CreateMessageResultWithTools:
- """Make LLM call using the fallback handler.
-
- Note: This function expects the caller (sample_step) to have validated that
- sampling_handler is set via determine_handler_mode(). The checks below are
- safeguards against internal misuse.
- """
- if context.fastmcp.sampling_handler is None:
- raise RuntimeError("sampling_handler is None")
- if context.request_context is None:
- raise RuntimeError("request_context is None")
-
- result = context.fastmcp.sampling_handler(
- messages,
- SamplingParams(
- system_prompt=system_prompt,
- messages=messages,
- temperature=temperature,
- max_tokens=max_tokens,
- model_preferences=_parse_model_preferences(model_preferences),
- tools=sdk_tools,
- tool_choice=tool_choice,
- ),
- # SamplingHandler is typed against the SDK's RequestContext placeholder,
- # but FastMCP hands handlers its own FastMCPRequestContext wrapper at
- # runtime; the two aren't structurally related in the type system.
- context.request_context, # ty: ignore[invalid-argument-type]
- )
-
- if inspect.isawaitable(result):
- result = await result
-
- result = cast("str | CreateMessageResult | CreateMessageResultWithTools", result)
-
- # Convert string to CreateMessageResult
- if isinstance(result, str):
- return CreateMessageResult(
- role="assistant",
- content=TextContent(type="text", text=result),
- model="unknown",
- stop_reason="endTurn",
- )
-
- return result
-
-
-async def execute_tools(
- tool_calls: list[ToolUseContent],
- tool_map: dict[str, SamplingTool],
- mask_error_details: bool = False,
- tool_concurrency: int | None = None,
-) -> list[ToolResultContent]:
- """Execute tool calls and return results.
-
- Args:
- tool_calls: List of tool use requests from the LLM.
- tool_map: Mapping from tool name to SamplingTool.
- mask_error_details: If True, mask detailed error messages from tool execution.
- When masked, only generic error messages are returned to the LLM.
- Tools can explicitly raise ToolError to bypass masking when they want
- to provide specific error messages to the LLM.
- tool_concurrency: Controls parallel execution of tools:
- - None (default): Sequential execution (one at a time)
- - 0: Unlimited parallel execution
- - N > 0: Execute at most N tools concurrently
- If any tool has sequential=True, all tools execute sequentially
- regardless of this setting.
-
- Returns:
- List of tool result content blocks in the same order as tool_calls.
- """
- if tool_concurrency is not None and tool_concurrency < 0:
- raise ValueError(
- f"tool_concurrency must be None, 0 (unlimited), or a positive integer, "
- f"got {tool_concurrency}"
- )
-
- async def _execute_single_tool(tool_use: ToolUseContent) -> ToolResultContent:
- """Execute a single tool and return its result."""
- tool = tool_map.get(tool_use.name)
- if tool is None:
- return ToolResultContent(
- type="tool_result",
- tool_use_id=tool_use.id,
- content=[
- TextContent(
- type="text",
- text=f"Error: Unknown tool '{tool_use.name}'",
- )
- ],
- is_error=True,
- )
-
- tracer = get_tracer()
- span_attrs = {
- "gen_ai.tool.name": tool_use.name,
- "fastmcp.tool.use_id": tool_use.id,
- }
- with tracer.start_as_current_span(
- f"sampling tool {tool_use.name}",
- kind=SpanKind.INTERNAL,
- attributes=span_attrs,
- ) as span:
- # Restore: `attributes=span_attrs` above lets on_start hooks and
- # the sampler see these values at creation time. But OTel's
- # Tracer.start_span builds the span from
- # `sampling_result.attributes`, not the `attributes` kwarg
- # directly — a custom Sampler whose SamplingResult.attributes
- # defaults to None silently drops everything we passed. This
- # only fires when the span ends up with no attributes at all, so
- # any sampler that supplied attributes of its own — forwarding
- # ours, redacting or replacing some, or substituting entirely its
- # own — is left untouched, as is an SDK attribute limit that
- # evicted some.
- if span.is_recording():
- restore_dropped_attributes(span, span_attrs)
- try:
- result_value = await tool.run(tool_use.input)
- return ToolResultContent(
- type="tool_result",
- tool_use_id=tool_use.id,
- content=[TextContent(type="text", text=str(result_value))],
- )
- except ToolError as e:
- if span.is_recording():
- span.set_attribute("error.type", "tool_error")
- span.record_exception(e)
- span.set_status(Status(StatusCode.ERROR, str(e)))
- logger.log(
- e.log_level,
- f"Error calling sampling tool '{tool_use.name}'",
- exc_info=True,
- )
- return ToolResultContent(
- type="tool_result",
- tool_use_id=tool_use.id,
- content=[TextContent(type="text", text=str(e))],
- is_error=True,
- )
- except Exception as e:
- if span.is_recording():
- span.set_attribute("error.type", type(e).__qualname__)
- span.record_exception(e)
- span.set_status(Status(StatusCode.ERROR, str(e)))
- logger.exception(f"Error calling sampling tool '{tool_use.name}'")
- if mask_error_details:
- error_text = f"Error executing tool '{tool_use.name}'"
- else:
- error_text = f"Error executing tool '{tool_use.name}': {e}"
- return ToolResultContent(
- type="tool_result",
- tool_use_id=tool_use.id,
- content=[TextContent(type="text", text=error_text)],
- is_error=True,
- )
-
- # Check if any tool requires sequential execution
- requires_sequential = any(
- tool.sequential
- for tool_use in tool_calls
- if (tool := tool_map.get(tool_use.name)) is not None
- )
-
- # Execute sequentially if required or if concurrency is None (default)
- if tool_concurrency is None or requires_sequential:
- tool_results: list[ToolResultContent] = []
- for tool_use in tool_calls:
- result = await _execute_single_tool(tool_use)
- tool_results.append(result)
- return tool_results
-
- # Execute in parallel
- if tool_concurrency == 0:
- # Unlimited parallel execution
- return await gather(_execute_single_tool(tc) for tc in tool_calls)
- else:
- # Bounded parallel execution with semaphore
- semaphore = anyio.Semaphore(tool_concurrency)
-
- async def bounded_execute(tool_use: ToolUseContent) -> ToolResultContent:
- async with semaphore:
- return await _execute_single_tool(tool_use)
-
- return await gather(bounded_execute(tc) for tc in tool_calls)
-
-
-# --- Helper functions for sampling ---
-
-
-def prepare_messages(
- messages: str | Sequence[str | SamplingMessage],
-) -> list[SamplingMessage]:
- """Convert various message formats to a list of SamplingMessage objects."""
- if isinstance(messages, str):
- return [
- SamplingMessage(
- content=TextContent(text=messages, type="text"), role="user"
- )
- ]
- else:
- return [
- SamplingMessage(content=TextContent(text=m, type="text"), role="user")
- if isinstance(m, str)
- else m
- for m in messages
- ]
-
-
-def 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().
-
- 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
-
- 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, FunctionTool, TransformedTool, or callable, got {type(t)}"
- )
-
- return sampling_tools if sampling_tools else None
-
-
-def extract_tool_calls(
- response: CreateMessageResult | CreateMessageResultWithTools,
-) -> list[ToolUseContent]:
- """Extract tool calls from a response."""
- content = response.content
- if isinstance(content, list):
- return [c for c in content if isinstance(c, ToolUseContent)]
- elif isinstance(content, ToolUseContent):
- return [content]
- return []
-
-
-def create_final_response_tool(result_type: type) -> SamplingTool:
- """Create a synthetic 'final_response' tool for structured output.
-
- This tool is used to capture structured responses from the LLM.
- The tool's schema is derived from the result_type.
- """
- type_adapter = get_cached_typeadapter(result_type)
- schema = type_adapter.json_schema()
- schema = compress_schema(schema, prune_titles=True)
-
- # Tool parameters must be object-shaped. Wrap primitives in {"value": }
- if schema.get("type") != "object":
- schema = {
- "type": "object",
- "properties": {"value": schema},
- "required": ["value"],
- }
-
- # The fn just returns the input as-is (validation happens in the loop)
- def final_response(**kwargs: Any) -> dict[str, Any]:
- return kwargs
-
- return SamplingTool(
- name="final_response",
- description=(
- "Call this tool to provide your final response. "
- "Use this when you have completed the task and are ready to return the result."
- ),
- parameters=schema,
- fn=final_response,
- )
-
-
-# --- Implementation functions for Context methods ---
-
-
-async def sample_step_impl(
- context: Context,
- messages: str | Sequence[str | SamplingMessage],
- *,
- system_prompt: str | None = None,
- temperature: float | None = None,
- max_tokens: int | None = None,
- model_preferences: ModelPreferences | str | list[str] | 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,
- tool_concurrency: int | None = None,
- client_available: bool = True,
-) -> SampleStep:
- """Implementation of Context.sample_step().
-
- Make a single LLM sampling call. This is a stateless function that makes
- exactly one LLM call and optionally executes any requested tools.
-
- When ``client_available`` is False (e.g. a modern 2026-07-28 connection with
- no back-channel), the client is never used and a configured sampling handler
- serves the request; the caller is responsible for raising a clear era error
- when no handler can serve it.
- """
- # Convert messages to SamplingMessage objects
- current_messages = prepare_messages(messages)
-
- # Convert tools to SamplingTools
- sampling_tools = prepare_tools(tools)
- sdk_tools: list[SDKTool] | None = (
- [t._to_sdk_tool() for t in sampling_tools] if sampling_tools else None
- )
- tool_map: dict[str, SamplingTool] = (
- {t.name: t for t in sampling_tools} if sampling_tools else {}
- )
-
- # Determine whether to use fallback handler or client
- use_fallback = determine_handler_mode(
- context, bool(sampling_tools), client_available=client_available
- )
-
- # Build tool choice
- effective_tool_choice: ToolChoice | None = None
- if tool_choice is not None:
- if tool_choice not in ("auto", "required", "none"):
- raise ValueError(
- f"Invalid tool_choice: {tool_choice!r}. "
- "Must be 'auto', 'required', or 'none'."
- )
- effective_tool_choice = ToolChoice.model_validate({"mode": tool_choice})
-
- # Effective max_tokens
- effective_max_tokens = max_tokens if max_tokens is not None else 512
-
- # Make the LLM call
- tracer = get_tracer()
- span_attrs = {
- "mcp.method.name": "sampling/createMessage",
- "fastmcp.server.name": context.fastmcp.name,
- }
- with tracer.start_as_current_span(
- "sampling create_message",
- kind=SpanKind.CLIENT,
- attributes=span_attrs,
- record_exception=False,
- set_status_on_exception=False,
- ) as span:
- # Restore: `attributes=span_attrs` above lets on_start hooks and the
- # sampler see these values at creation time. But OTel's
- # Tracer.start_span builds the span from
- # `sampling_result.attributes`, not the `attributes` kwarg directly —
- # a custom Sampler whose SamplingResult.attributes defaults to None
- # silently drops everything we passed. This only fires when the span
- # ends up with no attributes at all, so any sampler that supplied
- # attributes of its own — forwarding ours, redacting or replacing
- # some, or substituting entirely its own — is left untouched, as is
- # an SDK attribute limit that evicted some.
- if span.is_recording():
- restore_dropped_attributes(span, span_attrs)
- try:
- if use_fallback:
- response = await call_sampling_handler(
- context,
- current_messages,
- system_prompt=system_prompt,
- temperature=temperature,
- max_tokens=effective_max_tokens,
- model_preferences=model_preferences,
- sdk_tools=sdk_tools,
- tool_choice=effective_tool_choice,
- )
- else:
- # Deprecated upstream in SDK v2 but deliberately kept per compat
- # directive; removed with the multi-round-trip follow-up.
- response = await context.session.create_message( # ty: ignore[deprecated]
- messages=current_messages,
- system_prompt=system_prompt,
- temperature=temperature,
- max_tokens=effective_max_tokens,
- model_preferences=_parse_model_preferences(model_preferences),
- tools=sdk_tools,
- tool_choice=effective_tool_choice,
- related_request_id=context.origin_request_id,
- )
- except Exception as e:
- if span.is_recording():
- span.set_attribute("error.type", type(e).__qualname__)
- span.record_exception(e)
- span.set_status(Status(StatusCode.ERROR, str(e)))
- raise
-
- # Check if this is a tool use response
- is_tool_use_response = (
- isinstance(response, CreateMessageResultWithTools)
- and response.stop_reason == "toolUse"
- )
-
- # Always include the assistant response in history
- current_messages.append(SamplingMessage(role="assistant", content=response.content))
-
- # If not a tool use, return immediately
- if not is_tool_use_response:
- return SampleStep(response=response, history=current_messages)
-
- # If not executing tools, return with assistant message but no tool results
- if not auto_execute_tools:
- return SampleStep(response=response, history=current_messages)
-
- # Execute tools and add results to history
- step_tool_calls = extract_tool_calls(response)
- if step_tool_calls:
- effective_mask = (
- mask_error_details
- if mask_error_details is not None
- else settings.mask_error_details
- )
- tool_results: list[ToolResultContent] = await execute_tools(
- step_tool_calls,
- tool_map,
- mask_error_details=effective_mask,
- tool_concurrency=tool_concurrency,
- )
-
- if tool_results:
- current_messages.append(
- SamplingMessage(
- role="user",
- content=cast(list[SamplingMessageContentBlock], tool_results),
- )
- )
-
- return SampleStep(response=response, history=current_messages)
-
-
-async def sample_impl(
- context: Context,
- messages: str | Sequence[str | SamplingMessage],
- *,
- system_prompt: str | None = None,
- temperature: float | None = None,
- max_tokens: int | None = None,
- model_preferences: ModelPreferences | str | list[str] | 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,
- client_available: bool = True,
-) -> SamplingResult[ResultT]:
- """Implementation of Context.sample().
-
- Send a sampling request to the client and await the response. This method
- runs to completion automatically, executing a tool loop until the LLM
- provides a final text response.
-
- When ``client_available`` is False (e.g. a modern 2026-07-28 connection with
- no back-channel), the client is never used and a configured sampling handler
- serves the request; the caller is responsible for raising a clear era error
- when no handler can serve it.
- """
- # Safety limit to prevent infinite loops
- max_iterations = 100
-
- # Convert tools to SamplingTools
- sampling_tools = prepare_tools(tools)
-
- # Handle structured output with result_type
- tool_choice: str | None = None
- if result_type is not None and result_type is not str:
- final_response_tool = create_final_response_tool(result_type)
- sampling_tools = list(sampling_tools) if sampling_tools else []
- sampling_tools.append(final_response_tool)
-
- # Always require tool calls when result_type is set - the LLM must
- # eventually call final_response (text responses are not accepted)
- tool_choice = "required"
-
- # Convert messages for the loop
- current_messages: str | Sequence[str | SamplingMessage] = messages
-
- text_response_retries = 0
- consecutive_validation_failures = 0
-
- for _iteration in range(max_iterations):
- step = await sample_step_impl(
- context,
- messages=current_messages,
- system_prompt=system_prompt,
- temperature=temperature,
- max_tokens=max_tokens,
- model_preferences=model_preferences,
- tools=sampling_tools,
- tool_choice=tool_choice,
- mask_error_details=mask_error_details,
- tool_concurrency=tool_concurrency,
- client_available=client_available,
- )
-
- # Check for final_response tool call for structured output
- had_final_response = False
- if result_type is not None and result_type is not str and step.is_tool_use:
- for tool_call in step.tool_calls:
- if tool_call.name == "final_response":
- had_final_response = True
- # Validate and return the structured result
- type_adapter = get_cached_typeadapter(result_type)
-
- # Unwrap if we wrapped primitives (non-object schemas)
- input_data = tool_call.input
- original_schema = compress_schema(
- type_adapter.json_schema(), prune_titles=True
- )
- if (
- original_schema.get("type") != "object"
- and isinstance(input_data, dict)
- and "value" in input_data
- ):
- input_data = input_data["value"]
-
- try:
- validated_result = type_adapter.validate_python(input_data)
- text = json.dumps(
- type_adapter.dump_python(validated_result, mode="json")
- )
- return SamplingResult(
- text=text,
- result=validated_result,
- history=step.history,
- )
- except ValidationError as e:
- consecutive_validation_failures += 1
- if consecutive_validation_failures > _MAX_VALIDATION_RETRIES:
- raise RuntimeError(
- f"Structured output validation failed "
- f"{consecutive_validation_failures} consecutive "
- f"times for type {result_type.__name__}: {e}"
- ) from e
- # Validation failed - add error as tool result
- step.history.append(
- SamplingMessage(
- role="user",
- content=[
- ToolResultContent(
- type="tool_result",
- tool_use_id=tool_call.id,
- content=[
- TextContent(
- type="text",
- text=(
- f"Validation error: {e}. "
- "Please try again with valid data."
- ),
- )
- ],
- is_error=True,
- )
- ],
- )
- )
-
- # The LLM called tools but not final_response — reset validation counter
- if not had_final_response:
- consecutive_validation_failures = 0
-
- # If not a tool use response, we're done
- if not step.is_tool_use:
- # For structured output, the LLM must use the final_response tool
- if result_type is not None and result_type is not str:
- text_response_retries += 1
- if text_response_retries > _MAX_TEXT_RESPONSE_RETRIES:
- raise RuntimeError(
- f"Expected structured output of type {result_type.__name__}, "
- "but the LLM returned a text response instead of calling "
- f"the final_response tool ({text_response_retries} attempts)."
- )
- # Nudge the LLM to use the tool
- step.history.append(
- SamplingMessage(
- role="user",
- content=TextContent(
- type="text",
- text=(
- "You must call the `final_response` tool to provide "
- "your answer. Do not respond with text — use the tool."
- ),
- ),
- )
- )
- current_messages = step.history
- continue
- return SamplingResult(
- text=step.text,
- result=cast(ResultT, step.text if step.text else ""),
- history=step.history,
- )
-
- # Continue with the updated history
- current_messages = step.history
-
- # After first iteration, reset tool_choice to auto (unless structured output is required)
- if result_type is None or result_type is str:
- tool_choice = None
-
- raise RuntimeError(f"Sampling exceeded maximum iterations ({max_iterations})")
diff --git a/fastmcp_slim/fastmcp/server/sampling/sampling_tool.py b/fastmcp_slim/fastmcp/server/sampling/sampling_tool.py
deleted file mode 100644
index 05063dc53..000000000
--- a/fastmcp_slim/fastmcp/server/sampling/sampling_tool.py
+++ /dev/null
@@ -1,204 +0,0 @@
-"""SamplingTool for use during LLM sampling requests."""
-
-from __future__ import annotations
-
-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.exceptions import AuthorizationError
-from fastmcp.server.auth.authorization import AuthContext, run_auth_checks
-from fastmcp.server.dependencies import get_access_token
-from fastmcp.tools.base import ToolResult
-from fastmcp.tools.function_parsing import ParsedFunction
-from fastmcp.tools.function_tool import FunctionTool
-from fastmcp.tools.tool_transform import TransformedTool
-from fastmcp.utilities.types import FastMCPBaseModel
-
-
-class SamplingTool(FastMCPBaseModel):
- """A tool that can be used during LLM sampling.
-
- SamplingTools bundle a tool's schema (name, description, parameters) with
- an executor function, enabling servers to execute agentic workflows where
- the LLM can request tool calls during sampling.
-
- In most cases, pass functions directly to ctx.sample():
-
- def search(query: str) -> str:
- '''Search the web.'''
- return web_search(query)
-
- result = await context.sample(
- messages="Find info about Python",
- tools=[search], # Plain functions work directly
- )
-
- Create a SamplingTool explicitly when you need custom name/description:
-
- tool = SamplingTool.from_function(search, name="web_search")
- """
-
- name: str
- description: str | None = None
- parameters: dict[str, Any]
- fn: Callable[..., Any]
- sequential: bool = False
-
- model_config = ConfigDict(arbitrary_types_allowed=True)
-
- async def run(self, arguments: dict[str, Any] | None = None) -> Any:
- """Execute the tool with the given arguments.
-
- Args:
- arguments: Dictionary of arguments to pass to the tool function.
-
- Returns:
- The result of executing the tool function.
- """
- if arguments is None:
- arguments = {}
-
- result = self.fn(**arguments)
- if inspect.isawaitable(result):
- result = await result
- return result
-
- def _to_sdk_tool(self) -> SDKTool:
- """Convert to an mcp_types.Tool for SDK compatibility.
-
- This is used internally when passing tools to the MCP SDK's
- create_message() method.
- """
- return SDKTool(
- name=self.name,
- description=self.description,
- input_schema=self.parameters,
- )
-
- @classmethod
- def from_function(
- cls,
- fn: Callable[..., Any],
- *,
- name: str | None = None,
- description: str | None = None,
- sequential: bool = False,
- ) -> SamplingTool:
- """Create a SamplingTool from a function.
-
- The function's signature is analyzed to generate a JSON schema for
- the tool's parameters. Type hints are used to determine parameter types.
-
- Args:
- fn: The function to create a tool from.
- name: Optional name override. Defaults to the function's name.
- description: Optional description override. Defaults to the function's docstring.
- sequential: If True, this tool requires sequential execution and prevents
- parallel execution of all tools in the batch. Set to True for tools
- with shared state, file writes, or other operations that cannot run
- concurrently. Defaults to False.
-
- Returns:
- A SamplingTool wrapping the function.
-
- Raises:
- ValueError: If the function is a lambda without a name override.
- """
- parsed = ParsedFunction.from_function(fn, validate=True)
-
- if name is None and parsed.name == "":
- raise ValueError("You must provide a name for lambda functions")
-
- return cls(
- name=name or parsed.name,
- description=description if description is not None else parsed.description,
- parameters=parsed.input_schema,
- 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:
- # Enforce per-tool auth checks, mirroring what the server
- # dispatcher does for direct tool calls. Without this, an
- # auth-protected tool wrapped as a SamplingTool could be
- # invoked by the LLM during sampling without authorization.
- if tool.auth is not None:
- # Late import to avoid circular import with context.py
- from fastmcp.server.context import _current_transport
-
- is_stdio = _current_transport.get() == "stdio"
- if not is_stdio:
- token = get_access_token()
- ctx = AuthContext(token=token, component=tool)
- if not await run_auth_checks(tool.auth, ctx):
- raise AuthorizationError(
- f"Authorization failed for tool '{tool.name}': "
- "insufficient permissions"
- )
-
- 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/fastmcp_slim/fastmcp/server/server.py b/fastmcp_slim/fastmcp/server/server.py
index 15e03c5f2..4c4426282 100644
--- a/fastmcp_slim/fastmcp/server/server.py
+++ b/fastmcp_slim/fastmcp/server/server.py
@@ -102,7 +102,6 @@ from fastmcp.utilities.versions import (
if TYPE_CHECKING:
from fastmcp.client import Client
from fastmcp.client.client import SDKServer
- from fastmcp.client.sampling import SamplingHandler
from fastmcp.client.transports import ClientTransport, ClientTransportT
from fastmcp.server.extensions import ServerExtension
from fastmcp.server.providers.openapi import ComponentFn as OpenAPIComponentFn
@@ -178,11 +177,13 @@ _REMOVED_KWARGS: dict[str, str] = {
"include_tags": "Use `server.enable(tags=..., only=True)` after creating the server.",
"exclude_tags": "Use `server.disable(tags=...)` after creating the server.",
"tool_transformations": "Use `server.add_transform(ToolTransform(...))` after creating the server.",
+ "sampling_handler": "Server-initiated sampling was removed from MCP by SEP-2577. Call an LLM directly from your tool.",
+ "sampling_handler_behavior": "Server-initiated sampling was removed from MCP by SEP-2577. Call an LLM directly from your tool.",
}
def _check_removed_kwargs(kwargs: dict[str, Any]) -> None:
- """Raise helpful TypeErrors for kwargs removed in v3."""
+ """Raise helpful TypeErrors for kwargs FastMCP no longer accepts."""
for key in kwargs:
if key in _REMOVED_KWARGS:
raise TypeError(
@@ -355,8 +356,6 @@ class FastMCP(
cache_scope: Literal["public", "private"] | None = None,
tasks: bool | None = None,
session_state_store: AsyncKeyValue | None = None,
- sampling_handler: SamplingHandler | None = None,
- sampling_handler_behavior: Literal["always", "fallback"] | None = None,
client_log_level: mcp_types.LoggingLevel | None = None,
experimental_capabilities: dict[str, dict[str, Any]] | None = None,
**kwargs: Any,
@@ -538,11 +537,6 @@ class FastMCP(
# Set up MCP protocol handlers
self._setup_handlers()
- self.sampling_handler: SamplingHandler | None = sampling_handler
- self.sampling_handler_behavior: Literal["always", "fallback"] = (
- sampling_handler_behavior or "fallback"
- )
-
def __repr__(self) -> str:
return f"{type(self).__name__}({self.name!r})"
diff --git a/tests/client/test_sampling_result_types.py b/tests/client/test_sampling_result_types.py
deleted file mode 100644
index e581e2582..000000000
--- a/tests/client/test_sampling_result_types.py
+++ /dev/null
@@ -1,681 +0,0 @@
-import pytest
-from mcp_types import CreateMessageResultWithTools, TextContent, ToolUseContent
-
-from fastmcp import Client, Context, FastMCP
-from fastmcp.client.sampling import RequestContext, SamplingMessage, SamplingParams
-
-
-class TestSamplingResultType:
- """Tests for result_type parameter (structured output)."""
-
- async def test_result_type_creates_final_response_tool(self):
- """Test that result_type creates a synthetic final_response tool."""
- from mcp_types import CreateMessageResultWithTools, ToolUseContent
- from pydantic import BaseModel
-
- class MathResult(BaseModel):
- answer: int
- explanation: str
-
- received_tools: list = []
-
- def sampling_handler(
- messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext
- ) -> CreateMessageResultWithTools:
- received_tools.extend(params.tools or [])
-
- # Return the final_response tool call
- return CreateMessageResultWithTools(
- role="assistant",
- content=[
- ToolUseContent(
- type="tool_use",
- id="call_1",
- name="final_response",
- input={"answer": 42, "explanation": "The meaning of life"},
- )
- ],
- model="test-model",
- stop_reason="toolUse",
- )
-
- mcp = FastMCP(sampling_handler=sampling_handler)
-
- @mcp.tool
- async def math_tool(context: Context) -> str:
- result = await context.sample(
- messages="What is 6 * 7?",
- result_type=MathResult,
- )
- # result.result should be a MathResult object
- assert isinstance(result.result, MathResult)
- return f"{result.result.answer}: {result.result.explanation}"
-
- async with Client(mcp) as client:
- result = await client.call_tool("math_tool", {})
-
- # Check that final_response tool was added
- tool_names = [t.name for t in received_tools]
- assert "final_response" in tool_names
-
- # Check the result
- assert result.data == "42: The meaning of life"
-
- async def test_result_type_with_user_tools(self):
- """Test result_type works alongside user-provided tools."""
- from mcp_types import CreateMessageResultWithTools, ToolUseContent
- from pydantic import BaseModel
-
- class SearchResult(BaseModel):
- summary: str
- sources: list[str]
-
- def search(query: str) -> str:
- """Search for information."""
- return f"Found info about: {query}"
-
- call_count = 0
- tool_was_called = False
-
- def sampling_handler(
- messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext
- ) -> CreateMessageResultWithTools:
- nonlocal call_count, tool_was_called
- call_count += 1
-
- if call_count == 1:
- # First call: use the search tool
- return CreateMessageResultWithTools(
- role="assistant",
- content=[
- ToolUseContent(
- type="tool_use",
- id="call_1",
- name="search",
- input={"query": "Python tutorials"},
- )
- ],
- model="test-model",
- stop_reason="toolUse",
- )
- else:
- # Second call: call final_response
- tool_was_called = True
- return CreateMessageResultWithTools(
- role="assistant",
- content=[
- ToolUseContent(
- type="tool_use",
- id="call_2",
- name="final_response",
- input={
- "summary": "Python is great",
- "sources": ["python.org", "docs.python.org"],
- },
- )
- ],
- model="test-model",
- stop_reason="toolUse",
- )
-
- mcp = FastMCP(sampling_handler=sampling_handler)
-
- @mcp.tool
- async def research(context: Context) -> str:
- result = await context.sample(
- messages="Research Python",
- tools=[search],
- result_type=SearchResult,
- )
- assert isinstance(result.result, SearchResult)
- return f"{result.result.summary} - {len(result.result.sources)} sources"
-
- async with Client(mcp) as client:
- result = await client.call_tool("research", {})
-
- assert tool_was_called
- assert result.data == "Python is great - 2 sources"
-
- async def test_result_type_validation_error_retries(self):
- """Test that validation errors are sent back to LLM for retry."""
- from mcp_types import (
- CreateMessageResultWithTools,
- ToolResultContent,
- ToolUseContent,
- )
- from pydantic import BaseModel
-
- class StrictResult(BaseModel):
- value: int # Must be an int
-
- messages_received: list[list[SamplingMessage]] = []
-
- def sampling_handler(
- messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext
- ) -> CreateMessageResultWithTools:
- messages_received.append(list(messages))
-
- if len(messages_received) == 1:
- # First call: invalid type
- return CreateMessageResultWithTools(
- role="assistant",
- content=[
- ToolUseContent(
- type="tool_use",
- id="call_1",
- name="final_response",
- input={"value": "not_an_int"}, # Wrong type
- )
- ],
- model="test-model",
- stop_reason="toolUse",
- )
- else:
- # Second call: valid type after seeing error
- return CreateMessageResultWithTools(
- role="assistant",
- content=[
- ToolUseContent(
- type="tool_use",
- id="call_2",
- name="final_response",
- input={"value": 42}, # Correct type
- )
- ],
- model="test-model",
- stop_reason="toolUse",
- )
-
- mcp = FastMCP(sampling_handler=sampling_handler)
-
- @mcp.tool
- async def validate_tool(context: Context) -> str:
- result = await context.sample(
- messages="Give me a number",
- result_type=StrictResult,
- )
- assert isinstance(result.result, StrictResult)
- return str(result.result.value)
-
- async with Client(mcp) as client:
- result = await client.call_tool("validate_tool", {})
-
- # Should have retried after validation error
- assert len(messages_received) == 2
-
- # Check that error was passed back
- last_messages = messages_received[1]
- # Find the tool result in list content
- tool_result = None
- for msg in last_messages:
- # Tool results are now in a list
- if isinstance(msg.content, list):
- for item in msg.content:
- if isinstance(item, ToolResultContent):
- tool_result = item
- break
- elif isinstance(msg.content, ToolResultContent):
- tool_result = msg.content
- break
- assert tool_result is not None
- assert tool_result.is_error is True
- assert isinstance(tool_result.content[0], TextContent)
- error_text = tool_result.content[0].text
- assert "Validation error" in error_text
-
- # Final result should be correct
- assert result.data == "42"
-
- async def test_sampling_result_has_text_and_history(self):
- """Test that SamplingResult has text, result, and history attributes."""
- from mcp_types import CreateMessageResultWithTools
-
- def sampling_handler(
- messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext
- ) -> CreateMessageResultWithTools:
- return CreateMessageResultWithTools(
- role="assistant",
- content=[TextContent(type="text", text="Hello world")],
- model="test-model",
- stop_reason="endTurn",
- )
-
- mcp = FastMCP(sampling_handler=sampling_handler)
-
- @mcp.tool
- async def check_result(context: Context) -> str:
- result = await context.sample(messages="Say hello")
- # Check all attributes exist
- assert result.text == "Hello world"
- assert result.result == "Hello world"
- assert len(result.history) >= 1
- return "ok"
-
- async with Client(mcp) as client:
- result = await client.call_tool("check_result", {})
-
- assert result.data == "ok"
-
-
-class TestSampleStep:
- """Tests for ctx.sample_step() - single LLM call with manual control."""
-
- async def test_sample_step_basic(self):
- """Test basic sample_step returns text response."""
- from mcp_types import CreateMessageResultWithTools
-
- def sampling_handler(
- messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext
- ) -> CreateMessageResultWithTools:
- return CreateMessageResultWithTools(
- role="assistant",
- content=[TextContent(type="text", text="Hello from step")],
- model="test-model",
- stop_reason="endTurn",
- )
-
- mcp = FastMCP(sampling_handler=sampling_handler)
-
- @mcp.tool
- async def test_step(context: Context) -> str:
- step = await context.sample_step(messages="Hi")
- assert not step.is_tool_use
- assert step.text == "Hello from step"
- return step.text or ""
-
- async with Client(mcp) as client:
- result = await client.call_tool("test_step", {})
-
- assert result.data == "Hello from step"
-
- async def test_sample_step_with_tool_execution(self):
- """Test sample_step executes tools by default."""
- from mcp_types import CreateMessageResultWithTools, ToolUseContent
-
- call_count = 0
-
- def my_tool(x: int) -> str:
- """A test tool."""
- return f"result:{x}"
-
- def sampling_handler(
- messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext
- ) -> CreateMessageResultWithTools:
- nonlocal call_count
- call_count += 1
-
- if call_count == 1:
- return CreateMessageResultWithTools(
- role="assistant",
- content=[
- ToolUseContent(
- type="tool_use",
- id="call_1",
- name="my_tool",
- input={"x": 42},
- )
- ],
- model="test-model",
- stop_reason="toolUse",
- )
- else:
- return CreateMessageResultWithTools(
- role="assistant",
- content=[TextContent(type="text", text="Done")],
- model="test-model",
- stop_reason="endTurn",
- )
-
- mcp = FastMCP(sampling_handler=sampling_handler)
-
- @mcp.tool
- async def test_step(context: Context) -> str:
- messages: str | list[SamplingMessage] = "Run tool"
-
- while True:
- step = await context.sample_step(messages=messages, tools=[my_tool])
-
- if not step.is_tool_use:
- return step.text or ""
-
- # History should include tool results when execute_tools=True
- messages = step.history
-
- async with Client(mcp) as client:
- result = await client.call_tool("test_step", {})
-
- assert result.data == "Done"
- assert call_count == 2
-
- async def test_sample_step_execute_tools_false(self):
- """Test sample_step with execute_tools=False doesn't execute tools."""
- from mcp_types import CreateMessageResultWithTools, ToolUseContent
-
- tool_executed = False
-
- def my_tool() -> str:
- """A test tool."""
- nonlocal tool_executed
- tool_executed = True
- return "executed"
-
- def sampling_handler(
- messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext
- ) -> CreateMessageResultWithTools:
- return CreateMessageResultWithTools(
- role="assistant",
- content=[
- ToolUseContent(
- type="tool_use",
- id="call_1",
- name="my_tool",
- input={},
- )
- ],
- model="test-model",
- stop_reason="toolUse",
- )
-
- mcp = FastMCP(sampling_handler=sampling_handler)
-
- @mcp.tool
- async def test_step(context: Context) -> str:
- step = await context.sample_step(
- messages="Run tool",
- tools=[my_tool],
- execute_tools=False,
- )
- assert step.is_tool_use
- assert len(step.tool_calls) == 1
- assert step.tool_calls[0].name == "my_tool"
- # History should include assistant message but no tool results
- assert len(step.history) == 2 # user + assistant
- return "ok"
-
- async with Client(mcp) as client:
- result = await client.call_tool("test_step", {})
-
- assert result.data == "ok"
- assert not tool_executed # Tool should not have been executed
-
- async def test_sample_step_history_includes_assistant_message(self):
- """Test that history includes assistant message when execute_tools=False."""
- from mcp_types import CreateMessageResultWithTools, ToolUseContent
-
- def sampling_handler(
- messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext
- ) -> CreateMessageResultWithTools:
- return CreateMessageResultWithTools(
- role="assistant",
- content=[
- ToolUseContent(
- type="tool_use",
- id="call_1",
- name="my_tool",
- input={"query": "test"},
- )
- ],
- model="test-model",
- stop_reason="toolUse",
- )
-
- mcp = FastMCP(sampling_handler=sampling_handler)
-
- def my_tool(query: str) -> str:
- return f"result for {query}"
-
- @mcp.tool
- async def test_step(context: Context) -> str:
- step = await context.sample_step(
- messages="Search",
- tools=[my_tool],
- execute_tools=False,
- )
- # History should have: user message + assistant message
- assert len(step.history) == 2
- assert step.history[0].role == "user"
- assert step.history[1].role == "assistant"
- return "ok"
-
- async with Client(mcp) as client:
- result = await client.call_tool("test_step", {})
-
- assert result.data == "ok"
-
-
-class TestTextResponseRetry:
- """Tests for retry logic when LLM returns text instead of calling final_response."""
-
- @staticmethod
- def _text_reply(text: str = "some text"):
- from mcp_types import CreateMessageResultWithTools
-
- return CreateMessageResultWithTools(
- role="assistant",
- content=[TextContent(type="text", text=text)],
- model="m",
- stop_reason="endTurn",
- )
-
- @staticmethod
- def _tool_reply(value: int):
- from mcp_types import CreateMessageResultWithTools, ToolUseContent
-
- return CreateMessageResultWithTools(
- role="assistant",
- content=[
- ToolUseContent(
- type="tool_use",
- id="c1",
- name="final_response",
- input={"value": value},
- )
- ],
- model="m",
- stop_reason="toolUse",
- )
-
- async def test_text_response_then_success(self):
- """Text on first call, final_response on second -- verify call_count == 2."""
- from pydantic import BaseModel
-
- class R(BaseModel):
- value: int
-
- call_count = 0
-
- def handler(messages, params, ctx):
- nonlocal call_count
- call_count += 1
- return self._text_reply() if call_count == 1 else self._tool_reply(42)
-
- mcp = FastMCP(sampling_handler=handler)
-
- @mcp.tool
- async def t(context: Context) -> str:
- return str((await context.sample(messages="q", result_type=R)).result.value)
-
- async with Client(mcp) as client:
- result = await client.call_tool("t", {})
-
- assert call_count == 2
- assert result.data == "42"
-
- async def test_text_response_exceeds_max_retries(self):
- """Always text, never tool -- verify error after _MAX_TEXT_RESPONSE_RETRIES+1 calls."""
- from pydantic import BaseModel
-
- from fastmcp.exceptions import ToolError
- from fastmcp.server.sampling.run import _MAX_TEXT_RESPONSE_RETRIES
-
- class R(BaseModel):
- value: int
-
- call_count = 0
-
- def handler(messages, params, ctx):
- nonlocal call_count
- call_count += 1
- return self._text_reply()
-
- mcp = FastMCP(sampling_handler=handler)
-
- @mcp.tool
- async def t(context: Context) -> str:
- return str((await context.sample(messages="q", result_type=R)).result)
-
- async with Client(mcp) as client:
- with pytest.raises(ToolError, match="attempts"):
- await client.call_tool("t", {})
-
- assert call_count == _MAX_TEXT_RESPONSE_RETRIES + 1
-
- async def test_no_retry_when_result_type_is_none(self):
- """Text response with no result_type -- single call, normal return."""
- call_count = 0
-
- def handler(messages, params, ctx):
- nonlocal call_count
- call_count += 1
- return self._text_reply("hello")
-
- mcp = FastMCP(sampling_handler=handler)
-
- @mcp.tool
- async def t(context: Context) -> str:
- return (await context.sample(messages="q")).text or ""
-
- async with Client(mcp) as client:
- result = await client.call_tool("t", {})
-
- assert call_count == 1
- assert result.data == "hello"
-
-
-def _final_response(call_id: str, input_data: dict) -> CreateMessageResultWithTools:
- """Build a final_response tool-use reply."""
- return CreateMessageResultWithTools(
- role="assistant",
- content=[
- ToolUseContent(
- type="tool_use", id=call_id, name="final_response", input=input_data
- )
- ],
- model="test-model",
- stop_reason="toolUse",
- )
-
-
-def _tool_call(
- call_id: str, name: str, input_data: dict
-) -> CreateMessageResultWithTools:
- """Build a regular tool-use reply."""
- return CreateMessageResultWithTools(
- role="assistant",
- content=[
- ToolUseContent(type="tool_use", id=call_id, name=name, input=input_data)
- ],
- model="test-model",
- stop_reason="toolUse",
- )
-
-
-class TestValidationRetryCap:
- """Tests for the consecutive validation retry cap (PR #3851)."""
-
- async def test_validation_failures_within_cap_then_success(self):
- """Two consecutive failures followed by a valid response succeeds."""
- from pydantic import BaseModel
-
- class R(BaseModel):
- value: int
-
- call_count = 0
-
- def handler(messages, params, ctx):
- nonlocal call_count
- call_count += 1
- if call_count <= 2:
- return _final_response(f"c{call_count}", {"value": "bad"})
- return _final_response(f"c{call_count}", {"value": 99})
-
- mcp = FastMCP(sampling_handler=handler)
-
- @mcp.tool
- async def t(context: Context) -> str:
- r = await context.sample(messages="go", result_type=R)
- return str(r.result.value)
-
- async with Client(mcp) as client:
- result = await client.call_tool("t", {})
-
- assert call_count == 3
- assert result.data == "99"
-
- async def test_consecutive_validation_failures_exceed_cap(self):
- """Always-invalid responses raise ToolError after exceeding the cap."""
- from pydantic import BaseModel
-
- from fastmcp.exceptions import ToolError
- from fastmcp.server.sampling.run import _MAX_VALIDATION_RETRIES
-
- class R(BaseModel):
- value: int
-
- call_count = 0
-
- def handler(messages, params, ctx):
- nonlocal call_count
- call_count += 1
- return _final_response(f"c{call_count}", {"value": "wrong"})
-
- mcp = FastMCP(sampling_handler=handler)
-
- @mcp.tool
- async def t(context: Context) -> str:
- return str((await context.sample(messages="go", result_type=R)).result)
-
- async with Client(mcp) as client:
- with pytest.raises(ToolError, match="consecutive"):
- await client.call_tool("t", {})
-
- # 1 initial attempt + _MAX_VALIDATION_RETRIES retries
- assert call_count == _MAX_VALIDATION_RETRIES + 1
-
- async def test_validation_counter_resets_after_other_tool_call(self):
- """A tool call between validation failures resets the counter."""
- from pydantic import BaseModel
-
- class R(BaseModel):
- value: int
-
- def helper_tool(x: int) -> str:
- """A helper tool."""
- return f"result:{x}"
-
- call_count = 0
-
- def handler(messages, params, ctx):
- nonlocal call_count
- call_count += 1
- # fail -> other tool (resets counter) -> fail -> succeed
- if call_count == 1:
- return _final_response("c1", {"value": "bad"})
- if call_count == 2:
- return _tool_call("c2", "helper_tool", {"x": 1})
- if call_count == 3:
- return _final_response("c3", {"value": "bad"})
- return _final_response("c4", {"value": 42})
-
- mcp = FastMCP(sampling_handler=handler)
-
- @mcp.tool
- async def t(context: Context) -> str:
- r = await context.sample(messages="go", tools=[helper_tool], result_type=R)
- return str(r.result.value)
-
- async with Client(mcp) as client:
- result = await client.call_tool("t", {})
-
- assert call_count == 4
- assert result.data == "42"
diff --git a/tests/client/test_sampling_tool_loop.py b/tests/client/test_sampling_tool_loop.py
deleted file mode 100644
index 0e18bd25f..000000000
--- a/tests/client/test_sampling_tool_loop.py
+++ /dev/null
@@ -1,811 +0,0 @@
-from typing import cast
-
-from mcp_types import TextContent
-
-from fastmcp import Client, Context, FastMCP
-from fastmcp.client.sampling import RequestContext, SamplingMessage, SamplingParams
-from fastmcp.server.sampling import SamplingTool
-
-
-class TestAutomaticToolLoop:
- """Tests for automatic tool execution loop in ctx.sample()."""
-
- async def test_automatic_tool_loop_executes_tools(self):
- """Test that ctx.sample() automatically executes tool calls."""
- from mcp_types import CreateMessageResultWithTools, ToolUseContent
-
- call_count = 0
- tool_was_called = False
-
- def get_weather(city: str) -> str:
- """Get weather for a city."""
- nonlocal tool_was_called
- tool_was_called = True
- return f"Weather in {city}: sunny, 72°F"
-
- def sampling_handler(
- messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext
- ) -> CreateMessageResultWithTools:
- nonlocal call_count
- call_count += 1
-
- if call_count == 1:
- # First call: return tool use
- return CreateMessageResultWithTools(
- role="assistant",
- content=[
- ToolUseContent(
- type="tool_use",
- id="call_1",
- name="get_weather",
- input={"city": "Seattle"},
- )
- ],
- model="test-model",
- stop_reason="toolUse",
- )
- else:
- # Second call: return final response
- return CreateMessageResultWithTools(
- role="assistant",
- content=[TextContent(type="text", text="The weather is sunny!")],
- model="test-model",
- stop_reason="endTurn",
- )
-
- mcp = FastMCP(sampling_handler=sampling_handler)
-
- @mcp.tool
- async def weather_assistant(question: str, context: Context) -> str:
- result = await context.sample(
- messages=question,
- tools=[get_weather],
- )
- # Get text from SamplingResult
- return result.text or ""
-
- async with Client(mcp) as client:
- result = await client.call_tool(
- "weather_assistant", {"question": "What's the weather?"}
- )
-
- assert tool_was_called
- assert call_count == 2
- assert result.data == "The weather is sunny!"
-
- async def test_automatic_tool_loop_multiple_tools(self):
- """Test that multiple tool calls in one response are all executed."""
- from mcp_types import CreateMessageResultWithTools, ToolUseContent
-
- executed_tools: list[str] = []
-
- def tool_a(x: int) -> int:
- """Tool A."""
- executed_tools.append(f"tool_a({x})")
- return x * 2
-
- def tool_b(y: int) -> int:
- """Tool B."""
- executed_tools.append(f"tool_b({y})")
- return y + 10
-
- call_count = 0
-
- def sampling_handler(
- messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext
- ) -> CreateMessageResultWithTools:
- nonlocal call_count
- call_count += 1
-
- if call_count == 1:
- # Return multiple tool calls
- return CreateMessageResultWithTools(
- role="assistant",
- content=[
- ToolUseContent(
- type="tool_use", id="call_a", name="tool_a", input={"x": 5}
- ),
- ToolUseContent(
- type="tool_use", id="call_b", name="tool_b", input={"y": 3}
- ),
- ],
- model="test-model",
- stop_reason="toolUse",
- )
- else:
- return CreateMessageResultWithTools(
- role="assistant",
- content=[TextContent(type="text", text="Done!")],
- model="test-model",
- stop_reason="endTurn",
- )
-
- mcp = FastMCP(sampling_handler=sampling_handler)
-
- @mcp.tool
- async def multi_tool(context: Context) -> str:
- result = await context.sample(messages="Run tools", tools=[tool_a, tool_b])
- return result.text or ""
-
- async with Client(mcp) as client:
- result = await client.call_tool("multi_tool", {})
-
- assert executed_tools == ["tool_a(5)", "tool_b(3)"]
- assert result.data == "Done!"
-
- async def test_automatic_tool_loop_handles_unknown_tool(self):
- """Test that unknown tool names result in error being passed to LLM."""
- from mcp_types import (
- CreateMessageResultWithTools,
- ToolResultContent,
- ToolUseContent,
- )
-
- def known_tool() -> str:
- """A known tool."""
- return "known result"
-
- messages_received: list[list[SamplingMessage]] = []
-
- def sampling_handler(
- messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext
- ) -> CreateMessageResultWithTools:
- messages_received.append(list(messages))
-
- if len(messages_received) == 1:
- # Request unknown tool
- return CreateMessageResultWithTools(
- role="assistant",
- content=[
- ToolUseContent(
- type="tool_use",
- id="call_1",
- name="unknown_tool",
- input={},
- )
- ],
- model="test-model",
- stop_reason="toolUse",
- )
- else:
- return CreateMessageResultWithTools(
- role="assistant",
- content=[TextContent(type="text", text="Handled error")],
- model="test-model",
- stop_reason="endTurn",
- )
-
- mcp = FastMCP(sampling_handler=sampling_handler)
-
- @mcp.tool
- async def test_unknown(context: Context) -> str:
- result = await context.sample(messages="Test", tools=[known_tool])
- return result.text or ""
-
- async with Client(mcp) as client:
- result = await client.call_tool("test_unknown", {})
-
- # Check that error was passed back in messages
- assert len(messages_received) == 2
- last_messages = messages_received[1]
- # Find the tool result in list content
- tool_result = None
- for msg in last_messages:
- # Tool results are now in a list
- if isinstance(msg.content, list):
- for item in msg.content:
- if isinstance(item, ToolResultContent):
- tool_result = item
- break
- elif isinstance(msg.content, ToolResultContent):
- tool_result = msg.content
- break
- assert tool_result is not None
- assert tool_result.is_error is True
- # Content is list of TextContent objects
- assert isinstance(tool_result.content[0], TextContent)
- error_text = tool_result.content[0].text
- assert "Unknown tool" in error_text
- assert result.data == "Handled error"
-
- async def test_automatic_tool_loop_handles_tool_exception(self):
- """Test that tool exceptions are caught and passed to LLM as errors."""
- from mcp_types import (
- CreateMessageResultWithTools,
- ToolResultContent,
- ToolUseContent,
- )
-
- def failing_tool() -> str:
- """A tool that raises an exception."""
- raise ValueError("Tool failed intentionally")
-
- messages_received: list[list[SamplingMessage]] = []
-
- def sampling_handler(
- messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext
- ) -> CreateMessageResultWithTools:
- messages_received.append(list(messages))
-
- if len(messages_received) == 1:
- return CreateMessageResultWithTools(
- role="assistant",
- content=[
- ToolUseContent(
- type="tool_use",
- id="call_1",
- name="failing_tool",
- input={},
- )
- ],
- model="test-model",
- stop_reason="toolUse",
- )
- else:
- return CreateMessageResultWithTools(
- role="assistant",
- content=[TextContent(type="text", text="Handled error")],
- model="test-model",
- stop_reason="endTurn",
- )
-
- mcp = FastMCP(sampling_handler=sampling_handler)
-
- @mcp.tool
- async def test_exception(context: Context) -> str:
- result = await context.sample(messages="Test", tools=[failing_tool])
- return result.text or ""
-
- async with Client(mcp) as client:
- result = await client.call_tool("test_exception", {})
-
- # Check that error was passed back
- assert len(messages_received) == 2
- last_messages = messages_received[1]
- # Find the tool result in list content
- tool_result = None
- for msg in last_messages:
- # Tool results are now in a list
- if isinstance(msg.content, list):
- for item in msg.content:
- if isinstance(item, ToolResultContent):
- tool_result = item
- break
- elif isinstance(msg.content, ToolResultContent):
- tool_result = msg.content
- break
- assert tool_result is not None
- assert tool_result.is_error is True
- # Content is list of TextContent objects
- assert isinstance(tool_result.content[0], TextContent)
- error_text = tool_result.content[0].text
- assert "Tool failed intentionally" in error_text
- assert result.data == "Handled error"
-
- async def test_concurrent_tool_execution_default_sequential(self):
- """Test that tools execute sequentially by default."""
- import asyncio
-
- from mcp_types import CreateMessageResultWithTools, ToolUseContent
-
- # Ordering is guaranteed structurally (the loop awaits each tool call
- # to completion before starting the next when tool_concurrency is
- # None), so no real delay is needed to prove it - a single
- # `asyncio.sleep(0)` still yields control to the event loop.
- execution_order: list[str] = []
-
- async def slow_tool_a(x: int) -> int:
- """Slow tool A."""
- execution_order.append("tool_a_start")
- await asyncio.sleep(0)
- execution_order.append("tool_a_end")
- return x * 2
-
- async def slow_tool_b(y: int) -> int:
- """Slow tool B."""
- execution_order.append("tool_b_start")
- await asyncio.sleep(0)
- execution_order.append("tool_b_end")
- return y + 10
-
- call_count = 0
-
- def sampling_handler(
- messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext
- ) -> CreateMessageResultWithTools:
- nonlocal call_count
- call_count += 1
-
- if call_count == 1:
- return CreateMessageResultWithTools(
- role="assistant",
- content=[
- ToolUseContent(
- type="tool_use",
- id="call_a",
- name="slow_tool_a",
- input={"x": 5},
- ),
- ToolUseContent(
- type="tool_use",
- id="call_b",
- name="slow_tool_b",
- input={"y": 3},
- ),
- ],
- model="test-model",
- stop_reason="toolUse",
- )
- else:
- return CreateMessageResultWithTools(
- role="assistant",
- content=[TextContent(type="text", text="Done!")],
- model="test-model",
- stop_reason="endTurn",
- )
-
- mcp = FastMCP(sampling_handler=sampling_handler)
-
- @mcp.tool
- async def test_tool(context: Context) -> str:
- result = await context.sample(
- messages="Run tools",
- tools=[slow_tool_a, slow_tool_b],
- # Default: tool_concurrency=None (sequential)
- )
- return result.text or ""
-
- async with Client(mcp) as client:
- result = await client.call_tool("test_tool", {})
-
- assert result.data == "Done!"
- # Verify sequential execution: tool_a must complete before tool_b starts
- assert execution_order == [
- "tool_a_start",
- "tool_a_end",
- "tool_b_start",
- "tool_b_end",
- ]
-
- async def test_concurrent_tool_execution_unlimited(self):
- """Test unlimited parallel tool execution with tool_concurrency=0."""
- import asyncio
-
- from mcp_types import CreateMessageResultWithTools, ToolUseContent
-
- # tool_a blocks on an event that only tool_b sets. This is only
- # satisfiable if both tools are genuinely running concurrently: under
- # sequential execution tool_b would never start (tool_a would never
- # finish awaiting it) and the test would fail via the wait_for
- # timeout rather than racing on wall-clock timestamps.
- execution_order: list[str] = []
- tool_b_started = asyncio.Event()
-
- async def slow_tool_a(x: int) -> int:
- """Slow tool A."""
- execution_order.append("tool_a_start")
- await asyncio.wait_for(tool_b_started.wait(), timeout=1.0)
- execution_order.append("tool_a_end")
- return x * 2
-
- async def slow_tool_b(y: int) -> int:
- """Slow tool B."""
- execution_order.append("tool_b_start")
- tool_b_started.set()
- execution_order.append("tool_b_end")
- return y + 10
-
- call_count = 0
-
- def sampling_handler(
- messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext
- ) -> CreateMessageResultWithTools:
- nonlocal call_count
- call_count += 1
-
- if call_count == 1:
- return CreateMessageResultWithTools(
- role="assistant",
- content=[
- ToolUseContent(
- type="tool_use",
- id="call_a",
- name="slow_tool_a",
- input={"x": 5},
- ),
- ToolUseContent(
- type="tool_use",
- id="call_b",
- name="slow_tool_b",
- input={"y": 3},
- ),
- ],
- model="test-model",
- stop_reason="toolUse",
- )
- else:
- return CreateMessageResultWithTools(
- role="assistant",
- content=[TextContent(type="text", text="Done!")],
- model="test-model",
- stop_reason="endTurn",
- )
-
- mcp = FastMCP(sampling_handler=sampling_handler)
-
- @mcp.tool
- async def test_tool(context: Context) -> str:
- result = await context.sample(
- messages="Run tools",
- tools=[slow_tool_a, slow_tool_b],
- tool_concurrency=0, # Unlimited parallel
- )
- return result.text or ""
-
- async with Client(mcp) as client:
- result = await client.call_tool("test_tool", {})
-
- assert result.data == "Done!"
- # Verify parallel execution: tool_b started and finished entirely
- # inside tool_a's blocked wait, which is only possible if the two
- # tools were running concurrently.
- assert execution_order == [
- "tool_a_start",
- "tool_b_start",
- "tool_b_end",
- "tool_a_end",
- ]
-
- async def test_concurrent_tool_execution_bounded(self):
- """Test bounded parallel execution with tool_concurrency=2."""
- import asyncio
-
- from mcp_types import CreateMessageResultWithTools, ToolUseContent
-
- # tool_1 and tool_2 each block until *both* have started, which is
- # only possible if two slots are occupied simultaneously (proving
- # concurrency=2 admits two tools at once). tool_3 has no blocking
- # branch, so its appearance in the log tells us when the real
- # semaphore in the implementation let it in - only after a slot
- # frees, i.e. after tool_1 or tool_2 finishes.
- execution_order: list[str] = []
- both_started = asyncio.Event()
- started_names: set[str] = set()
-
- async def slow_tool(name: str) -> str:
- """Generic tool used to observe bounded concurrency."""
- execution_order.append(f"{name}_start")
- if name in ("tool_1", "tool_2"):
- started_names.add(name)
- if {"tool_1", "tool_2"} <= started_names:
- both_started.set()
- await asyncio.wait_for(both_started.wait(), timeout=1.0)
- execution_order.append(f"{name}_end")
- return f"{name} done"
-
- call_count = 0
-
- def sampling_handler(
- messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext
- ) -> CreateMessageResultWithTools:
- nonlocal call_count
- call_count += 1
-
- if call_count == 1:
- # Request 3 tools (with concurrency=2, first 2 run parallel, then 3rd)
- return CreateMessageResultWithTools(
- role="assistant",
- content=[
- ToolUseContent(
- type="tool_use",
- id="call_1",
- name="slow_tool",
- input={"name": "tool_1"},
- ),
- ToolUseContent(
- type="tool_use",
- id="call_2",
- name="slow_tool",
- input={"name": "tool_2"},
- ),
- ToolUseContent(
- type="tool_use",
- id="call_3",
- name="slow_tool",
- input={"name": "tool_3"},
- ),
- ],
- model="test-model",
- stop_reason="toolUse",
- )
- else:
- return CreateMessageResultWithTools(
- role="assistant",
- content=[TextContent(type="text", text="Done!")],
- model="test-model",
- stop_reason="endTurn",
- )
-
- mcp = FastMCP(sampling_handler=sampling_handler)
-
- @mcp.tool
- async def test_tool(context: Context) -> str:
- result = await context.sample(
- messages="Run tools",
- tools=[slow_tool],
- tool_concurrency=2, # Max 2 concurrent
- )
- return result.text or ""
-
- async with Client(mcp) as client:
- result = await client.call_tool("test_tool", {})
-
- assert result.data == "Done!"
- # Verify that at most 2 tools run concurrently
- # First 2 tools should start before either ends
- assert execution_order[0] in ["tool_1_start", "tool_2_start"]
- assert execution_order[1] in ["tool_1_start", "tool_2_start"]
- # Third tool should start after at least one of the first two finishes
- tool_3_start_idx = execution_order.index("tool_3_start")
- assert (
- "tool_1_end" in execution_order[:tool_3_start_idx]
- or "tool_2_end" in execution_order[:tool_3_start_idx]
- )
-
- async def test_sequential_tool_forces_sequential_execution(self):
- """Test that sequential=True forces all tools to execute sequentially."""
- import asyncio
-
- from mcp_types import CreateMessageResultWithTools, ToolUseContent
-
- # A sequential=True tool in the batch forces the whole batch through
- # the plain for-loop path (see run.py's `requires_sequential`), so
- # ordering is guaranteed structurally and no real delay is needed.
- execution_order: list[str] = []
-
- async def normal_tool(x: int) -> int:
- """Normal tool."""
- execution_order.append("normal_start")
- await asyncio.sleep(0)
- execution_order.append("normal_end")
- return x * 2
-
- async def sequential_tool(y: int) -> int:
- """Sequential tool."""
- execution_order.append("sequential_start")
- await asyncio.sleep(0)
- execution_order.append("sequential_end")
- return y + 10
-
- call_count = 0
-
- def sampling_handler(
- messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext
- ) -> CreateMessageResultWithTools:
- nonlocal call_count
- call_count += 1
-
- if call_count == 1:
- return CreateMessageResultWithTools(
- role="assistant",
- content=[
- ToolUseContent(
- type="tool_use",
- id="call_1",
- name="normal_tool",
- input={"x": 5},
- ),
- ToolUseContent(
- type="tool_use",
- id="call_2",
- name="sequential_tool",
- input={"y": 3},
- ),
- ],
- model="test-model",
- stop_reason="toolUse",
- )
- else:
- return CreateMessageResultWithTools(
- role="assistant",
- content=[TextContent(type="text", text="Done!")],
- model="test-model",
- stop_reason="endTurn",
- )
-
- mcp = FastMCP(sampling_handler=sampling_handler)
-
- @mcp.tool
- async def test_tool(context: Context) -> str:
- # Create tools with sequential=True for one of them
- normal = SamplingTool.from_function(normal_tool, sequential=False)
- sequential = SamplingTool.from_function(sequential_tool, sequential=True)
-
- result = await context.sample(
- messages="Run tools",
- tools=[normal, sequential],
- tool_concurrency=0, # Request unlimited, but sequential tool forces sequential
- )
- return result.text or ""
-
- async with Client(mcp) as client:
- result = await client.call_tool("test_tool", {})
-
- assert result.data == "Done!"
- # Verify sequential execution: first tool must complete before second starts
- assert execution_order[0] in ["normal_start", "sequential_start"]
- assert execution_order[1] in ["normal_end", "sequential_end"]
- # Ensure the second tool starts after the first ends
- if execution_order[0] == "normal_start":
- assert execution_order[1] == "normal_end"
- assert execution_order[2] == "sequential_start"
- else:
- assert execution_order[1] == "sequential_end"
- assert execution_order[2] == "normal_start"
-
- async def test_concurrent_tool_execution_error_handling(self):
- """Test that errors are captured per-tool in parallel execution."""
- from mcp_types import (
- CreateMessageResultWithTools,
- ToolResultContent,
- ToolUseContent,
- )
-
- def good_tool() -> str:
- return "success"
-
- def bad_tool() -> str:
- raise ValueError("Tool error")
-
- messages_received: list[list[SamplingMessage]] = []
-
- def sampling_handler(
- messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext
- ) -> CreateMessageResultWithTools:
- messages_received.append(list(messages))
-
- if len(messages_received) == 1:
- return CreateMessageResultWithTools(
- role="assistant",
- content=[
- ToolUseContent(
- type="tool_use", id="call_1", name="good_tool", input={}
- ),
- ToolUseContent(
- type="tool_use", id="call_2", name="bad_tool", input={}
- ),
- ],
- model="test-model",
- stop_reason="toolUse",
- )
- else:
- return CreateMessageResultWithTools(
- role="assistant",
- content=[TextContent(type="text", text="Handled errors")],
- model="test-model",
- stop_reason="endTurn",
- )
-
- mcp = FastMCP(sampling_handler=sampling_handler)
-
- @mcp.tool
- async def test_tool(context: Context) -> str:
- result = await context.sample(
- messages="Run tools",
- tools=[good_tool, bad_tool],
- tool_concurrency=0, # Parallel execution
- )
- return result.text or ""
-
- async with Client(mcp) as client:
- result = await client.call_tool("test_tool", {})
-
- assert result.data == "Handled errors"
- # Check that tool results include both success and error
- tool_result_message = messages_received[1][-1]
- assert tool_result_message.role == "user"
- tool_results = cast(list[ToolResultContent], tool_result_message.content)
- assert len(tool_results) == 2
- # One should be success, one should be error
- assert any(not r.is_error for r in tool_results)
- assert any(r.is_error for r in tool_results)
-
- async def test_concurrent_tool_result_order_preserved(self):
- """Test that tool results maintain the same order as tool calls."""
- import asyncio
-
- from mcp_types import (
- CreateMessageResultWithTools,
- ToolResultContent,
- ToolUseContent,
- )
-
- # Chain events so the tools finish in a different order (2, 3, 1)
- # than they were called (1, 2, 3), without depending on real delays:
- # tool 2 finishes immediately and unblocks tool 3, which finishes and
- # unblocks tool 1. This only resolves if all three run concurrently -
- # under sequential execution tool 1 would deadlock waiting on tool 3,
- # which itself would never have been started yet.
- tool_2_done = asyncio.Event()
- tool_3_done = asyncio.Event()
-
- async def tool_with_delay(value: int) -> int:
- """Tool that finishes out of call order."""
- if value == 1:
- await asyncio.wait_for(tool_3_done.wait(), timeout=1.0)
- elif value == 3:
- await asyncio.wait_for(tool_2_done.wait(), timeout=1.0)
-
- if value == 2:
- tool_2_done.set()
- elif value == 3:
- tool_3_done.set()
- return value
-
- messages_received: list[list[SamplingMessage]] = []
-
- def sampling_handler(
- messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext
- ) -> CreateMessageResultWithTools:
- messages_received.append(list(messages))
-
- if len(messages_received) == 1:
- # Call order is 1, 2, 3 but they finish out of order (2, 3, 1)
- return CreateMessageResultWithTools(
- role="assistant",
- content=[
- ToolUseContent(
- type="tool_use",
- id="call_1",
- name="tool_with_delay",
- input={"value": 1},
- ),
- ToolUseContent(
- type="tool_use",
- id="call_2",
- name="tool_with_delay",
- input={"value": 2},
- ),
- ToolUseContent(
- type="tool_use",
- id="call_3",
- name="tool_with_delay",
- input={"value": 3},
- ),
- ],
- model="test-model",
- stop_reason="toolUse",
- )
- else:
- return CreateMessageResultWithTools(
- role="assistant",
- content=[TextContent(type="text", text="Done!")],
- model="test-model",
- stop_reason="endTurn",
- )
-
- mcp = FastMCP(sampling_handler=sampling_handler)
-
- @mcp.tool
- async def test_tool(context: Context) -> str:
- result = await context.sample(
- messages="Run tools",
- tools=[tool_with_delay],
- tool_concurrency=0, # Parallel execution
- )
- return result.text or ""
-
- async with Client(mcp) as client:
- result = await client.call_tool("test_tool", {})
-
- assert result.data == "Done!"
- # Check that results are in the correct order (1, 2, 3) despite finishing order (2, 3, 1)
- tool_result_message = messages_received[1][-1]
- tool_results = cast(list[ToolResultContent], tool_result_message.content)
- assert len(tool_results) == 3
- assert tool_results[0].tool_use_id == "call_1"
- assert tool_results[1].tool_use_id == "call_2"
- assert tool_results[2].tool_use_id == "call_3"
- # Check values are correct
- result_texts = [cast(TextContent, r.content[0]).text for r in tool_results]
- assert result_texts == ["1", "2", "3"]
diff --git a/tests/server/sampling/__init__.py b/tests/server/sampling/__init__.py
deleted file mode 100644
index e69de29bb..000000000
diff --git a/tests/server/sampling/test_prepare_tools.py b/tests/server/sampling/test_prepare_tools.py
deleted file mode 100644
index b0639b492..000000000
--- a/tests/server/sampling/test_prepare_tools.py
+++ /dev/null
@@ -1,111 +0,0 @@
-"""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] # ty:ignore[invalid-argument-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
deleted file mode 100644
index 792161ea3..000000000
--- a/tests/server/sampling/test_sampling_tool.py
+++ /dev/null
@@ -1,440 +0,0 @@
-"""Tests for SamplingTool."""
-
-import pytest
-from mcp.server.auth.middleware.auth_context import auth_context_var
-from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser
-
-from fastmcp.exceptions import AuthorizationError
-from fastmcp.server.auth import AccessToken, require_scopes
-from fastmcp.server.context import _current_transport
-from fastmcp.server.sampling import SamplingTool
-from fastmcp.tools.function_tool import FunctionTool
-from fastmcp.tools.tool_transform import ArgTransform, TransformedTool
-
-
-class TestSamplingToolFromFunction:
- """Tests for SamplingTool.from_function()."""
-
- def test_from_simple_function(self):
- def search(query: str) -> str:
- """Search the web."""
- return f"Results for: {query}"
-
- tool = SamplingTool.from_function(search)
-
- assert tool.name == "search"
- assert tool.description == "Search the web."
- assert "query" in tool.parameters.get("properties", {})
- assert tool.fn is search
-
- def test_from_function_with_overrides(self):
- def search(query: str) -> str:
- return f"Results for: {query}"
-
- tool = SamplingTool.from_function(
- search,
- name="web_search",
- description="Search the internet",
- )
-
- assert tool.name == "web_search"
- assert tool.description == "Search the internet"
-
- def test_from_lambda_requires_name(self):
- with pytest.raises(ValueError, match="must provide a name for lambda"):
- SamplingTool.from_function(lambda x: x)
-
- def test_from_lambda_with_name(self):
- tool = SamplingTool.from_function(lambda x: x * 2, name="double")
-
- assert tool.name == "double"
-
- def test_from_async_function(self):
- async def async_search(query: str) -> str:
- """Async search."""
- return f"Async results for: {query}"
-
- tool = SamplingTool.from_function(async_search)
-
- assert tool.name == "async_search"
- assert tool.description == "Async search."
-
- def test_multiple_parameters(self):
- def search(query: str, limit: int = 10, include_images: bool = False) -> str:
- """Search with options."""
- return f"Results for: {query}"
-
- tool = SamplingTool.from_function(search)
- props = tool.parameters.get("properties", {})
-
- assert "query" in props
- assert "limit" in props
- assert "include_images" in props
-
-
-class TestSamplingToolRun:
- """Tests for SamplingTool.run()."""
-
- async def test_run_sync_function(self):
- def add(a: int, b: int) -> int:
- """Add two numbers."""
- return a + b
-
- tool = SamplingTool.from_function(add)
- result = await tool.run({"a": 2, "b": 3})
- assert result == 5
-
- async def test_run_async_function(self):
- async def async_add(a: int, b: int) -> int:
- """Add two numbers asynchronously."""
- return a + b
-
- tool = SamplingTool.from_function(async_add)
- result = await tool.run({"a": 2, "b": 3})
- assert result == 5
-
- async def test_run_with_no_arguments(self):
- def get_value() -> str:
- """Return a fixed value."""
- return "hello"
-
- tool = SamplingTool.from_function(get_value)
- result = await tool.run()
- assert result == "hello"
-
- async def test_run_with_none_arguments(self):
- def get_value() -> str:
- """Return a fixed value."""
- return "hello"
-
- tool = SamplingTool.from_function(get_value)
- result = await tool.run(None)
- assert result == "hello"
-
-
-class TestSamplingToolSDKConversion:
- """Tests for SamplingTool._to_sdk_tool() internal method."""
-
- def test_to_sdk_tool(self):
- def search(query: str) -> str:
- """Search the web."""
- return f"Results for: {query}"
-
- tool = SamplingTool.from_function(search)
- sdk_tool = tool._to_sdk_tool()
-
- assert sdk_tool.name == "search"
- assert sdk_tool.description == "Search the web."
- assert "query" in sdk_tool.input_schema.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] # ty:ignore[invalid-argument-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] # ty:ignore[invalid-argument-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}
-
-
-class TestSamplingToolAuthEnforcement:
- """Tests that auth-protected tools enforce auth when used via sampling."""
-
- async def test_auth_protected_tool_blocked_without_token(self):
- """An auth-protected tool wrapped as SamplingTool must reject
- calls when no valid token is present in a non-stdio transport."""
-
- def secret_action() -> str:
- """Do something privileged."""
- return "secret"
-
- function_tool = FunctionTool.from_function(
- secret_action,
- auth=require_scopes("admin"),
- )
- sampling_tool = SamplingTool.from_callable_tool(function_tool)
-
- transport_token = _current_transport.set("streamable-http")
- try:
- with pytest.raises(AuthorizationError, match="insufficient permissions"):
- await sampling_tool.run({})
- finally:
- _current_transport.reset(transport_token)
-
- async def test_auth_protected_tool_blocked_with_wrong_scopes(self):
- """An auth-protected tool rejects calls when the token lacks
- the required scopes."""
-
- def secret_action() -> str:
- """Do something privileged."""
- return "secret"
-
- function_tool = FunctionTool.from_function(
- secret_action,
- auth=require_scopes("admin"),
- )
- sampling_tool = SamplingTool.from_callable_tool(function_tool)
-
- token = AccessToken(
- token="test",
- client_id="c",
- scopes=["read"],
- expires_at=None,
- claims={},
- )
- transport_token = _current_transport.set("streamable-http")
- auth_token = auth_context_var.set(AuthenticatedUser(token))
- try:
- with pytest.raises(AuthorizationError, match="insufficient permissions"):
- await sampling_tool.run({})
- finally:
- auth_context_var.reset(auth_token)
- _current_transport.reset(transport_token)
-
- async def test_auth_protected_tool_allowed_with_correct_scopes(self):
- """An auth-protected tool succeeds when the token has the
- required scopes."""
-
- def secret_action() -> str:
- """Do something privileged."""
- return "secret"
-
- function_tool = FunctionTool.from_function(
- secret_action,
- auth=require_scopes("admin"),
- )
- sampling_tool = SamplingTool.from_callable_tool(function_tool)
-
- token = AccessToken(
- token="test",
- client_id="c",
- scopes=["admin"],
- expires_at=None,
- claims={},
- )
- transport_token = _current_transport.set("streamable-http")
- auth_token = auth_context_var.set(AuthenticatedUser(token))
- try:
- result = await sampling_tool.run({})
- assert result == "secret"
- finally:
- auth_context_var.reset(auth_token)
- _current_transport.reset(transport_token)
-
- async def test_auth_protected_tool_skipped_on_stdio(self):
- """Auth checks are skipped for stdio transport, matching
- server dispatcher behavior."""
-
- def secret_action() -> str:
- """Do something privileged."""
- return "secret"
-
- function_tool = FunctionTool.from_function(
- secret_action,
- auth=require_scopes("admin"),
- )
- sampling_tool = SamplingTool.from_callable_tool(function_tool)
-
- transport_token = _current_transport.set("stdio")
- try:
- result = await sampling_tool.run({})
- assert result == "secret"
- finally:
- _current_transport.reset(transport_token)
-
- async def test_tool_without_auth_runs_normally(self):
- """Tools without auth still run without any auth context."""
-
- def public_action() -> str:
- """Do something public."""
- return "public"
-
- function_tool = FunctionTool.from_function(public_action)
- sampling_tool = SamplingTool.from_callable_tool(function_tool)
-
- result = await sampling_tool.run({})
- assert result == "public"
-
- async def test_auth_protected_transformed_tool_blocked(self):
- """Auth checks also apply to TransformedTools with auth."""
-
- def secret_action(x: int) -> int:
- """Privileged computation."""
- return x * 2
-
- function_tool = FunctionTool.from_function(
- secret_action,
- auth=require_scopes("compute"),
- )
- transformed_tool = TransformedTool.from_tool(
- function_tool,
- transform_args={"x": ArgTransform(name="value")},
- )
- sampling_tool = SamplingTool.from_callable_tool(transformed_tool)
-
- transport_token = _current_transport.set("streamable-http")
- try:
- with pytest.raises(AuthorizationError, match="insufficient permissions"):
- await sampling_tool.run({"value": 5})
- finally:
- _current_transport.reset(transport_token)
diff --git a/tests/server/telemetry/test_sampling_tracing.py b/tests/server/telemetry/test_sampling_tracing.py
deleted file mode 100644
index ffd3f19f8..000000000
--- a/tests/server/telemetry/test_sampling_tracing.py
+++ /dev/null
@@ -1,881 +0,0 @@
-"""Tracing coverage for sampling create_message and tool-execution spans.
-
-Regression focus: the `sampling create_message` span is created with
-`record_exception=False, set_status_on_exception=False` and records the
-exception manually in its `except` block. A failed sampling call must
-therefore produce exactly ONE exception event, not two.
-
-`ctx.sample` requires the server to send a request down to the client, which
-only the older protocol's back-channel supports, so every client below pins
-`mode="legacy"`.
-"""
-
-from __future__ import annotations
-
-import pytest
-from mcp_types import TextContent
-from opentelemetry.context import Context as OTelContext
-from opentelemetry.sdk.trace import (
- ReadableSpan,
- Span,
- SpanLimits,
- SpanProcessor,
- TracerProvider,
-)
-from opentelemetry.sdk.trace.export import SimpleSpanProcessor
-from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
-from opentelemetry.sdk.trace.sampling import Decision, Sampler, SamplingResult
-from opentelemetry.trace import StatusCode
-from opentelemetry.util import types as otel_types
-
-from fastmcp import Client, Context, FastMCP
-from fastmcp.client.sampling import RequestContext, SamplingMessage, SamplingParams
-
-
-class OnStartRecorder(SpanProcessor):
- def __init__(self) -> None:
- self.attributes: dict[str, dict[str, object]] = {}
-
- def on_start(self, span: Span, parent_context: OTelContext | None = None) -> None:
- self.attributes[span.name] = dict(span.attributes or {})
-
-
-class NonForwardingSampler(Sampler):
- """Samples every span but never forwards the attributes it was handed.
-
- See `tests/telemetry/test_span_attributes.py` for the full explanation:
- OTel's `Tracer.start_span` builds the finished span from
- `sampling_result.attributes`, not from the `attributes` kwarg passed to
- `start_as_current_span`, so a custom sampler like this one reproduces the
- regression where a non-forwarding sampler silently drops FastMCP's
- attributes.
- """
-
- def should_sample(
- self,
- parent_context: OTelContext | None,
- trace_id: int,
- name: str,
- kind: object = None,
- attributes: object = None,
- links: object = None,
- trace_state: object = None,
- ) -> SamplingResult:
- return SamplingResult(Decision.RECORD_AND_SAMPLE)
-
- def get_description(self) -> str:
- return "NonForwardingSampler"
-
-
-class RedactingSampler(Sampler):
- """Forwards the attributes it receives, but replaces `mcp.method.name`.
-
- See `tests/telemetry/test_span_attributes.py` for the full explanation:
- a sampler can legitimately keep an attribute FastMCP set while replacing
- its value (e.g. redacting the method name for privacy), and that decision
- must survive the restore step.
- """
-
- def should_sample(
- self,
- parent_context: OTelContext | None,
- trace_id: int,
- name: str,
- kind: object = None,
- attributes: otel_types.Attributes = None,
- links: object = None,
- trace_state: object = None,
- ) -> SamplingResult:
- forwarded = dict(attributes or {})
- forwarded["mcp.method.name"] = "REDACTED"
- return SamplingResult(Decision.RECORD_AND_SAMPLE, attributes=forwarded)
-
- def get_description(self) -> str:
- return "RedactingSampler"
-
-
-class AttributeAddingSampler(Sampler):
- """Forwards the attributes it receives unchanged and adds its own."""
-
- def should_sample(
- self,
- parent_context: OTelContext | None,
- trace_id: int,
- name: str,
- kind: object = None,
- attributes: otel_types.Attributes = None,
- links: object = None,
- trace_state: object = None,
- ) -> SamplingResult:
- forwarded = dict(attributes or {})
- forwarded["sampling.policy"] = "always_on"
- return SamplingResult(Decision.RECORD_AND_SAMPLE, attributes=forwarded)
-
- def get_description(self) -> str:
- return "AttributeAddingSampler"
-
-
-class FilteringSampler(Sampler):
- """Discards every attribute it receives and substitutes its own.
-
- See `tests/telemetry/test_span_attributes.py` for the full explanation:
- a sampler can legitimately supply only its own attributes (e.g. to strip
- component names or resource URIs for privacy or cardinality control),
- and the restore step must not reintroduce FastMCP's attributes in that
- case — doing so would defeat the filter.
- """
-
- def should_sample(
- self,
- parent_context: OTelContext | None,
- trace_id: int,
- name: str,
- kind: object = None,
- attributes: otel_types.Attributes = None,
- links: object = None,
- trace_state: object = None,
- ) -> SamplingResult:
- return SamplingResult(
- Decision.RECORD_AND_SAMPLE, attributes={"sampling.policy": "filtered"}
- )
-
- def get_description(self) -> str:
- return "FilteringSampler"
-
-
-@pytest.fixture
-def on_start_recorder(
- monkeypatch: pytest.MonkeyPatch,
- trace_exporter: InMemorySpanExporter,
-) -> OnStartRecorder:
- recorder = OnStartRecorder()
- provider = TracerProvider()
- provider.add_span_processor(recorder)
- provider.add_span_processor(SimpleSpanProcessor(trace_exporter))
- tracer = provider.get_tracer("test")
- monkeypatch.setattr("fastmcp.server.sampling.run.get_tracer", lambda: tracer)
- return recorder
-
-
-def _spans_named(exporter: InMemorySpanExporter, name: str):
- return [s for s in exporter.get_finished_spans() if s.name == name]
-
-
-def _exception_events(span):
- return [e for e in span.events if e.name == "exception"]
-
-
-class TestSamplingCreateMessageSpan:
- async def test_success_creates_span_with_attributes(
- self,
- trace_exporter: InMemorySpanExporter,
- on_start_recorder: OnStartRecorder,
- ):
- def sampling_handler(
- messages: list[SamplingMessage],
- params: SamplingParams,
- ctx: RequestContext,
- ) -> str:
- return "sampled-text"
-
- mcp = FastMCP("sampling-server")
-
- @mcp.tool
- async def ask(question: str, context: Context) -> str:
- result = await context.sample(messages=question)
- return result.text or ""
-
- async with Client(
- mcp, mode="legacy", sampling_handler=sampling_handler
- ) as client:
- await client.call_tool("ask", {"question": "hi"})
-
- spans = _spans_named(trace_exporter, "sampling create_message")
- assert len(spans) == 1
- span = spans[0]
- assert span.attributes is not None
- assert span.attributes["mcp.method.name"] == "sampling/createMessage"
- assert span.attributes["fastmcp.server.name"] == "sampling-server"
- assert on_start_recorder.attributes["sampling create_message"] == {
- "mcp.method.name": "sampling/createMessage",
- "fastmcp.server.name": "sampling-server",
- }
- # Success path must not record any exception.
- assert _exception_events(span) == []
- assert span.status.status_code != StatusCode.ERROR
-
- async def test_failure_records_exception_exactly_once(
- self, trace_exporter: InMemorySpanExporter
- ):
- """Regression: span created with record_exception=False so the manual
- record_exception in the except block fires exactly once (no duplicate
- exception events from OTel auto-recording on `with` exit)."""
-
- def sampling_handler(
- messages: list[SamplingMessage],
- params: SamplingParams,
- ctx: RequestContext,
- ) -> str:
- raise RuntimeError("sampling boom")
-
- mcp = FastMCP("sampling-server")
-
- @mcp.tool
- async def ask(question: str, context: Context) -> str:
- result = await context.sample(messages=question)
- return result.text or ""
-
- with pytest.raises(Exception):
- async with Client(
- mcp, mode="legacy", sampling_handler=sampling_handler
- ) as client:
- await client.call_tool("ask", {"question": "hi"})
-
- spans = _spans_named(trace_exporter, "sampling create_message")
- assert len(spans) == 1
- span = spans[0]
- assert span.status.status_code == StatusCode.ERROR
- assert span.attributes is not None
- assert "error.type" in span.attributes
- # The whole point of the fix: exactly one exception event.
- assert len(_exception_events(span)) == 1
-
-
-class TestSamplingToolSpan:
- async def test_tool_error_span_records_exception_once(
- self,
- trace_exporter: InMemorySpanExporter,
- on_start_recorder: OnStartRecorder,
- ):
- from mcp_types import CreateMessageResultWithTools, ToolUseContent
-
- call_count = 0
-
- def boom_tool() -> str:
- raise ValueError("tool exploded")
-
- def sampling_handler(
- messages: list[SamplingMessage],
- params: SamplingParams,
- ctx: RequestContext,
- ) -> CreateMessageResultWithTools:
- nonlocal call_count
- call_count += 1
- if call_count == 1:
- return CreateMessageResultWithTools(
- role="assistant",
- content=[
- ToolUseContent(
- type="tool_use",
- id="call_1",
- name="boom_tool",
- input={},
- )
- ],
- model="test-model",
- stop_reason="toolUse",
- )
- return CreateMessageResultWithTools(
- role="assistant",
- content=[TextContent(type="text", text="done")],
- model="test-model",
- stop_reason="endTurn",
- )
-
- mcp = FastMCP(sampling_handler=sampling_handler)
-
- @mcp.tool
- async def driver(context: Context) -> str:
- result = await context.sample(messages="go", tools=[boom_tool])
- return result.text or ""
-
- async with Client(mcp) as client:
- await client.call_tool("driver", {})
-
- spans = _spans_named(trace_exporter, "sampling tool boom_tool")
- assert len(spans) == 1
- span = spans[0]
- assert span.status.status_code == StatusCode.ERROR
- assert span.attributes is not None
- assert span.attributes["gen_ai.tool.name"] == "boom_tool"
- assert on_start_recorder.attributes["sampling tool boom_tool"] == {
- "gen_ai.tool.name": "boom_tool",
- "fastmcp.tool.use_id": "call_1",
- }
- assert "error.type" in span.attributes
- # Tool spans catch-and-convert (no re-raise), so OTel auto-recording
- # never fires; the manual record_exception must fire exactly once.
- assert len(_exception_events(span)) == 1
-
-
-class TestAttributesSurviveANonForwardingSampler:
- """Regression: `sampling create_message` and `sampling tool ...` spans
- must keep FastMCP's attributes even when the configured Sampler doesn't
- forward the `attributes` it was handed to its `SamplingResult`.
- """
-
- @pytest.fixture
- def non_forwarding_recorder(
- self,
- monkeypatch: pytest.MonkeyPatch,
- trace_exporter: InMemorySpanExporter,
- ) -> OnStartRecorder:
- recorder = OnStartRecorder()
- provider = TracerProvider(sampler=NonForwardingSampler())
- provider.add_span_processor(recorder)
- provider.add_span_processor(SimpleSpanProcessor(trace_exporter))
- tracer = provider.get_tracer("test")
- monkeypatch.setattr("fastmcp.server.sampling.run.get_tracer", lambda: tracer)
- return recorder
-
- async def test_create_message_span_keeps_attributes(
- self,
- trace_exporter: InMemorySpanExporter,
- non_forwarding_recorder: OnStartRecorder,
- ):
- def sampling_handler(
- messages: list[SamplingMessage],
- params: SamplingParams,
- ctx: RequestContext,
- ) -> str:
- return "sampled-text"
-
- mcp = FastMCP("sampling-server")
-
- @mcp.tool
- async def ask(question: str, context: Context) -> str:
- result = await context.sample(messages=question)
- return result.text or ""
-
- async with Client(
- mcp, mode="legacy", sampling_handler=sampling_handler
- ) as client:
- await client.call_tool("ask", {"question": "hi"})
-
- spans = _spans_named(trace_exporter, "sampling create_message")
- assert len(spans) == 1
- span = spans[0]
- assert span.attributes is not None
- assert span.attributes["mcp.method.name"] == "sampling/createMessage"
- assert span.attributes["fastmcp.server.name"] == "sampling-server"
- # The sampler never forwards attributes, so on_start legitimately sees
- # none — this documents that limitation rather than asserting around it.
- assert non_forwarding_recorder.attributes["sampling create_message"] == {}
-
- async def test_sampling_tool_span_keeps_attributes(
- self,
- trace_exporter: InMemorySpanExporter,
- non_forwarding_recorder: OnStartRecorder,
- ):
- from mcp_types import CreateMessageResultWithTools, ToolUseContent
-
- def echo_tool(text: str) -> str:
- return text
-
- call_count = 0
-
- def sampling_handler(
- messages: list[SamplingMessage],
- params: SamplingParams,
- ctx: RequestContext,
- ) -> CreateMessageResultWithTools:
- nonlocal call_count
- call_count += 1
- if call_count == 1:
- return CreateMessageResultWithTools(
- role="assistant",
- content=[
- ToolUseContent(
- type="tool_use",
- id="call_1",
- name="echo_tool",
- input={"text": "hi"},
- )
- ],
- model="test-model",
- stop_reason="toolUse",
- )
- return CreateMessageResultWithTools(
- role="assistant",
- content=[TextContent(type="text", text="done")],
- model="test-model",
- stop_reason="endTurn",
- )
-
- mcp = FastMCP(sampling_handler=sampling_handler)
-
- @mcp.tool
- async def driver(context: Context) -> str:
- result = await context.sample(messages="go", tools=[echo_tool])
- return result.text or ""
-
- async with Client(mcp) as client:
- await client.call_tool("driver", {})
-
- spans = _spans_named(trace_exporter, "sampling tool echo_tool")
- assert len(spans) == 1
- span = spans[0]
- assert span.attributes is not None
- assert span.attributes["gen_ai.tool.name"] == "echo_tool"
- assert span.attributes["fastmcp.tool.use_id"] == "call_1"
- # The sampler never forwards attributes, so on_start legitimately sees
- # none — this documents that limitation rather than asserting around it.
- assert non_forwarding_recorder.attributes["sampling tool echo_tool"] == {}
-
-
-class TestAttributeRestoreRespectsSampler:
- """Restoring missing attributes must not clobber attributes a sampler
- deliberately kept and modified, must coexist with attributes a sampler
- adds of its own, and must not fire at all when a sampler supplies only
- its own attributes and drops FastMCP's entirely."""
-
- @pytest.fixture
- def redacting_tracer(
- self, monkeypatch: pytest.MonkeyPatch, trace_exporter: InMemorySpanExporter
- ) -> None:
- provider = TracerProvider(sampler=RedactingSampler())
- provider.add_span_processor(SimpleSpanProcessor(trace_exporter))
- tracer = provider.get_tracer("test")
- monkeypatch.setattr("fastmcp.server.sampling.run.get_tracer", lambda: tracer)
-
- @pytest.fixture
- def attribute_adding_tracer(
- self, monkeypatch: pytest.MonkeyPatch, trace_exporter: InMemorySpanExporter
- ) -> None:
- provider = TracerProvider(sampler=AttributeAddingSampler())
- provider.add_span_processor(SimpleSpanProcessor(trace_exporter))
- tracer = provider.get_tracer("test")
- monkeypatch.setattr("fastmcp.server.sampling.run.get_tracer", lambda: tracer)
-
- @pytest.fixture
- def filtering_tracer(
- self, monkeypatch: pytest.MonkeyPatch, trace_exporter: InMemorySpanExporter
- ) -> None:
- provider = TracerProvider(sampler=FilteringSampler())
- provider.add_span_processor(SimpleSpanProcessor(trace_exporter))
- tracer = provider.get_tracer("test")
- monkeypatch.setattr("fastmcp.server.sampling.run.get_tracer", lambda: tracer)
-
- async def test_create_message_span_keeps_redacted_value(
- self,
- trace_exporter: InMemorySpanExporter,
- redacting_tracer: None,
- ):
- def sampling_handler(
- messages: list[SamplingMessage],
- params: SamplingParams,
- ctx: RequestContext,
- ) -> str:
- return "sampled-text"
-
- mcp = FastMCP("sampling-server")
-
- @mcp.tool
- async def ask(question: str, context: Context) -> str:
- result = await context.sample(messages=question)
- return result.text or ""
-
- async with Client(
- mcp, mode="legacy", sampling_handler=sampling_handler
- ) as client:
- await client.call_tool("ask", {"question": "hi"})
-
- spans = _spans_named(trace_exporter, "sampling create_message")
- assert len(spans) == 1
- span = spans[0]
- assert span.attributes is not None
- # The redaction survives — it must not be overwritten by a restore.
- assert span.attributes["mcp.method.name"] == "REDACTED"
- # Attributes the sampler didn't touch are still present.
- assert span.attributes["fastmcp.server.name"] == "sampling-server"
-
- async def test_create_message_span_keeps_sampler_added_attribute(
- self,
- trace_exporter: InMemorySpanExporter,
- attribute_adding_tracer: None,
- ):
- def sampling_handler(
- messages: list[SamplingMessage],
- params: SamplingParams,
- ctx: RequestContext,
- ) -> str:
- return "sampled-text"
-
- mcp = FastMCP("sampling-server")
-
- @mcp.tool
- async def ask(question: str, context: Context) -> str:
- result = await context.sample(messages=question)
- return result.text or ""
-
- async with Client(
- mcp, mode="legacy", sampling_handler=sampling_handler
- ) as client:
- await client.call_tool("ask", {"question": "hi"})
-
- spans = _spans_named(trace_exporter, "sampling create_message")
- assert len(spans) == 1
- span = spans[0]
- assert span.attributes is not None
- assert span.attributes["sampling.policy"] == "always_on"
- assert span.attributes["mcp.method.name"] == "sampling/createMessage"
- assert span.attributes["fastmcp.server.name"] == "sampling-server"
-
- async def test_tool_span_keeps_redacted_value(
- self,
- trace_exporter: InMemorySpanExporter,
- redacting_tracer: None,
- ):
- from mcp_types import CreateMessageResultWithTools, ToolUseContent
-
- def echo_tool(text: str) -> str:
- return text
-
- call_count = 0
-
- def sampling_handler(
- messages: list[SamplingMessage],
- params: SamplingParams,
- ctx: RequestContext,
- ) -> CreateMessageResultWithTools:
- nonlocal call_count
- call_count += 1
- if call_count == 1:
- return CreateMessageResultWithTools(
- role="assistant",
- content=[
- ToolUseContent(
- type="tool_use",
- id="call_1",
- name="echo_tool",
- input={"text": "hi"},
- )
- ],
- model="test-model",
- stop_reason="toolUse",
- )
- return CreateMessageResultWithTools(
- role="assistant",
- content=[TextContent(type="text", text="done")],
- model="test-model",
- stop_reason="endTurn",
- )
-
- mcp = FastMCP(sampling_handler=sampling_handler)
-
- @mcp.tool
- async def driver(context: Context) -> str:
- result = await context.sample(messages="go", tools=[echo_tool])
- return result.text or ""
-
- async with Client(mcp) as client:
- await client.call_tool("driver", {})
-
- spans = _spans_named(trace_exporter, "sampling tool echo_tool")
- assert len(spans) == 1
- span = spans[0]
- assert span.attributes is not None
- # RedactingSampler only touches mcp.method.name, which this span
- # doesn't set — its attributes are forwarded unchanged, confirming
- # the restore step doesn't disturb them either.
- assert span.attributes["gen_ai.tool.name"] == "echo_tool"
- assert span.attributes["fastmcp.tool.use_id"] == "call_1"
-
- async def test_tool_span_keeps_sampler_added_attribute(
- self,
- trace_exporter: InMemorySpanExporter,
- attribute_adding_tracer: None,
- ):
- from mcp_types import CreateMessageResultWithTools, ToolUseContent
-
- def echo_tool(text: str) -> str:
- return text
-
- call_count = 0
-
- def sampling_handler(
- messages: list[SamplingMessage],
- params: SamplingParams,
- ctx: RequestContext,
- ) -> CreateMessageResultWithTools:
- nonlocal call_count
- call_count += 1
- if call_count == 1:
- return CreateMessageResultWithTools(
- role="assistant",
- content=[
- ToolUseContent(
- type="tool_use",
- id="call_1",
- name="echo_tool",
- input={"text": "hi"},
- )
- ],
- model="test-model",
- stop_reason="toolUse",
- )
- return CreateMessageResultWithTools(
- role="assistant",
- content=[TextContent(type="text", text="done")],
- model="test-model",
- stop_reason="endTurn",
- )
-
- mcp = FastMCP(sampling_handler=sampling_handler)
-
- @mcp.tool
- async def driver(context: Context) -> str:
- result = await context.sample(messages="go", tools=[echo_tool])
- return result.text or ""
-
- async with Client(mcp) as client:
- await client.call_tool("driver", {})
-
- spans = _spans_named(trace_exporter, "sampling tool echo_tool")
- assert len(spans) == 1
- span = spans[0]
- assert span.attributes is not None
- assert span.attributes["sampling.policy"] == "always_on"
- assert span.attributes["gen_ai.tool.name"] == "echo_tool"
- assert span.attributes["fastmcp.tool.use_id"] == "call_1"
-
- async def test_create_message_span_filtered_attributes_are_not_restored(
- self,
- trace_exporter: InMemorySpanExporter,
- filtering_tracer: None,
- ):
- """Regression: a sampler that intentionally supplies only its own
- attributes must not have FastMCP's attributes restored on top."""
-
- def sampling_handler(
- messages: list[SamplingMessage],
- params: SamplingParams,
- ctx: RequestContext,
- ) -> str:
- return "sampled-text"
-
- mcp = FastMCP("sampling-server")
-
- @mcp.tool
- async def ask(question: str, context: Context) -> str:
- result = await context.sample(messages=question)
- return result.text or ""
-
- async with Client(
- mcp, mode="legacy", sampling_handler=sampling_handler
- ) as client:
- await client.call_tool("ask", {"question": "hi"})
-
- spans = _spans_named(trace_exporter, "sampling create_message")
- assert len(spans) == 1
- span = spans[0]
- assert dict(span.attributes or {}) == {"sampling.policy": "filtered"}
-
- async def test_tool_span_filtered_attributes_are_not_restored(
- self,
- trace_exporter: InMemorySpanExporter,
- filtering_tracer: None,
- ):
- """Regression: a sampler that intentionally supplies only its own
- attributes must not have FastMCP's attributes restored on top."""
- from mcp_types import CreateMessageResultWithTools, ToolUseContent
-
- def echo_tool(text: str) -> str:
- return text
-
- call_count = 0
-
- def sampling_handler(
- messages: list[SamplingMessage],
- params: SamplingParams,
- ctx: RequestContext,
- ) -> CreateMessageResultWithTools:
- nonlocal call_count
- call_count += 1
- if call_count == 1:
- return CreateMessageResultWithTools(
- role="assistant",
- content=[
- ToolUseContent(
- type="tool_use",
- id="call_1",
- name="echo_tool",
- input={"text": "hi"},
- )
- ],
- model="test-model",
- stop_reason="toolUse",
- )
- return CreateMessageResultWithTools(
- role="assistant",
- content=[TextContent(type="text", text="done")],
- model="test-model",
- stop_reason="endTurn",
- )
-
- mcp = FastMCP(sampling_handler=sampling_handler)
-
- @mcp.tool
- async def driver(context: Context) -> str:
- result = await context.sample(messages="go", tools=[echo_tool])
- return result.text or ""
-
- async with Client(mcp) as client:
- await client.call_tool("driver", {})
-
- spans = _spans_named(trace_exporter, "sampling tool echo_tool")
- assert len(spans) == 1
- span = spans[0]
- assert dict(span.attributes or {}) == {"sampling.policy": "filtered"}
-
-
-class TestRestoreDoesNotChurnAttributeLimitEvictions:
- """Regression: under a low `OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT`, the restore
- step in `fastmcp.server.sampling.run` must not reinsert an attribute the
- SDK's bounded attribute map already evicted.
-
- See `tests/telemetry/test_span_attributes.py::
- test_restore_does_not_churn_sdk_attribute_limit_evictions` for the full
- explanation: an evicted key looks identical to a sampler-omitted one from
- inside the restore helper, so reinserting it churns which attributes the
- SDK ultimately retains and inflates `dropped_attributes` beyond what the
- limit alone already cost. This proves the same discriminator holds
- end-to-end through the real sampling call path, not just at the helper
- level.
- """
-
- @staticmethod
- async def _run_create_message(
- span_limits: SpanLimits, *, stub_restore: bool
- ) -> ReadableSpan:
- with pytest.MonkeyPatch.context() as mp:
- exporter = InMemorySpanExporter()
- provider = TracerProvider(span_limits=span_limits)
- provider.add_span_processor(SimpleSpanProcessor(exporter))
- tracer = provider.get_tracer("test")
- mp.setattr("fastmcp.server.sampling.run.get_tracer", lambda: tracer)
- if stub_restore:
- mp.setattr(
- "fastmcp.server.sampling.run.restore_dropped_attributes",
- lambda span, attrs: None,
- )
-
- def sampling_handler(
- messages: list[SamplingMessage],
- params: SamplingParams,
- ctx: RequestContext,
- ) -> str:
- return "sampled-text"
-
- mcp = FastMCP("sampling-server")
-
- @mcp.tool
- async def ask(question: str, context: Context) -> str:
- result = await context.sample(messages=question)
- return result.text or ""
-
- async with Client(
- mcp, mode="legacy", sampling_handler=sampling_handler
- ) as client:
- await client.call_tool("ask", {"question": "hi"})
-
- spans = _spans_named(exporter, "sampling create_message")
- assert len(spans) == 1
- return spans[0]
-
- @staticmethod
- async def _run_tool_span(
- span_limits: SpanLimits, *, stub_restore: bool
- ) -> ReadableSpan:
- from mcp_types import CreateMessageResultWithTools, ToolUseContent
-
- with pytest.MonkeyPatch.context() as mp:
- exporter = InMemorySpanExporter()
- provider = TracerProvider(span_limits=span_limits)
- provider.add_span_processor(SimpleSpanProcessor(exporter))
- tracer = provider.get_tracer("test")
- mp.setattr("fastmcp.server.sampling.run.get_tracer", lambda: tracer)
- if stub_restore:
- mp.setattr(
- "fastmcp.server.sampling.run.restore_dropped_attributes",
- lambda span, attrs: None,
- )
-
- def echo_tool(text: str) -> str:
- return text
-
- call_count = 0
-
- def sampling_handler(
- messages: list[SamplingMessage],
- params: SamplingParams,
- ctx: RequestContext,
- ) -> CreateMessageResultWithTools:
- nonlocal call_count
- call_count += 1
- if call_count == 1:
- return CreateMessageResultWithTools(
- role="assistant",
- content=[
- ToolUseContent(
- type="tool_use",
- id="call_1",
- name="echo_tool",
- input={"text": "hi"},
- )
- ],
- model="test-model",
- stop_reason="toolUse",
- )
- return CreateMessageResultWithTools(
- role="assistant",
- content=[TextContent(type="text", text="done")],
- model="test-model",
- stop_reason="endTurn",
- )
-
- mcp = FastMCP(sampling_handler=sampling_handler)
-
- @mcp.tool
- async def driver(context: Context) -> str:
- result = await context.sample(messages="go", tools=[echo_tool])
- return result.text or ""
-
- async with Client(mcp) as client:
- await client.call_tool("driver", {})
-
- spans = _spans_named(exporter, "sampling tool echo_tool")
- assert len(spans) == 1
- return spans[0]
-
- async def test_create_message_span(self, monkeypatch: pytest.MonkeyPatch):
- # Two attributes on this span (`mcp.method.name`, `fastmcp.server.name`)
- # — a limit of 1 guarantees eviction.
- monkeypatch.setenv("OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT", "1")
- span_limits = SpanLimits()
- assert span_limits.max_span_attributes == 1
-
- baseline = await self._run_create_message(span_limits, stub_restore=True)
- # The whole test is moot if the limit didn't actually bind.
- assert baseline.dropped_attributes > 0
-
- with_restore = await self._run_create_message(span_limits, stub_restore=False)
-
- assert dict(with_restore.attributes or {}) == dict(baseline.attributes or {})
- assert with_restore.dropped_attributes == baseline.dropped_attributes
-
- async def test_tool_span(self, monkeypatch: pytest.MonkeyPatch):
- # Two attributes on this span (`gen_ai.tool.name`, `fastmcp.tool.use_id`)
- # — a limit of 1 guarantees eviction.
- monkeypatch.setenv("OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT", "1")
- span_limits = SpanLimits()
- assert span_limits.max_span_attributes == 1
-
- baseline = await self._run_tool_span(span_limits, stub_restore=True)
- # The whole test is moot if the limit didn't actually bind.
- assert baseline.dropped_attributes > 0
-
- with_restore = await self._run_tool_span(span_limits, stub_restore=False)
-
- assert dict(with_restore.attributes or {}) == dict(baseline.attributes or {})
- assert with_restore.dropped_attributes == baseline.dropped_attributes
From 704b74b3ab9a27b66ffaf52e01d1bb0f539f1bee Mon Sep 17 00:00:00 2001
From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>
Date: Sun, 26 Jul 2026 14:38:26 -0400
Subject: [PATCH 02/15] Update tests for the removed sampling and roots server
API
---
tests/client/client/test_error_handling.py | 35 ---
tests/client/test_roots.py | 30 +-
tests/client/test_sampling.py | 227 ++++-----------
tests/conformance/server.py | 10 -
tests/server/middleware/test_middleware.py | 4 -
.../middleware/test_middleware_nested.py | 10 +-
.../providers/proxy/test_proxy_client.py | 48 +++-
tests/server/test_context.py | 23 --
tests/server/test_protocol_eras.py | 263 ++----------------
9 files changed, 142 insertions(+), 508 deletions(-)
diff --git a/tests/client/client/test_error_handling.py b/tests/client/client/test_error_handling.py
index 7bb500d8b..8f40e2ce6 100644
--- a/tests/client/client/test_error_handling.py
+++ b/tests/client/client/test_error_handling.py
@@ -21,7 +21,6 @@ from fastmcp.client import Client
from fastmcp.client.mixins.tools import _parse_call_tool_result
from fastmcp.client.transports import FastMCPTransport
from fastmcp.exceptions import PromptError, ResourceError, ToolError
-from fastmcp.server.sampling.run import SamplingTool, execute_tools
from fastmcp.server.server import FastMCP
@@ -416,37 +415,3 @@ class TestLogLevel:
for record in caplog.records
)
- async def test_sampling_tool_error_with_custom_log_level(self, caplog):
- """ToolError with custom log_level in sampling should log at specified level."""
-
- async def custom_level_sampling_tool(x: int) -> int:
- raise ToolError("Expected sampling error", log_level=logging.WARNING)
-
- tool = SamplingTool.from_function(custom_level_sampling_tool)
- tool_use = ToolUseContent(
- type="tool_use",
- id="test-id",
- name="custom_level_sampling_tool",
- input={"x": 42},
- )
-
- with caplog.at_level(logging.WARNING):
- results = await execute_tools(
- tool_calls=[tool_use],
- tool_map={"custom_level_sampling_tool": tool},
- mask_error_details=False,
- )
-
- assert len(results) == 1
- assert results[0].is_error
- assert "Expected sampling error" in results[0].content[0].text # type: ignore
- assert any(
- "Error calling sampling tool" in record.message
- and record.levelname == "WARNING"
- for record in caplog.records
- )
- assert not any(
- "Error calling sampling tool" in record.message
- and record.levelname == "ERROR"
- for record in caplog.records
- )
diff --git a/tests/client/test_roots.py b/tests/client/test_roots.py
index e71f0c849..609bbdfd5 100644
--- a/tests/client/test_roots.py
+++ b/tests/client/test_roots.py
@@ -1,16 +1,23 @@
import pytest
+from mcp_types import Root
from fastmcp import Client, Context, FastMCP
@pytest.fixture
def fastmcp_server():
+ """A server that issues a handshake-era `roots/list` request.
+
+ `Context` has no `list_roots()` — server-initiated requests are not part of
+ FastMCP's server API. This server reaches the SDK session directly to stand
+ in for a legacy upstream, so the client's `roots=` handling stays covered.
+ """
mcp = FastMCP()
@mcp.tool
async def list_roots(context: Context) -> list[str]:
- roots = await context.list_roots()
- return [str(r.uri) for r in roots]
+ result = await context.session.list_roots()
+ return [str(r.uri) for r in result.roots]
return mcp
@@ -36,10 +43,27 @@ class TestClientRoots:
@pytest.mark.parametrize("roots", [["file://x/y/z", "file://x/y/z"]])
async def test_valid_roots(self, fastmcp_server: FastMCP, roots: list[str]):
- # ctx.list_roots is a legacy-era server-initiated feature.
+ # `roots/list` is a server-initiated request, so it only exists on the
+ # handshake era; SEP-2577 removed it from the modern protocol.
async with Client(fastmcp_server, mode="legacy", roots=roots) as client:
result = await client.call_tool("list_roots", {})
assert result.data == [
"file://x/y/z",
"file://x/y/z",
]
+
+ async def test_roots_handler_answers_a_legacy_server(self, fastmcp_server: FastMCP):
+ """A callable `roots=` handler still answers a legacy server's request."""
+ calls: list[object] = []
+
+ async def roots_handler(ctx) -> list[Root]:
+ calls.append(ctx)
+ return [Root(uri="file://from/handler")] # ty: ignore[invalid-argument-type]
+
+ async with Client(
+ fastmcp_server, mode="legacy", roots=roots_handler
+ ) as client:
+ result = await client.call_tool("list_roots", {})
+
+ assert len(calls) == 1
+ assert result.data == ["file://from/handler"]
diff --git a/tests/client/test_sampling.py b/tests/client/test_sampling.py
index 98ec74494..e82e71093 100644
--- a/tests/client/test_sampling.py
+++ b/tests/client/test_sampling.py
@@ -1,71 +1,95 @@
import json
-from typing import cast
-from unittest.mock import AsyncMock
+import mcp_types
import pytest
from mcp_types import TextContent
from pydantic_core import to_json
from fastmcp import Client, Context, FastMCP
from fastmcp.client.sampling import RequestContext, SamplingMessage, SamplingParams
-from fastmcp.server.sampling import SamplingResult, SamplingTool
from fastmcp.utilities.types import Image
+async def _sample(
+ context: Context,
+ messages: list[SamplingMessage],
+ *,
+ system_prompt: str | None = None,
+) -> str:
+ """Issue a handshake-era `sampling/createMessage` request from a server.
+
+ `Context` has no `sample()` — server-initiated sampling is not part of
+ FastMCP's server API. These tests cover the *client* side, which must keep
+ answering a legacy server, so the stand-in server reaches the SDK session
+ directly.
+ """
+ result = await context.session.create_message(
+ messages=messages,
+ system_prompt=system_prompt,
+ max_tokens=512,
+ related_request_id=context.origin_request_id,
+ )
+ assert isinstance(result.content, TextContent)
+ return result.content.text
+
+
@pytest.fixture
def fastmcp_server():
mcp = FastMCP()
@mcp.tool
async def simple_sample(message: str, context: Context) -> str:
- result = await context.sample("Hello, world!")
- assert isinstance(result, SamplingResult)
- assert result.text is not None
- return result.text
+ return await _sample(
+ context,
+ [
+ SamplingMessage(
+ role="user",
+ content=TextContent(type="text", text="Hello, world!"),
+ )
+ ],
+ )
@mcp.tool
async def sample_with_system_prompt(message: str, context: Context) -> str:
- result = await context.sample("Hello, world!", system_prompt="You love FastMCP")
- assert isinstance(result, SamplingResult)
- assert result.text is not None
- return result.text
+ return await _sample(
+ context,
+ [
+ SamplingMessage(
+ role="user",
+ content=TextContent(type="text", text="Hello, world!"),
+ )
+ ],
+ system_prompt="You love FastMCP",
+ )
@mcp.tool
async def sample_with_messages(message: str, context: Context) -> str:
- result = await context.sample(
+ return await _sample(
+ context,
[
- "Hello!",
SamplingMessage(
- content=TextContent(
- type="text", text="How can I assist you today?"
- ),
- role="assistant",
+ role="user", content=TextContent(type="text", text="Hello!")
),
- ]
+ SamplingMessage(
+ role="assistant",
+ content=TextContent(type="text", text="How can I assist you today?"),
+ ),
+ ],
)
- assert isinstance(result, SamplingResult)
- assert result.text is not None
- return result.text
@mcp.tool
async def sample_with_image(image_bytes: bytes, context: Context) -> str:
image = Image(data=image_bytes)
-
- result = await context.sample(
+ return await _sample(
+ context,
[
SamplingMessage(
content=TextContent(type="text", text="What's in this image?"),
role="user",
),
- SamplingMessage(
- content=image.to_image_content(),
- role="user",
- ),
- ]
+ SamplingMessage(content=image.to_image_content(), role="user"),
+ ],
)
- assert isinstance(result, SamplingResult)
- assert result.text is not None
- return result.text
return mcp
@@ -123,26 +147,6 @@ async def test_sampling_with_messages(fastmcp_server: FastMCP):
assert result.data == "I need to think."
-async def test_sampling_with_fallback(fastmcp_server: FastMCP):
- openai_sampling_handler = AsyncMock(return_value="But I need to think")
-
- fastmcp_server = FastMCP(
- sampling_handler=openai_sampling_handler,
- )
-
- @fastmcp_server.tool
- async def sample_with_fallback(context: Context) -> str:
- sampling_result = await context.sample("Do not think.")
- return cast(TextContent, sampling_result).text
-
- client = Client(fastmcp_server)
-
- async with client:
- call_tool_result = await client.call_tool("sample_with_fallback")
-
- assert call_tool_result.data == "But I need to think"
-
-
async def test_sampling_with_image(fastmcp_server: FastMCP):
def sampling_handler(
messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext
@@ -193,8 +197,6 @@ class TestSamplingDefaultCapabilities:
{"sampling": {"tools": {}}}, ensuring compatibility with servers
that don't recognize the tools sub-field (e.g. older Java MCP SDK).
"""
- import mcp_types
-
server = FastMCP()
def handler(
@@ -209,8 +211,6 @@ class TestSamplingDefaultCapabilities:
async def test_set_sampling_callback_default_capabilities_omit_tools(self):
"""set_sampling_callback should also default to no tools capability."""
- import mcp_types
-
server = FastMCP()
client = Client(server)
client.set_sampling_callback(lambda msgs, params, ctx: "ok")
@@ -220,8 +220,6 @@ class TestSamplingDefaultCapabilities:
async def test_explicit_tools_capability_is_preserved(self):
"""Explicitly passing tools capability should be respected."""
- import mcp_types
-
server = FastMCP()
def handler(
@@ -238,118 +236,3 @@ class TestSamplingDefaultCapabilities:
caps = client._session_kwargs["sampling_capabilities"]
assert isinstance(caps, mcp_types.SamplingCapability)
assert caps.tools is not None
-
-
-class TestSamplingWithTools:
- """Tests for sampling with tools functionality."""
-
- async def test_sampling_with_tools_requires_capability(self):
- """Test that sampling with tools raises error when client lacks capability."""
- import mcp_types
-
- from fastmcp.exceptions import ToolError
-
- server = FastMCP()
-
- def search(query: str) -> str:
- """Search the web."""
- return f"Results for: {query}"
-
- @server.tool
- async def sample_with_tool(context: Context) -> str:
- # This should fail because the client doesn't advertise tools capability
- result = await context.sample(
- messages="Search for Python tutorials",
- tools=[search],
- )
- return str(result)
-
- def sampling_handler(
- messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext
- ) -> str:
- return "Response"
-
- # Explicitly disable tools capability by passing SamplingCapability without tools
- async with Client(
- server,
- mode="legacy",
- sampling_handler=sampling_handler,
- sampling_capabilities=mcp_types.SamplingCapability(), # No tools
- ) as client:
- with pytest.raises(ToolError, match="sampling.tools capability"):
- await client.call_tool("sample_with_tool", {})
-
- async def test_sampling_with_tools_fallback_handler_can_return_string(self):
- """Test that fallback handler can return a string even when tools are provided.
-
- The LLM might choose not to use any tools and just return a text response.
- """
- # This handler returns a string - valid even when tools are provided
- simple_handler = AsyncMock(return_value="Direct response without tools")
-
- mcp = FastMCP(sampling_handler=simple_handler)
-
- def search(query: str) -> str:
- """Search the web."""
- return f"Results for: {query}"
-
- @mcp.tool
- async def sample_with_tool(context: Context) -> str:
- result = await context.sample(
- messages="Search for Python tutorials",
- tools=[search],
- )
- return result.text or "no text"
-
- # Client without sampling handler - will use server's fallback
- async with Client(mcp) as client:
- result = await client.call_tool("sample_with_tool", {})
-
- # Handler returned string directly, which is treated as final text response
- assert result.data == "Direct response without tools"
-
- def test_sampling_tool_schema(self):
- """Test that SamplingTool generates correct schema."""
-
- def search(query: str, limit: int = 10) -> str:
- """Search the web for results."""
- return f"Results for: {query}"
-
- tool = SamplingTool.from_function(search)
- assert tool.name == "search"
- assert tool.description == "Search the web for results."
- assert "query" in tool.parameters.get("properties", {})
- assert "limit" in tool.parameters.get("properties", {})
-
- async def test_sampling_tool_run(self):
- """Test that SamplingTool.run() executes correctly."""
-
- def add(a: int, b: int) -> int:
- """Add two numbers."""
- return a + b
-
- tool = SamplingTool.from_function(add)
- result = await tool.run({"a": 5, "b": 3})
- assert result == 8
-
- async def test_sampling_tool_run_async(self):
- """Test that SamplingTool.run() works with async functions."""
-
- async def async_multiply(a: int, b: int) -> int:
- """Multiply two numbers."""
- return a * b
-
- tool = SamplingTool.from_function(async_multiply)
- result = await tool.run({"a": 4, "b": 7})
- assert result == 28
-
- def test_tool_choice_parameter(self):
- """Test that tool_choice parameter accepts string literals."""
- from fastmcp.server.context import ToolChoiceOption
-
- # Verify ToolChoiceOption type accepts the valid string values
- choices: list[ToolChoiceOption] = ["auto", "required", "none"]
- assert len(choices) == 3
- assert "auto" in choices
- assert "required" in choices
- assert "none" in choices
diff --git a/tests/conformance/server.py b/tests/conformance/server.py
index 9a6a97c28..ac6b20aef 100644
--- a/tests/conformance/server.py
+++ b/tests/conformance/server.py
@@ -134,16 +134,6 @@ async def test_tool_with_progress(ctx: Context) -> str:
return "Progress test complete."
-@server.tool(name="test_sampling")
-async def test_sampling(prompt: str, ctx: Context) -> str:
- """Requests LLM sampling via the client."""
- result = await ctx.sample(
- messages=[prompt],
- result_type=str,
- )
- return f"Sampling result: {result}"
-
-
class _UserInfo(BaseModel):
username: str
email: str
diff --git a/tests/server/middleware/test_middleware.py b/tests/server/middleware/test_middleware.py
index c625a7d55..715413017 100644
--- a/tests/server/middleware/test_middleware.py
+++ b/tests/server/middleware/test_middleware.py
@@ -147,10 +147,6 @@ def mcp_server(recording_middleware):
async def log_tool(context: Context) -> None:
await context.info(message="test log")
- @mcp.tool
- async def sample_tool(context: Context) -> None:
- await context.sample("hello")
-
mcp.add_middleware(recording_middleware)
# Register a progress notification handler (v2 API: (ctx, params)).
diff --git a/tests/server/middleware/test_middleware_nested.py b/tests/server/middleware/test_middleware_nested.py
index cc2eea6c0..374a07cc8 100644
--- a/tests/server/middleware/test_middleware_nested.py
+++ b/tests/server/middleware/test_middleware_nested.py
@@ -149,10 +149,6 @@ def mcp_server(recording_middleware):
async def log_tool(context: Context) -> None:
await context.info(message="test log")
- @mcp.tool
- async def sample_tool(context: Context) -> None:
- await context.sample("hello")
-
mcp.add_middleware(recording_middleware)
# Register a progress notification handler (v2 API: (ctx, params)).
@@ -205,10 +201,6 @@ class TestNestedMiddlewareHooks:
async def log_tool(context: Context) -> None:
await context.info(message="test log")
- @mcp.tool
- async def sample_tool(context: Context) -> None:
- await context.sample("hello")
-
mcp.add_middleware(nested_middleware)
return mcp
@@ -510,7 +502,7 @@ class TestProxyServer:
async with Client(proxy_server) as client:
await client.list_tools()
- assert TAGS == [{"add-tool"}, set(), set(), set()]
+ assert TAGS == [{"add-tool"}, set(), set()]
class TestToolCallDenial:
diff --git a/tests/server/providers/proxy/test_proxy_client.py b/tests/server/providers/proxy/test_proxy_client.py
index bc3b861fc..7638ef1b9 100644
--- a/tests/server/providers/proxy/test_proxy_client.py
+++ b/tests/server/providers/proxy/test_proxy_client.py
@@ -8,6 +8,7 @@ from mcp_types import (
LoggingLevel,
ModelHint,
ModelPreferences,
+ Root,
TextContent,
)
from pydantic import BaseModel, Field
@@ -58,6 +59,34 @@ class TestProxyClientEraDefault:
assert cast(Client, factory()).mode == "auto"
+async def _backend_list_roots(context: Context) -> list[Root]:
+ """Issue a handshake-era `roots/list` from a backend server.
+
+ `Context` has no `list_roots()`: server-initiated requests are not part of
+ FastMCP's server API. These helpers reach the SDK session directly to stand
+ in for a legacy upstream, which is the only thing the proxy relay forwards.
+ """
+ result = await context.session.list_roots()
+ return result.roots
+
+
+async def _backend_sample(context: Context) -> str:
+ """Issue a handshake-era `sampling/createMessage` from a backend server."""
+ result = await context.session.create_message(
+ messages=[
+ SamplingMessage(
+ role="user", content=TextContent(type="text", text="Hello, world!")
+ )
+ ],
+ system_prompt="You love FastMCP",
+ temperature=0.5,
+ max_tokens=100,
+ model_preferences=ModelPreferences(hints=[ModelHint(name="gpt-4o")]),
+ related_request_id=context.origin_request_id,
+ )
+ return result.content.text if isinstance(result.content, TextContent) else ""
+
+
@pytest.fixture
def fastmcp_server():
mcp = FastMCP("TestServer")
@@ -68,21 +97,13 @@ def fastmcp_server():
@mcp.tool
async def list_roots(context: Context) -> list[str]:
- roots = await context.list_roots()
- return [str(r.uri) for r in roots]
+ return [str(r.uri) for r in await _backend_list_roots(context)]
@mcp.tool
async def sampling(
context: Context,
) -> str:
- result = await context.sample(
- "Hello, world!",
- system_prompt="You love FastMCP",
- temperature=0.5,
- max_tokens=100,
- model_preferences="gpt-4o",
- )
- return result.text or ""
+ return await _backend_sample(context)
@dataclass
class Person:
@@ -514,17 +535,16 @@ def roots_backend_server():
@mcp.resource("data://roots")
async def roots_resource(context: Context) -> list[str]:
- roots = await context.list_roots()
- return [str(r.uri) for r in roots]
+ return [str(r.uri) for r in await _backend_list_roots(context)]
@mcp.resource("data://roots/{key}")
async def roots_template(key: str, context: Context) -> str:
- roots = await context.list_roots()
+ roots = await _backend_list_roots(context)
return ", ".join(f"{key}:{r.uri}" for r in roots)
@mcp.prompt
async def roots_prompt(context: Context) -> str:
- roots = await context.list_roots()
+ roots = await _backend_list_roots(context)
return ", ".join(str(r.uri) for r in roots)
return mcp
diff --git a/tests/server/test_context.py b/tests/server/test_context.py
index 384a307ac..e37e5531b 100644
--- a/tests/server/test_context.py
+++ b/tests/server/test_context.py
@@ -8,7 +8,6 @@ from fastmcp.server.context import (
reset_transport,
set_transport,
)
-from fastmcp.server.sampling.run import _parse_model_preferences
from fastmcp.server.server import FastMCP
@@ -17,28 +16,6 @@ def context():
return Context(fastmcp=FastMCP())
-class TestParseModelPreferences:
- def test_parse_model_preferences_string(self, context):
- mp = _parse_model_preferences("claude-haiku-4-5")
- assert isinstance(mp, ModelPreferences)
- assert mp.hints is not None
- assert mp.hints[0].name == "claude-haiku-4-5"
-
- def test_parse_model_preferences_list(self, context):
- mp = _parse_model_preferences(["claude-haiku-4-5", "claude"])
- assert isinstance(mp, ModelPreferences)
- assert mp.hints is not None
- assert [h.name for h in mp.hints] == ["claude-haiku-4-5", "claude"]
-
- def test_parse_model_preferences_object(self, context):
- obj = ModelPreferences(hints=[])
- assert _parse_model_preferences(obj) is obj
-
- def test_parse_model_preferences_invalid_type(self, context):
- with pytest.raises(ValueError):
- _parse_model_preferences(model_preferences=123) # pyright: ignore[reportArgumentType] # type: ignore[invalid-argument-type] # ty:ignore[invalid-argument-type]
-
-
class TestSessionId:
def test_session_id_with_http_headers(self, context):
"""Test that session_id returns the value from mcp-session-id header."""
diff --git a/tests/server/test_protocol_eras.py b/tests/server/test_protocol_eras.py
index 972ba1733..47bb0ce75 100644
--- a/tests/server/test_protocol_eras.py
+++ b/tests/server/test_protocol_eras.py
@@ -30,7 +30,7 @@ from mcp.shared.exceptions import MCPError
from pydantic import FileUrl
from fastmcp import Client as FastMCPClient
-from fastmcp import Context, FastMCP, settings
+from fastmcp import Context, FastMCP
from fastmcp.exceptions import PromptError, ResourceError
from fastmcp.server.elicitation import AcceptedElicitation
from fastmcp.server.middleware import Middleware
@@ -215,16 +215,6 @@ def push_server() -> FastMCP:
assert isinstance(result, AcceptedElicitation)
return f"elicited {result.data}"
- @mcp.tool
- async def do_sample(ctx: Context) -> str:
- result = await ctx.sample("hello")
- return f"sampled {result.text}"
-
- @mcp.tool
- async def do_list_roots(ctx: Context) -> str:
- roots = await ctx.list_roots()
- return f"roots {[str(r.uri) for r in roots]}"
-
@mcp.tool
async def do_log(ctx: Context) -> str:
await ctx.info("a log line")
@@ -264,31 +254,12 @@ async def test_elicit_works_on_legacy(push_server):
assert _texts(result.content) == ["elicited 7"]
-async def test_sample_works_on_legacy(push_server):
- async with SDKClient(
- _server(push_server), mode="legacy", sampling_callback=_sampling_cb
- ) as client:
- result = await client.call_tool("do_sample", {})
- assert result.is_error is False
- assert _texts(result.content) == ["sampled sampled-text"]
-
-
-async def test_list_roots_works_on_legacy(push_server):
- async with SDKClient(
- _server(push_server), mode="legacy", list_roots_callback=_roots_cb
- ) as client:
- result = await client.call_tool("do_list_roots", {})
- assert result.is_error is False
- assert _texts(result.content) == ["roots ['file:///tmp']"]
-
-
@pytest.mark.parametrize("mode", MODERN_MODES)
-@pytest.mark.parametrize("tool", ["do_elicit", "do_sample", "do_list_roots"])
-async def test_push_features_degrade_on_modern(push_server, mode, tool):
- """Server-initiated requests (elicitation/sampling/roots) are removed at
- 2026-07-28 (SEP-2577), so a tool that uses them must degrade to a surfaced
- error rather than hang or crash the connection. This asserts the
- degradation happens and reaches the caller as an isError result.
+async def test_elicit_degrades_on_modern(push_server, mode):
+ """Elicitation is a server-initiated request, removed at 2026-07-28
+ (SEP-2577), so a tool that uses it must degrade to a surfaced error rather
+ than hang or crash the connection. The connection survives: a subsequent
+ normal call still works.
"""
async with SDKClient(
_server(push_server),
@@ -297,230 +268,46 @@ async def test_push_features_degrade_on_modern(push_server, mode, tool):
sampling_callback=_sampling_cb,
list_roots_callback=_roots_cb,
) as client:
- result = await client.call_tool(tool, {})
+ result = await client.call_tool("do_elicit", {})
assert result.is_error is True
- # A subsequent normal call still works: the connection survived the
- # per-request failure rather than tearing down the whole session.
log_result = await client.call_tool("do_log", {})
assert log_result.is_error is False
-async def test_list_roots_degradation_message_is_clear_on_modern(push_server):
- """`ctx.list_roots()` sends with no related_request_id, so the SDK selects
- the connection's no-back-channel outbound and raises the self-explanatory
- NoBackChannelError. This is the *good* degradation message and we assert it.
- """
- async with SDKClient(_server(push_server), mode="2026-07-28") as client:
- result = await client.call_tool("do_list_roots", {})
- assert result.is_error is True
- message = " ".join(_texts(result.content)).lower()
- assert "back-channel" in message and "server-initiated" in message
-
-
-@pytest.mark.parametrize("tool", ["do_elicit", "do_sample"])
-async def test_elicit_sample_degradation_message_is_clear_on_modern(push_server, tool):
- """FastMCP era-gates elicit/sample: on a 2026-07-28 connection they raise a
- clear, era-aware error before hitting the wire, instead of the SDK's opaque
- 'Method not found' (sdk-feedback.md #10). Both messages name the removed
- server-initiated capability so the caller knows why the request degraded.
+async def test_elicit_degradation_message_is_clear_on_modern(push_server):
+ """FastMCP era-gates elicit: on a 2026-07-28 connection it raises a clear,
+ era-aware error before hitting the wire, instead of the SDK's opaque
+ 'Method not found' (sdk-feedback.md #10).
"""
async with SDKClient(
_server(push_server),
mode="2026-07-28",
elicitation_callback=_accept_elicit,
- sampling_callback=_sampling_cb,
) as client:
- result = await client.call_tool(tool, {})
- assert result.is_error is True
- message = " ".join(_texts(result.content)).lower()
- assert "server-initiated" in message
-
-
-# ---------------------------------------------------------------------------
-# 3a-bis. Server-configured sampling handler answers WITHOUT the client
-# back-channel, so ctx.sample()/ctx.sample_step() must keep working on modern
-# connections. The era-gate only fires when nothing can serve the request.
-# ---------------------------------------------------------------------------
-
-
-def _handler_server(behavior) -> FastMCP:
- """A server whose sampling is answered by a server-side handler."""
-
- def sampling_handler(messages, params, ctx) -> str:
- return "handler-answer"
-
- mcp = FastMCP("handler", sampling_handler=sampling_handler)
- if behavior is not None:
- mcp.sampling_handler_behavior = behavior
-
- @mcp.tool
- async def do_sample(ctx: Context) -> str:
- result = await ctx.sample("hello")
- return f"sampled {result.text}"
-
- @mcp.tool
- async def do_sample_step(ctx: Context) -> str:
- step = await ctx.sample_step("hello")
- return f"stepped {step.text}"
-
- return mcp
-
-
-@pytest.mark.parametrize("mode", MODERN_MODES)
-@pytest.mark.parametrize("behavior", ["always", "fallback"])
-@pytest.mark.parametrize("method", ["do_sample", "do_sample_step"])
-async def test_server_sampling_handler_works_on_modern(mode, behavior, method):
- """A server-side sampling handler answers entirely server-side, so it works
- on modern (2026-07-28) connections regardless of behavior. The era-gate must
- NOT block these — nothing touches the removed client back-channel. Crucially,
- 'fallback' must go straight to the handler (no bare client-attempt failure)."""
- server = _handler_server(behavior)
- async with SDKClient(_server(server), mode=mode) as client:
- result = await client.call_tool(method, {})
- assert result.is_error is False
- assert "handler-answer" in " ".join(_texts(result.content))
-
-
-@pytest.mark.parametrize("behavior", ["always", "fallback"])
-@pytest.mark.parametrize("method", ["do_sample", "do_sample_step"])
-async def test_server_sampling_handler_works_on_legacy(behavior, method):
- """Handshake-era behavior is unchanged: the server-side handler still answers
- on legacy connections."""
- server = _handler_server(behavior)
- async with SDKClient(_server(server), mode="legacy") as client:
- result = await client.call_tool(method, {})
- assert result.is_error is False
- assert "handler-answer" in " ".join(_texts(result.content))
-
-
-@pytest.mark.parametrize("mode", MODERN_MODES)
-@pytest.mark.parametrize("method", ["do_sample", "do_sample_step"])
-async def test_sampling_without_handler_still_era_gated_on_modern(
- push_server, mode, method
-):
- """With no server-side handler configured, the request would hit the removed
- client back-channel, so the clear era error still fires on modern."""
- # push_server only defines do_sample; add a do_sample_step twin inline.
- mcp = FastMCP("no-handler")
-
- @mcp.tool
- async def do_sample(ctx: Context) -> str:
- result = await ctx.sample("hello")
- return f"sampled {result.text}"
-
- @mcp.tool
- async def do_sample_step(ctx: Context) -> str:
- step = await ctx.sample_step("hello")
- return f"stepped {step.text}"
-
- async with SDKClient(
- _server(mcp), mode=mode, sampling_callback=_sampling_cb
- ) as client:
- result = await client.call_tool(method, {})
+ result = await client.call_tool("do_elicit", {})
assert result.is_error is True
assert "server-initiated" in " ".join(_texts(result.content)).lower()
# ---------------------------------------------------------------------------
-# 3b. Sampling deprecation warning (SEP-2577): ctx.sample/ctx.sample_step warn
+# 3a-bis. Sampling and roots are not in the server API at all
# ---------------------------------------------------------------------------
-@pytest.fixture
-def reset_sample_warn_flag():
- """Reset the process-wide warn-once flag so a warning can be observed."""
- import fastmcp.server.context as context_module
-
- original = set(context_module._sample_deprecation_warned)
- context_module._sample_deprecation_warned.clear()
- try:
- yield
- finally:
- context_module._sample_deprecation_warned.clear()
- context_module._sample_deprecation_warned.update(original)
+@pytest.mark.parametrize("name", ["sample", "sample_step", "list_roots"])
+def test_removed_server_initiated_methods_are_absent(name):
+ """FastMCP 4 targets the modern protocol, so the capabilities SEP-2577
+ removed are not in the server-authoring API — not deprecated, not era-gated,
+ absent. A server that calls them fails at attribute lookup, in every era.
+ """
+ assert not hasattr(Context, name)
-@pytest.mark.parametrize("method", ["do_sample", "do_sample_step"])
-async def test_sampling_emits_deprecation_warning(reset_sample_warn_flag, method):
- """`ctx.sample()` and `ctx.sample_step()` emit a FastMCPDeprecationWarning
- naming SEP-2577 and the server-side-LLM migration path."""
- from fastmcp.exceptions import FastMCPDeprecationWarning
-
- mcp = FastMCP("warn")
-
- @mcp.tool
- async def do_sample(ctx: Context) -> str:
- await ctx.sample("hello")
- return "ok"
-
- @mcp.tool
- async def do_sample_step(ctx: Context) -> str:
- await ctx.sample_step("hello")
- return "ok"
-
- with pytest.warns(FastMCPDeprecationWarning, match="SEP-2577"):
- async with SDKClient(
- _server(mcp), mode="legacy", sampling_callback=_sampling_cb
- ) as client:
- await client.call_tool(method, {})
-
-
-async def test_sampling_deprecation_warning_fires_once_per_process(
- reset_sample_warn_flag,
-):
- """The deprecation warning is warn-once: a second sample call in the same
- process does not re-warn."""
- from fastmcp.exceptions import FastMCPDeprecationWarning
-
- mcp = FastMCP("warn-once")
-
- @mcp.tool
- async def do_sample(ctx: Context) -> str:
- await ctx.sample("hello")
- return "ok"
-
- with pytest.warns(FastMCPDeprecationWarning):
- async with SDKClient(
- _server(mcp), mode="legacy", sampling_callback=_sampling_cb
- ) as client:
- await client.call_tool("do_sample", {})
-
- import warnings as _warnings
-
- with _warnings.catch_warnings():
- _warnings.simplefilter("error", FastMCPDeprecationWarning)
- async with SDKClient(
- _server(mcp), mode="legacy", sampling_callback=_sampling_cb
- ) as client:
- result = await client.call_tool("do_sample", {})
- assert result.is_error is False
-
-
-async def test_sampling_deprecation_warning_suppressible_via_settings(
- reset_sample_warn_flag, monkeypatch
-):
- """Setting `deprecation_warnings=False` suppresses the sampling warning,
- matching the house pattern for every other FastMCP deprecation."""
- import warnings as _warnings
-
- from fastmcp.exceptions import FastMCPDeprecationWarning
-
- monkeypatch.setattr(settings, "deprecation_warnings", False)
-
- mcp = FastMCP("no-warn")
-
- @mcp.tool
- async def do_sample(ctx: Context) -> str:
- await ctx.sample("hello")
- return "ok"
-
- with _warnings.catch_warnings():
- _warnings.simplefilter("error", FastMCPDeprecationWarning)
- async with SDKClient(
- _server(mcp), mode="legacy", sampling_callback=_sampling_cb
- ) as client:
- result = await client.call_tool("do_sample", {})
- assert result.is_error is False
+@pytest.mark.parametrize("kwarg", ["sampling_handler", "sampling_handler_behavior"])
+def test_server_sampling_handler_kwargs_are_rejected(kwarg):
+ """The server-side sampling handler existed only to answer `ctx.sample()`."""
+ with pytest.raises(TypeError, match="SEP-2577"):
+ FastMCP("gone", **{kwarg: None})
@pytest.mark.parametrize("mode", MODERN_MODES)
From fca339084b2e6b79173bad1a6fe2fd8b3c774e2f Mon Sep 17 00:00:00 2001
From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>
Date: Sun, 26 Jul 2026 14:39:31 -0400
Subject: [PATCH 03/15] Era-gate client.set_logging_level on modern connections
---
fastmcp_slim/fastmcp/client/client.py | 17 ++++++++++++++++-
tests/server/test_protocol_eras.py | 22 ++++++++--------------
2 files changed, 24 insertions(+), 15 deletions(-)
diff --git a/fastmcp_slim/fastmcp/client/client.py b/fastmcp_slim/fastmcp/client/client.py
index 0bf9b9b54..42192830a 100644
--- a/fastmcp_slim/fastmcp/client/client.py
+++ b/fastmcp_slim/fastmcp/client/client.py
@@ -1351,7 +1351,22 @@ class Client(
)
async def set_logging_level(self, level: mcp_types.LoggingLevel) -> None:
- """Send a logging/setLevel request."""
+ """Send a logging/setLevel request.
+
+ Handshake-era servers only. `logging/setLevel` asks the server to
+ remember a level for the rest of the session, and the 2026-07-28
+ protocol has no session to remember it in — the method is absent from
+ that era's registry. Log *notifications* are unaffected: they ride the
+ request's own stream, so a server's `ctx.info()` still reaches you.
+ Filter by level on the receiving side instead, in your `log_handler`.
+ """
+ if self.protocol_version in MODERN_PROTOCOL_VERSIONS:
+ raise RuntimeError(
+ "logging/setLevel is not available on MCP 2026-07-28 "
+ "connections; the method requires per-session server state that "
+ "the modern protocol does not have. Filter incoming log "
+ "messages by level in your log_handler instead."
+ )
# Deprecated upstream in SDK v2 but deliberately kept per compat directive;
# removed with the multi-round-trip follow-up.
await self._await_with_session_monitoring(
diff --git a/tests/server/test_protocol_eras.py b/tests/server/test_protocol_eras.py
index 47bb0ce75..90cae9bb4 100644
--- a/tests/server/test_protocol_eras.py
+++ b/tests/server/test_protocol_eras.py
@@ -353,23 +353,17 @@ async def test_session_id_access_does_not_crash_on_modern(sessionless_server, mo
@pytest.mark.parametrize("mode", MODERN_MODES)
-async def test_set_logging_level_does_not_crash_on_modern(sessionless_server, mode):
- """logging/setLevel is a session-id-keyed, deprecated-at-2026 operation.
- On a sessionless modern in-memory connection it must degrade cleanly (either
- succeed as a no-op or raise a surfaced MCPError) rather than crash the
- connection. Characterization: capture whichever the current contract is.
+async def test_set_logging_level_is_era_gated_on_modern(sessionless_server, mode):
+ """`logging/setLevel` asks a server to remember a level for the session, and
+ the modern era has no session — the method is absent from its registry. The
+ FastMCP client says so plainly instead of no-opping or surfacing the SDK's
+ opaque "Method not found", and the connection stays usable afterward.
"""
- async with SDKClient(_server(sessionless_server), mode=mode) as client:
- outcome: str
- try:
- await client.set_logging_level("debug") # ty: ignore[deprecated]
- outcome = "ok"
- except MCPError:
- outcome = "mcperror"
- # Either way the connection is still usable afterward.
+ async with FastMCPClient(sessionless_server, mode=mode) as client:
+ with pytest.raises(RuntimeError, match="2026-07-28"):
+ await client.set_logging_level("debug")
result = await client.call_tool("read_session_id", {})
assert result.is_error is False
- assert outcome in {"ok", "mcperror"}
# ---------------------------------------------------------------------------
From 1a43a3b8e962d9697e05f37e24322da08a2ac783 Mon Sep 17 00:00:00 2001
From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>
Date: Sun, 26 Jul 2026 14:43:18 -0400
Subject: [PATCH 04/15] Document that server-initiated sampling and roots are
not in FastMCP 4
---
dev-docs/v4-notes/change-register.md | 33 +++++++---
dev-docs/v4-notes/feature-program.md | 16 ++---
dev-docs/v4-notes/index.md | 2 +-
docs/clients/sampling.mdx | 4 +-
docs/deployment/http.mdx | 2 +-
.../upgrading/from-fastmcp-3.mdx | 34 +++++-----
docs/getting-started/whats-new.mdx | 2 +-
docs/servers/context.mdx | 15 ++---
docs/servers/elicitation.mdx | 2 +-
docs/servers/sampling.mdx | 66 +++++++++++++++++++
docs/servers/server.mdx | 12 +---
11 files changed, 126 insertions(+), 62 deletions(-)
create mode 100644 docs/servers/sampling.mdx
diff --git a/dev-docs/v4-notes/change-register.md b/dev-docs/v4-notes/change-register.md
index 1e791f735..af6d36049 100644
--- a/dev-docs/v4-notes/change-register.md
+++ b/dev-docs/v4-notes/change-register.md
@@ -432,31 +432,44 @@ The push-style Context features that require the server to call back into the cl
| `ctx.info` / logging notifications | Supported | Supported |
| Tools, resources, prompts, completions | Supported | Supported |
| `ctx.elicit` (imperative) | Supported | Not on the back-channel — use [elicitation on the modern protocol](https://gofastmcp.com/servers/elicitation#elicitation-on-the-modern-protocol) |
-| `ctx.sample` / `ctx.sample_step` | Supported (deprecated) | Removed — call an LLM server-side |
-| `ctx.list_roots` | Supported | Via the [guard pattern](https://gofastmcp.com/servers/elicitation#elicitation-on-the-modern-protocol) |
+| `ctx.sample` / `ctx.sample_step` | Not in the API | Not in the API — call an LLM server-side |
+| `ctx.list_roots` | Not in the API | Not in the API — take paths as arguments, or use the [guard pattern](https://gofastmcp.com/servers/elicitation#elicitation-on-the-modern-protocol) |
+| `client.set_logging_level()` | Supported | Raises — `logging/setLevel` is absent from the era's registry |
| Background tasks (`task=True`) | Runs synchronously — never tasked | Supported via the tasks extension |
-Tools that rely on `ctx.elicit` or `ctx.list_roots` continue to work against clients on the session-based eras. On the modern era, elicitation is reachable through the multi-round "guard" pattern instead (a tool returns an `InputRequiredResult`; see the New entry below). Sampling is the exception: it is deprecated on every era and will not return on modern connections (see the Deprecated entry below).
+Tools that rely on `ctx.elicit` continue to work against clients on the session-based eras; on the modern era, elicitation is reachable through the multi-round "guard" pattern instead (a tool returns an `InputRequiredResult`; see the New entry below). Sampling and roots have no era row to speak of — they left the server API entirely (see the Removed entry below).
-Ordinary `ctx.info` usage emits an SDK-level `MCPDeprecationWarning` ("The logging capability is deprecated as of 2026-07-28 (SEP-2577)"). That warning comes from the SDK, not FastMCP, and is benign — logging keeps working on session-based connections per the matrix. `ctx.sample`/`ctx.sample_step` additionally emit a FastMCP-owned `FastMCPDeprecationWarning` (see below). The upgrade guide calls both out explicitly.
+Ordinary `ctx.info` usage emits an SDK-level `MCPDeprecationWarning` ("The logging capability is deprecated as of 2026-07-28 (SEP-2577)"). That warning comes from the SDK, not FastMCP, and is benign — logging *notifications* ride the request's own stream and work on every era, including the modern one. The upgrade guide calls it out explicitly.
Wire interop across the transition is verified: a 3.4.3 client against a v4 server and a v4 client against a 3.4.3 server are bidirectionally clean across 9 operations over HTTP (WS2).
*Verify:* `docs/getting-started/upgrading/from-fastmcp-3.mdx` (the published matrix and SDK-warning note), `tests/server/test_protocol_eras.py`.
-### Sampling deprecated, era-gated — Deprecated
+### Server-initiated sampling and roots removed from the server API — Breaking
-`ctx.sample()` and `ctx.sample_step()` are deprecated and slated for removal in a future FastMCP release. Server-initiated sampling relies on the `createMessage` back-channel that SEP-2577 removed from the wire as of `2026-07-28`, and unlike elicitation it has no multi-round-trip replacement (the agentic loop would exhaust the round-trip budget). Both methods now emit a `FastMCPDeprecationWarning` once per process (gated on `settings.deprecation_warnings`), and on a `2026-07-28` connection they raise a clear `ToolError` before touching the wire. The client-side sampling handler infrastructure (anthropic/openai/google_genai) is retained for future MRTR work and is not deprecated. The migration is to call an LLM directly from your server rather than borrowing the client's model.
+FastMCP 4 is a modern MCP toolkit, so the capabilities the modern protocol removed are not in its server-authoring API. `Context.sample()`, `Context.sample_step()`, and `Context.list_roots()` are gone, along with the whole `fastmcp/server/sampling/` package (`SamplingTool`, `SampleStep`, `SamplingResult`, the tool loop, structured-result sampling) and the server-side handler arguments `FastMCP(sampling_handler=..., sampling_handler_behavior=...)`. These were previously deprecated-and-era-gated; they are now absent. Calling them raises `AttributeError`; the constructor kwargs raise a `TypeError` naming SEP-2577 and the migration.
-The dead TODO at `server/context.py` (a background-task sampling relay that was never built) is removed: that relay is not being built, so the note is gone rather than left as a promise.
+The motivating failure is that the gate had become the default experience. `Client` now defaults to `mode="auto"`, which negotiates `2026-07-28` against a FastMCP server, so an unmodified `ctx.sample()` server failed on an ordinary client connection. Four shipped examples (`examples/sampling/`) were broken by that flip; they are deleted rather than ported, and remain available on `release/3.x`.
-*Verify:* `fastmcp_slim/fastmcp/server/context.py` (`_warn_sampling_deprecated`, `_is_modern_protocol`, the `sample`/`sample_step` gates), `docs/servers/sampling.mdx` (deprecation banner), `tests/server/test_protocol_eras.py` (warning + era-gate tests).
+Server-initiated sampling and roots are *requests* — the server sends one and blocks for the answer — which needs a back-channel the sessionless protocol does not have. Unlike elicitation, generation has no multi-round-trip replacement (an agentic loop would exhaust the round-trip budget). The migration is to call an LLM directly from the server for generation, and to accept paths as tool arguments (or via `InputRequiredResult.input_requests`, which still carries a `ListRootsRequest`) for roots.
+
+**What is deliberately kept.** Client-side `Client(sampling_handler=..., roots=...)` and the provider handlers (anthropic/openai/google_genai) stay: a FastMCP client must still answer a legacy server's requests, and removing them would break interop with older servers. `docs/clients/sampling.mdx` and `docs/clients/roots.mdx` stay as real documentation. Logging is untouched — `ctx.log`/`info`/`debug`/`warning`/`error` are notifications that ride the request's own stream and work on every era.
+
+**Proxy relay.** `ProxyClient`'s default `roots` and `sampling_handler` are client-side handlers that relay a handshake-era backend's requests to the proxy's own front client. They are kept, because a proxy is a client to its backend and falls squarely under the interop guarantee above. They no longer route through the removed `Context` methods: both now call the SDK session directly (`ctx.session.list_roots()` / `ctx.session.create_message()`), an internal path with no public authoring surface. The relay is reachable only when both legs speak the handshake era.
+
+*Verify:* `fastmcp_slim/fastmcp/server/context.py` (no `sample`/`sample_step`/`list_roots`), `fastmcp_slim/fastmcp/server/server.py` (`_REMOVED_KWARGS`), `fastmcp_slim/fastmcp/server/providers/proxy.py` (`default_proxy_roots_handler`, `default_proxy_sampling_handler`), `docs/servers/sampling.mdx` (rewritten in place as the explainer), `tests/server/test_protocol_eras.py` (`test_removed_server_initiated_methods_are_absent`), `tests/server/providers/proxy/test_proxy_client.py` (relay still green).
+
+### `client.set_logging_level()` era-gated — Breaking (modern era)
+
+`logging/setLevel` asks a server to remember a level for the rest of the session, and it is absent from the `2026-07-28` method registry because that era has no session to remember it in. It previously surfaced the SDK's opaque "Method not found". `Client.set_logging_level()` now raises a `RuntimeError` naming the era and pointing at level-filtering in the client's `log_handler`; it is unchanged on handshake-era connections. It is never a silent no-op.
+
+*Verify:* `fastmcp_slim/fastmcp/client/client.py` (`set_logging_level`), `tests/server/test_protocol_eras.py` (`test_set_logging_level_is_era_gated_on_modern`).
### Push-feature degradation quality — Resolved (was sdk-feedback #10)
-On a `2026-07-28` connection the degradation error used to differ by feature: `ctx.list_roots` raised a clear `NoBackChannelError`, while `ctx.elicit` / `ctx.sample` surfaced a bare "Method not found" because those methods attach a `related_request_id` and reach client dispatch before failing. FastMCP now era-gates `ctx.elicit` and `ctx.sample`/`ctx.sample_step` to raise a clear, era-aware `ToolError` before the wire ("server-initiated sampling is not available on MCP 2026-07-28 connections…" and "elicitation via server-initiated requests is unavailable on 2026-07-28 connections."). The strict xfail that captured #10 is flipped to a passing test.
+On a `2026-07-28` connection `ctx.elicit` used to surface a bare "Method not found", because it attaches a `related_request_id` and reaches client dispatch before failing. FastMCP now era-gates `ctx.elicit` to raise a clear, era-aware `ToolError` before the wire ("elicitation via server-initiated requests is unavailable on 2026-07-28 connections."). The strict xfail that captured #10 is flipped to a passing test. The sampling half of #10 is moot: `ctx.sample` no longer exists.
-*Verify:* `tests/server/test_protocol_eras.py` (`test_elicit_sample_degradation_message_is_clear_on_modern`, now a real test), `server/context.py` (era gates).
+*Verify:* `tests/server/test_protocol_eras.py` (`test_elicit_degradation_message_is_clear_on_modern`, now a real test), `server/context.py` (era gate).
### Server-level cache hints (SEP-2549) — New (opt-in feature)
diff --git a/dev-docs/v4-notes/feature-program.md b/dev-docs/v4-notes/feature-program.md
index eb135f910..4e185d6dd 100644
--- a/dev-docs/v4-notes/feature-program.md
+++ b/dev-docs/v4-notes/feature-program.md
@@ -13,21 +13,15 @@ Code blocks marked as sketches show the *intended* API and do not resolve agains
## Sampling removal
-**Status: Deprecation and era-gating shipped (#4448); removal slated for 4.0.**
+**Status: Shipped in 4.0.**
-Sampling is the push-shaped API where a server borrows the client's model mid-call (`ctx.sample`, `ctx.sample_step`). The `2026-07-28` era removes server-initiated requests, so this API cannot work on modern connections. Background-task sampling is dead under v2 — a worker's back-channel is gone once the submitting request returns, and no sampling relay was ever built (sdk-feedback #9).
+Sampling was the push-shaped API where a server borrows the client's model mid-call (`ctx.sample`, `ctx.sample_step`). The `2026-07-28` era removes server-initiated requests, so it cannot work on modern connections, and `Client`'s flip to `mode="auto"` made a modern connection the default — the era gate had become the default experience rather than an edge case. Background-task sampling was dead under v2 in any event: a worker's back-channel is gone once the submitting request returns, and no relay was ever built (sdk-feedback #9).
-The plan is Option A: **deprecate the push-sampling API now and remove it in the 4.0 release.** The first two steps shipped in #4448:
+Deprecation and era-gating shipped in #4448. The removal completes the plan: `ctx.sample`, `ctx.sample_step`, `ctx.list_roots`, `server/sampling/` (including `SamplingTool` and structured-result sampling), `FastMCP(sampling_handler=..., sampling_handler_behavior=...)`, and `examples/sampling/` are all gone. The server-authoring API is now the modern protocol's API, with nothing in it that only works against old clients.
-- **Done:** `ctx.sample` / `ctx.sample_step` emit a `FastMCPDeprecationWarning` (once per process, gated on `settings.deprecation_warnings`).
-- **Done:** both are era-gated to raise a clear, era-aware `ToolError` on `2026-07-28` before the wire, which also fixed the opaque "Method not found" of sdk-feedback #10.
-- **Pending 4.0:** remove `ctx.sample`, `ctx.sample_step`, `server/sampling/`, `SamplingTool`, and structured-result sampling.
+The migration story is honest: there is **no drop-in**. The guidance is architectural — call an LLM from your server directly, with your own API key, rather than borrowing the client's model. For roots, take paths as tool arguments or ask through the guard pattern, whose `input_requests` map still carries a `ListRootsRequest`.
-The migration story is honest: there is **no drop-in** on modern connections. The guidance is architectural — call an LLM from your server directly, with your own API key, rather than borrowing the client's model. That shift is the real answer, and it is why the removal justifies a major version.
-
-The client-side provider handlers (Anthropic, OpenAI, Google GenAI) are **retained** regardless: MRTR needs them to answer sampling input-requests from the client side. What is removed is the server-side push emitter, which the SDK never built for the modern era.
-
-Sampling still functions on the legacy eras. Users also see an SDK-level `MCPDeprecationWarning` on ordinary `ctx.sample` usage (the SDK deprecated the capability wire-side per SEP-2577). FastMCP's own deprecation — the warning with migration guidance, plus the era-gating — shipped in #4448; only the final removal remains for 4.0.
+The client-side provider handlers (Anthropic, OpenAI, Google GenAI) and `Client(sampling_handler=..., roots=...)` are **retained**: a FastMCP client still has to answer a legacy server's requests, and MRTR needs them from the client side. What is removed is the server-side push emitter. `ProxyClient`'s default relay handlers are retained for the same interop reason and now call the SDK session directly.
## MRTR elicitation
diff --git a/dev-docs/v4-notes/index.md b/dev-docs/v4-notes/index.md
index f09d19dcd..282c37af2 100644
--- a/dev-docs/v4-notes/index.md
+++ b/dev-docs/v4-notes/index.md
@@ -16,7 +16,7 @@ FastMCP v4.0 is an engine swap. Three forces drive the major version:
**Protocol version 2026-07-28.** The SDK v2 serves multiple protocol eras from one server. Alongside the session-based handshake eras, it introduces the sessionless `2026-07-28` era, which discovers capabilities through `server/discover` and removes server-initiated requests (SEP-2577). This formally supersedes FastMCP's earlier "latest protocol only" stance: a single server now works with clients across the protocol transition.
-**Sampling removal.** The `2026-07-28` era removes the server's ability to push a request back to the client mid-call. That takes the push-shaped sampling API (`ctx.sample`, `ctx.sample_step`) off the table on modern connections. Rather than leave it half-working, v4 deprecates it now and removes it in the 4.0 release — a real architectural shift for servers that borrowed the client's model, and one that justifies the major bump.
+**Sampling and roots removed from the server API.** The `2026-07-28` era removes the server's ability to push a request back to the client mid-call, which takes `ctx.sample`, `ctx.sample_step`, and `ctx.list_roots` off the table. Rather than leave them half-working against old clients only, 4.0 removes them from the server API entirely — a real architectural shift for servers that borrowed the client's model, and one that justifies the major bump. Client-side handlers stay, because a modern client still has to answer a legacy server.
## Release strategy
diff --git a/docs/clients/sampling.mdx b/docs/clients/sampling.mdx
index 1b83ae45d..fbbf4ff6d 100644
--- a/docs/clients/sampling.mdx
+++ b/docs/clients/sampling.mdx
@@ -11,10 +11,12 @@ import { VersionBadge } from "/snippets/version-badge.mdx";
Use this when you need to respond to server requests for LLM completions.
-MCP servers can request LLM completions from clients during tool execution. This enables servers to delegate AI reasoning to the client, which controls which LLM is used and how requests are made.
+A handshake-era MCP server can request an LLM completion from its client during tool execution, delegating the reasoning to whichever model the client controls. Answering those requests is what a sampling handler does, and it is how a FastMCP client stays interoperable with servers built before the protocol changed.
**Sampling requires the older MCP protocol.** A server requests sampling by sending a request down to the client, and protocol version `2026-07-28` removed the server's ability to do that. Clients default to `mode="auto"`, which negotiates the newest version both sides support, so every example on this page passes `mode="legacy"`. See [protocol negotiation](/clients/client#protocol-negotiation).
+
+This page is about the client answering. Writing a FastMCP *server* is the other direction, and there is no `ctx.sample()` there — see [Sampling](/servers/sampling) under Servers for why, and for calling an LLM from your own server instead.
## Handler Template
diff --git a/docs/deployment/http.mdx b/docs/deployment/http.mdx
index 3eb7bf10b..573f726d1 100644
--- a/docs/deployment/http.mdx
+++ b/docs/deployment/http.mdx
@@ -698,7 +698,7 @@ When deploying FastMCP behind a load balancer or running multiple server instanc
#### Understanding Sessions
-By default, FastMCP's Streamable HTTP transport maintains server-side sessions. Sessions enable stateful MCP features like [elicitation](/servers/elicitation) and [sampling](/servers/sampling), where the server needs to maintain context across multiple requests from the same client.
+By default, FastMCP's Streamable HTTP transport maintains server-side sessions. Sessions carry the handshake-era back-channel that server-initiated requests like [elicitation](/servers/elicitation) depend on, where the server needs to maintain context across multiple requests from the same client.
This works perfectly for single-instance deployments. However, sessions are stored in memory on each server instance, which creates challenges when scaling horizontally.
diff --git a/docs/getting-started/upgrading/from-fastmcp-3.mdx b/docs/getting-started/upgrading/from-fastmcp-3.mdx
index a076ef30c..b2365e89b 100644
--- a/docs/getting-started/upgrading/from-fastmcp-3.mdx
+++ b/docs/getting-started/upgrading/from-fastmcp-3.mdx
@@ -227,37 +227,40 @@ The camelCase bridge is a migration aid, not a permanent fixture. It works today
## SDK deprecation warnings you may see
-Ordinary use of `ctx.info` (client logging) and `ctx.sample` now emits an SDK-level `MCPDeprecationWarning`:
+Ordinary use of `ctx.info` (client logging) emits an SDK-level `MCPDeprecationWarning`:
```
-The logging/sampling capability is deprecated as of 2026-07-28 (SEP-2577)
+The logging capability is deprecated as of 2026-07-28 (SEP-2577)
```
-These warnings come from the MCP SDK, not from FastMCP. For logging they are benign: `ctx.info` keeps working on session-based connections exactly as the protocol table below describes, and the SDK is only signaling the protocol's direction. For sampling, FastMCP additionally emits its own `FastMCPDeprecationWarning`: `ctx.sample` and `ctx.sample_step` are deprecated and slated for removal, so treat that warning as a prompt to migrate to server-side LLM calls rather than as informational.
+The warning comes from the MCP SDK, not from FastMCP, and it is benign. `ctx.info` and the rest of the logging methods keep working on every era, including the modern one — a log message is a *notification*, which rides the response stream the caller already opened. The SDK is signaling the protocol's direction for the capability declaration, not the notification itself.
## Protocol version support
FastMCP servers built on the SDK v2 serve multiple protocol eras from the same server. The SDK negotiates the era each client speaks: the sessionless `2026-07-28` era (which discovers capabilities through `server/discover`) and earlier session-based handshake versions are all handled simultaneously. This formally supersedes FastMCP's earlier "latest protocol only" stance — a single server now works with clients across the protocol transition.
-Not every Context feature is available on every era yet. The imperative push APIs that call back into the client mid-execution — `ctx.elicit`, `ctx.sample`, and `ctx.list_roots` — depend on the session-based back-channel of the earlier eras, so on a `2026-07-28` connection they raise a clear, era-aware error rather than reaching the client. Elicitation itself still reaches the user on the modern era, through the guard pattern: a tool *returns* an `InputRequiredResult` describing what it needs, and the client answers with a fresh call (see [Elicitation on the modern protocol](/servers/elicitation#elicitation-on-the-modern-protocol)). Logging notifications and the request/response features flow on every era.
+FastMCP 4 is a modern MCP toolkit, so the server API is the modern protocol's API. Where a capability survived the transition in a different shape, FastMCP carries it across in that shape. Where the protocol removed a capability outright, FastMCP 4 does not carry a version of it that only works on old connections: **`ctx.sample()`, `ctx.sample_step()`, and `ctx.list_roots()` are gone from `Context` entirely**, along with the `sampling_handler=` and `sampling_handler_behavior=` arguments to `FastMCP()`. Calling them raises `AttributeError` on every era, not an era-specific runtime error, and `FastMCP(sampling_handler=...)` raises `TypeError` naming the migration.
-Sampling is the exception that does not come back, and the reason is the protocol rather than an unfinished FastMCP feature. SEP-2577 deprecated server-initiated sampling, so `ctx.sample` and `ctx.sample_step` are **deprecated** and will be removed in a future FastMCP release. Elicitation moved to the guard pattern because the modern protocol still carries elicitation requests; sampling has no equivalent path because the protocol deprecated the pattern itself. The migration is to call an LLM directly from your server rather than borrowing the client's model. See [Sampling](/servers/sampling) for details.
+This is a deliberate stance rather than an unfinished port. Server-initiated sampling and roots are *requests*: the server sends one and blocks for an answer, which needs a live back-channel the sessionless protocol does not have. Since `fastmcp.Client` now negotiates the modern protocol by default, keeping these methods would mean shipping an API whose default outcome is a runtime failure. Elicitation is the one server-initiated capability that survives, because the modern protocol carries it in a new shape: a tool *returns* an `InputRequiredResult` describing what it needs, and the client answers with a fresh call (see [Elicitation on the modern protocol](/servers/elicitation#elicitation-on-the-modern-protocol)).
+
+Migrating is direct in both cases. For sampling, [call an LLM from your server](/servers/sampling) with your own API key — your tool then behaves the same for every client, including the many that never implemented sampling. For roots, accept the paths you need as tool arguments, or ask for them through the same guard pattern, whose `input_requests` map carries a roots request alongside elicitation.
| Context feature | Earlier eras (session-based) | `2026-07-28` (sessionless) |
| --- | --- | --- |
| `ctx.info` / logging notifications | Supported | Supported |
| Tools, resources, prompts, completions | Supported | Supported |
| `ctx.elicit` | Supported | Use the guard pattern (return `InputRequiredResult`) |
-| `ctx.sample` / `ctx.sample_step` | Supported (deprecated) | Removed — call an LLM server-side |
-| `ctx.list_roots` | Supported | Via the guard pattern (`input_requests` carries roots requests) |
+| `ctx.sample` / `ctx.sample_step` | Removed from the API — call an LLM server-side | Removed from the API — call an LLM server-side |
+| `ctx.list_roots` | Removed from the API — take paths as arguments, or use the guard pattern | Removed from the API — take paths as arguments, or use the guard pattern |
+| `client.set_logging_level()` | Supported | Raises — `logging/setLevel` needs session state the era lacks |
| `Middleware.on_initialize` | Runs on connect | Never runs — there is no `initialize` handshake |
| Session state (`ctx.set_state` across calls) | Persists for the session | Does not persist — every request is a fresh connection |
| Background tasks (`task=True`) | Runs synchronously — never tasked | Supported via the tasks extension |
-If your tools rely on `ctx.elicit` or `ctx.list_roots`, they continue to work against clients on the earlier eras; on the modern era, reach for the guard pattern instead (see [Elicitation on the modern protocol](/servers/elicitation#elicitation-on-the-modern-protocol)). Sampling is deprecated on every era and will not return on modern connections — migrate those tools to server-side LLM calls.
-
Two of these bite by default now, because **`fastmcp.Client` defaults to `mode="auto"`** in v4 — an ordinary `Client(server)` negotiates the newest protocol both sides share, which against a FastMCP server is the sessionless `2026-07-28` era. On that era there is no `initialize` handshake, so a `Middleware.on_initialize` hook never runs; and each request is a fresh connection, so state written with `ctx.set_state` in one call is not visible in the next. A server that gates access in `on_initialize` or relies on per-session state must keep its clients on the session-based era. The narrow escape is per-client: `Client(server, mode="legacy")`. The durable, server-side answer is to declare the versions the server actually serves so a modern client is refused at connect time rather than silently losing those features — see the server's protocol-version restriction (added alongside this change).
+The client side is unaffected by any of this. A `fastmcp.Client` still answers a legacy server's sampling and roots requests through `sampling_handler=` and `roots=` — see [client sampling](/clients/sampling) and [client roots](/clients/roots) — because a modern client still has to interoperate with servers built before the protocol changed.
+
## Upgrade checklist
Most servers upgrade untouched. Work down this list to find the ones that don't:
@@ -265,11 +268,12 @@ Most servers upgrade untouched. Work down this list to find the ones that don't:
1. **Bump your environment.** Raise any pin below `pydantic>=2.12`; upgrade FastAPI if your resolver complains about Starlette `<1.0.1`.
2. **Fix imports that moved out.** Replace `from mcp.types import X` with `from mcp_types import X`, and update any import from the [removed modules](#moved-imports) (`fastmcp.server.proxy`, `fastmcp.server.openapi`, `fastmcp.server.apps`, the `fastmcp.tools.tool` / `resources.resource` / `prompts.prompt` component shims).
3. **Update removed server APIs.** Swap `as_proxy` → `create_proxy`, `import_server` → `mount`, `mount(prefix=)` → `mount(namespace=)`, and the [other removed methods and keywords](#removed-server-methods-and-mount-keywords).
-4. **Update removed tool parameters.** Replace tool `serializer=` (return a `ToolResult`), `exclude_args=` (use `Depends()`), and `StreamableHttpTransport(sse_read_timeout=)`.
-5. **Fix `McpError` construction.** Positional `McpError(ErrorData(...))` becomes keyword `McpError(code=..., message=...)`. Catching is unchanged.
-6. **Move httpx to httpx2.** Grep for `except httpx.` and for custom `httpx_client_factory` / `httpx.Auth` objects handed to FastMCP, and swap the import to `httpx2`.
-7. **Decide the client era.** `Client` now defaults to `mode="auto"`. If a server relies on `on_initialize` or per-session state, keep its clients on `mode="legacy"` or restrict the server's served protocol versions.
-8. **Verify behavior changes.** Confirm templated resources that legitimately accept `..` or absolute paths are exempted, and update any client that matched the old `-32002` resource-not-found code.
-9. **Run with the camelCase bridge off.** Set `mcp_camelcase_compat = False` (or `FASTMCP_MCP_CAMELCASE_COMPAT=false`) in CI to surface every remaining camelCase read as a hard `AttributeError` before the shims are removed.
+4. **Replace `ctx.sample` and `ctx.list_roots`.** Both are gone from `Context`, as are `FastMCP(sampling_handler=...)` and `sampling_handler_behavior=`. Call an LLM directly from your server for generation; take file paths as tool arguments for roots.
+5. **Update removed tool parameters.** Replace tool `serializer=` (return a `ToolResult`), `exclude_args=` (use `Depends()`), and `StreamableHttpTransport(sse_read_timeout=)`.
+6. **Fix `McpError` construction.** Positional `McpError(ErrorData(...))` becomes keyword `McpError(code=..., message=...)`. Catching is unchanged.
+7. **Move httpx to httpx2.** Grep for `except httpx.` and for custom `httpx_client_factory` / `httpx.Auth` objects handed to FastMCP, and swap the import to `httpx2`.
+8. **Decide the client era.** `Client` now defaults to `mode="auto"`. If a server relies on `on_initialize` or per-session state, keep its clients on `mode="legacy"` or restrict the server's served protocol versions.
+9. **Verify behavior changes.** Confirm templated resources that legitimately accept `..` or absolute paths are exempted, and update any client that matched the old `-32002` resource-not-found code.
+10. **Run with the camelCase bridge off.** Set `mcp_camelcase_compat = False` (or `FASTMCP_MCP_CAMELCASE_COMPAT=false`) in CI to surface every remaining camelCase read as a hard `AttributeError` before the shims are removed.
The executable version of this checklist lives in [`tests/test_upgrade_from_v3.py`](https://github.com/PrefectHQ/fastmcp/blob/main/tests/test_upgrade_from_v3.py): it builds representative 3.x-style servers and asserts they run unchanged, and pins every removed surface to the exact error it now raises.
diff --git a/docs/getting-started/whats-new.mdx b/docs/getting-started/whats-new.mdx
index 24eee9ac5..8fe57e719 100644
--- a/docs/getting-started/whats-new.mdx
+++ b/docs/getting-started/whats-new.mdx
@@ -25,7 +25,7 @@ A FastMCP 4 server answers clients across the protocol transition from one deplo
The same negotiation runs from the client, and its default flipped. A plain `Client(url)` now probes for the modern protocol and adopts it when the server offers it, falling back to the handshake otherwise — where every earlier FastMCP version pinned the handshake outright. That flip is what brings the modern capabilities within reach of ordinary client code: a task-enabled tool hands back a handle to poll, and multi-round-trip elicitation resolves across successive requests, neither requiring the caller to opt in. Set `mode="legacy"` to pin the handshake when you need the session-based back-channel or the classic `initialize` result. See [Protocol negotiation](/clients/client#protocol-negotiation).
-The modern protocol is sessionless, so it drops the server's ability to call back into the client mid-request (SEP-2577). Imperative `ctx.elicit` and `ctx.list_roots` move to a request-shaped pattern on modern connections, and server-initiated sampling — which has no such replacement — is [deprecated](/servers/sampling). Everything else about writing a server is unchanged.
+The modern protocol is sessionless, so it drops the server's ability to call back into the client mid-request (SEP-2577), and FastMCP 4's server API reflects that rather than papering over it. `ctx.elicit` moves to a request-shaped pattern that works on modern connections. `ctx.sample`, `ctx.sample_step`, and `ctx.list_roots` are not in the API at all — the capabilities they wrapped no longer exist in the protocol FastMCP 4 targets, so shipping methods that only work against old clients would be shipping a trap. [Call an LLM from your server](/servers/sampling) for generation, and take file paths as tool arguments for roots. Logging is untouched: `ctx.info` and its siblings are notifications, which ride the response stream and reach the client on every era. Everything else about writing a server is unchanged.
## State without a session
diff --git a/docs/servers/context.mdx b/docs/servers/context.mdx
index 48fcb74d0..bd0a4faa2 100644
--- a/docs/servers/context.mdx
+++ b/docs/servers/context.mdx
@@ -21,7 +21,6 @@ The `Context` object provides a clean interface to access MCP features within yo
- **Progress Reporting**: Update the client on the progress of long-running operations
- **Resource Access**: List and read data from resources registered with the server
- **Prompt Access**: List and retrieve prompts registered with the server
-- **LLM Sampling**: Request the client's LLM to generate text based on provided messages
- **User Elicitation**: Request structured input from users during tool execution
- **Request State**: Pass values and non-serializable resources between middleware and handlers within a request (for state that persists across requests, see [Session State](/servers/sessions))
- **Session Visibility**: [Control which components are visible](/servers/visibility#per-session-visibility) to the current session
@@ -152,17 +151,13 @@ if result.action == "accept":
See [User Elicitation](/servers/elicitation) for detailed examples and supported response types.
-### LLM Sampling
+### Server-initiated requests
-
+
+`Context` has no `sample()` or `list_roots()`. Both were server→client *requests*, and the modern MCP protocol has no channel to carry them ([SEP-2577](/servers/sampling)). For generation, [call an LLM directly from your server](/servers/sampling). For roots, accept paths as tool arguments, or ask for them through the [guard pattern](/servers/elicitation#elicitation-on-the-modern-protocol), which carries a roots request in `input_requests`.
-Request the client's LLM to generate text based on provided messages, useful for leveraging AI capabilities within your tools.
-
-```python
-response = await ctx.sample("Analyze this data", temperature=0.7)
-```
-
-See [LLM Sampling](/servers/sampling) for comprehensive usage and advanced techniques.
+Logging is unaffected — `ctx.info()` and friends are *notifications*, which ride the response stream and work on every protocol era.
+
### Progress Reporting
diff --git a/docs/servers/elicitation.mdx b/docs/servers/elicitation.mdx
index 9b7b0c164..9712db02c 100644
--- a/docs/servers/elicitation.mdx
+++ b/docs/servers/elicitation.mdx
@@ -545,7 +545,7 @@ If you need to support both eras, branch on `ctx.protocol_version`: return an `I
Elicitation is the most common request to carry this way, and **roots** requests work identically — the `input_requests` map holds them the same way, and each answer comes back in `ctx.input_responses` under its key (an `ElicitResult` or `ListRootsResult`). See [Client Roots](/clients/roots) for what a roots request contains. `fastmcp.Client` answers both from the handlers you already configured, so a guard tool that mixes them needs no extra client wiring.
-The map can structurally hold a **sampling** request too (its answer would be a `CreateMessageResult`), but SEP-2577 deprecated server-initiated sampling on the modern protocol, so reach for a direct server-side LLM call instead of routing generation through a guard round. See [Sampling](/servers/sampling).
+The map can structurally hold a **sampling** request too (its answer would be a `CreateMessageResult`), but MCP removed server-initiated sampling as a pattern rather than only one spelling of it, so generation belongs in your server. See [Sampling](/servers/sampling) for how to call an LLM directly.
### Middleware
diff --git a/docs/servers/sampling.mdx b/docs/servers/sampling.mdx
new file mode 100644
index 000000000..5aeacd413
--- /dev/null
+++ b/docs/servers/sampling.mdx
@@ -0,0 +1,66 @@
+---
+title: Sampling
+sidebarTitle: Sampling
+description: Server-initiated sampling is not part of FastMCP 4 — here is why, and what to build instead.
+icon: robot
+---
+
+FastMCP 4 targets the modern MCP protocol, and that protocol has no channel for a server to send a request to a client. A tool cannot pause mid-execution to borrow the caller's model and wait for a completion, so server-initiated sampling is not part of the FastMCP 4 server API. There is no `ctx.sample()` and no server-side sampling handler. Generation belongs to your server now: you call an LLM with your own credentials, the same way you would call any other service.
+
+This follows the protocol rather than getting ahead of it. MCP removed server-initiated requests in the `2026-07-28` revision ([SEP-2577](https://modelcontextprotocol.io/community/sep-guidelines)), and FastMCP 4's client negotiates that revision by default. Keeping `ctx.sample()` around would mean shipping a method whose ordinary, default outcome is a runtime error.
+
+## Requests and notifications
+
+The distinction that makes this make sense is between *asking* and *telling*.
+
+A notification is fire-and-forget. Your server emits it and moves on, and it travels down the response stream the caller already opened for the request in flight. Nothing has to be held open on the server's behalf, so notifications survive the move to a stateless protocol untouched. This is why [logging](/servers/logging) still works exactly as it always has: `ctx.info()`, `ctx.debug()`, and the rest reach the client mid-call on every protocol era.
+
+```python
+from fastmcp import Context, FastMCP
+
+mcp = FastMCP("Reports")
+
+
+@mcp.tool
+async def build_report(rows: int, ctx: Context) -> str:
+ await ctx.info(f"Processing {rows} rows")
+ return "done"
+```
+
+Sampling is the other kind. It is a *request* — the server sends `sampling/createMessage` and then blocks until an answer comes back the other way. That requires a live, addressable connection the server can reach into, which is precisely the thing a stateless protocol does not have. There is no version of sampling that fits, which is why it has no replacement in the way elicitation does. Elicitation moved to the [guard pattern](/servers/elicitation#elicitation-on-the-modern-protocol), where a tool *returns* a description of what it needs and the client answers with a fresh call; generation does not decompose into rounds that way, because an agentic loop would spend the round-trip budget several times over.
+
+## Calling an LLM directly
+
+Your server calls the model. Hold a provider API key in your server's environment, create the client once at module scope so connections are reused across calls, and generate inside the tool. The result is a plain async function call with no protocol involvement, which also means you choose the model, control the prompt, see the token usage, and can test the tool without a client attached.
+
+```python
+import anthropic
+from fastmcp import FastMCP
+
+mcp = FastMCP("Summarizer")
+llm = anthropic.AsyncAnthropic()
+
+
+@mcp.tool
+async def summarize(text: str) -> str:
+ """Summarize a document in two sentences."""
+ response = await llm.messages.create(
+ model="claude-sonnet-4-5",
+ max_tokens=512,
+ system="Summarize the user's text in exactly two sentences.",
+ messages=[{"role": "user", "content": text}],
+ )
+ return response.content[0].text
+```
+
+Any provider SDK works the same way — swap the client and the call, and the tool signature is unchanged. Because generation is now ordinary application code, the surrounding concerns become ordinary too: retries, timeouts, caching, and cost accounting are yours to place where you want them rather than negotiated across a protocol boundary.
+
+The trade this makes is explicit. Sampling let a server borrow the caller's model and the caller's bill; calling directly means you supply the key and pay for the tokens. In exchange your tool behaves identically for every client, including the many that never implemented sampling at all.
+
+## Clients answering servers
+
+The client half of sampling is unaffected. A `fastmcp.Client` connecting to a handshake-era server may still receive `sampling/createMessage` requests from it, and passing `sampling_handler=` is how you answer them — see [Sampling](/clients/sampling) under Clients. That path exists for interoperating with older servers and has nothing to do with authoring one.
+
+
+Servers on FastMCP 3 still have `ctx.sample()` and `ctx.sample_step()`, documented in the [FastMCP 3 sampling guide](/v3/servers/sampling). Nothing changes for them until they upgrade.
+
diff --git a/docs/servers/server.mdx b/docs/servers/server.mdx
index 9f6dbe8fa..082830b12 100644
--- a/docs/servers/server.mdx
+++ b/docs/servers/server.mdx
@@ -211,19 +211,9 @@ These parameters tune how the server processes requests and communicates with cl
-### Handlers and Storage
-
-These parameters provide custom handlers for MCP capabilities and persistent storage for session state.
+### Storage
-
- Custom handler for MCP sampling requests (server-initiated LLM calls). See [Sampling](/servers/sampling) for details
-
-
-
- When `"fallback"`, the sampling handler is used only when no tool-specific handler exists. When `"always"`, this handler is used for all sampling requests
-
-
Persistent key-value store for session state that survives across requests. Defaults to an in-memory store. Provide a custom implementation for persistence across server restarts
From 90f2e190d00beedefbc2b7866fe3fa0db1c363a8 Mon Sep 17 00:00:00 2001
From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>
Date: Sun, 26 Jul 2026 14:45:55 -0400
Subject: [PATCH 05/15] Silence ty deprecation diagnostics and drop stale
sampling doc mentions
---
docs/servers/telemetry.mdx | 6 +++---
docs/servers/tools.mdx | 11 ++---------
tests/client/test_roots.py | 8 +++-----
tests/client/test_sampling.py | 6 ++++--
tests/server/providers/proxy/test_proxy_client.py | 4 ++--
tests/server/test_protocol_eras.py | 2 +-
6 files changed, 15 insertions(+), 22 deletions(-)
diff --git a/docs/servers/telemetry.mdx b/docs/servers/telemetry.mdx
index 7e7356f5e..e902fe5a6 100644
--- a/docs/servers/telemetry.mdx
+++ b/docs/servers/telemetry.mdx
@@ -238,7 +238,7 @@ Custom spans are most useful around work that is expensive or hard to debug:
- External calls such as databases, vector stores, HTTP APIs, or queue operations
- Multi-step tool logic where one stage dominates latency
- Prompt or resource generation that fans out to other systems
-- Sampling calls made from inside a tool via `ctx.sample(...)`
+- LLM calls a tool makes to a model provider
Avoid wrapping every small helper function or simple in-memory transformation. That usually adds noise without making traces easier to interpret.
@@ -286,9 +286,9 @@ async def docs_resource(slug: str) -> str:
return await load_doc(slug)
```
-### Sampling calls inside tools
+### LLM calls inside tools
-If your tool uses `ctx.sample(...)`, keep the LLM work nested under the tool span so traces show both application logic and model latency together.
+A tool that [calls an LLM directly](/servers/sampling) should keep the model work nested under the tool span, so traces show application logic and model latency together.
For providers with their own OTEL integrations, prefer enabling that instrumentation rather than manually creating a span around every model call. For example, if you use Google GenAI, `logfire.instrument_google_genai()` will emit child spans with token and request metadata under the active FastMCP tool span.
diff --git a/docs/servers/tools.mdx b/docs/servers/tools.mdx
index 2e5fe4d85..197ca2340 100644
--- a/docs/servers/tools.mdx
+++ b/docs/servers/tools.mdx
@@ -1063,15 +1063,9 @@ async def process_data(data_uri: str, ctx: Context) -> dict:
# Report progress
await ctx.report_progress(progress=50, total=100)
-
- # Example request to the client's LLM for help
- summary = await ctx.sample(f"Summarize this in 10 words: {data[:200]}")
-
+
await ctx.report_progress(progress=100, total=100)
- return {
- "length": len(data),
- "summary": summary.text
- }
+ return {"length": len(data)}
```
The Context object provides access to:
@@ -1079,7 +1073,6 @@ The Context object provides access to:
- **Logging**: `ctx.debug()`, `ctx.info()`, `ctx.warning()`, `ctx.error()`
- **Progress Reporting**: `ctx.report_progress(progress, total)`
- **Resource Access**: `ctx.read_resource(uri)`
-- **LLM Sampling**: `ctx.sample(...)`
- **Request Information**: `ctx.request_id`, `ctx.client_id`
For full documentation on the Context object and all its capabilities, see the [Context documentation](/servers/context).
diff --git a/tests/client/test_roots.py b/tests/client/test_roots.py
index 609bbdfd5..9d87202c0 100644
--- a/tests/client/test_roots.py
+++ b/tests/client/test_roots.py
@@ -16,7 +16,7 @@ def fastmcp_server():
@mcp.tool
async def list_roots(context: Context) -> list[str]:
- result = await context.session.list_roots()
+ result = await context.session.list_roots() # ty: ignore[deprecated]
return [str(r.uri) for r in result.roots]
return mcp
@@ -58,11 +58,9 @@ class TestClientRoots:
async def roots_handler(ctx) -> list[Root]:
calls.append(ctx)
- return [Root(uri="file://from/handler")] # ty: ignore[invalid-argument-type]
+ return [Root(uri="file://from/handler")]
- async with Client(
- fastmcp_server, mode="legacy", roots=roots_handler
- ) as client:
+ async with Client(fastmcp_server, mode="legacy", roots=roots_handler) as client:
result = await client.call_tool("list_roots", {})
assert len(calls) == 1
diff --git a/tests/client/test_sampling.py b/tests/client/test_sampling.py
index e82e71093..f442419a9 100644
--- a/tests/client/test_sampling.py
+++ b/tests/client/test_sampling.py
@@ -23,7 +23,7 @@ async def _sample(
answering a legacy server, so the stand-in server reaches the SDK session
directly.
"""
- result = await context.session.create_message(
+ result = await context.session.create_message( # ty: ignore[deprecated]
messages=messages,
system_prompt=system_prompt,
max_tokens=512,
@@ -72,7 +72,9 @@ def fastmcp_server():
),
SamplingMessage(
role="assistant",
- content=TextContent(type="text", text="How can I assist you today?"),
+ content=TextContent(
+ type="text", text="How can I assist you today?"
+ ),
),
],
)
diff --git a/tests/server/providers/proxy/test_proxy_client.py b/tests/server/providers/proxy/test_proxy_client.py
index 7638ef1b9..caa4b9075 100644
--- a/tests/server/providers/proxy/test_proxy_client.py
+++ b/tests/server/providers/proxy/test_proxy_client.py
@@ -66,13 +66,13 @@ async def _backend_list_roots(context: Context) -> list[Root]:
FastMCP's server API. These helpers reach the SDK session directly to stand
in for a legacy upstream, which is the only thing the proxy relay forwards.
"""
- result = await context.session.list_roots()
+ result = await context.session.list_roots() # ty: ignore[deprecated]
return result.roots
async def _backend_sample(context: Context) -> str:
"""Issue a handshake-era `sampling/createMessage` from a backend server."""
- result = await context.session.create_message(
+ result = await context.session.create_message( # ty: ignore[deprecated]
messages=[
SamplingMessage(
role="user", content=TextContent(type="text", text="Hello, world!")
diff --git a/tests/server/test_protocol_eras.py b/tests/server/test_protocol_eras.py
index 90cae9bb4..aabb54d68 100644
--- a/tests/server/test_protocol_eras.py
+++ b/tests/server/test_protocol_eras.py
@@ -307,7 +307,7 @@ def test_removed_server_initiated_methods_are_absent(name):
def test_server_sampling_handler_kwargs_are_rejected(kwarg):
"""The server-side sampling handler existed only to answer `ctx.sample()`."""
with pytest.raises(TypeError, match="SEP-2577"):
- FastMCP("gone", **{kwarg: None})
+ FastMCP("gone", **{kwarg: None}) # ty: ignore[invalid-argument-type]
@pytest.mark.parametrize("mode", MODERN_MODES)
From b9fcef1889cbc76215c4dbac79dd8ee837ce3451 Mon Sep 17 00:00:00 2001
From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>
Date: Sun, 26 Jul 2026 15:07:24 -0400
Subject: [PATCH 06/15] Baseline tools-call-sampling; fix removal leftovers
flagged by ruff
---
tests/client/client/test_error_handling.py | 3 +--
tests/conformance/expected-failures.yml | 4 ++++
tests/server/test_context.py | 1 -
3 files changed, 5 insertions(+), 3 deletions(-)
diff --git a/tests/client/client/test_error_handling.py b/tests/client/client/test_error_handling.py
index 8f40e2ce6..96e85d56d 100644
--- a/tests/client/client/test_error_handling.py
+++ b/tests/client/client/test_error_handling.py
@@ -14,7 +14,7 @@ import logging
import mcp_types
import pytest
-from mcp_types import TextContent, ToolUseContent
+from mcp_types import TextContent
from pydantic import AnyUrl
from fastmcp.client import Client
@@ -414,4 +414,3 @@ class TestLogLevel:
and record.levelname == "ERROR"
for record in caplog.records
)
-
diff --git a/tests/conformance/expected-failures.yml b/tests/conformance/expected-failures.yml
index 46b2081de..45ab3c1e3 100644
--- a/tests/conformance/expected-failures.yml
+++ b/tests/conformance/expected-failures.yml
@@ -4,3 +4,7 @@ server:
- resources-subscribe
- resources-unsubscribe
- dns-rebinding-protection
+ # Server-initiated sampling was removed from the server API (SEP-2577), so
+ # the fixture has no `test_sampling` tool for this handshake-era scenario to
+ # drive. Modern sampling is exercised by input-required-result-basic-sampling.
+ - tools-call-sampling
diff --git a/tests/server/test_context.py b/tests/server/test_context.py
index e37e5531b..26b57d694 100644
--- a/tests/server/test_context.py
+++ b/tests/server/test_context.py
@@ -1,7 +1,6 @@
from unittest.mock import MagicMock
import pytest
-from mcp_types import ModelPreferences
from fastmcp.server.context import (
Context,
From 18aa6a09d6720e2c4b9a1deb9e154a9195517197 Mon Sep 17 00:00:00 2001
From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>
Date: Sun, 26 Jul 2026 15:19:11 -0400
Subject: [PATCH 07/15] Document sampling handlers on both protocol routes;
qualify log-level override
---
docs/clients/sampling.mdx | 12 ++++++------
docs/more/settings.mdx | 2 +-
docs/servers/server.mdx | 2 +-
.../fastmcp/client/sampling/handlers/anthropic.py | 6 +++---
.../fastmcp/client/sampling/handlers/google_genai.py | 6 +++---
5 files changed, 14 insertions(+), 14 deletions(-)
diff --git a/docs/clients/sampling.mdx b/docs/clients/sampling.mdx
index fbbf4ff6d..35cb8f2e7 100644
--- a/docs/clients/sampling.mdx
+++ b/docs/clients/sampling.mdx
@@ -1,7 +1,7 @@
---
title: LLM Sampling
sidebarTitle: Sampling
-description: Handle server-initiated LLM completion requests.
+description: Answer a server's request for an LLM completion.
icon: robot
---
@@ -9,14 +9,14 @@ import { VersionBadge } from "/snippets/version-badge.mdx";
-Use this when you need to respond to server requests for LLM completions.
+Use this when a server asks your client to run an LLM completion on its behalf.
-A handshake-era MCP server can request an LLM completion from its client during tool execution, delegating the reasoning to whichever model the client controls. Answering those requests is what a sampling handler does, and it is how a FastMCP client stays interoperable with servers built before the protocol changed.
+A sampling handler is the client's answer to that request: the server describes the messages it wants completed, your handler runs them against whatever model you control, and the result goes back. Servers reach your handler by two different routes, and a handler you register serves both.
+
+On a **handshake-era** connection the server pushes a `sampling/createMessage` request down the open session mid-tool-call. On a **modern** (`2026-07-28`) connection there is no such channel, so a server instead *returns* an input-required result naming what it needs, and your client answers it and re-calls the tool. Both paths dispatch to the same `sampling_handler`, so register one and it works either way — this page's examples pin `mode="legacy"` only because they demonstrate the push route with a legacy server.
-**Sampling requires the older MCP protocol.** A server requests sampling by sending a request down to the client, and protocol version `2026-07-28` removed the server's ability to do that. Clients default to `mode="auto"`, which negotiates the newest version both sides support, so every example on this page passes `mode="legacy"`. See [protocol negotiation](/clients/client#protocol-negotiation).
-
-This page is about the client answering. Writing a FastMCP *server* is the other direction, and there is no `ctx.sample()` there — see [Sampling](/servers/sampling) under Servers for why, and for calling an LLM from your own server instead.
+This page is the client side. Writing a FastMCP **server** is the other direction, and there is no `ctx.sample()` there — see [Sampling](/servers/sampling) under Servers for why, and for calling an LLM from your own server instead.
## Handler Template
diff --git a/docs/more/settings.mdx b/docs/more/settings.mdx
index 8395b8900..0fa4ca0e2 100644
--- a/docs/more/settings.mdx
+++ b/docs/more/settings.mdx
@@ -23,7 +23,7 @@ You can change which `.env` file is loaded by setting the `FASTMCP_ENV_FILE` env
|---|---|---|---|
| `FASTMCP_LOG_LEVEL` | `Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]` | `INFO` | Log level for FastMCP's own logging output. Case-insensitive. |
| `FASTMCP_LOG_ENABLED` | `bool` | `true` | Enable or disable FastMCP logging entirely. |
-| `FASTMCP_CLIENT_LOG_LEVEL` | `Literal["debug", "info", "notice", "warning", "error", "critical", "alert", "emergency"]` | None | Default minimum log level for messages sent to MCP clients via `context.log()`. When set, messages below this level are suppressed. Individual clients can override this per-session using the MCP `logging/setLevel` request. |
+| `FASTMCP_CLIENT_LOG_LEVEL` | `Literal["debug", "info", "notice", "warning", "error", "critical", "alert", "emergency"]` | None | Default minimum log level for messages sent to MCP clients via `context.log()`. When set, messages below this level are suppressed. Handshake-era clients can override this per-session using the MCP `logging/setLevel` request; the modern protocol has no session to hold that level, so clients on it filter by level in their own log handler instead. |
| `FASTMCP_ENABLE_RICH_LOGGING` | `bool` | `true` | Use rich formatting for log output. Set to `false` for plain Python logging. |
| `FASTMCP_ENABLE_RICH_TRACEBACKS` | `bool` | `true` | Use rich tracebacks for errors. |
| `FASTMCP_DEPRECATION_WARNINGS` | `bool` | `true` | Show deprecation warnings. |
diff --git a/docs/servers/server.mdx b/docs/servers/server.mdx
index 082830b12..ee778f887 100644
--- a/docs/servers/server.mdx
+++ b/docs/servers/server.mdx
@@ -195,7 +195,7 @@ These parameters tune how the server processes requests and communicates with cl
- Default minimum log level for messages sent to MCP clients via `context.log()`. When set, messages below this level are suppressed. Individual clients can override this per-session using the MCP `logging/setLevel` request. One of `"debug"`, `"info"`, `"notice"`, `"warning"`, `"error"`, `"critical"`, `"alert"`, or `"emergency"`
+ Default minimum log level for messages sent to MCP clients via `context.log()`. When set, messages below this level are suppressed. Handshake-era clients can override this per-session using the MCP `logging/setLevel` request; the modern protocol has no session to hold that level, so clients on it filter by level in their own log handler instead. One of `"debug"`, `"info"`, `"notice"`, `"warning"`, `"error"`, `"critical"`, `"alert"`, or `"emergency"`
diff --git a/fastmcp_slim/fastmcp/client/sampling/handlers/anthropic.py b/fastmcp_slim/fastmcp/client/sampling/handlers/anthropic.py
index 0ee24ef8d..f72c4c344 100644
--- a/fastmcp_slim/fastmcp/client/sampling/handlers/anthropic.py
+++ b/fastmcp_slim/fastmcp/client/sampling/handlers/anthropic.py
@@ -83,9 +83,9 @@ class AnthropicSamplingHandler:
client=AsyncAnthropic(),
)
- # Sampling is a server-initiated request, so it only exists on the
- # handshake era; pass `mode="legacy"` to answer one.
- client = Client(server_url, sampling_handler=handler, mode="legacy")
+ # Answers a handshake-era server's push request and a modern server's
+ # input-required round alike.
+ client = Client("https://example.com/mcp", sampling_handler=handler)
```
"""
diff --git a/fastmcp_slim/fastmcp/client/sampling/handlers/google_genai.py b/fastmcp_slim/fastmcp/client/sampling/handlers/google_genai.py
index c49c77635..23a6fa033 100644
--- a/fastmcp_slim/fastmcp/client/sampling/handlers/google_genai.py
+++ b/fastmcp_slim/fastmcp/client/sampling/handlers/google_genai.py
@@ -71,9 +71,9 @@ class GoogleGenaiSamplingHandler:
client=GoogleGenaiClient(),
)
- # Sampling is a server-initiated request, so it only exists on the
- # handshake era; pass `mode="legacy"` to answer one.
- client = FastMCPClient(server_url, sampling_handler=handler, mode="legacy")
+ # Answers a handshake-era server's push request and a modern server's
+ # input-required round alike.
+ client = FastMCPClient("https://example.com/mcp", sampling_handler=handler)
```
"""
From cf7edc895c676fbf56fb216eed8e0b550408a0c6 Mon Sep 17 00:00:00 2001
From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>
Date: Sun, 26 Jul 2026 19:22:37 -0400
Subject: [PATCH 08/15] Docs: sampling and roots work on modern via the guard
pattern
The imperative ctx.sample()/ctx.list_roots() stay removed, but both
capabilities survive as input-required requests, as tests/conformance
exercises on 2026-07-28. Direct LLM calls remain the recommendation for
generation; roots has no round-trip-budget objection.
---
docs/clients/client.mdx | 2 +-
docs/clients/roots.mdx | 12 ++--
docs/clients/sampling.mdx | 2 +-
.../upgrading/from-fastmcp-3.mdx | 14 ++--
docs/getting-started/whats-new.mdx | 2 +-
docs/servers/context.mdx | 2 +-
docs/servers/elicitation.mdx | 4 +-
docs/servers/sampling.mdx | 69 +++++++++++++++++--
8 files changed, 80 insertions(+), 27 deletions(-)
diff --git a/docs/clients/client.mdx b/docs/clients/client.mdx
index 6040cf88e..e45081101 100644
--- a/docs/clients/client.mdx
+++ b/docs/clients/client.mdx
@@ -342,7 +342,7 @@ See [Prompts](/clients/prompts) for detailed documentation including argument se
The client supports callback handlers for advanced server interactions. These let you respond to server-initiated requests and receive notifications.
-Sampling, elicitation, and roots are all server-initiated, so they belong to the handshake era described under [protocol negotiation](#protocol-negotiation). A default client negotiates the newest era both peers share, where the server has no back-channel to push those requests down, so an example that exercises them pins `mode="legacy"`. Logging and progress arrive as notifications on the response stream and work in either era.
+Sampling, elicitation, and roots are the requests a server makes of the client, and a server reaches your handlers by two routes depending on the era it speaks — see [protocol negotiation](#protocol-negotiation). On the handshake era it pushes the request down the open session mid-call. On the modern era there is no back-channel, so the server returns an input-required result naming what it needs and the client answers with a fresh call. Both routes dispatch to the same handler, so registering one covers both; the example below pins `mode="legacy"` only because it demonstrates the push route. Logging and progress arrive as notifications on the response stream and work in either era.
```python
from fastmcp import Client
diff --git a/docs/clients/roots.mdx b/docs/clients/roots.mdx
index cb50c0447..f9476fa65 100644
--- a/docs/clients/roots.mdx
+++ b/docs/clients/roots.mdx
@@ -1,7 +1,7 @@
---
title: Client Roots
sidebarTitle: Roots
-description: Provide local context and resource boundaries to MCP servers.
+description: Tell servers which local paths your client can reach.
icon: folder-tree
---
@@ -13,9 +13,9 @@ Use this when you need to tell servers what local resources the client has acces
Roots inform servers about resources the client can provide. Servers can use this information to adjust behavior or provide more relevant responses.
-
-**Roots require the older MCP protocol.** A server reads roots by sending a request down to the client, and protocol version `2026-07-28` removed the server's ability to do that. Clients default to `mode="auto"`, which negotiates the newest version both sides support, so the examples below pass `mode="legacy"`. See [protocol negotiation](/clients/client#protocol-negotiation).
-
+You register roots once, with `roots=`, and the client answers however the server asks for them. A **handshake-era** server pushes a `roots/list` request down the open session and reads the reply mid-call. A **modern** (`2026-07-28`) server has no such channel, so it returns an input-required result naming a roots request instead; `fastmcp.Client` fulfils it from the same `roots=` you registered and re-issues the call with the answer attached. One registration covers both routes, and the examples below work on either — the default `mode="auto"` negotiates whichever era the server speaks. See [protocol negotiation](/clients/client#protocol-negotiation) for how that choice is made.
+
+Roots is a good fit for the modern route rather than merely a workable one. A server asks for roots once and then has what it needs, so the single extra round trip buys the whole answer — unlike generation, where a loop of guard rounds would spend the round-trip budget over and over, which is why [server-side sampling](/servers/sampling) recommends calling an LLM directly instead. On the server side the request travels in the `input_requests` map of an `InputRequiredResult`, described under [the guard pattern](/servers/elicitation#sampling-and-roots).
## Static Roots
@@ -26,14 +26,13 @@ from fastmcp import Client
client = Client(
"my_mcp_server.py",
- mode="legacy",
roots=["/path/to/root1", "/path/to/root2"]
)
```
## Dynamic Roots
-Use a callback to compute roots dynamically when the server requests them:
+Use a callback to compute roots when the server asks for them. The callback runs on both routes — a pushed `roots/list` on a handshake connection, and a returned roots request on a modern one:
```python
from fastmcp import Client
@@ -45,7 +44,6 @@ async def roots_callback(context: RequestContext) -> list[str]:
client = Client(
"my_mcp_server.py",
- mode="legacy",
roots=roots_callback
)
```
diff --git a/docs/clients/sampling.mdx b/docs/clients/sampling.mdx
index 35cb8f2e7..fe8e25272 100644
--- a/docs/clients/sampling.mdx
+++ b/docs/clients/sampling.mdx
@@ -16,7 +16,7 @@ A sampling handler is the client's answer to that request: the server describes
On a **handshake-era** connection the server pushes a `sampling/createMessage` request down the open session mid-tool-call. On a **modern** (`2026-07-28`) connection there is no such channel, so a server instead *returns* an input-required result naming what it needs, and your client answers it and re-calls the tool. Both paths dispatch to the same `sampling_handler`, so register one and it works either way — this page's examples pin `mode="legacy"` only because they demonstrate the push route with a legacy server.
-This page is the client side. Writing a FastMCP **server** is the other direction, and there is no `ctx.sample()` there — see [Sampling](/servers/sampling) under Servers for why, and for calling an LLM from your own server instead.
+This page is the client side. Writing a FastMCP **server** is the other direction, and there is no `ctx.sample()` there — see [Sampling](/servers/sampling) under Servers for how a server returns a sampling request instead, and why calling an LLM directly is usually the better choice for generation.
## Handler Template
diff --git a/docs/getting-started/upgrading/from-fastmcp-3.mdx b/docs/getting-started/upgrading/from-fastmcp-3.mdx
index b2365e89b..eea42872d 100644
--- a/docs/getting-started/upgrading/from-fastmcp-3.mdx
+++ b/docs/getting-started/upgrading/from-fastmcp-3.mdx
@@ -239,19 +239,19 @@ The warning comes from the MCP SDK, not from FastMCP, and it is benign. `ctx.inf
FastMCP servers built on the SDK v2 serve multiple protocol eras from the same server. The SDK negotiates the era each client speaks: the sessionless `2026-07-28` era (which discovers capabilities through `server/discover`) and earlier session-based handshake versions are all handled simultaneously. This formally supersedes FastMCP's earlier "latest protocol only" stance — a single server now works with clients across the protocol transition.
-FastMCP 4 is a modern MCP toolkit, so the server API is the modern protocol's API. Where a capability survived the transition in a different shape, FastMCP carries it across in that shape. Where the protocol removed a capability outright, FastMCP 4 does not carry a version of it that only works on old connections: **`ctx.sample()`, `ctx.sample_step()`, and `ctx.list_roots()` are gone from `Context` entirely**, along with the `sampling_handler=` and `sampling_handler_behavior=` arguments to `FastMCP()`. Calling them raises `AttributeError` on every era, not an era-specific runtime error, and `FastMCP(sampling_handler=...)` raises `TypeError` naming the migration.
+FastMCP 4 is a modern MCP toolkit, so the server API is the modern protocol's API. Where a capability survived the transition in a different shape, FastMCP carries it across in that shape and drops the old spelling rather than shipping a method that only works on old connections: **`ctx.sample()`, `ctx.sample_step()`, and `ctx.list_roots()` are gone from `Context` entirely**, along with the `sampling_handler=` and `sampling_handler_behavior=` arguments to `FastMCP()`. Calling them raises `AttributeError` on every era, not an era-specific runtime error, and `FastMCP(sampling_handler=...)` raises `TypeError` naming the migration.
-This is a deliberate stance rather than an unfinished port. Server-initiated sampling and roots are *requests*: the server sends one and blocks for an answer, which needs a live back-channel the sessionless protocol does not have. Since `fastmcp.Client` now negotiates the modern protocol by default, keeping these methods would mean shipping an API whose default outcome is a runtime failure. Elicitation is the one server-initiated capability that survives, because the modern protocol carries it in a new shape: a tool *returns* an `InputRequiredResult` describing what it needs, and the client answers with a fresh call (see [Elicitation on the modern protocol](/servers/elicitation#elicitation-on-the-modern-protocol)).
+This is a deliberate stance rather than an unfinished port. `ctx.sample()` and `ctx.list_roots()` were *pushes*: the server sent a request down a live back-channel and blocked for the answer, and the sessionless protocol has no such channel. Since `fastmcp.Client` now negotiates the modern protocol by default, keeping those methods would mean shipping an API whose default outcome is a runtime failure. What the protocol removed is the pushing, not the asking — sampling, elicitation, and roots all still reach the client through the [guard pattern](/servers/elicitation#elicitation-on-the-modern-protocol), where a tool *returns* an `InputRequiredResult` describing what it needs, the client answers, and it calls again with the answer attached.
-Migrating is direct in both cases. For sampling, [call an LLM from your server](/servers/sampling) with your own API key — your tool then behaves the same for every client, including the many that never implemented sampling. For roots, accept the paths you need as tool arguments, or ask for them through the same guard pattern, whose `input_requests` map carries a roots request alongside elicitation.
+Migrating differs by capability. For **roots**, the guard pattern is the direct replacement and a good one: a server asks once and has what it needs, so returning a roots request costs a single round trip. Accepting the paths as tool arguments remains simpler when the caller can just supply them. For **sampling**, the guard route works the same way and conforms on `2026-07-28`, but generation usually belongs in your server — every round is a full request-response cycle, so a generation loop pays that cost repeatedly. [Call an LLM from your server](/servers/sampling) with your own API key and your tool behaves the same for every client, including the many that never implemented sampling; reach for the guard route when the point is specifically to use the caller's model.
| Context feature | Earlier eras (session-based) | `2026-07-28` (sessionless) |
| --- | --- | --- |
| `ctx.info` / logging notifications | Supported | Supported |
| Tools, resources, prompts, completions | Supported | Supported |
| `ctx.elicit` | Supported | Use the guard pattern (return `InputRequiredResult`) |
-| `ctx.sample` / `ctx.sample_step` | Removed from the API — call an LLM server-side | Removed from the API — call an LLM server-side |
-| `ctx.list_roots` | Removed from the API — take paths as arguments, or use the guard pattern | Removed from the API — take paths as arguments, or use the guard pattern |
+| `ctx.sample` / `ctx.sample_step` | Method removed — call an LLM server-side | Method removed — call an LLM server-side, or ask via the guard pattern |
+| `ctx.list_roots` | Method removed — take paths as tool arguments | Method removed — ask via the guard pattern, or take paths as tool arguments |
| `client.set_logging_level()` | Supported | Raises — `logging/setLevel` needs session state the era lacks |
| `Middleware.on_initialize` | Runs on connect | Never runs — there is no `initialize` handshake |
| Session state (`ctx.set_state` across calls) | Persists for the session | Does not persist — every request is a fresh connection |
@@ -259,7 +259,7 @@ Migrating is direct in both cases. For sampling, [call an LLM from your server](
Two of these bite by default now, because **`fastmcp.Client` defaults to `mode="auto"`** in v4 — an ordinary `Client(server)` negotiates the newest protocol both sides share, which against a FastMCP server is the sessionless `2026-07-28` era. On that era there is no `initialize` handshake, so a `Middleware.on_initialize` hook never runs; and each request is a fresh connection, so state written with `ctx.set_state` in one call is not visible in the next. A server that gates access in `on_initialize` or relies on per-session state must keep its clients on the session-based era. The narrow escape is per-client: `Client(server, mode="legacy")`. The durable, server-side answer is to declare the versions the server actually serves so a modern client is refused at connect time rather than silently losing those features — see the server's protocol-version restriction (added alongside this change).
-The client side is unaffected by any of this. A `fastmcp.Client` still answers a legacy server's sampling and roots requests through `sampling_handler=` and `roots=` — see [client sampling](/clients/sampling) and [client roots](/clients/roots) — because a modern client still has to interoperate with servers built before the protocol changed.
+The client side is unaffected by any of this. `sampling_handler=` and `roots=` mean what they always did — see [client sampling](/clients/sampling) and [client roots](/clients/roots) — and one registration serves both routes: a handshake-era server's pushed request and a modern server's returned one dispatch to the same handler, so a client needs no era-specific wiring either way.
## Upgrade checklist
@@ -268,7 +268,7 @@ Most servers upgrade untouched. Work down this list to find the ones that don't:
1. **Bump your environment.** Raise any pin below `pydantic>=2.12`; upgrade FastAPI if your resolver complains about Starlette `<1.0.1`.
2. **Fix imports that moved out.** Replace `from mcp.types import X` with `from mcp_types import X`, and update any import from the [removed modules](#moved-imports) (`fastmcp.server.proxy`, `fastmcp.server.openapi`, `fastmcp.server.apps`, the `fastmcp.tools.tool` / `resources.resource` / `prompts.prompt` component shims).
3. **Update removed server APIs.** Swap `as_proxy` → `create_proxy`, `import_server` → `mount`, `mount(prefix=)` → `mount(namespace=)`, and the [other removed methods and keywords](#removed-server-methods-and-mount-keywords).
-4. **Replace `ctx.sample` and `ctx.list_roots`.** Both are gone from `Context`, as are `FastMCP(sampling_handler=...)` and `sampling_handler_behavior=`. Call an LLM directly from your server for generation; take file paths as tool arguments for roots.
+4. **Replace `ctx.sample` and `ctx.list_roots`.** Both are gone from `Context`, as are `FastMCP(sampling_handler=...)` and `sampling_handler_behavior=`. Call an LLM directly from your server for generation; ask for roots through the guard pattern, or take file paths as tool arguments.
5. **Update removed tool parameters.** Replace tool `serializer=` (return a `ToolResult`), `exclude_args=` (use `Depends()`), and `StreamableHttpTransport(sse_read_timeout=)`.
6. **Fix `McpError` construction.** Positional `McpError(ErrorData(...))` becomes keyword `McpError(code=..., message=...)`. Catching is unchanged.
7. **Move httpx to httpx2.** Grep for `except httpx.` and for custom `httpx_client_factory` / `httpx.Auth` objects handed to FastMCP, and swap the import to `httpx2`.
diff --git a/docs/getting-started/whats-new.mdx b/docs/getting-started/whats-new.mdx
index 8fe57e719..076d766cc 100644
--- a/docs/getting-started/whats-new.mdx
+++ b/docs/getting-started/whats-new.mdx
@@ -25,7 +25,7 @@ A FastMCP 4 server answers clients across the protocol transition from one deplo
The same negotiation runs from the client, and its default flipped. A plain `Client(url)` now probes for the modern protocol and adopts it when the server offers it, falling back to the handshake otherwise — where every earlier FastMCP version pinned the handshake outright. That flip is what brings the modern capabilities within reach of ordinary client code: a task-enabled tool hands back a handle to poll, and multi-round-trip elicitation resolves across successive requests, neither requiring the caller to opt in. Set `mode="legacy"` to pin the handshake when you need the session-based back-channel or the classic `initialize` result. See [Protocol negotiation](/clients/client#protocol-negotiation).
-The modern protocol is sessionless, so it drops the server's ability to call back into the client mid-request (SEP-2577), and FastMCP 4's server API reflects that rather than papering over it. `ctx.elicit` moves to a request-shaped pattern that works on modern connections. `ctx.sample`, `ctx.sample_step`, and `ctx.list_roots` are not in the API at all — the capabilities they wrapped no longer exist in the protocol FastMCP 4 targets, so shipping methods that only work against old clients would be shipping a trap. [Call an LLM from your server](/servers/sampling) for generation, and take file paths as tool arguments for roots. Logging is untouched: `ctx.info` and its siblings are notifications, which ride the response stream and reach the client on every era. Everything else about writing a server is unchanged.
+The modern protocol is sessionless, so it drops the server's ability to call back into the client mid-request (SEP-2577), and FastMCP 4's server API reflects that rather than papering over it. `ctx.elicit` moves to a request-shaped pattern that works on modern connections. `ctx.sample`, `ctx.sample_step`, and `ctx.list_roots` are not in the API at all — they pushed a request into a live connection, and shipping methods that only work against old clients would be shipping a trap. The capabilities behind them survive through the same request-shaped pattern: a tool returns a sampling or roots request and reads the answer on the next round. For roots that is the natural replacement, since one round trip buys the whole answer. For generation, [call an LLM from your server](/servers/sampling) — a loop of guard rounds spends the round-trip budget several times over. Logging is untouched: `ctx.info` and its siblings are notifications, which ride the response stream and reach the client on every era. Everything else about writing a server is unchanged.
## State without a session
diff --git a/docs/servers/context.mdx b/docs/servers/context.mdx
index bd0a4faa2..5e3711213 100644
--- a/docs/servers/context.mdx
+++ b/docs/servers/context.mdx
@@ -154,7 +154,7 @@ See [User Elicitation](/servers/elicitation) for detailed examples and supported
### Server-initiated requests
-`Context` has no `sample()` or `list_roots()`. Both were server→client *requests*, and the modern MCP protocol has no channel to carry them ([SEP-2577](/servers/sampling)). For generation, [call an LLM directly from your server](/servers/sampling). For roots, accept paths as tool arguments, or ask for them through the [guard pattern](/servers/elicitation#elicitation-on-the-modern-protocol), which carries a roots request in `input_requests`.
+`Context` has no `sample()` or `list_roots()`. Both *pushed* a request into a live client connection, and the modern MCP protocol has no channel to carry that ([SEP-2577](/servers/sampling)). Both capabilities remain reachable through the [guard pattern](/servers/elicitation#sampling-and-roots), where a tool returns a sampling or roots request in `input_requests` and reads the answer from `ctx.input_responses` on the next round. That is the natural route for roots. For generation, [call an LLM directly from your server](/servers/sampling) instead — each guard round costs a full round trip.
Logging is unaffected — `ctx.info()` and friends are *notifications*, which ride the response stream and work on every protocol era.
diff --git a/docs/servers/elicitation.mdx b/docs/servers/elicitation.mdx
index 35677f81a..937dc04d3 100644
--- a/docs/servers/elicitation.mdx
+++ b/docs/servers/elicitation.mdx
@@ -596,9 +596,9 @@ The same protocol requirement applies: returning an `InputRequiredResult` from a
### Sampling and roots
-Elicitation is the most common request to carry this way, and **roots** requests work identically — the `input_requests` map holds them the same way, and each answer comes back in `ctx.input_responses` under its key (an `ElicitResult` or `ListRootsResult`). See [Client Roots](/clients/roots) for what a roots request contains. `fastmcp.Client` answers both from the handlers you already configured, so a guard tool that mixes them needs no extra client wiring.
+Elicitation is the most common request to carry this way, and **roots** and **sampling** requests work identically — the `input_requests` map holds a `ListRootsRequest` or a `CreateMessageRequest` the same way it holds an `ElicitRequest`, and each answer comes back in `ctx.input_responses` under its key as a `ListRootsResult` or `CreateMessageResult`. A single map can mix all three. `fastmcp.Client` answers each one from the handlers you already configured — `elicitation_handler=`, `roots=`, `sampling_handler=` — so a guard tool that mixes them needs no extra client wiring. See [Client Roots](/clients/roots) for what a roots request contains.
-The map can structurally hold a **sampling** request too (its answer would be a `CreateMessageResult`), but MCP removed server-initiated sampling as a pattern rather than only one spelling of it, so generation belongs in your server. See [Sampling](/servers/sampling) for how to call an LLM directly.
+The two differ in when you should reach for them. Roots suits this pattern well: a server asks once and has what it needs, so one extra round trip buys the whole answer. Generation usually does not, because every round is a full request-response cycle and a loop pays that cost each time — [call an LLM directly from your server](/servers/sampling) unless the point is specifically to use the caller's model.
### Middleware
diff --git a/docs/servers/sampling.mdx b/docs/servers/sampling.mdx
index 5aeacd413..cc2154fcf 100644
--- a/docs/servers/sampling.mdx
+++ b/docs/servers/sampling.mdx
@@ -1,13 +1,15 @@
---
title: Sampling
sidebarTitle: Sampling
-description: Server-initiated sampling is not part of FastMCP 4 — here is why, and what to build instead.
+description: Generate text from a FastMCP server — by calling an LLM directly, or by asking the client to sample.
icon: robot
---
-FastMCP 4 targets the modern MCP protocol, and that protocol has no channel for a server to send a request to a client. A tool cannot pause mid-execution to borrow the caller's model and wait for a completion, so server-initiated sampling is not part of the FastMCP 4 server API. There is no `ctx.sample()` and no server-side sampling handler. Generation belongs to your server now: you call an LLM with your own credentials, the same way you would call any other service.
+FastMCP 4 targets the modern MCP protocol, and that protocol has no channel for a server to push a request into a live client connection. A tool cannot pause mid-execution to borrow the caller's model and resume when the completion arrives, so the imperative methods that did exactly that are gone: there is no `ctx.sample()` and no `ctx.sample_step()`, and `FastMCP()` no longer accepts `sampling_handler=`. Calling them raises `AttributeError` on every protocol era rather than failing at runtime only against modern clients.
-This follows the protocol rather than getting ahead of it. MCP removed server-initiated requests in the `2026-07-28` revision ([SEP-2577](https://modelcontextprotocol.io/community/sep-guidelines)), and FastMCP 4's client negotiates that revision by default. Keeping `ctx.sample()` around would mean shipping a method whose ordinary, default outcome is a runtime error.
+The *capability* survives in a different shape. A tool that genuinely needs the caller's model asks for a completion by **returning** a request for one, the same [guard pattern](/servers/elicitation#elicitation-on-the-modern-protocol) elicitation uses: the round ends as an ordinary response, the client runs the completion against its own model, and it calls the tool again with the answer attached. FastMCP's own conformance suite exercises this on `2026-07-28`.
+
+For most generation you should skip that round trip and call an LLM directly from your server. Each guard round is a full request-response cycle, so a tool that generates in a loop spends the round-trip budget several times over, while a direct call is an ordinary async function call inside a single tool invocation. Ask the client to sample when the point is to use *the caller's* model — their credentials, their choice of provider, their bill. Call an LLM yourself when the point is to generate text.
## Requests and notifications
@@ -27,11 +29,64 @@ async def build_report(rows: int, ctx: Context) -> str:
return "done"
```
-Sampling is the other kind. It is a *request* — the server sends `sampling/createMessage` and then blocks until an answer comes back the other way. That requires a live, addressable connection the server can reach into, which is precisely the thing a stateless protocol does not have. There is no version of sampling that fits, which is why it has no replacement in the way elicitation does. Elicitation moved to the [guard pattern](/servers/elicitation#elicitation-on-the-modern-protocol), where a tool *returns* a description of what it needs and the client answers with a fresh call; generation does not decompose into rounds that way, because an agentic loop would spend the round-trip budget several times over.
+Sampling is the other kind. It is a *request* — `sampling/createMessage` goes out and the caller must answer before the tool can continue. Pushing that request needs a live, addressable connection the server can reach into, which is precisely what a stateless protocol does not have, and MCP removed server-initiated requests in the `2026-07-28` revision ([SEP-2577](https://modelcontextprotocol.io/community/sep-guidelines)) for that reason. What the protocol removed is the *pushing*, not the asking. Asking survives by inverting who initiates the next message: instead of the server reaching down mid-call, the tool returns a description of what it needs and the client comes back.
+
+## Asking the client to sample
+
+A tool asks for a completion by returning an `InputRequiredResult` whose `input_requests` map holds a `CreateMessageRequest` under a key you choose. That result completes the round normally. The client runs the completion, then re-issues the same `call_tool` with the answer attached, and your tool reads it from `ctx.input_responses` under the same key — a `CreateMessageResult`. Because the tool runs from the top on every round, the presence of `ctx.input_responses` is what tells the two rounds apart: `None` on the first call, populated on the continuation.
+
+`fastmcp.Client` drives this loop for you and answers from the [`sampling_handler`](/clients/sampling) you already registered, so a client configured for a handshake-era server needs no extra wiring to satisfy a modern guard tool.
+
+```python
+from fastmcp import Context, FastMCP
+from mcp_types import (
+ CreateMessageRequest,
+ CreateMessageRequestParams,
+ CreateMessageResult,
+ InputRequiredResult,
+ SamplingMessage,
+ TextContent,
+)
+
+mcp = FastMCP("Research")
+
+
+@mcp.tool
+async def ask_the_caller(question: str, ctx: Context) -> str | InputRequiredResult:
+ """Put a question to the caller's model and report what it answered."""
+ responses = ctx.input_responses
+ if responses is None:
+ return InputRequiredResult(
+ result_type="input_required",
+ input_requests={
+ "answer": CreateMessageRequest(
+ method="sampling/createMessage",
+ params=CreateMessageRequestParams(
+ messages=[
+ SamplingMessage(
+ role="user",
+ content=TextContent(type="text", text=question),
+ )
+ ],
+ max_tokens=100,
+ ),
+ )
+ },
+ )
+
+ answer = responses["answer"]
+ if isinstance(answer, CreateMessageResult) and isinstance(
+ answer.content, TextContent
+ ):
+ return answer.content.text
+ return "The client returned no completion."
+```
+
+Returning an `InputRequiredResult` needs a `2026-07-28` connection; FastMCP names the era mismatch if an older client reaches the tool. The map can carry several requests at once and mix kinds — a sampling request beside an elicitation or a roots request — and each answer comes back under its own key. [Elicitation](/servers/elicitation#sampling-and-roots) covers the mechanics of the pattern in full, including how to carry state across rounds.
## Calling an LLM directly
-Your server calls the model. Hold a provider API key in your server's environment, create the client once at module scope so connections are reused across calls, and generate inside the tool. The result is a plain async function call with no protocol involvement, which also means you choose the model, control the prompt, see the token usage, and can test the tool without a client attached.
+For generation, your server calls the model. Hold a provider API key in your server's environment, create the client once at module scope so connections are reused across calls, and generate inside the tool. The result is a plain async function call with no protocol involvement, which also means you choose the model, control the prompt, see the token usage, and can test the tool without a client attached.
```python
import anthropic
@@ -53,13 +108,13 @@ async def summarize(text: str) -> str:
return response.content[0].text
```
-Any provider SDK works the same way — swap the client and the call, and the tool signature is unchanged. Because generation is now ordinary application code, the surrounding concerns become ordinary too: retries, timeouts, caching, and cost accounting are yours to place where you want them rather than negotiated across a protocol boundary.
+Any provider SDK works the same way — swap the client and the call, and the tool signature is unchanged. Because generation is now ordinary application code, the surrounding concerns become ordinary too: retries, timeouts, caching, and cost accounting are yours to place where you want them rather than negotiated across a protocol boundary. A tool that chains several generations pays nothing extra for the second and third, where the guard route would pay a full round trip for each.
The trade this makes is explicit. Sampling let a server borrow the caller's model and the caller's bill; calling directly means you supply the key and pay for the tokens. In exchange your tool behaves identically for every client, including the many that never implemented sampling at all.
## Clients answering servers
-The client half of sampling is unaffected. A `fastmcp.Client` connecting to a handshake-era server may still receive `sampling/createMessage` requests from it, and passing `sampling_handler=` is how you answer them — see [Sampling](/clients/sampling) under Clients. That path exists for interoperating with older servers and has nothing to do with authoring one.
+Both routes land in the same place on the client. A `fastmcp.Client` passes `sampling_handler=` once, and that handler answers a handshake-era server's pushed `sampling/createMessage` request and a modern server's returned sampling request alike — see [Sampling](/clients/sampling) under Clients.
Servers on FastMCP 3 still have `ctx.sample()` and `ctx.sample_step()`, documented in the [FastMCP 3 sampling guide](/v3/servers/sampling). Nothing changes for them until they upgrade.
From d5ff8316020fc12ba91b325b8bd1777ecd96e218 Mon Sep 17 00:00:00 2001
From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>
Date: Sun, 26 Jul 2026 19:33:40 -0400
Subject: [PATCH 09/15] Change register: record the guard route for sampling
and roots
---
dev-docs/v4-notes/change-register.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/dev-docs/v4-notes/change-register.md b/dev-docs/v4-notes/change-register.md
index af6d36049..39ecabdbd 100644
--- a/dev-docs/v4-notes/change-register.md
+++ b/dev-docs/v4-notes/change-register.md
@@ -451,7 +451,7 @@ FastMCP 4 is a modern MCP toolkit, so the capabilities the modern protocol remov
The motivating failure is that the gate had become the default experience. `Client` now defaults to `mode="auto"`, which negotiates `2026-07-28` against a FastMCP server, so an unmodified `ctx.sample()` server failed on an ordinary client connection. Four shipped examples (`examples/sampling/`) were broken by that flip; they are deleted rather than ported, and remain available on `release/3.x`.
-Server-initiated sampling and roots are *requests* — the server sends one and blocks for the answer — which needs a back-channel the sessionless protocol does not have. Unlike elicitation, generation has no multi-round-trip replacement (an agentic loop would exhaust the round-trip budget). The migration is to call an LLM directly from the server for generation, and to accept paths as tool arguments (or via `InputRequiredResult.input_requests`, which still carries a `ListRootsRequest`) for roots.
+Server-initiated sampling and roots are *requests* — the server sends one and blocks for the answer — which needs a back-channel the sessionless protocol does not have. What the protocol removed is the *pushing*, not the asking: both capabilities remain reachable through the guard pattern, where a tool returns an `InputRequiredResult` whose `input_requests` map carries a `CreateMessageRequest` or a `ListRootsRequest`, the client answers it, and the tool re-runs and reads `ctx.input_responses`. `Client._drive_input_required()` dispatches those to the same `sampling_handler` / `roots` handler a handshake-era server would have pushed to, and `tests/conformance/server.py` exercises both routes. For roots that guard round is the recommended modern path. For generation it is available but usually the wrong tool — each round is a full request-response cycle, so an agentic loop exhausts the round-trip budget — and the recommended migration stays a direct LLM call from the server.
**What is deliberately kept.** Client-side `Client(sampling_handler=..., roots=...)` and the provider handlers (anthropic/openai/google_genai) stay: a FastMCP client must still answer a legacy server's requests, and removing them would break interop with older servers. `docs/clients/sampling.mdx` and `docs/clients/roots.mdx` stay as real documentation. Logging is untouched — `ctx.log`/`info`/`debug`/`warning`/`error` are notifications that ride the request's own stream and work on every era.
From 7fe3c1e8bd74876eafd8e665d0a9b398773f046d Mon Sep 17 00:00:00 2001
From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>
Date: Sun, 26 Jul 2026 20:23:23 -0400
Subject: [PATCH 10/15] Editorial pass on the sampling and roots docs
---
docs/clients/client.mdx | 12 +--
docs/clients/roots.mdx | 10 +-
docs/clients/sampling.mdx | 96 +++++++++----------
docs/deployment/http.mdx | 2 +-
.../upgrading/from-fastmcp-3.mdx | 8 +-
docs/getting-started/whats-new.mdx | 4 +-
docs/servers/context.mdx | 9 +-
docs/servers/elicitation.mdx | 4 +-
docs/servers/sampling.mdx | 70 +++++---------
docs/servers/tools.mdx | 12 +--
10 files changed, 94 insertions(+), 133 deletions(-)
diff --git a/docs/clients/client.mdx b/docs/clients/client.mdx
index e45081101..092738e7c 100644
--- a/docs/clients/client.mdx
+++ b/docs/clients/client.mdx
@@ -185,16 +185,11 @@ Set `mode="legacy"` to force the initialize handshake. This behaves identically
client = Client("https://example.com/mcp", mode="legacy")
```
-Legacy mode is also what you need for the capabilities that depend on a live session between client and server. The handshake opens a persistent back-channel the server can push requests down, and the modern era removed it. Pin `mode="legacy"` when your code relies on any of these:
-
-- **[Sampling](/clients/sampling)** — server-initiated LLM completion requests
-- **[Roots](/clients/roots)** — server-initiated requests for the client's roots
-- **[Elicitation](/clients/elicitation)** — server-initiated requests for user input, which modern connections replace with [input-required rounds](/clients/elicitation#input-required-rounds)
-- `client.ping()` and `transport.get_session_id()`
+Legacy mode is also what carries the *pushed* form of a server's requests. The handshake opens a persistent back-channel down which a server can send a sampling, roots, or elicitation request mid-call, and the modern era removed it. Your handlers are unaffected by that: a [sampling](/clients/sampling), [roots](/clients/roots), or [elicitation](/clients/elicitation) handler you register answers a modern server's [input-required rounds](/clients/elicitation#input-required-rounds) from the same registration. Pin `mode="legacy"` when you connect to a server that pushes, or when your code calls `client.ping()` or `transport.get_session_id()`, which need the session the modern era does not open.
Conversely, [background tasks](/clients/tasks) are **modern-only**: the tasks capability is negotiated over `2026-07-28` connections, so `mode="legacy"` never triggers one and a task-enabled tool just runs synchronously.
-A FastMCP server serves both eras, so a default client negotiates the modern one and these raise an era-specific error. Pinning the handshake restores them.
+A FastMCP server serves both eras, so a default client negotiates the modern one and the session-dependent calls raise an era-specific error there. Pinning the handshake restores them.
You can also pin a specific modern protocol version to adopt it directly, without a discovery probe:
@@ -342,7 +337,7 @@ See [Prompts](/clients/prompts) for detailed documentation including argument se
The client supports callback handlers for advanced server interactions. These let you respond to server-initiated requests and receive notifications.
-Sampling, elicitation, and roots are the requests a server makes of the client, and a server reaches your handlers by two routes depending on the era it speaks — see [protocol negotiation](#protocol-negotiation). On the handshake era it pushes the request down the open session mid-call. On the modern era there is no back-channel, so the server returns an input-required result naming what it needs and the client answers with a fresh call. Both routes dispatch to the same handler, so registering one covers both; the example below pins `mode="legacy"` only because it demonstrates the push route. Logging and progress arrive as notifications on the response stream and work in either era.
+Sampling, elicitation, and roots are the requests a server makes of the client. A server reaches your handler by whichever route its [era](#protocol-negotiation) allows — pushed down the open session on the handshake, returned as an input-required result on the modern protocol — and both routes dispatch to the same handler, so one registration covers both. Logging and progress arrive as notifications on the response stream and work in either era.
```python
from fastmcp import Client
@@ -360,7 +355,6 @@ async def sampling_handler(messages, params, context):
client = Client(
"my_mcp_server.py",
- mode="legacy",
log_handler=log_handler,
progress_handler=progress_handler,
sampling_handler=sampling_handler,
diff --git a/docs/clients/roots.mdx b/docs/clients/roots.mdx
index f9476fa65..9de83cf61 100644
--- a/docs/clients/roots.mdx
+++ b/docs/clients/roots.mdx
@@ -11,15 +11,13 @@ import { VersionBadge } from '/snippets/version-badge.mdx'
Use this when you need to tell servers what local resources the client has access to.
-Roots inform servers about resources the client can provide. Servers can use this information to adjust behavior or provide more relevant responses.
+A root is a path your client is willing to expose — a project directory, a workspace, a document store. Servers read them to scope their work, so a tool that searches files searches where you pointed it, and a server that gets no roots has to ask the user for paths instead. Roots describe where the client can reach; the server takes them as its working boundary.
-You register roots once, with `roots=`, and the client answers however the server asks for them. A **handshake-era** server pushes a `roots/list` request down the open session and reads the reply mid-call. A **modern** (`2026-07-28`) server has no such channel, so it returns an input-required result naming a roots request instead; `fastmcp.Client` fulfils it from the same `roots=` you registered and re-issues the call with the answer attached. One registration covers both routes, and the examples below work on either — the default `mode="auto"` negotiates whichever era the server speaks. See [protocol negotiation](/clients/client#protocol-negotiation) for how that choice is made.
-
-Roots is a good fit for the modern route rather than merely a workable one. A server asks for roots once and then has what it needs, so the single extra round trip buys the whole answer — unlike generation, where a loop of guard rounds would spend the round-trip budget over and over, which is why [server-side sampling](/servers/sampling) recommends calling an LLM directly instead. On the server side the request travels in the `input_requests` map of an `InputRequiredResult`, described under [the guard pattern](/servers/elicitation#sampling-and-roots).
+Register them once with `roots=`, and the client answers however the server asks. A handshake-era server pushes a `roots/list` request down the open session and reads the reply mid-call; a modern (`2026-07-28`) server has no such channel, so it returns a roots request and `fastmcp.Client` fulfils it from the same registration and re-issues the call with the answer attached. The default `mode="auto"` negotiates whichever era the server speaks, so the examples below work on either — see [protocol negotiation](/clients/client#protocol-negotiation) for how that choice is made, and [the guard pattern](/servers/elicitation#sampling-and-roots) for how a server issues the modern form.
## Static Roots
-Provide a list of roots when creating the client:
+When the paths are known up front, pass them as a list. The client holds them for the life of the connection and hands back the same set every time a server asks.
```python
from fastmcp import Client
@@ -32,7 +30,7 @@ client = Client(
## Dynamic Roots
-Use a callback to compute roots when the server asks for them. The callback runs on both routes — a pushed `roots/list` on a handshake connection, and a returned roots request on a modern one:
+Pass a callback instead when the roots depend on something the client learns at runtime, such as the workspace the user has open. It runs at the moment a server asks, on either route, and receives the request context so you can see which request it is answering:
```python
from fastmcp import Client
diff --git a/docs/clients/sampling.mdx b/docs/clients/sampling.mdx
index fe8e25272..746dc7ba4 100644
--- a/docs/clients/sampling.mdx
+++ b/docs/clients/sampling.mdx
@@ -11,57 +11,44 @@ import { VersionBadge } from "/snippets/version-badge.mdx";
Use this when a server asks your client to run an LLM completion on its behalf.
-A sampling handler is the client's answer to that request: the server describes the messages it wants completed, your handler runs them against whatever model you control, and the result goes back. Servers reach your handler by two different routes, and a handler you register serves both.
+Sampling is how a server borrows your model. Rather than hold an API key of its own, the server describes the messages it wants completed and asks you to run them — you pick the model, and you pay for the tokens. Your side of that arrangement is one function, a **sampling handler**, registered when you create the client.
-On a **handshake-era** connection the server pushes a `sampling/createMessage` request down the open session mid-tool-call. On a **modern** (`2026-07-28`) connection there is no such channel, so a server instead *returns* an input-required result naming what it needs, and your client answers it and re-calls the tool. Both paths dispatch to the same `sampling_handler`, so register one and it works either way — this page's examples pin `mode="legacy"` only because they demonstrate the push route with a legacy server.
-
-
-This page is the client side. Writing a FastMCP **server** is the other direction, and there is no `ctx.sample()` there — see [Sampling](/servers/sampling) under Servers for how a server returns a sampling request instead, and why calling an LLM directly is usually the better choice for generation.
-
+The handler receives the conversation the server wants completed, the parameters it asked for, and a request context carrying metadata about the call. Return the generated text as a string and FastMCP wraps it in the protocol's result for you; return a `CreateMessageResult` yourself when you want to report the real model name or hand back content that isn't text. If the handler raises, the client sends the error back in place of a completion and the server's tool decides what to do about it.
## Handler Template
```python
from fastmcp import Client
from fastmcp.client.sampling import SamplingMessage, SamplingParams, RequestContext
+from mcp_types import TextContent
+
async def sampling_handler(
messages: list[SamplingMessage],
params: SamplingParams,
- context: RequestContext
+ context: RequestContext,
) -> str:
- """
- Handle server requests for LLM completions.
-
- Args:
- messages: Conversation messages to send to the LLM
- params: Sampling parameters (temperature, max_tokens, etc.)
- context: Request context with metadata
-
- Returns:
- Generated text response from your LLM
- """
- # Extract message content
- conversation = []
- for message in messages:
- content = message.content.text if hasattr(message.content, 'text') else str(message.content)
- conversation.append(f"{message.role}: {content}")
-
- # Use the system prompt if provided
+ """Run the server's messages against your LLM and return the completion."""
+ conversation = [
+ f"{message.role}: {message.content.text}"
+ for message in messages
+ if isinstance(message.content, TextContent)
+ ]
system_prompt = params.system_prompt or "You are a helpful assistant."
- # Integrate with your LLM service here
+ # Call your LLM here with `conversation` and `system_prompt`.
return "Generated response based on the messages"
-client = Client(
- "my_mcp_server.py",
- mode="legacy",
- sampling_handler=sampling_handler,
-)
+
+client = Client("my_mcp_server.py", sampling_handler=sampling_handler)
```
+The client answers with this handler however the server asks for a completion. The default `mode="auto"` negotiates whichever protocol era the server speaks, and one handler covers both of the routes an era can use — see [Request Routes](#request-routes).
+
## Handler Parameters
+Everything the server sends arrives in the first two arguments. The messages are the conversation to complete; the parameters are how the server would like it completed. You decide how much of that to honor, since the client owns the model — a preference your provider cannot express is yours to ignore.
+
The role of the message
@@ -73,11 +60,11 @@ client = Client(
-
+
Optional system prompt the server wants to use
-
+
Server preferences for model selection (hints, cost/speed/intelligence priorities)
@@ -85,11 +72,11 @@ client = Client(
Sampling temperature
-
+
Maximum tokens to generate
-
+
Stop sequences for sampling
@@ -97,14 +84,14 @@ client = Client(
Tools the LLM can use during sampling
-
+
Tool usage behavior (`auto`, `required`, or `none`)
## Built-in Handlers
-FastMCP provides built-in handlers for OpenAI, Anthropic, and Google Gemini APIs that support the full sampling API including tool use.
+Writing the provider call yourself is rarely worth it. FastMCP ships handlers for OpenAI, Anthropic, and Google Gemini that implement the full sampling API, tool use included, and translate the protocol's parameters into each provider's own. Give one a default model and pass it where your own handler would go. Write a custom handler when you need routing across providers, caching, or a provider FastMCP does not cover.
### OpenAI Handler
@@ -116,19 +103,19 @@ from fastmcp.client.sampling.handlers.openai import OpenAISamplingHandler
client = Client(
"my_mcp_server.py",
- mode="legacy",
sampling_handler=OpenAISamplingHandler(default_model="gpt-4o"),
)
```
-For OpenAI-compatible APIs (like local models):
+Point the handler at any OpenAI-compatible API, including a local model server, by passing your own provider client:
```python
+from fastmcp import Client
+from fastmcp.client.sampling.handlers.openai import OpenAISamplingHandler
from openai import AsyncOpenAI
client = Client(
"my_mcp_server.py",
- mode="legacy",
sampling_handler=OpenAISamplingHandler(
default_model="llama-3.1-70b",
client=AsyncOpenAI(base_url="http://localhost:8000/v1"),
@@ -150,7 +137,6 @@ from fastmcp.client.sampling.handlers.anthropic import AnthropicSamplingHandler
client = Client(
"my_mcp_server.py",
- mode="legacy",
sampling_handler=AnthropicSamplingHandler(default_model="claude-sonnet-4-5"),
)
```
@@ -169,7 +155,6 @@ from fastmcp.client.sampling.handlers.google_genai import GoogleGenaiSamplingHan
client = Client(
"my_mcp_server.py",
- mode="legacy",
sampling_handler=GoogleGenaiSamplingHandler(default_model="gemini-2.0-flash"),
)
```
@@ -178,25 +163,32 @@ client = Client(
Install the Google Gemini handler with `pip install 'fastmcp[gemini]'`.
-## Sampling Capabilities
+The [source of these handlers](https://github.com/PrefectHQ/fastmcp/tree/main/fastmcp_slim/fastmcp/client/sampling/handlers) is the best reference for writing your own.
-When you provide a `sampling_handler`, FastMCP automatically advertises full sampling capabilities to the server, including tool support. To disable tool support for simpler handlers:
+## Tool Use
+
+A sampling request can carry tools. When it does, your handler passes them to the model and returns whatever comes back, tool calls included — the server executes the tools itself and sends a follow-up sampling request with the results if it needs another turn. Your handler never runs a tool.
+
+Registering any `sampling_handler` advertises full sampling support, tools included. A handler that only generates text should say so, so servers know not to send tools it will drop:
```python
+from fastmcp import Client
from mcp_types import SamplingCapability
+
+async def text_only_handler(messages, params, context) -> str:
+ return "Generated response based on the messages"
+
+
client = Client(
"my_mcp_server.py",
- mode="legacy",
- sampling_handler=basic_handler,
- sampling_capabilities=SamplingCapability(), # No tool support
+ sampling_handler=text_only_handler,
+ sampling_capabilities=SamplingCapability(),
)
```
-## Tool Execution
+## Request Routes
-Tool execution happens on the server side. The client's role is to pass tools to the LLM and return the LLM's response (which may include tool use requests). The server then executes the tools and may send follow-up sampling requests with tool results.
+Servers reach your handler by two routes, and which one applies depends on the protocol era the connection negotiated. A handshake-era server pushes a `sampling/createMessage` request down the open session while a tool is running and waits for the reply. A modern (`2026-07-28`) connection has no such channel, so the tool ends its round by returning a request for a completion instead; the client answers from your handler and calls the tool again with the result attached.
-
-To implement a custom sampling handler, see the [handler source code](https://github.com/PrefectHQ/fastmcp/tree/main/fastmcp_slim/fastmcp/client/sampling/handlers) as a reference.
-
+One registration covers both, so this is rarely something you configure — it matters only when you pin an era, since `mode="legacy"` is the sole route that carries a pushed request. See [protocol negotiation](/clients/client#protocol-negotiation) for how the era is chosen, and [Sampling](/servers/sampling) under Servers for how a server issues these requests.
diff --git a/docs/deployment/http.mdx b/docs/deployment/http.mdx
index 573f726d1..9c0750a39 100644
--- a/docs/deployment/http.mdx
+++ b/docs/deployment/http.mdx
@@ -698,7 +698,7 @@ When deploying FastMCP behind a load balancer or running multiple server instanc
#### Understanding Sessions
-By default, FastMCP's Streamable HTTP transport maintains server-side sessions. Sessions carry the handshake-era back-channel that server-initiated requests like [elicitation](/servers/elicitation) depend on, where the server needs to maintain context across multiple requests from the same client.
+By default, FastMCP's Streamable HTTP transport maintains server-side sessions. A session holds the context a server keeps across multiple requests from the same client, and it carries the handshake-era back-channel that server-initiated requests like [elicitation](/servers/elicitation) push down.
This works perfectly for single-instance deployments. However, sessions are stored in memory on each server instance, which creates challenges when scaling horizontally.
diff --git a/docs/getting-started/upgrading/from-fastmcp-3.mdx b/docs/getting-started/upgrading/from-fastmcp-3.mdx
index eea42872d..f764a5923 100644
--- a/docs/getting-started/upgrading/from-fastmcp-3.mdx
+++ b/docs/getting-started/upgrading/from-fastmcp-3.mdx
@@ -239,11 +239,11 @@ The warning comes from the MCP SDK, not from FastMCP, and it is benign. `ctx.inf
FastMCP servers built on the SDK v2 serve multiple protocol eras from the same server. The SDK negotiates the era each client speaks: the sessionless `2026-07-28` era (which discovers capabilities through `server/discover`) and earlier session-based handshake versions are all handled simultaneously. This formally supersedes FastMCP's earlier "latest protocol only" stance — a single server now works with clients across the protocol transition.
-FastMCP 4 is a modern MCP toolkit, so the server API is the modern protocol's API. Where a capability survived the transition in a different shape, FastMCP carries it across in that shape and drops the old spelling rather than shipping a method that only works on old connections: **`ctx.sample()`, `ctx.sample_step()`, and `ctx.list_roots()` are gone from `Context` entirely**, along with the `sampling_handler=` and `sampling_handler_behavior=` arguments to `FastMCP()`. Calling them raises `AttributeError` on every era, not an era-specific runtime error, and `FastMCP(sampling_handler=...)` raises `TypeError` naming the migration.
+**`ctx.sample()`, `ctx.sample_step()`, and `ctx.list_roots()` are gone from `Context`**, along with the `sampling_handler=` and `sampling_handler_behavior=` arguments to `FastMCP()`. Touching a removed method raises `AttributeError` on every era, and `FastMCP(sampling_handler=...)` raises a `TypeError` naming the migration, so the break surfaces when you upgrade rather than in production against whichever client happens to negotiate the modern era.
-This is a deliberate stance rather than an unfinished port. `ctx.sample()` and `ctx.list_roots()` were *pushes*: the server sent a request down a live back-channel and blocked for the answer, and the sessionless protocol has no such channel. Since `fastmcp.Client` now negotiates the modern protocol by default, keeping those methods would mean shipping an API whose default outcome is a runtime failure. What the protocol removed is the pushing, not the asking — sampling, elicitation, and roots all still reach the client through the [guard pattern](/servers/elicitation#elicitation-on-the-modern-protocol), where a tool *returns* an `InputRequiredResult` describing what it needs, the client answers, and it calls again with the answer attached.
+All three *pushed*: the server sent a request down a live back-channel and blocked for the answer, and the sessionless protocol has no such channel. Since `fastmcp.Client` now negotiates the modern protocol by default, a method like that would fail against a default client. What the protocol removed is the pushing, not the asking — sampling, elicitation, and roots all still reach the client through the [guard pattern](/servers/elicitation#elicitation-on-the-modern-protocol), where a tool *returns* an `InputRequiredResult` describing what it needs, the client answers, and it calls again with the answer attached.
-Migrating differs by capability. For **roots**, the guard pattern is the direct replacement and a good one: a server asks once and has what it needs, so returning a roots request costs a single round trip. Accepting the paths as tool arguments remains simpler when the caller can just supply them. For **sampling**, the guard route works the same way and conforms on `2026-07-28`, but generation usually belongs in your server — every round is a full request-response cycle, so a generation loop pays that cost repeatedly. [Call an LLM from your server](/servers/sampling) with your own API key and your tool behaves the same for every client, including the many that never implemented sampling; reach for the guard route when the point is specifically to use the caller's model.
+Migrating differs by capability. For **roots**, the guard pattern is the direct replacement: a server asks once and has what it needs, so the extra round buys the whole answer, and taking the paths as tool arguments is simpler still when the caller can just supply them. For **sampling**, the guard route works the same way, but generation usually belongs in your server, because every round is a full request-response cycle and a generation loop pays that cost repeatedly. [Call an LLM from your server](/servers/sampling) with your own API key and your tool behaves the same for every client, including the many that never implemented sampling; reach for the guard route when the point is specifically to use the caller's model.
| Context feature | Earlier eras (session-based) | `2026-07-28` (sessionless) |
| --- | --- | --- |
@@ -259,7 +259,7 @@ Migrating differs by capability. For **roots**, the guard pattern is the direct
Two of these bite by default now, because **`fastmcp.Client` defaults to `mode="auto"`** in v4 — an ordinary `Client(server)` negotiates the newest protocol both sides share, which against a FastMCP server is the sessionless `2026-07-28` era. On that era there is no `initialize` handshake, so a `Middleware.on_initialize` hook never runs; and each request is a fresh connection, so state written with `ctx.set_state` in one call is not visible in the next. A server that gates access in `on_initialize` or relies on per-session state must keep its clients on the session-based era. The narrow escape is per-client: `Client(server, mode="legacy")`. The durable, server-side answer is to declare the versions the server actually serves so a modern client is refused at connect time rather than silently losing those features — see the server's protocol-version restriction (added alongside this change).
-The client side is unaffected by any of this. `sampling_handler=` and `roots=` mean what they always did — see [client sampling](/clients/sampling) and [client roots](/clients/roots) — and one registration serves both routes: a handshake-era server's pushed request and a modern server's returned one dispatch to the same handler, so a client needs no era-specific wiring either way.
+The client side is unaffected. `sampling_handler=` and `roots=` mean what they always did — see [client sampling](/clients/sampling) and [client roots](/clients/roots) — and one registration serves both routes, since a handshake-era server's pushed request and a modern server's returned one dispatch to the same handler.
## Upgrade checklist
diff --git a/docs/getting-started/whats-new.mdx b/docs/getting-started/whats-new.mdx
index 076d766cc..70091a68d 100644
--- a/docs/getting-started/whats-new.mdx
+++ b/docs/getting-started/whats-new.mdx
@@ -25,7 +25,9 @@ A FastMCP 4 server answers clients across the protocol transition from one deplo
The same negotiation runs from the client, and its default flipped. A plain `Client(url)` now probes for the modern protocol and adopts it when the server offers it, falling back to the handshake otherwise — where every earlier FastMCP version pinned the handshake outright. That flip is what brings the modern capabilities within reach of ordinary client code: a task-enabled tool hands back a handle to poll, and multi-round-trip elicitation resolves across successive requests, neither requiring the caller to opt in. Set `mode="legacy"` to pin the handshake when you need the session-based back-channel or the classic `initialize` result. See [Protocol negotiation](/clients/client#protocol-negotiation).
-The modern protocol is sessionless, so it drops the server's ability to call back into the client mid-request (SEP-2577), and FastMCP 4's server API reflects that rather than papering over it. `ctx.elicit` moves to a request-shaped pattern that works on modern connections. `ctx.sample`, `ctx.sample_step`, and `ctx.list_roots` are not in the API at all — they pushed a request into a live connection, and shipping methods that only work against old clients would be shipping a trap. The capabilities behind them survive through the same request-shaped pattern: a tool returns a sampling or roots request and reads the answer on the next round. For roots that is the natural replacement, since one round trip buys the whole answer. For generation, [call an LLM from your server](/servers/sampling) — a loop of guard rounds spends the round-trip budget several times over. Logging is untouched: `ctx.info` and its siblings are notifications, which ride the response stream and reach the client on every era. Everything else about writing a server is unchanged.
+The modern protocol is sessionless, so it drops the server's ability to call back into the client mid-request (SEP-2577), and FastMCP 4's server API reflects that. `ctx.elicit` moves to a request-shaped pattern that works on modern connections: the tool returns a description of the input it needs, and the client answers with a fresh call. `ctx.sample`, `ctx.sample_step`, and `ctx.list_roots` are gone from the API, because each of them pushed a request down a live connection and a method that only works against old clients is a trap.
+
+Both capabilities survive in the same request-shaped form. Asking for roots that way is the natural replacement, since one round trip buys the whole answer. Generation usually belongs in the server instead, because a loop of asking rounds spends the round-trip budget over and over — [call an LLM from your server](/servers/sampling). Logging is untouched: `ctx.info` and its siblings are notifications, and notifications ride the response stream on every era. Everything else about writing a server is unchanged.
## State without a session
diff --git a/docs/servers/context.mdx b/docs/servers/context.mdx
index 5e3711213..5a7511154 100644
--- a/docs/servers/context.mdx
+++ b/docs/servers/context.mdx
@@ -151,14 +151,9 @@ if result.action == "accept":
See [User Elicitation](/servers/elicitation) for detailed examples and supported response types.
-### Server-initiated requests
-
-
-`Context` has no `sample()` or `list_roots()`. Both *pushed* a request into a live client connection, and the modern MCP protocol has no channel to carry that ([SEP-2577](/servers/sampling)). Both capabilities remain reachable through the [guard pattern](/servers/elicitation#sampling-and-roots), where a tool returns a sampling or roots request in `input_requests` and reads the answer from `ctx.input_responses` on the next round. That is the natural route for roots. For generation, [call an LLM directly from your server](/servers/sampling) instead — each guard round costs a full round trip.
-
-Logging is unaffected — `ctx.info()` and friends are *notifications*, which ride the response stream and work on every protocol era.
-
+### Sampling and Roots
+Neither capability has a `Context` method. Both used to *push* a request into a live client connection, which the modern MCP protocol has no channel to carry, so a tool now asks for them by returning the request and reading the answer on the next round — the same [guard pattern](/servers/elicitation#sampling-and-roots) elicitation uses on modern connections. That route is the natural one for roots; for generation, [call an LLM directly from your server](/servers/sampling).
### Progress Reporting
diff --git a/docs/servers/elicitation.mdx b/docs/servers/elicitation.mdx
index 937dc04d3..5584ba78b 100644
--- a/docs/servers/elicitation.mdx
+++ b/docs/servers/elicitation.mdx
@@ -596,9 +596,9 @@ The same protocol requirement applies: returning an `InputRequiredResult` from a
### Sampling and roots
-Elicitation is the most common request to carry this way, and **roots** and **sampling** requests work identically — the `input_requests` map holds a `ListRootsRequest` or a `CreateMessageRequest` the same way it holds an `ElicitRequest`, and each answer comes back in `ctx.input_responses` under its key as a `ListRootsResult` or `CreateMessageResult`. A single map can mix all three. `fastmcp.Client` answers each one from the handlers you already configured — `elicitation_handler=`, `roots=`, `sampling_handler=` — so a guard tool that mixes them needs no extra client wiring. See [Client Roots](/clients/roots) for what a roots request contains.
+Elicitation is the most common request to carry this way, and the map carries the others just as well. A `ListRootsRequest` or a `CreateMessageRequest` sits in `input_requests` exactly as an `ElicitRequest` does, and its answer arrives in `ctx.input_responses` under the same key as a `ListRootsResult` or a `CreateMessageResult`. One map can mix all three, and `fastmcp.Client` answers each from the handlers it already has — `elicitation_handler=`, `roots=`, and `sampling_handler=` — so a tool that asks for a mixture needs no extra client wiring. [Client Roots](/clients/roots) covers what a roots request contains.
-The two differ in when you should reach for them. Roots suits this pattern well: a server asks once and has what it needs, so one extra round trip buys the whole answer. Generation usually does not, because every round is a full request-response cycle and a loop pays that cost each time — [call an LLM directly from your server](/servers/sampling) unless the point is specifically to use the caller's model.
+Roots and sampling differ in how well they suit the round trip. A server asks for roots once and then has what it needs, so the extra round buys the whole answer. Generation rarely works out that way, because every round is a full request-response cycle and a tool that generates in a loop pays that cost each time — [call an LLM directly from your server](/servers/sampling) unless the point is specifically to use the caller's model.
### Middleware
diff --git a/docs/servers/sampling.mdx b/docs/servers/sampling.mdx
index cc2154fcf..5c0bf4901 100644
--- a/docs/servers/sampling.mdx
+++ b/docs/servers/sampling.mdx
@@ -5,37 +5,43 @@ description: Generate text from a FastMCP server — by calling an LLM directly,
icon: robot
---
-FastMCP 4 targets the modern MCP protocol, and that protocol has no channel for a server to push a request into a live client connection. A tool cannot pause mid-execution to borrow the caller's model and resume when the completion arrives, so the imperative methods that did exactly that are gone: there is no `ctx.sample()` and no `ctx.sample_step()`, and `FastMCP()` no longer accepts `sampling_handler=`. Calling them raises `AttributeError` on every protocol era rather than failing at runtime only against modern clients.
+A tool that needs text generated calls a model to get it, and in FastMCP 4 that call is ordinary Python: your server holds an API key, creates a provider client, and awaits a completion inside the tool. No protocol is involved, so the tool behaves the same for every client — including the many that never implemented sampling at all.
-The *capability* survives in a different shape. A tool that genuinely needs the caller's model asks for a completion by **returning** a request for one, the same [guard pattern](/servers/elicitation#elicitation-on-the-modern-protocol) elicitation uses: the round ends as an ordinary response, the client runs the completion against its own model, and it calls the tool again with the answer attached. FastMCP's own conformance suite exercises this on `2026-07-28`.
+The alternative is to ask the caller. Sampling borrows *the caller's* model — their provider, their credentials, their bill — by returning a request for a completion that the client fulfils and hands back. Every ask costs a full round trip, so it earns its keep when using the caller's model is the point, and rarely otherwise.
-For most generation you should skip that round trip and call an LLM directly from your server. Each guard round is a full request-response cycle, so a tool that generates in a loop spends the round-trip budget several times over, while a direct call is an ordinary async function call inside a single tool invocation. Ask the client to sample when the point is to use *the caller's* model — their credentials, their choice of provider, their bill. Call an LLM yourself when the point is to generate text.
+`Context` carries no sampling methods in FastMCP 4. If you came here looking for `ctx.sample()`, [the removed methods](#the-removed-methods) covers what happened and where the capability went.
-## Requests and notifications
+## Calling an LLM directly
-The distinction that makes this make sense is between *asking* and *telling*.
-
-A notification is fire-and-forget. Your server emits it and moves on, and it travels down the response stream the caller already opened for the request in flight. Nothing has to be held open on the server's behalf, so notifications survive the move to a stateless protocol untouched. This is why [logging](/servers/logging) still works exactly as it always has: `ctx.info()`, `ctx.debug()`, and the rest reach the client mid-call on every protocol era.
+Hold a provider API key in your server's environment, create the client once at module scope so connections are reused across calls, and generate inside the tool. You choose the model, control the prompt, see the token usage, and can test the tool with no client attached.
```python
-from fastmcp import Context, FastMCP
+import anthropic
+from fastmcp import FastMCP
-mcp = FastMCP("Reports")
+mcp = FastMCP("Summarizer")
+llm = anthropic.AsyncAnthropic()
@mcp.tool
-async def build_report(rows: int, ctx: Context) -> str:
- await ctx.info(f"Processing {rows} rows")
- return "done"
+async def summarize(text: str) -> str:
+ """Summarize a document in two sentences."""
+ response = await llm.messages.create(
+ model="claude-sonnet-4-5",
+ max_tokens=512,
+ system="Summarize the user's text in exactly two sentences.",
+ messages=[{"role": "user", "content": text}],
+ )
+ return response.content[0].text
```
-Sampling is the other kind. It is a *request* — `sampling/createMessage` goes out and the caller must answer before the tool can continue. Pushing that request needs a live, addressable connection the server can reach into, which is precisely what a stateless protocol does not have, and MCP removed server-initiated requests in the `2026-07-28` revision ([SEP-2577](https://modelcontextprotocol.io/community/sep-guidelines)) for that reason. What the protocol removed is the *pushing*, not the asking. Asking survives by inverting who initiates the next message: instead of the server reaching down mid-call, the tool returns a description of what it needs and the client comes back.
+Any provider SDK works the same way — swap the client and the call, and the tool signature is unchanged. Because generation is ordinary application code, the concerns around it are ordinary too: retries, timeouts, caching, and cost accounting go wherever you want them rather than being negotiated across a protocol boundary. A tool that chains several generations pays nothing extra for the second and third, where asking the caller would pay a full round trip for each.
-## Asking the client to sample
+## Asking the caller's model
A tool asks for a completion by returning an `InputRequiredResult` whose `input_requests` map holds a `CreateMessageRequest` under a key you choose. That result completes the round normally. The client runs the completion, then re-issues the same `call_tool` with the answer attached, and your tool reads it from `ctx.input_responses` under the same key — a `CreateMessageResult`. Because the tool runs from the top on every round, the presence of `ctx.input_responses` is what tells the two rounds apart: `None` on the first call, populated on the continuation.
-`fastmcp.Client` drives this loop for you and answers from the [`sampling_handler`](/clients/sampling) you already registered, so a client configured for a handshake-era server needs no extra wiring to satisfy a modern guard tool.
+`fastmcp.Client` drives that loop for you and answers from the [`sampling_handler`](/clients/sampling) it already has, so a client written for a handshake-era server needs no extra wiring to satisfy a modern tool that asks this way.
```python
from fastmcp import Context, FastMCP
@@ -82,39 +88,15 @@ async def ask_the_caller(question: str, ctx: Context) -> str | InputRequiredResu
return "The client returned no completion."
```
-Returning an `InputRequiredResult` needs a `2026-07-28` connection; FastMCP names the era mismatch if an older client reaches the tool. The map can carry several requests at once and mix kinds — a sampling request beside an elicitation or a roots request — and each answer comes back under its own key. [Elicitation](/servers/elicitation#sampling-and-roots) covers the mechanics of the pattern in full, including how to carry state across rounds.
+Returning an `InputRequiredResult` needs a `2026-07-28` connection, and FastMCP names the era mismatch if an older client reaches the tool; the conformance suite exercises this route on that version. The map can carry several requests at once and mix kinds — a sampling request beside an elicitation or a roots request — with each answer coming back under its own key. [Elicitation](/servers/elicitation#sampling-and-roots) covers the mechanics of the pattern in full, including how to carry state across rounds.
-## Calling an LLM directly
+## The removed methods
-For generation, your server calls the model. Hold a provider API key in your server's environment, create the client once at module scope so connections are reused across calls, and generate inside the tool. The result is a plain async function call with no protocol involvement, which also means you choose the model, control the prompt, see the token usage, and can test the tool without a client attached.
+`Context` has no `sample()` and no `sample_step()`; touching either raises `AttributeError` on every protocol era, rather than failing at runtime only against modern clients. `FastMCP()` accepts neither `sampling_handler=` nor `sampling_handler_behavior=`, and naming one raises a `TypeError` that points at the migration.
-```python
-import anthropic
-from fastmcp import FastMCP
+The reason is the distinction MCP draws between telling and asking. A notification is fire-and-forget: the server emits it and moves on, and it travels down the response stream the caller already opened, so nothing has to be held open on the server's behalf. That is why [logging](/servers/logging) is untouched by any of this — `ctx.info()` and its siblings reach the client mid-call on every era. Sampling is the other kind. `sampling/createMessage` goes out and the caller must answer before the tool can continue, which needs a live, addressable connection the server can reach into, and the `2026-07-28` revision removed server-initiated requests ([SEP-2577](https://modelcontextprotocol.io/community/sep-guidelines)) precisely because a stateless protocol has no such thing.
-mcp = FastMCP("Summarizer")
-llm = anthropic.AsyncAnthropic()
-
-
-@mcp.tool
-async def summarize(text: str) -> str:
- """Summarize a document in two sentences."""
- response = await llm.messages.create(
- model="claude-sonnet-4-5",
- max_tokens=512,
- system="Summarize the user's text in exactly two sentences.",
- messages=[{"role": "user", "content": text}],
- )
- return response.content[0].text
-```
-
-Any provider SDK works the same way — swap the client and the call, and the tool signature is unchanged. Because generation is now ordinary application code, the surrounding concerns become ordinary too: retries, timeouts, caching, and cost accounting are yours to place where you want them rather than negotiated across a protocol boundary. A tool that chains several generations pays nothing extra for the second and third, where the guard route would pay a full round trip for each.
-
-The trade this makes is explicit. Sampling let a server borrow the caller's model and the caller's bill; calling directly means you supply the key and pay for the tokens. In exchange your tool behaves identically for every client, including the many that never implemented sampling at all.
-
-## Clients answering servers
-
-Both routes land in the same place on the client. A `fastmcp.Client` passes `sampling_handler=` once, and that handler answers a handshake-era server's pushed `sampling/createMessage` request and a modern server's returned sampling request alike — see [Sampling](/clients/sampling) under Clients.
+What the protocol removed is the pushing, not the asking, so the capability survives in the shape described above. Keeping `ctx.sample()` alongside it would mean shipping a method whose outcome against a default client — one that negotiates the modern era — is a runtime failure.
Servers on FastMCP 3 still have `ctx.sample()` and `ctx.sample_step()`, documented in the [FastMCP 3 sampling guide](/v3/servers/sampling). Nothing changes for them until they upgrade.
diff --git a/docs/servers/tools.mdx b/docs/servers/tools.mdx
index 197ca2340..42fd0fb17 100644
--- a/docs/servers/tools.mdx
+++ b/docs/servers/tools.mdx
@@ -1056,16 +1056,14 @@ mcp = FastMCP(name="ContextDemo")
async def process_data(data_uri: str, ctx: Context) -> dict:
"""Process data from a resource with progress reporting."""
await ctx.info(f"Processing data from {data_uri}")
-
- # Read a resource
- resource = await ctx.read_resource(data_uri)
- data = resource[0].content if resource else ""
-
- # Report progress
+
+ result = await ctx.read_resource(data_uri)
+ data = result.contents[0].content if result.contents else ""
await ctx.report_progress(progress=50, total=100)
+ summary = str(data)[:200]
await ctx.report_progress(progress=100, total=100)
- return {"length": len(data)}
+ return {"length": len(data), "summary": summary}
```
The Context object provides access to:
From 4a616d6e398291753e44f6889729bd8217506ef2 Mon Sep 17 00:00:00 2001
From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>
Date: Sun, 26 Jul 2026 20:42:12 -0400
Subject: [PATCH 11/15] Flag the sampling removal at the top of the page
---
docs/servers/sampling.mdx | 6 ++++--
1 file changed, 4 insertions(+), 2 deletions(-)
diff --git a/docs/servers/sampling.mdx b/docs/servers/sampling.mdx
index 5c0bf4901..4a306c56e 100644
--- a/docs/servers/sampling.mdx
+++ b/docs/servers/sampling.mdx
@@ -5,12 +5,14 @@ description: Generate text from a FastMCP server — by calling an LLM directly,
icon: robot
---
+
+**`ctx.sample()` and `ctx.sample_step()` were removed in FastMCP 4.** The modern MCP protocol gives a server no channel to push a request to its client, so there is nothing left for those methods to do. Generate by [calling an LLM directly](#calling-an-llm-directly), or [ask the caller's model](#asking-the-callers-model) when borrowing their model is the point.
+
+
A tool that needs text generated calls a model to get it, and in FastMCP 4 that call is ordinary Python: your server holds an API key, creates a provider client, and awaits a completion inside the tool. No protocol is involved, so the tool behaves the same for every client — including the many that never implemented sampling at all.
The alternative is to ask the caller. Sampling borrows *the caller's* model — their provider, their credentials, their bill — by returning a request for a completion that the client fulfils and hands back. Every ask costs a full round trip, so it earns its keep when using the caller's model is the point, and rarely otherwise.
-`Context` carries no sampling methods in FastMCP 4. If you came here looking for `ctx.sample()`, [the removed methods](#the-removed-methods) covers what happened and where the capability went.
-
## Calling an LLM directly
Hold a provider API key in your server's environment, create the client once at module scope so connections are reused across calls, and generate inside the tool. You choose the model, control the prompt, see the token usage, and can test the tool with no client attached.
From 0e9cab86fd1c66db7a665cde0802f83511872e28 Mon Sep 17 00:00:00 2001
From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>
Date: Sun, 26 Jul 2026 21:10:41 -0400
Subject: [PATCH 12/15] Restore the version badge and point sampling users at
3.x
---
docs/servers/sampling.mdx | 8 +++++++-
1 file changed, 7 insertions(+), 1 deletion(-)
diff --git a/docs/servers/sampling.mdx b/docs/servers/sampling.mdx
index 4a306c56e..d5a39df9e 100644
--- a/docs/servers/sampling.mdx
+++ b/docs/servers/sampling.mdx
@@ -5,8 +5,14 @@ description: Generate text from a FastMCP server — by calling an LLM directly,
icon: robot
---
+import { VersionBadge } from "/snippets/version-badge.mdx"
+
+
+
-**`ctx.sample()` and `ctx.sample_step()` were removed in FastMCP 4.** The modern MCP protocol gives a server no channel to push a request to its client, so there is nothing left for those methods to do. Generate by [calling an LLM directly](#calling-an-llm-directly), or [ask the caller's model](#asking-the-callers-model) when borrowing their model is the point.
+**`ctx.sample()` and `ctx.sample_step()` were removed in FastMCP 4.** The modern MCP protocol gives a server no channel to push a request to its client, so there is nothing left for those methods to do.
+
+To build a server that uses sampling, stay on [FastMCP 3.x](/v3/servers/sampling). On FastMCP 4, generate by [calling an LLM directly](#calling-an-llm-directly), or [ask the caller's model](#asking-the-callers-model) when borrowing their model is the point.
A tool that needs text generated calls a model to get it, and in FastMCP 4 that call is ordinary Python: your server holds an API key, creates a provider client, and awaits a completion inside the tool. No protocol is involved, so the tool behaves the same for every client — including the many that never implemented sampling at all.
From 0172e4c4d415e404aa25a71dd81f7999a343a8cc Mon Sep 17 00:00:00 2001
From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>
Date: Mon, 27 Jul 2026 09:35:30 -0400
Subject: [PATCH 13/15] Keep the sampling conformance scenario live; fix roots
example URIs
---
docs/clients/roots.mdx | 4 ++--
tests/conformance/expected-failures.yml | 6 ------
tests/conformance/server.py | 25 +++++++++++++++++++++++++
3 files changed, 27 insertions(+), 8 deletions(-)
diff --git a/docs/clients/roots.mdx b/docs/clients/roots.mdx
index 9de83cf61..08f5c9786 100644
--- a/docs/clients/roots.mdx
+++ b/docs/clients/roots.mdx
@@ -24,7 +24,7 @@ from fastmcp import Client
client = Client(
"my_mcp_server.py",
- roots=["/path/to/root1", "/path/to/root2"]
+ roots=["file:///path/to/root1", "file:///path/to/root2"]
)
```
@@ -38,7 +38,7 @@ from fastmcp.client.roots import RequestContext
async def roots_callback(context: RequestContext) -> list[str]:
print(f"Server requested roots (Request ID: {context.request_id})")
- return ["/path/to/root1", "/path/to/root2"]
+ return ["file:///path/to/root1", "file:///path/to/root2"]
client = Client(
"my_mcp_server.py",
diff --git a/tests/conformance/expected-failures.yml b/tests/conformance/expected-failures.yml
index 28ece5894..9e3ed9dde 100644
--- a/tests/conformance/expected-failures.yml
+++ b/tests/conformance/expected-failures.yml
@@ -21,9 +21,3 @@ server:
# need the tool to declare which one it wants, which is an unmade API
# decision rather than a bug.
- tasks-mrtr-composition
-
- # Server-initiated sampling was removed from the server API (SEP-2577), so the
- # fixture has no `test_sampling` tool for this handshake-era scenario to drive.
- # Modern sampling rides the guard pattern and is covered by
- # input-required-result-basic-sampling.
- - tools-call-sampling
diff --git a/tests/conformance/server.py b/tests/conformance/server.py
index 12d58f802..cc8a020ae 100644
--- a/tests/conformance/server.py
+++ b/tests/conformance/server.py
@@ -173,6 +173,31 @@ async def test_tool_with_progress(ctx: Context) -> str:
return "Progress test complete."
+@server.tool(name="test_sampling")
+async def test_sampling(prompt: str, ctx: Context) -> str:
+ """Requests LLM sampling via the client.
+
+ `Context` has no `sample()` — server-initiated sampling is not part of
+ FastMCP's server API. The handshake-era wire path is still supported and
+ still shipped (the proxy relay uses it), so this fixture reaches the SDK
+ session directly to keep the scenario covered.
+ """
+ result = await ctx.session.create_message( # ty: ignore[deprecated]
+ messages=[
+ mcp_types.SamplingMessage(
+ role="user",
+ content=mcp_types.TextContent(type="text", text=prompt),
+ )
+ ],
+ max_tokens=512,
+ related_request_id=ctx.origin_request_id,
+ )
+ text = (
+ result.content.text if isinstance(result.content, mcp_types.TextContent) else ""
+ )
+ return f"Sampling result: {text}"
+
+
class _UserInfo(BaseModel):
username: str
email: str
From c4dcf833ca0d900a64bea047d67c71ba91d123b6 Mon Sep 17 00:00:00 2001
From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>
Date: Mon, 27 Jul 2026 09:37:15 -0400
Subject: [PATCH 14/15] Upgrade guide: staying on 3.x is an option for sampling
servers
---
docs/getting-started/upgrading/from-fastmcp-3.mdx | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/docs/getting-started/upgrading/from-fastmcp-3.mdx b/docs/getting-started/upgrading/from-fastmcp-3.mdx
index f764a5923..86bcd58ad 100644
--- a/docs/getting-started/upgrading/from-fastmcp-3.mdx
+++ b/docs/getting-started/upgrading/from-fastmcp-3.mdx
@@ -243,7 +243,7 @@ FastMCP servers built on the SDK v2 serve multiple protocol eras from the same s
All three *pushed*: the server sent a request down a live back-channel and blocked for the answer, and the sessionless protocol has no such channel. Since `fastmcp.Client` now negotiates the modern protocol by default, a method like that would fail against a default client. What the protocol removed is the pushing, not the asking — sampling, elicitation, and roots all still reach the client through the [guard pattern](/servers/elicitation#elicitation-on-the-modern-protocol), where a tool *returns* an `InputRequiredResult` describing what it needs, the client answers, and it calls again with the answer attached.
-Migrating differs by capability. For **roots**, the guard pattern is the direct replacement: a server asks once and has what it needs, so the extra round buys the whole answer, and taking the paths as tool arguments is simpler still when the caller can just supply them. For **sampling**, the guard route works the same way, but generation usually belongs in your server, because every round is a full request-response cycle and a generation loop pays that cost repeatedly. [Call an LLM from your server](/servers/sampling) with your own API key and your tool behaves the same for every client, including the many that never implemented sampling; reach for the guard route when the point is specifically to use the caller's model.
+Migrating differs by capability. For **roots**, the guard pattern is the direct replacement: a server asks once and has what it needs, so the extra round buys the whole answer, and taking the paths as tool arguments is simpler still when the caller can just supply them. For **sampling**, the guard route works the same way, but generation usually belongs in your server, because every round is a full request-response cycle and a generation loop pays that cost repeatedly. [Call an LLM from your server](/servers/sampling) with your own API key and your tool behaves the same for every client, including the many that never implemented sampling; reach for the guard route when the point is specifically to use the caller's model. If borrowing the caller's model *is* your server — you hold no key of your own, and the token bill was never yours to pay — staying on FastMCP 3.x is the honest answer until that changes.
| Context feature | Earlier eras (session-based) | `2026-07-28` (sessionless) |
| --- | --- | --- |
@@ -268,7 +268,7 @@ Most servers upgrade untouched. Work down this list to find the ones that don't:
1. **Bump your environment.** Raise any pin below `pydantic>=2.12`; upgrade FastAPI if your resolver complains about Starlette `<1.0.1`.
2. **Fix imports that moved out.** Replace `from mcp.types import X` with `from mcp_types import X`, and update any import from the [removed modules](#moved-imports) (`fastmcp.server.proxy`, `fastmcp.server.openapi`, `fastmcp.server.apps`, the `fastmcp.tools.tool` / `resources.resource` / `prompts.prompt` component shims).
3. **Update removed server APIs.** Swap `as_proxy` → `create_proxy`, `import_server` → `mount`, `mount(prefix=)` → `mount(namespace=)`, and the [other removed methods and keywords](#removed-server-methods-and-mount-keywords).
-4. **Replace `ctx.sample` and `ctx.list_roots`.** Both are gone from `Context`, as are `FastMCP(sampling_handler=...)` and `sampling_handler_behavior=`. Call an LLM directly from your server for generation; ask for roots through the guard pattern, or take file paths as tool arguments.
+4. **Replace `ctx.sample` and `ctx.list_roots`.** Both are gone from `Context`, as are `FastMCP(sampling_handler=...)` and `sampling_handler_behavior=`. Call an LLM directly from your server for generation; ask for roots through the guard pattern, or take file paths as tool arguments. A server whose purpose is to use the caller's model should stay on FastMCP 3.x rather than migrate.
5. **Update removed tool parameters.** Replace tool `serializer=` (return a `ToolResult`), `exclude_args=` (use `Depends()`), and `StreamableHttpTransport(sse_read_timeout=)`.
6. **Fix `McpError` construction.** Positional `McpError(ErrorData(...))` becomes keyword `McpError(code=..., message=...)`. Catching is unchanged.
7. **Move httpx to httpx2.** Grep for `except httpx.` and for custom `httpx_client_factory` / `httpx.Auth` objects handed to FastMCP, and swap the import to `httpx2`.
From b2b2b0f9189e388c4105309aeae89718bfe7d096 Mon Sep 17 00:00:00 2001
From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>
Date: Mon, 27 Jul 2026 09:43:52 -0400
Subject: [PATCH 15/15] Elicitation: state the era split once, not twice
---
docs/clients/elicitation.mdx | 4 +---
1 file changed, 1 insertion(+), 3 deletions(-)
diff --git a/docs/clients/elicitation.mdx b/docs/clients/elicitation.mdx
index bdd8eba15..73cc6fdb7 100644
--- a/docs/clients/elicitation.mdx
+++ b/docs/clients/elicitation.mdx
@@ -13,10 +13,8 @@ Use this when you need to respond to server requests for user input during tool
Elicitation allows MCP servers to request structured input from users during operations. Instead of requiring all inputs upfront, servers can interactively ask for missing parameters, request clarification, or gather additional context.
-Two routes reach that outcome, and the protocol version the client negotiates decides which one applies. On older versions the server pushes an elicitation request down to the client, over the connection the `initialize` handshake opens; that is the flow the next few sections describe. On `2026-07-28` and later the server instead returns a description of what it needs, and the client answers with a fresh call — see [input-required rounds](#input-required-rounds). You write the same `elicitation_handler` either way — FastMCP routes it to whichever mechanism the connection supports.
-
-**This page shows the older protocol's elicitation flow.** On protocol version `2026-07-28` the server instead returns a description of what it needs and the client answers with a new call — see [input-required rounds](#input-required-rounds). The same `elicitation_handler` serves both. Clients default to `mode="auto"`, so the examples below pass `mode="legacy"` to exercise the server-initiated flow. See [protocol negotiation](/clients/client#protocol-negotiation).
+**These sections show the server-initiated flow, which the handshake-era protocol uses.** On `2026-07-28` the server asks by returning a request instead — see [input-required rounds](#input-required-rounds). One `elicitation_handler` serves both, so the examples below pin `mode="legacy"` only to exercise the pushed form.
## Handler Template