Add Anthropic sampling handler and consolidate sampling examples

- Add AnthropicSamplingHandler in server/sampling/anthropic.py
- Consolidate all sampling examples into examples/sampling/ with rich output
- Examples: text.py, structured_output.py, tool_use.py, server_fallback.py
- Add anthropic optional dependency
This commit is contained in:
Jeremiah Lowin 2025-12-09 15:04:35 -05:00
commit 7e02fa29b2
14 changed files with 1297 additions and 281 deletions

View file

@ -1,28 +0,0 @@
# Advanced Sampling Examples
These examples demonstrate FastMCP's sampling API with real LLM backends.
## Prerequisites
```bash
pip install fastmcp[openai]
export OPENAI_API_KEY=your-key
```
## Examples
### Structured Output (`structured_output.py`)
Uses `result_type` to get validated Pydantic models from the LLM:
```bash
python examples/advanced_sampling/structured_output.py
```
### Tool Use (`tool_use.py`)
Gives the LLM tools to use during sampling, with automatic tool execution:
```bash
python examples/advanced_sampling/tool_use.py
```

View file

@ -1,68 +0,0 @@
"""
Structured Output Example
This example demonstrates using `result_type` to get structured responses from an LLM.
The server exposes a tool that uses sampling to analyze text sentiment, returning
a structured Pydantic model instead of raw text.
Prerequisites:
pip install fastmcp[openai]
export OPENAI_API_KEY=your-key
Run:
python examples/advanced_sampling/structured_output.py
"""
import asyncio
from pydantic import BaseModel
from fastmcp import Client, Context, FastMCP
from fastmcp.experimental.sampling.handlers.openai import OpenAISamplingHandler
# Define a structured output model
class SentimentAnalysis(BaseModel):
sentiment: str # "positive", "negative", or "neutral"
confidence: float # 0.0 to 1.0
keywords: list[str] # Key words that influenced the analysis
# Create an MCP server with a sampling tool
mcp = FastMCP("Sentiment Analyzer")
@mcp.tool
async def analyze_sentiment(text: str, ctx: Context) -> dict:
"""Analyze the sentiment of the given text."""
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 and return structured results.",
result_type=SentimentAnalysis,
)
# result.result is a validated SentimentAnalysis instance
return result.result.model_dump() # type: ignore[attr-defined]
async def main():
# Create an OpenAI-backed sampling handler
sampling_handler = OpenAISamplingHandler(default_model="gpt-4o-mini")
# Connect to the server with the sampling handler
async with Client(mcp, sampling_handler=sampling_handler) as client:
# Call the tool with some test text
result = await client.call_tool(
"analyze_sentiment",
{
"text": "I absolutely love this product! It exceeded all my expectations."
},
)
print("Analysis Result:")
print(f" Sentiment: {result.data['sentiment']}")
print(f" Confidence: {result.data['confidence']:.1%}")
print(f" Keywords: {', '.join(result.data['keywords'])}")
if __name__ == "__main__":
asyncio.run(main())

View file

@ -1,85 +0,0 @@
"""
Tool Use Example
This example demonstrates sampling with tools, where the LLM can use helper
functions to complete a task. The server exposes a "research assistant" tool
that uses sampling with search capabilities.
Prerequisites:
pip install fastmcp[openai]
export OPENAI_API_KEY=your-key
Run:
python examples/advanced_sampling/tool_use.py
"""
import asyncio
from pydantic import BaseModel
from fastmcp import Client, Context, FastMCP
from fastmcp.experimental.sampling.handlers.openai import OpenAISamplingHandler
# Define tools (available to the LLM during sampling)
def search_web(query: str) -> str:
"""Search the web for information."""
# Simulated search results
results = {
"python async": "Python's asyncio provides async/await syntax for concurrent code.",
"fastmcp": "FastMCP is a framework for building MCP servers and clients in Python.",
"mcp protocol": "MCP (Model Context Protocol) enables AI models to use tools and resources.",
}
for key, value in results.items():
if key in query.lower():
return value
return f"No results found for: {query}"
def get_word_count(text: str) -> str:
"""Count words in text."""
return str(len(text.split()))
# Structured output for the final response
class ResearchReport(BaseModel):
summary: str
sources_used: list[str]
confidence: float
# Create the MCP server
mcp = FastMCP("Research Assistant")
@mcp.tool
async def research(question: str, ctx: Context) -> dict:
"""Research a question using available tools and return a structured report."""
result = await ctx.sample(
messages=f"Research this question and provide a comprehensive answer:\n\n{question}",
system_prompt="You are a research assistant. Use the available tools to gather information, then call final_response with your structured report.",
tools=[search_web, get_word_count],
result_type=ResearchReport,
max_iterations=5,
)
return result.result.model_dump() # type: ignore[attr-defined]
async def main():
sampling_handler = OpenAISamplingHandler(default_model="gpt-4o-mini")
async with Client(mcp, sampling_handler=sampling_handler) as client:
result = await client.call_tool(
"research",
{"question": "What is FastMCP and how does it relate to the MCP protocol?"},
)
print("Research Report:")
print(f" Summary: {result.data['summary']}")
print(f" Sources: {', '.join(result.data['sources_used'])}")
print(f" Confidence: {result.data['confidence']}%")
if __name__ == "__main__":
asyncio.run(main())

View file

@ -1,52 +0,0 @@
"""
Example of using sampling to request an LLM completion via Marvin
"""
import asyncio
import marvin
from mcp.types import TextContent
from fastmcp import Client, Context, FastMCP
from fastmcp.client.sampling import RequestContext, SamplingMessage, SamplingParams
# -- Create a server that sends a sampling request to the LLM
mcp = FastMCP("Sampling Example")
@mcp.tool
async def example_tool(prompt: str, context: Context) -> str:
"""Sample a completion from the LLM."""
response = await context.sample(
"What is your favorite programming language?",
system_prompt="You love languages named after snakes.",
)
assert isinstance(response, TextContent)
return response.text
# -- Create a client that can handle the sampling request
async def sampling_fn(
messages: list[SamplingMessage],
params: SamplingParams,
ctx: RequestContext,
) -> str:
return await marvin.say_async(
message=[m.content.text for m in messages],
instructions=params.systemPrompt,
)
async def run():
async with Client(mcp, sampling_handler=sampling_fn) as client:
result = await client.call_tool(
"example_tool", {"prompt": "What is the best programming language?"}
)
print(result)
if __name__ == "__main__":
asyncio.run(run())

View file

@ -0,0 +1,151 @@
# /// script
# dependencies = ["anthropic", "fastmcp", "rich"]
# ///
"""
Server-Side Sampling Fallback Example
When the CLIENT has no sampling handler, the SERVER's handler is used instead.
MCP Flow with Fallback:
1. Client calls server tool (client has NO sampling handler)
2. Server tool calls ctx.sample()
3. Client can't handle sampling → server's fallback handler is used
4. Server's handler calls the LLM directly
5. Response returns to server, then to client
Run:
python examples/sampling/server_fallback.py
"""
import asyncio
from rich.console import Console
from rich.panel import Panel
from rich.text import Text
from fastmcp import Client, Context, FastMCP
from fastmcp.server.sampling.anthropic import AnthropicSamplingHandler
console = Console()
# ============================================================================
# SERVER - Note: sampling_handler is on the SERVER, not the client
# ============================================================================
class LoggingAnthropicHandler(AnthropicSamplingHandler):
async def __call__(self, messages, params, context):
console.print(
" [bold blue]⚡ FALLBACK[/] Server's handler calling Claude...",
highlight=False,
)
result = await super().__call__(messages, params, context)
console.print(
" [bold blue]⚡ FALLBACK[/] Response received", highlight=False
)
return result
mcp = FastMCP(
"Server with Fallback",
sampling_handler=LoggingAnthropicHandler(default_model="claude-sonnet-4-20250514"),
)
@mcp.tool
async def get_fun_fact(topic: str, ctx: Context) -> str:
"""Get a fun fact about a topic."""
console.print(" [bold yellow]📦 SERVER[/] Tool 'get_fun_fact' called")
console.print(
" [bold yellow]📦 SERVER[/] Requesting sampling (client has no handler!)"
)
result = await ctx.sample(
messages=f"Tell me one fun fact about: {topic}",
system_prompt="Share a fascinating fact in 1-2 sentences.",
)
console.print(" [bold yellow]📦 SERVER[/] Got response via fallback handler")
return result.text # type: ignore[return-value]
@mcp.tool
async def translate(text: str, language: str, ctx: Context) -> str:
"""Translate text to another language."""
console.print(" [bold yellow]📦 SERVER[/] Tool 'translate' called")
console.print(
" [bold yellow]📦 SERVER[/] Requesting sampling (client has no handler!)"
)
result = await ctx.sample(
messages=f"Translate to {language}: {text}",
system_prompt="Provide only the translation.",
)
console.print(" [bold yellow]📦 SERVER[/] Got response via fallback handler")
return result.text # type: ignore[return-value]
# ============================================================================
# CLIENT - Note: NO sampling_handler provided!
# ============================================================================
async def main():
console.print()
console.print(
Panel.fit(
"[bold]Server Fallback Example[/]\n\n"
"The [green]CLIENT[/] has [bold red]no sampling handler[/].\n"
"The [yellow]SERVER's[/] fallback handler is used instead.",
border_style="bright_black",
)
)
console.print()
# IMPORTANT: No sampling_handler!
async with Client(mcp) as client:
console.rule(style="dim")
console.print(
"[bold green]🖥️ CLIENT[/] Calling 'get_fun_fact' [dim](no sampling handler!)[/]"
)
console.print()
result = await client.call_tool("get_fun_fact", {"topic": "octopuses"})
console.print()
console.print("[bold green]🖥️ CLIENT[/] Result:")
console.print(Panel(result.data, border_style="green"))
console.print()
console.rule(style="dim")
console.print(
"[bold green]🖥️ CLIENT[/] Calling 'translate' [dim](no sampling handler!)[/]"
)
console.print()
result = await client.call_tool(
"translate", {"text": "Hello, how are you?", "language": "Spanish"}
)
console.print()
console.print("[bold green]🖥️ CLIENT[/] Result:")
console.print(Panel(result.data, border_style="green"))
console.print()
# Summary
summary = Text()
summary.append("Key concept: ", style="bold")
summary.append(
"When the client lacks sampling support, the server's\n"
"fallback handler steps in. This lets servers guarantee\n"
"sampling works regardless of client capabilities."
)
console.print(Panel(summary, border_style="bright_black"))
console.print()
if __name__ == "__main__":
asyncio.run(main())

View file

@ -0,0 +1,163 @@
# /// script
# dependencies = ["anthropic", "fastmcp", "rich"]
# ///
"""
Structured Output Example
Use `result_type` to get validated Pydantic models from an LLM.
MCP Flow:
1. Client calls server tool
2. Server makes sampling request with result_type=YourModel
3. LLM response is parsed and validated against the Pydantic schema
4. Server returns structured data to client
Run:
python examples/sampling/structured_output.py
"""
import asyncio
from enum import Enum
from pydantic import BaseModel, Field
from rich.console import Console
from rich.panel import Panel
from rich.table import Table
from fastmcp import Client, Context, FastMCP
from fastmcp.server.sampling.anthropic import AnthropicSamplingHandler
console = Console()
# ============================================================================
# PYDANTIC MODELS - Define the schema for LLM responses
# ============================================================================
class Sentiment(str, Enum):
POSITIVE = "positive"
NEGATIVE = "negative"
NEUTRAL = "neutral"
MIXED = "mixed"
class SentimentAnalysis(BaseModel):
sentiment: Sentiment = Field(description="Overall sentiment")
confidence: float = Field(ge=0.0, le=1.0, description="Confidence 0.0-1.0")
reasoning: str = Field(description="Brief explanation")
key_phrases: list[str] = Field(description="Influential phrases")
# ============================================================================
# SAMPLING HANDLER WITH LOGGING
# ============================================================================
class LoggingAnthropicHandler(AnthropicSamplingHandler):
async def __call__(self, messages, params, context):
console.print(
" [bold blue]⚡ SAMPLING[/] Calling Claude API...", highlight=False
)
result = await super().__call__(messages, params, context)
console.print(
" [bold blue]⚡ SAMPLING[/] Response received", highlight=False
)
return result
# ============================================================================
# SERVER
# ============================================================================
mcp = FastMCP("Sentiment Analyzer")
@mcp.tool
async def analyze_sentiment(text: str, ctx: Context) -> dict:
"""Analyze sentiment and return structured results."""
console.print(" [bold yellow]📦 SERVER[/] Tool 'analyze_sentiment' called")
console.print(
" [bold yellow]📦 SERVER[/] Sampling with result_type=SentimentAnalysis"
)
result = await ctx.sample(
messages=f"Analyze the sentiment of this text:\n\n{text}",
system_prompt="You are a sentiment analysis expert.",
result_type=SentimentAnalysis,
)
console.print(
f" [bold yellow]📦 SERVER[/] Got validated {type(result.result).__name__}"
)
return result.result.model_dump() # type: ignore[union-attr]
# ============================================================================
# CLIENT
# ============================================================================
async def main():
console.print()
console.print(
Panel.fit(
"[bold]Structured Output Example[/]\n\n"
"The [cyan]result_type[/] parameter ensures LLM responses\n"
"match your Pydantic schema.",
border_style="bright_black",
)
)
console.print()
handler = LoggingAnthropicHandler(default_model="claude-sonnet-4-20250514")
test_texts = [
("I love this! Best purchase ever!", "😊"),
("Terrible. Would not recommend.", "😞"),
("It's okay. Nothing special.", "😐"),
]
async with Client(mcp, sampling_handler=handler) as client:
for text, _ in test_texts:
console.rule(style="dim")
console.print(f"[bold green]🖥️ CLIENT[/] Analyzing: [italic]{text}[/]")
console.print()
result = await client.call_tool("analyze_sentiment", {"text": text})
data = result.data
# Display result in a nice table
console.print()
console.print("[bold green]🖥️ CLIENT[/] Received structured result:")
emoji = {"positive": "😊", "negative": "😞", "neutral": "😐", "mixed": "🤔"}
table = Table(show_header=False, box=None, padding=(0, 2))
table.add_column(style="bold")
table.add_column()
table.add_row(
"Sentiment", f"{emoji.get(data['sentiment'], '')} {data['sentiment']}"
)
table.add_row(
"Confidence",
f"[green]{'' * int(data['confidence'] * 10)}[/][dim]{'' * (10 - int(data['confidence'] * 10))}[/] {data['confidence']:.0%}",
)
table.add_row("Reasoning", data["reasoning"])
table.add_row("Key phrases", ", ".join(data["key_phrases"]))
console.print(Panel(table, border_style="green"))
console.print()
console.print(
Panel(
"[bold]Key concept:[/] The result_type parameter enforces a Pydantic schema.\n"
"Invalid LLM responses are automatically rejected and retried.",
border_style="bright_black",
)
)
console.print()
if __name__ == "__main__":
asyncio.run(main())

152
examples/sampling/text.py Normal file
View file

@ -0,0 +1,152 @@
# /// script
# dependencies = ["anthropic", "fastmcp", "rich"]
# ///
"""
Text Sampling Example
The simplest form of sampling: send text to an LLM and get text back.
MCP Sampling Flow:
1. Client calls a tool on the server
2. Server tool needs LLM help, makes a sampling request
3. The sampling handler (on client) calls the LLM
4. Response flows back through the chain
Run:
python examples/sampling/text.py
"""
import asyncio
from rich.console import Console
from rich.panel import Panel
from rich.text import Text
from fastmcp import Client, Context, FastMCP
from fastmcp.server.sampling.anthropic import AnthropicSamplingHandler
console = Console()
# ============================================================================
# SAMPLING HANDLER WITH LOGGING
# Wraps AnthropicSamplingHandler to show when LLM calls happen
# ============================================================================
class LoggingAnthropicHandler(AnthropicSamplingHandler):
"""Sampling handler that logs when it calls the LLM."""
async def __call__(self, messages, params, context):
console.print(
" [bold blue]⚡ SAMPLING[/] Calling Claude API...", highlight=False
)
result = await super().__call__(messages, params, context)
console.print(
" [bold blue]⚡ SAMPLING[/] Response received", highlight=False
)
return result
# ============================================================================
# SERVER
# ============================================================================
mcp = FastMCP("Creative Writer")
@mcp.tool
async def write_haiku(topic: str, ctx: Context) -> str:
"""Write a haiku about the given topic."""
console.print(
f" [bold yellow]📦 SERVER[/] Tool 'write_haiku' called with topic={topic!r}"
)
console.print(" [bold yellow]📦 SERVER[/] Requesting LLM completion...")
result = await ctx.sample(
messages=f"Write a haiku about: {topic}",
system_prompt="You are a poet. Write only the haiku, nothing else.",
)
console.print(" [bold yellow]📦 SERVER[/] Returning result to client")
return result.text # type: ignore[return-value]
@mcp.tool
async def explain_simply(concept: str, ctx: Context) -> str:
"""Explain a concept in simple terms."""
console.print(
f" [bold yellow]📦 SERVER[/] Tool 'explain_simply' called with concept={concept!r}"
)
console.print(" [bold yellow]📦 SERVER[/] Requesting LLM completion...")
result = await ctx.sample(
messages=f"Explain this concept: {concept}",
system_prompt="Explain in 1-2 simple sentences a child could understand.",
)
console.print(" [bold yellow]📦 SERVER[/] Returning result to client")
return result.text # type: ignore[return-value]
# ============================================================================
# CLIENT
# ============================================================================
async def main():
console.print()
console.print(
Panel.fit(
"[bold]Text Sampling Example[/]\n\n"
"Watch the flow: [green]CLIENT[/] → [yellow]SERVER[/] → [blue]SAMPLING[/] → [yellow]SERVER[/] → [green]CLIENT[/]",
border_style="bright_black",
)
)
console.print()
handler = LoggingAnthropicHandler(default_model="claude-sonnet-4-20250514")
async with Client(mcp, sampling_handler=handler) as client:
# Example 1
console.rule("[bold green]Example 1: Write a Haiku", style="green")
console.print("[bold green]🖥️ CLIENT[/] Calling tool 'write_haiku'")
console.print()
result = await client.call_tool("write_haiku", {"topic": "async programming"})
console.print()
console.print("[bold green]🖥️ CLIENT[/] Got result:")
console.print(Panel(result.data, border_style="green", padding=(0, 2)))
console.print()
# Example 2
console.rule("[bold green]Example 2: Simple Explanation", style="green")
console.print("[bold green]🖥️ CLIENT[/] Calling tool 'explain_simply'")
console.print()
result = await client.call_tool("explain_simply", {"concept": "recursion"})
console.print()
console.print("[bold green]🖥️ CLIENT[/] Got result:")
console.print(Panel(result.data, border_style="green", padding=(0, 2)))
console.print()
# Summary
summary = Text()
summary.append("Flow: ", style="bold")
summary.append("CLIENT", style="green")
summary.append(" calls tool → ")
summary.append("SERVER", style="yellow")
summary.append(" needs LLM → ")
summary.append("SAMPLING", style="blue")
summary.append(" calls Claude → response flows back")
console.print(
Panel(summary, title="[bold]How it works[/]", border_style="bright_black")
)
console.print()
if __name__ == "__main__":
asyncio.run(main())

View file

@ -0,0 +1,169 @@
# /// script
# dependencies = ["anthropic", "fastmcp", "rich"]
# ///
"""
Tool Use Example
Give the LLM tools to use during sampling.
MCP Flow with Tools:
1. Client calls server tool
2. Server makes sampling request with tools=[...]
3. LLM decides to call a tool tool executes result fed back to LLM
4. Loop continues until LLM gives final answer (or max_iterations)
5. Server returns final response to client
Run:
python examples/sampling/tool_use.py
"""
import asyncio
import random
from datetime import datetime
from rich.console import Console
from rich.panel import Panel
from fastmcp import Client, Context, FastMCP
from fastmcp.server.sampling.anthropic import AnthropicSamplingHandler
console = Console()
# ============================================================================
# TOOLS - Functions the LLM can call during sampling
# ============================================================================
def calculate(expression: str) -> str:
"""Evaluate a math expression. Use Python syntax (e.g., 2**10 for power)."""
console.print(
f" [bold magenta]🔧 TOOL[/] calculate({expression!r})", highlight=False
)
try:
allowed = {"abs": abs, "round": round, "min": min, "max": max}
result = eval(expression, {"__builtins__": {}}, allowed)
console.print(f" [bold magenta]🔧 TOOL[/] → {result}", highlight=False)
return str(result)
except Exception as e:
return f"Error: {e}"
def get_current_time() -> str:
"""Get the current date and time."""
console.print(
" [bold magenta]🔧 TOOL[/] get_current_time()", highlight=False
)
result = datetime.now().strftime("%A, %B %d, %Y at %I:%M %p")
console.print(f" [bold magenta]🔧 TOOL[/] → {result}", highlight=False)
return result
def roll_dice(sides: int = 6, count: int = 1) -> str:
"""Roll dice and return results."""
console.print(
f" [bold magenta]🔧 TOOL[/] roll_dice(sides={sides}, count={count})",
highlight=False,
)
rolls = [random.randint(1, sides) for _ in range(count)]
result = f"Rolled {count}d{sides}: {rolls} (total: {sum(rolls)})"
console.print(f" [bold magenta]🔧 TOOL[/] → {result}", highlight=False)
return result
# ============================================================================
# SAMPLING HANDLER WITH LOGGING
# ============================================================================
class LoggingAnthropicHandler(AnthropicSamplingHandler):
async def __call__(self, messages, params, context):
console.print(
" [bold blue]⚡ SAMPLING[/] Calling Claude API...", highlight=False
)
result = await super().__call__(messages, params, context)
console.print(
" [bold blue]⚡ SAMPLING[/] Response received", highlight=False
)
return result
# ============================================================================
# SERVER
# ============================================================================
mcp = FastMCP("Assistant with Tools")
@mcp.tool
async def ask(question: str, ctx: Context) -> str:
"""Ask a question. The LLM can use tools to help answer."""
console.print(" [bold yellow]📦 SERVER[/] Tool 'ask' called")
console.print(
" [bold yellow]📦 SERVER[/] Sampling with tools=[calculate, get_current_time, roll_dice]"
)
console.print()
result = await ctx.sample(
messages=question,
system_prompt="You have tools available. Use them when helpful. Be concise.",
tools=[calculate, get_current_time, roll_dice],
max_iterations=5,
)
console.print()
console.print(" [bold yellow]📦 SERVER[/] Tool loop complete, returning answer")
return result.text # type: ignore[return-value]
# ============================================================================
# CLIENT
# ============================================================================
async def main():
console.print()
console.print(
Panel.fit(
"[bold]Tool Use Example[/]\n\n"
"Watch the LLM use [magenta]TOOLS[/] during sampling.\n"
"The tool loop runs until the LLM has a final answer.",
border_style="bright_black",
)
)
console.print()
handler = LoggingAnthropicHandler(default_model="claude-sonnet-4-20250514")
questions = [
"What's 15% tip on a $47.50 bill?",
"What time is it right now?",
"Roll 2 dice and tell me if I got doubles.",
]
async with Client(mcp, sampling_handler=handler) as client:
for question in questions:
console.rule(style="dim")
console.print(f"[bold green]🖥️ CLIENT[/] Question: [italic]{question}[/]")
console.print()
result = await client.call_tool("ask", {"question": question})
console.print()
console.print("[bold green]🖥️ CLIENT[/] Answer:")
console.print(Panel(result.data, border_style="green"))
console.print()
console.print(
Panel(
"[bold]Key concept:[/] The [cyan]tools[/] parameter lets the LLM call functions.\n"
"FastMCP handles the tool execution loop automatically.\n"
"[cyan]max_iterations[/] prevents infinite loops.",
border_style="bright_black",
)
)
console.print()
if __name__ == "__main__":
asyncio.run(main())

View file

@ -1,38 +0,0 @@
# /// script
# dependencies = ["openai", "fastmcp"]
# ///
import asyncio
import os
from mcp.types import ContentBlock
from openai import OpenAI
from fastmcp import FastMCP
from fastmcp.experimental.sampling.handlers.openai import OpenAISamplingHandler
from fastmcp.server.context import Context
async def async_main():
server = FastMCP(
name="OpenAI Sampling Fallback Example",
sampling_handler=OpenAISamplingHandler(
default_model=os.getenv("MODEL") or "gpt-4o-mini", # pyright: ignore[reportArgumentType]
client=OpenAI(
api_key=os.getenv("API_KEY"),
base_url=os.getenv("BASE_URL"),
),
),
)
@server.tool
async def test_sample_fallback(ctx: Context) -> ContentBlock:
return await ctx.sample(
messages=["hello world!"],
)
await server.run_http_async()
if __name__ == "__main__":
asyncio.run(async_main())

View file

@ -47,12 +47,13 @@ classifiers = [
]
[project.optional-dependencies]
anthropic = ["anthropic>=0.40.0"]
openai = ["openai>=1.102.0"]
[dependency-groups]
dev = [
"dirty-equals>=0.9.0",
"fastmcp[openai]",
"fastmcp[anthropic,openai]",
# add optional dependencies for fastmcp dev
"fastapi>=0.115.12",
"inline-snapshot[dirty-equals]>=0.27.2",

View file

@ -241,9 +241,7 @@ class OpenAISamplingHandler(BaseLLMSamplingHandler):
if content.content:
for item in content.content:
if isinstance(item, TextContent):
result_parts.append(
{"type": "text", "text": item.text}
)
result_parts.append({"type": "text", "text": item.text})
openai_messages.append(
ChatCompletionToolMessageParam(
role="tool",

View file

@ -0,0 +1,391 @@
"""Anthropic sampling handler for FastMCP servers."""
from collections.abc import Awaitable, Callable, Iterator, Sequence
from typing import Any
from mcp import ClientSession, ServerSession
from mcp.shared.context import LifespanContextT, RequestContext
from mcp.types import CreateMessageRequestParams as SamplingParams
from mcp.types import (
CreateMessageResult,
CreateMessageResultWithTools,
ModelPreferences,
SamplingMessage,
StopReason,
TextContent,
Tool,
ToolChoice,
ToolResultContent,
ToolUseContent,
)
try:
from anthropic import Anthropic, NotGiven
from anthropic._types import NOT_GIVEN
from anthropic.types import (
Message,
MessageParam,
TextBlock,
TextBlockParam,
ToolParam,
ToolResultBlockParam,
ToolUseBlock,
ToolUseBlockParam,
)
from anthropic.types.model_param import ModelParam
from anthropic.types.tool_choice_param import ToolChoiceParam
except ImportError as e:
raise ImportError(
"The `anthropic` package is not installed. "
"Please install `fastmcp[anthropic]` or add `anthropic` to your dependencies manually."
) from e
SamplingHandlerResult = str | CreateMessageResult | CreateMessageResultWithTools
ServerSamplingHandler = Callable[
[
list[SamplingMessage],
SamplingParams,
RequestContext[ServerSession, LifespanContextT],
],
SamplingHandlerResult | Awaitable[SamplingHandlerResult],
]
class AnthropicSamplingHandler:
"""Sampling handler that uses the Anthropic API."""
def __init__(self, default_model: ModelParam, client: Anthropic | None = None):
self.client: Anthropic = client or Anthropic()
self.default_model: ModelParam = default_model
async def __call__(
self,
messages: list[SamplingMessage],
params: SamplingParams,
context: RequestContext[ServerSession, LifespanContextT]
| RequestContext[ClientSession, LifespanContextT],
) -> CreateMessageResult | CreateMessageResultWithTools:
anthropic_messages: list[MessageParam] = self._convert_to_anthropic_messages(
messages=messages,
)
model: ModelParam = self._select_model_from_preferences(params.modelPreferences)
# Convert MCP tools to Anthropic format
anthropic_tools: list[ToolParam] | NotGiven = NOT_GIVEN
if params.tools:
anthropic_tools = self._convert_tools_to_anthropic(params.tools)
# Convert tool_choice to Anthropic format
anthropic_tool_choice: ToolChoiceParam | NotGiven = NOT_GIVEN
if params.toolChoice:
anthropic_tool_choice = self._convert_tool_choice_to_anthropic(
params.toolChoice
)
response = self.client.messages.create(
model=model,
messages=anthropic_messages,
system=params.systemPrompt or NOT_GIVEN,
temperature=params.temperature or NOT_GIVEN,
max_tokens=params.maxTokens,
stop_sequences=params.stopSequences or NOT_GIVEN,
tools=anthropic_tools,
tool_choice=anthropic_tool_choice,
)
# Return appropriate result type based on whether tools were provided
if params.tools:
return self._message_to_result_with_tools(response)
return self._message_to_create_message_result(response)
@staticmethod
def _iter_models_from_preferences(
model_preferences: ModelPreferences | str | list[str] | None,
) -> Iterator[str]:
if model_preferences is None:
return
if isinstance(model_preferences, str):
yield model_preferences
if isinstance(model_preferences, list):
yield from model_preferences
if isinstance(model_preferences, ModelPreferences):
if not (hints := model_preferences.hints):
return
for hint in hints:
if not (name := hint.name):
continue
yield name
@staticmethod
def _convert_to_anthropic_messages(
messages: Sequence[SamplingMessage],
) -> list[MessageParam]:
anthropic_messages: list[MessageParam] = []
if isinstance(messages, str):
anthropic_messages.append(
MessageParam(
role="user",
content=messages,
)
)
if isinstance(messages, list):
for message in messages:
if isinstance(message, str):
anthropic_messages.append(
MessageParam(
role="user",
content=message,
)
)
continue
content = message.content
# Handle list content (from CreateMessageResultWithTools)
if isinstance(content, list):
content_blocks: list[
TextBlockParam | ToolUseBlockParam | ToolResultBlockParam
] = []
for item in content:
if isinstance(item, ToolUseContent):
content_blocks.append(
ToolUseBlockParam(
type="tool_use",
id=item.id,
name=item.name,
input=item.input,
)
)
elif isinstance(item, TextContent):
content_blocks.append(
TextBlockParam(type="text", text=item.text)
)
elif isinstance(item, ToolResultContent):
# Extract text content from the result
result_content: str | list[TextBlockParam] = ""
if item.content:
text_blocks: list[TextBlockParam] = []
for sub_item in item.content:
if isinstance(sub_item, TextContent):
text_blocks.append(
TextBlockParam(
type="text", text=sub_item.text
)
)
if len(text_blocks) == 1:
result_content = text_blocks[0]["text"]
elif text_blocks:
result_content = text_blocks
content_blocks.append(
ToolResultBlockParam(
type="tool_result",
tool_use_id=item.toolUseId,
content=result_content,
)
)
if content_blocks:
anthropic_messages.append(
MessageParam(
role=message.role,
content=content_blocks, # type: ignore[arg-type]
)
)
continue
# Handle ToolUseContent (assistant's tool calls)
if isinstance(content, ToolUseContent):
anthropic_messages.append(
MessageParam(
role="assistant",
content=[
ToolUseBlockParam(
type="tool_use",
id=content.id,
name=content.name,
input=content.input,
)
],
)
)
continue
# Handle ToolResultContent (user's tool results)
if isinstance(content, ToolResultContent):
result_content_str: str | list[TextBlockParam] = ""
if content.content:
text_parts: list[TextBlockParam] = []
for item in content.content:
if isinstance(item, TextContent):
text_parts.append(
TextBlockParam(type="text", text=item.text)
)
if len(text_parts) == 1:
result_content_str = text_parts[0]["text"]
elif text_parts:
result_content_str = text_parts
anthropic_messages.append(
MessageParam(
role="user",
content=[
ToolResultBlockParam(
type="tool_result",
tool_use_id=content.toolUseId,
content=result_content_str,
)
],
)
)
continue
# Handle TextContent
if isinstance(content, TextContent):
anthropic_messages.append(
MessageParam(
role=message.role,
content=content.text,
)
)
continue
raise ValueError(f"Unsupported content type: {type(content)}")
return anthropic_messages
@staticmethod
def _message_to_create_message_result(
message: Message,
) -> CreateMessageResult:
if len(message.content) == 0:
raise ValueError("No content in response from Anthropic")
first_block = message.content[0]
if isinstance(first_block, TextBlock):
return CreateMessageResult(
content=TextContent(type="text", text=first_block.text),
role="assistant",
model=message.model,
)
raise ValueError(f"Unexpected content type in response: {type(first_block)}")
def _select_model_from_preferences(
self, model_preferences: ModelPreferences | str | list[str] | None
) -> ModelParam:
# Anthropic model names to check against
known_models = {
"claude-3-opus-20240229",
"claude-3-sonnet-20240229",
"claude-3-haiku-20240307",
"claude-3-5-sonnet-20240620",
"claude-3-5-sonnet-20241022",
"claude-3-5-haiku-20241022",
"claude-sonnet-4-20250514",
"claude-opus-4-5-20251101",
}
for model_option in self._iter_models_from_preferences(model_preferences):
# Accept any model that starts with "claude" or is in known models
if model_option.startswith("claude") or model_option in known_models:
return model_option
return self.default_model
@staticmethod
def _convert_tools_to_anthropic(tools: list[Tool]) -> list[ToolParam]:
"""Convert MCP tools to Anthropic tool format."""
anthropic_tools: list[ToolParam] = []
for tool in tools:
# Build input_schema dict, ensuring required fields
input_schema: dict[str, Any] = dict(tool.inputSchema)
if "type" not in input_schema:
input_schema["type"] = "object"
anthropic_tools.append(
ToolParam(
name=tool.name,
description=tool.description or "",
input_schema=input_schema, # type: ignore[arg-type]
)
)
return anthropic_tools
@staticmethod
def _convert_tool_choice_to_anthropic(
tool_choice: ToolChoice,
) -> ToolChoiceParam:
"""Convert MCP tool_choice to Anthropic format."""
if tool_choice.mode == "auto":
return {"type": "auto"}
elif tool_choice.mode == "required":
return {"type": "any"}
elif tool_choice.mode == "none":
# Anthropic doesn't have a "none" option, use auto
return {"type": "auto"}
else:
return {"type": "auto"}
@staticmethod
def _message_to_result_with_tools(
message: Message,
) -> CreateMessageResultWithTools:
"""Convert Anthropic response to CreateMessageResultWithTools."""
if len(message.content) == 0:
raise ValueError("No content in response from Anthropic")
# Determine stop reason
stop_reason: StopReason
if message.stop_reason == "tool_use":
stop_reason = "toolUse"
elif message.stop_reason == "end_turn":
stop_reason = "endTurn"
elif message.stop_reason == "max_tokens":
stop_reason = "maxTokens"
elif message.stop_reason == "stop_sequence":
stop_reason = "endTurn"
else:
stop_reason = "endTurn"
# Build content list
content: list[TextContent | ToolUseContent] = []
for block in message.content:
if isinstance(block, TextBlock):
content.append(TextContent(type="text", text=block.text))
elif isinstance(block, ToolUseBlock):
# Anthropic returns input as dict directly
arguments = block.input if isinstance(block.input, dict) else {}
content.append(
ToolUseContent(
type="tool_use",
id=block.id,
name=block.name,
input=arguments,
)
)
# Must have at least some content
if not content:
raise ValueError("No content in response from Anthropic")
return CreateMessageResultWithTools(
content=content,
role="assistant",
model=message.model,
stopReason=stop_reason,
)

View file

@ -0,0 +1,238 @@
from unittest.mock import MagicMock
import pytest
from anthropic import Anthropic
from anthropic.types import Message, TextBlock, ToolUseBlock, Usage
from mcp.types import (
CreateMessageResult,
CreateMessageResultWithTools,
ModelHint,
ModelPreferences,
SamplingMessage,
TextContent,
ToolUseContent,
)
from fastmcp.server.sampling.anthropic import AnthropicSamplingHandler
def test_convert_sampling_messages_to_anthropic_messages():
msgs = AnthropicSamplingHandler._convert_to_anthropic_messages(
messages=[
SamplingMessage(
role="user", content=TextContent(type="text", text="hello")
),
SamplingMessage(
role="assistant", content=TextContent(type="text", text="ok")
),
],
)
assert msgs == [
{"role": "user", "content": "hello"},
{"role": "assistant", "content": "ok"},
]
def test_convert_to_anthropic_messages_raises_on_non_text():
from fastmcp.utilities.types import Image
with pytest.raises(ValueError):
AnthropicSamplingHandler._convert_to_anthropic_messages(
messages=[
SamplingMessage(
role="user",
content=Image(data=b"abc").to_image_content(),
)
],
)
@pytest.mark.parametrize(
"prefs,expected",
[
("claude-3-5-sonnet-20241022", "claude-3-5-sonnet-20241022"),
(
ModelPreferences(hints=[ModelHint(name="claude-3-5-sonnet-20241022")]),
"claude-3-5-sonnet-20241022",
),
(["claude-3-5-sonnet-20241022", "other"], "claude-3-5-sonnet-20241022"),
(None, "fallback-model"),
(["unknown-model"], "fallback-model"),
],
)
def test_select_model_from_preferences(prefs, expected):
mock_client = MagicMock(spec=Anthropic)
handler = AnthropicSamplingHandler(
default_model="fallback-model", client=mock_client
)
assert handler._select_model_from_preferences(prefs) == expected
def test_message_to_create_message_result():
mock_client = MagicMock(spec=Anthropic)
handler = AnthropicSamplingHandler(
default_model="fallback-model", client=mock_client
)
message = Message(
id="msg_123",
type="message",
role="assistant",
content=[TextBlock(type="text", text="HELPFUL CONTENT FROM A VERY SMART LLM")],
model="claude-3-5-sonnet-20241022",
stop_reason="end_turn",
stop_sequence=None,
usage=Usage(input_tokens=10, output_tokens=20),
)
result: CreateMessageResult = handler._message_to_create_message_result(message)
assert result == CreateMessageResult(
content=TextContent(type="text", text="HELPFUL CONTENT FROM A VERY SMART LLM"),
role="assistant",
model="claude-3-5-sonnet-20241022",
)
def test_message_to_result_with_tools():
message = Message(
id="msg_123",
type="message",
role="assistant",
content=[
TextBlock(type="text", text="I'll help you with that."),
ToolUseBlock(
type="tool_use",
id="toolu_123",
name="get_weather",
input={"location": "San Francisco"},
),
],
model="claude-3-5-sonnet-20241022",
stop_reason="tool_use",
stop_sequence=None,
usage=Usage(input_tokens=10, output_tokens=20),
)
result: CreateMessageResultWithTools = (
AnthropicSamplingHandler._message_to_result_with_tools(message)
)
assert result.role == "assistant"
assert result.model == "claude-3-5-sonnet-20241022"
assert result.stopReason == "toolUse"
assert len(result.content) == 2
assert result.content[0] == TextContent(
type="text", text="I'll help you with that."
)
assert result.content[1] == ToolUseContent(
type="tool_use",
id="toolu_123",
name="get_weather",
input={"location": "San Francisco"},
)
def test_convert_tool_choice_auto():
result = AnthropicSamplingHandler._convert_tool_choice_to_anthropic(
MagicMock(mode="auto")
)
assert result == {"type": "auto"}
def test_convert_tool_choice_required():
result = AnthropicSamplingHandler._convert_tool_choice_to_anthropic(
MagicMock(mode="required")
)
assert result == {"type": "any"}
def test_convert_tool_choice_none():
result = AnthropicSamplingHandler._convert_tool_choice_to_anthropic(
MagicMock(mode="none")
)
# Anthropic doesn't have "none", falls back to "auto"
assert result == {"type": "auto"}
def test_convert_tools_to_anthropic():
from mcp.types import Tool
tools = [
Tool(
name="get_weather",
description="Get the current weather",
inputSchema={
"type": "object",
"properties": {"location": {"type": "string"}},
"required": ["location"],
},
)
]
result = AnthropicSamplingHandler._convert_tools_to_anthropic(tools)
assert len(result) == 1
assert result[0]["name"] == "get_weather"
assert result[0]["description"] == "Get the current weather"
assert result[0]["input_schema"] == {
"type": "object",
"properties": {"location": {"type": "string"}},
"required": ["location"],
}
def test_convert_messages_with_tool_use_content():
"""Test converting messages that include tool use content from assistant."""
msgs = AnthropicSamplingHandler._convert_to_anthropic_messages(
messages=[
SamplingMessage(
role="assistant",
content=ToolUseContent(
type="tool_use",
id="toolu_123",
name="get_weather",
input={"location": "NYC"},
),
),
],
)
assert len(msgs) == 1
assert msgs[0]["role"] == "assistant"
assert msgs[0]["content"] == [
{
"type": "tool_use",
"id": "toolu_123",
"name": "get_weather",
"input": {"location": "NYC"},
}
]
def test_convert_messages_with_tool_result_content():
"""Test converting messages that include tool result content from user."""
from mcp.types import ToolResultContent
msgs = AnthropicSamplingHandler._convert_to_anthropic_messages(
messages=[
SamplingMessage(
role="user",
content=ToolResultContent(
type="tool_result",
toolUseId="toolu_123",
content=[TextContent(type="text", text="72F and sunny")],
),
),
],
)
assert len(msgs) == 1
assert msgs[0]["role"] == "user"
assert msgs[0]["content"] == [
{
"type": "tool_result",
"tool_use_id": "toolu_123",
"content": "72F and sunny",
}
]

36
uv.lock generated
View file

@ -24,6 +24,25 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
]
[[package]]
name = "anthropic"
version = "0.75.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "distro" },
{ name = "docstring-parser" },
{ name = "httpx" },
{ name = "jiter" },
{ name = "pydantic" },
{ name = "sniffio" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/04/1f/08e95f4b7e2d35205ae5dcbb4ae97e7d477fc521c275c02609e2931ece2d/anthropic-0.75.0.tar.gz", hash = "sha256:e8607422f4ab616db2ea5baacc215dd5f028da99ce2f022e33c7c535b29f3dfb", size = 439565, upload-time = "2025-11-24T20:41:45.28Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/60/1c/1cd02b7ae64302a6e06724bf80a96401d5313708651d277b1458504a1730/anthropic-0.75.0-py3-none-any.whl", hash = "sha256:ea8317271b6c15d80225a9f3c670152746e88805a7a61e14d4a374577164965b", size = 388164, upload-time = "2025-11-24T20:41:43.587Z" },
]
[[package]]
name = "anyio"
version = "4.12.0"
@ -685,6 +704,9 @@ dependencies = [
]
[package.optional-dependencies]
anthropic = [
{ name = "anthropic" },
]
openai = [
{ name = "openai" },
]
@ -693,7 +715,7 @@ openai = [
dev = [
{ name = "dirty-equals" },
{ name = "fastapi" },
{ name = "fastmcp", extra = ["openai"] },
{ name = "fastmcp", extra = ["anthropic", "openai"] },
{ name = "inline-snapshot", extra = ["dirty-equals"] },
{ name = "ipython", version = "8.37.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
{ name = "ipython", version = "9.8.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
@ -718,12 +740,13 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "anthropic", marker = "extra == 'anthropic'", specifier = ">=0.40.0" },
{ name = "authlib", specifier = ">=1.6.5" },
{ name = "cyclopts", specifier = ">=4.0.0" },
{ name = "exceptiongroup", specifier = ">=1.2.2" },
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "jsonschema-path", specifier = ">=0.3.4" },
{ name = "mcp", specifier = ">=1.23.1" },
{ name = "mcp", specifier = ">=1.23.3" },
{ name = "openai", marker = "extra == 'openai'", specifier = ">=1.102.0" },
{ name = "openapi-pydantic", specifier = ">=0.5.1" },
{ name = "platformdirs", specifier = ">=4.0.0" },
@ -736,12 +759,13 @@ requires-dist = [
{ name = "uvicorn", specifier = ">=0.35" },
{ name = "websockets", specifier = ">=15.0.1" },
]
provides-extras = ["openai"]
provides-extras = ["anthropic", "openai"]
[package.metadata.requires-dev]
dev = [
{ name = "dirty-equals", specifier = ">=0.9.0" },
{ name = "fastapi", specifier = ">=0.115.12" },
{ name = "fastmcp", extras = ["anthropic"] },
{ name = "fastmcp", extras = ["openai"] },
{ name = "inline-snapshot", extras = ["dirty-equals"], specifier = ">=0.27.2" },
{ name = "ipython", specifier = ">=8.12.3" },
@ -1237,7 +1261,7 @@ wheels = [
[[package]]
name = "mcp"
version = "1.23.1"
version = "1.23.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
@ -1255,9 +1279,9 @@ dependencies = [
{ name = "typing-inspection" },
{ name = "uvicorn", marker = "sys_platform != 'emscripten'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/12/42/10c0c09ca27aceacd8c428956cfabdd67e3d328fe55c4abc16589285d294/mcp-1.23.1.tar.gz", hash = "sha256:7403e053e8e2283b1e6ae631423cb54736933fea70b32422152e6064556cd298", size = 596519, upload-time = "2025-12-02T18:41:12.807Z" }
sdist = { url = "https://files.pythonhosted.org/packages/a7/a4/d06a303f45997e266f2c228081abe299bbcba216cb806128e2e49095d25f/mcp-1.23.3.tar.gz", hash = "sha256:b3b0da2cc949950ce1259c7bfc1b081905a51916fcd7c8182125b85e70825201", size = 600697, upload-time = "2025-12-09T16:04:37.351Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9f/9e/26e1d2d2c6afe15dfba5ca6799eeeea7656dce625c22766e4c57305e9cc2/mcp-1.23.1-py3-none-any.whl", hash = "sha256:3ce897fcc20a41bd50b4c58d3aa88085f11f505dcc0eaed48930012d34c731d8", size = 231433, upload-time = "2025-12-02T18:41:11.195Z" },
{ url = "https://files.pythonhosted.org/packages/32/c6/13c1a26b47b3f3a3b480783001ada4268917c9f42d78a079c336da2e75e5/mcp-1.23.3-py3-none-any.whl", hash = "sha256:32768af4b46a1b4f7df34e2bfdf5c6011e7b63d7f1b0e321d0fdef4cd6082031", size = 231570, upload-time = "2025-12-09T16:04:35.56Z" },
]
[[package]]