diff --git a/examples/advanced_sampling/README.md b/examples/advanced_sampling/README.md deleted file mode 100644 index 4ffa247c7..000000000 --- a/examples/advanced_sampling/README.md +++ /dev/null @@ -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 -``` diff --git a/examples/advanced_sampling/structured_output.py b/examples/advanced_sampling/structured_output.py deleted file mode 100644 index 6a2756817..000000000 --- a/examples/advanced_sampling/structured_output.py +++ /dev/null @@ -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()) diff --git a/examples/advanced_sampling/tool_use.py b/examples/advanced_sampling/tool_use.py deleted file mode 100644 index a3302e47a..000000000 --- a/examples/advanced_sampling/tool_use.py +++ /dev/null @@ -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()) diff --git a/examples/sampling.py b/examples/sampling.py deleted file mode 100644 index cfb9c395a..000000000 --- a/examples/sampling.py +++ /dev/null @@ -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()) diff --git a/examples/sampling/server_fallback.py b/examples/sampling/server_fallback.py new file mode 100644 index 000000000..6bcc2bade --- /dev/null +++ b/examples/sampling/server_fallback.py @@ -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()) diff --git a/examples/sampling/structured_output.py b/examples/sampling/structured_output.py new file mode 100644 index 000000000..946575cb7 --- /dev/null +++ b/examples/sampling/structured_output.py @@ -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()) diff --git a/examples/sampling/text.py b/examples/sampling/text.py new file mode 100644 index 000000000..b64ece840 --- /dev/null +++ b/examples/sampling/text.py @@ -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()) diff --git a/examples/sampling/tool_use.py b/examples/sampling/tool_use.py new file mode 100644 index 000000000..ea2b26c32 --- /dev/null +++ b/examples/sampling/tool_use.py @@ -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()) diff --git a/examples/sampling_fallback.py b/examples/sampling_fallback.py deleted file mode 100644 index 31a855131..000000000 --- a/examples/sampling_fallback.py +++ /dev/null @@ -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()) diff --git a/pyproject.toml b/pyproject.toml index 648e7c6c3..790052a9e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/src/fastmcp/experimental/sampling/handlers/openai.py b/src/fastmcp/experimental/sampling/handlers/openai.py index 26cc22e6a..dc9cb029b 100644 --- a/src/fastmcp/experimental/sampling/handlers/openai.py +++ b/src/fastmcp/experimental/sampling/handlers/openai.py @@ -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", diff --git a/src/fastmcp/server/sampling/anthropic.py b/src/fastmcp/server/sampling/anthropic.py new file mode 100644 index 000000000..3a1b911d0 --- /dev/null +++ b/src/fastmcp/server/sampling/anthropic.py @@ -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, + ) diff --git a/tests/server/sampling/test_anthropic_handler.py b/tests/server/sampling/test_anthropic_handler.py new file mode 100644 index 000000000..226bf4e6e --- /dev/null +++ b/tests/server/sampling/test_anthropic_handler.py @@ -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", + } + ] diff --git a/uv.lock b/uv.lock index 8bcd4e76d..b3a36dbe6 100644 --- a/uv.lock +++ b/uv.lock @@ -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]]