From 626faa4494a2e2d07b752f4899b1f41925730841 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sun, 14 Dec 2025 15:28:30 -0500 Subject: [PATCH 1/5] Add AnthropicSamplingHandler Adds a sampling handler for the Anthropic API at fastmcp.client.sampling.handlers.anthropic, alongside the existing OpenAI handler. Includes full support for tool calling. Install with: pip install fastmcp[anthropic] --- pyproject.toml | 3 +- .../client/sampling/handlers/anthropic.py | 366 ++++++++++++++++++ .../handlers/test_anthropic_handler.py | 237 ++++++++++++ uv.lock | 29 +- 4 files changed, 631 insertions(+), 4 deletions(-) create mode 100644 src/fastmcp/client/sampling/handlers/anthropic.py create mode 100644 tests/client/sampling/handlers/test_anthropic_handler.py diff --git a/pyproject.toml b/pyproject.toml index 024d03be4c..5deb617845 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/client/sampling/handlers/anthropic.py b/src/fastmcp/client/sampling/handlers/anthropic.py new file mode 100644 index 0000000000..f3dad23cdd --- /dev/null +++ b/src/fastmcp/client/sampling/handlers/anthropic.py @@ -0,0 +1,366 @@ +"""Anthropic sampling handler for FastMCP.""" + +from collections.abc import Iterator, Sequence +from typing import Any + +from mcp.types import CreateMessageRequestParams as SamplingParams +from mcp.types import ( + CreateMessageResult, + CreateMessageResultWithTools, + ModelPreferences, + SamplingMessage, + SamplingMessageContentBlock, + 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_any_param import ToolChoiceAnyParam + from anthropic.types.tool_choice_auto_param import ToolChoiceAutoParam + from anthropic.types.tool_choice_param import ToolChoiceParam +except ImportError as e: + raise ImportError( + "The `anthropic` package is not installed. " + "Install it with `pip install fastmcp[anthropic]` or add `anthropic` to your dependencies." + ) from e + +__all__ = ["AnthropicSamplingHandler"] + + +class AnthropicSamplingHandler: + """Sampling handler that uses the Anthropic API. + + Example: + ```python + from anthropic import Anthropic + from fastmcp import FastMCP + from fastmcp.client.sampling.handlers.anthropic import AnthropicSamplingHandler + + handler = AnthropicSamplingHandler( + default_model="claude-haiku-4-5-20250514", + client=Anthropic(), + ) + + server = FastMCP(sampling_handler=handler) + ``` + """ + + def __init__( + self, default_model: ModelParam, client: Anthropic | None = None + ) -> None: + self.client: Anthropic = client or Anthropic() + self.default_model: ModelParam = default_model + + async def __call__( + self, + messages: list[SamplingMessage], + params: SamplingParams, + context: Any, + ) -> 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 + + elif isinstance(model_preferences, list): + yield from model_preferences + + elif 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] = [] + + for message in messages: + 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: + for model_option in self._iter_models_from_preferences(model_preferences): + # Accept any model that starts with "claude" + if model_option.startswith("claude"): + 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 ToolChoiceAutoParam(type="auto") + elif tool_choice.mode == "required": + return ToolChoiceAnyParam(type="any") + elif tool_choice.mode == "none": + # Anthropic doesn't have a "none" option, use auto + return ToolChoiceAutoParam(type="auto") + else: + return ToolChoiceAutoParam(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[SamplingMessageContentBlock] = [] + + 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/client/sampling/handlers/test_anthropic_handler.py b/tests/client/sampling/handlers/test_anthropic_handler.py new file mode 100644 index 0000000000..a836be1046 --- /dev/null +++ b/tests/client/sampling/handlers/test_anthropic_handler.py @@ -0,0 +1,237 @@ +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.client.sampling.handlers.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" + content = result.content_as_list + assert len(content) == 2 + assert content[0] == TextContent(type="text", text="I'll help you with that.") + assert 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 342cb8a5fb..b26901997a 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,6 +740,7 @@ 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" }, @@ -736,13 +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 = ["openai"] }, + { name = "fastmcp", extras = ["anthropic", "openai"] }, { name = "inline-snapshot", extras = ["dirty-equals"], specifier = ">=0.27.2" }, { name = "ipython", specifier = ">=8.12.3" }, { name = "pdbpp", specifier = ">=0.11.7" }, From a4897b4ce28b456ce3fcff8afc474fb3c422d95c Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sun, 14 Dec 2025 15:31:09 -0500 Subject: [PATCH 2/5] Update default model --- src/fastmcp/client/sampling/handlers/anthropic.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/fastmcp/client/sampling/handlers/anthropic.py b/src/fastmcp/client/sampling/handlers/anthropic.py index f3dad23cdd..1114afc245 100644 --- a/src/fastmcp/client/sampling/handlers/anthropic.py +++ b/src/fastmcp/client/sampling/handlers/anthropic.py @@ -54,7 +54,7 @@ class AnthropicSamplingHandler: from fastmcp.client.sampling.handlers.anthropic import AnthropicSamplingHandler handler = AnthropicSamplingHandler( - default_model="claude-haiku-4-5-20250514", + default_model="claude-sonnet-4-5", client=Anthropic(), ) From a3e839e65f4ca0dcbfae9cb20b40a0a184c49a9c Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sun, 14 Dec 2025 15:33:27 -0500 Subject: [PATCH 3/5] Update sampling docs to cover both OpenAI and Anthropic handlers --- docs/clients/sampling.mdx | 50 +++++++++++++++++++++++++++++++++++---- docs/servers/sampling.mdx | 32 ++++++++++++------------- 2 files changed, 61 insertions(+), 21 deletions(-) diff --git a/docs/clients/sampling.mdx b/docs/clients/sampling.mdx index dba91b7a91..22a0e3eefa 100644 --- a/docs/clients/sampling.mdx +++ b/docs/clients/sampling.mdx @@ -177,11 +177,15 @@ client = Client( ) ``` -## Using the OpenAI Handler +## Built-in Handlers -For full-featured sampling with tool support, use the built-in OpenAI handler. It handles message conversion, tool calls, and response formatting automatically: +FastMCP provides built-in sampling handlers for OpenAI and Anthropic APIs. These handlers support the full sampling API including tool use, handling message conversion and response formatting automatically. + +### OpenAI Handler + +The OpenAI handler works with OpenAI's API and any OpenAI-compatible provider: ```python from fastmcp import Client @@ -193,7 +197,7 @@ client = Client( ) ``` -The handler works with any OpenAI-compatible API by passing a custom client: +For OpenAI-compatible APIs (like local models), pass a custom client: ```python from openai import AsyncOpenAI @@ -208,9 +212,45 @@ client = Client( ``` -Tool execution happens on the server side. The client's role is to pass tools to the LLM and return the LLM's response (which may include tool use requests). The server then executes the tools and may send follow-up sampling requests with tool results. +Install the OpenAI handler with `pip install fastmcp[openai]`. + + +### Anthropic Handler + +The Anthropic handler uses Claude models via the Anthropic API: + +```python +from fastmcp import Client +from fastmcp.client.sampling.handlers.anthropic import AnthropicSamplingHandler + +client = Client( + "my_mcp_server.py", + sampling_handler=AnthropicSamplingHandler(default_model="claude-sonnet-4-5"), +) +``` + +You can pass a custom client for advanced configuration: + +```python +from anthropic import Anthropic + +client = Client( + "my_mcp_server.py", + sampling_handler=AnthropicSamplingHandler( + default_model="claude-sonnet-4-5", + client=Anthropic(api_key="your-key"), + ), +) +``` + + +Install the Anthropic handler with `pip install fastmcp[anthropic]`. +### Tool Execution + +Tool execution happens on the server side. The client's role is to pass tools to the LLM and return the LLM's response (which may include tool use requests). The server then executes the tools and may send follow-up sampling requests with tool results. + -To implement a custom sampling handler, see the [OpenAISamplingHandler source code](https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/sampling/handlers/openai.py) as a reference. +To implement a custom sampling handler, see the [handler source code](https://github.com/jlowin/fastmcp/tree/main/src/fastmcp/client/sampling/handlers) as a reference. \ No newline at end of file diff --git a/docs/servers/sampling.mdx b/docs/servers/sampling.mdx index 4e19b9521b..841d5b01f3 100644 --- a/docs/servers/sampling.mdx +++ b/docs/servers/sampling.mdx @@ -443,32 +443,32 @@ tool_result = ToolResultContent( Client support for sampling is optional—some clients may not implement it. To ensure your tools work regardless of client capabilities, configure a `sampling_handler` that sends requests directly to an LLM provider. -### OpenAI Handler - -FastMCP provides an OpenAI-compatible handler that works with OpenAI's API and compatible providers. It supports the full sampling API including tools, automatically converting your Python functions to OpenAI's function calling format. +FastMCP provides built-in handlers for [OpenAI and Anthropic APIs](/clients/sampling#built-in-handlers). These handlers support the full sampling API including tools, automatically converting your Python functions to each provider's format. - The OpenAI handler requires the `openai` package. Install it with: - ```bash - pip install fastmcp[openai] - # or - pip install openai - ``` - You'll also need to set the `OPENAI_API_KEY` environment variable or pass it directly to the client. +Install handlers with `pip install fastmcp[openai]` or `pip install fastmcp[anthropic]`. ```python -import os -from openai import OpenAI from fastmcp import FastMCP from fastmcp.client.sampling.handlers.openai import OpenAISamplingHandler server = FastMCP( name="My Server", - sampling_handler=OpenAISamplingHandler( - default_model="gpt-4o-mini", - client=OpenAI(api_key=os.getenv("OPENAI_API_KEY")), - ), + sampling_handler=OpenAISamplingHandler(default_model="gpt-4o-mini"), + sampling_handler_behavior="fallback", +) +``` + +Or with Anthropic: + +```python +from fastmcp import FastMCP +from fastmcp.client.sampling.handlers.anthropic import AnthropicSamplingHandler + +server = FastMCP( + name="My Server", + sampling_handler=AnthropicSamplingHandler(default_model="claude-sonnet-4-5"), sampling_handler_behavior="fallback", ) ``` From 51ae947db267ad913bfb421d5fef6dcd0a1a41fb Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sun, 14 Dec 2025 15:42:30 -0500 Subject: [PATCH 4/5] Use AsyncAnthropic, fix falsy value handling, handle tool_choice none --- docs/clients/sampling.mdx | 10 ++-- .../client/sampling/handlers/anthropic.py | 51 ++++++++++++------- .../handlers/test_anthropic_handler.py | 19 +++++-- 3 files changed, 53 insertions(+), 27 deletions(-) diff --git a/docs/clients/sampling.mdx b/docs/clients/sampling.mdx index 22a0e3eefa..7b254f91b9 100644 --- a/docs/clients/sampling.mdx +++ b/docs/clients/sampling.mdx @@ -179,12 +179,12 @@ client = Client( ## Built-in Handlers - - FastMCP provides built-in sampling handlers for OpenAI and Anthropic APIs. These handlers support the full sampling API including tool use, handling message conversion and response formatting automatically. ### OpenAI Handler + + The OpenAI handler works with OpenAI's API and any OpenAI-compatible provider: ```python @@ -217,6 +217,8 @@ Install the OpenAI handler with `pip install fastmcp[openai]`. ### Anthropic Handler + + The Anthropic handler uses Claude models via the Anthropic API: ```python @@ -232,13 +234,13 @@ client = Client( You can pass a custom client for advanced configuration: ```python -from anthropic import Anthropic +from anthropic import AsyncAnthropic client = Client( "my_mcp_server.py", sampling_handler=AnthropicSamplingHandler( default_model="claude-sonnet-4-5", - client=Anthropic(api_key="your-key"), + client=AsyncAnthropic(api_key="your-key"), ), ) ``` diff --git a/src/fastmcp/client/sampling/handlers/anthropic.py b/src/fastmcp/client/sampling/handlers/anthropic.py index 1114afc245..aa37e99388 100644 --- a/src/fastmcp/client/sampling/handlers/anthropic.py +++ b/src/fastmcp/client/sampling/handlers/anthropic.py @@ -19,7 +19,7 @@ ) try: - from anthropic import Anthropic, NotGiven + from anthropic import AsyncAnthropic, NotGiven from anthropic._types import NOT_GIVEN from anthropic.types import ( Message, @@ -49,13 +49,13 @@ class AnthropicSamplingHandler: Example: ```python - from anthropic import Anthropic + from anthropic import AsyncAnthropic from fastmcp import FastMCP from fastmcp.client.sampling.handlers.anthropic import AnthropicSamplingHandler handler = AnthropicSamplingHandler( default_model="claude-sonnet-4-5", - client=Anthropic(), + client=AsyncAnthropic(), ) server = FastMCP(sampling_handler=handler) @@ -63,9 +63,9 @@ class AnthropicSamplingHandler: """ def __init__( - self, default_model: ModelParam, client: Anthropic | None = None + self, default_model: ModelParam, client: AsyncAnthropic | None = None ) -> None: - self.client: Anthropic = client or Anthropic() + self.client: AsyncAnthropic = client or AsyncAnthropic() self.default_model: ModelParam = default_model async def __call__( @@ -86,19 +86,29 @@ async def __call__( anthropic_tools = self._convert_tools_to_anthropic(params.tools) # Convert tool_choice to Anthropic format + # Returns None if mode is "none", signaling tools should be omitted 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( + converted = self._convert_tool_choice_to_anthropic(params.toolChoice) + if converted is None: + # tool_choice="none" means don't use tools + anthropic_tools = NOT_GIVEN + else: + anthropic_tool_choice = converted + + response = await self.client.messages.create( model=model, messages=anthropic_messages, - system=params.systemPrompt or NOT_GIVEN, - temperature=params.temperature or NOT_GIVEN, + system=( + params.systemPrompt if params.systemPrompt is not None else NOT_GIVEN + ), + temperature=( + params.temperature if params.temperature is not None else NOT_GIVEN + ), max_tokens=params.maxTokens, - stop_sequences=params.stopSequences or NOT_GIVEN, + stop_sequences=( + params.stopSequences if params.stopSequences is not None else NOT_GIVEN + ), tools=anthropic_tools, tool_choice=anthropic_tool_choice, ) @@ -302,17 +312,22 @@ def _convert_tools_to_anthropic(tools: list[Tool]) -> list[ToolParam]: @staticmethod def _convert_tool_choice_to_anthropic( tool_choice: ToolChoice, - ) -> ToolChoiceParam: - """Convert MCP tool_choice to Anthropic format.""" + ) -> ToolChoiceParam | None: + """Convert MCP tool_choice to Anthropic format. + + Returns None for "none" mode, signaling that tools should be omitted + from the request entirely (Anthropic doesn't have an explicit "none" option). + """ if tool_choice.mode == "auto": return ToolChoiceAutoParam(type="auto") elif tool_choice.mode == "required": return ToolChoiceAnyParam(type="any") elif tool_choice.mode == "none": - # Anthropic doesn't have a "none" option, use auto - return ToolChoiceAutoParam(type="auto") + # Anthropic doesn't have a "none" option - return None to signal + # that tools should be omitted from the request entirely + return None else: - return ToolChoiceAutoParam(type="auto") + raise ValueError(f"Unsupported tool_choice mode: {tool_choice.mode!r}") @staticmethod def _message_to_result_with_tools( diff --git a/tests/client/sampling/handlers/test_anthropic_handler.py b/tests/client/sampling/handlers/test_anthropic_handler.py index a836be1046..a348cba205 100644 --- a/tests/client/sampling/handlers/test_anthropic_handler.py +++ b/tests/client/sampling/handlers/test_anthropic_handler.py @@ -1,7 +1,7 @@ from unittest.mock import MagicMock import pytest -from anthropic import Anthropic +from anthropic import AsyncAnthropic from anthropic.types import Message, TextBlock, ToolUseBlock, Usage from mcp.types import ( CreateMessageResult, @@ -62,7 +62,7 @@ def test_convert_to_anthropic_messages_raises_on_non_text(): ], ) def test_select_model_from_preferences(prefs, expected): - mock_client = MagicMock(spec=Anthropic) + mock_client = MagicMock(spec=AsyncAnthropic) handler = AnthropicSamplingHandler( default_model="fallback-model", client=mock_client ) @@ -70,7 +70,7 @@ def test_select_model_from_preferences(prefs, expected): def test_message_to_create_message_result(): - mock_client = MagicMock(spec=Anthropic) + mock_client = MagicMock(spec=AsyncAnthropic) handler = AnthropicSamplingHandler( default_model="fallback-model", client=mock_client ) @@ -136,6 +136,7 @@ def test_convert_tool_choice_auto(): result = AnthropicSamplingHandler._convert_tool_choice_to_anthropic( MagicMock(mode="auto") ) + assert result is not None assert result["type"] == "auto" @@ -143,6 +144,7 @@ def test_convert_tool_choice_required(): result = AnthropicSamplingHandler._convert_tool_choice_to_anthropic( MagicMock(mode="required") ) + assert result is not None assert result["type"] == "any" @@ -150,8 +152,15 @@ 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" + # Anthropic doesn't have "none", returns None to signal tools should be omitted + assert result is None + + +def test_convert_tool_choice_unknown_raises(): + with pytest.raises(ValueError, match="Unsupported tool_choice mode"): + AnthropicSamplingHandler._convert_tool_choice_to_anthropic( + MagicMock(mode="unknown") + ) def test_convert_tools_to_anthropic(): From a272306fce27b87d1a205fea47d8ed9cce32f1f9 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sun, 14 Dec 2025 16:01:51 -0500 Subject: [PATCH 5/5] Propagate isError to Anthropic, join multiple text blocks, fix docs --- docs/clients/sampling.mdx | 2 +- .../client/sampling/handlers/anthropic.py | 16 +++++++++++----- .../sampling/handlers/test_anthropic_handler.py | 1 + 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/docs/clients/sampling.mdx b/docs/clients/sampling.mdx index 7b254f91b9..af1b656566 100644 --- a/docs/clients/sampling.mdx +++ b/docs/clients/sampling.mdx @@ -240,7 +240,7 @@ client = Client( "my_mcp_server.py", sampling_handler=AnthropicSamplingHandler( default_model="claude-sonnet-4-5", - client=AsyncAnthropic(api_key="your-key"), + client=AsyncAnthropic(), # Uses ANTHROPIC_API_KEY env var ), ) ``` diff --git a/src/fastmcp/client/sampling/handlers/anthropic.py b/src/fastmcp/client/sampling/handlers/anthropic.py index aa37e99388..f644febf74 100644 --- a/src/fastmcp/client/sampling/handlers/anthropic.py +++ b/src/fastmcp/client/sampling/handlers/anthropic.py @@ -190,6 +190,7 @@ def _convert_to_anthropic_messages( type="tool_result", tool_use_id=item.toolUseId, content=result_content, + is_error=item.isError if item.isError else False, ) ) @@ -242,6 +243,7 @@ def _convert_to_anthropic_messages( type="tool_result", tool_use_id=content.toolUseId, content=result_content_str, + is_error=content.isError if content.isError else False, ) ], ) @@ -269,16 +271,20 @@ def _message_to_create_message_result( if len(message.content) == 0: raise ValueError("No content in response from Anthropic") - first_block = message.content[0] - - if isinstance(first_block, TextBlock): + # Join all text blocks to avoid dropping content + text = "".join( + block.text for block in message.content if isinstance(block, TextBlock) + ) + if text: return CreateMessageResult( - content=TextContent(type="text", text=first_block.text), + content=TextContent(type="text", text=text), role="assistant", model=message.model, ) - raise ValueError(f"Unexpected content type in response: {type(first_block)}") + raise ValueError( + f"No text content in response from Anthropic: {[type(b).__name__ for b in message.content]}" + ) def _select_model_from_preferences( self, model_preferences: ModelPreferences | str | list[str] | None diff --git a/tests/client/sampling/handlers/test_anthropic_handler.py b/tests/client/sampling/handlers/test_anthropic_handler.py index a348cba205..57a464adab 100644 --- a/tests/client/sampling/handlers/test_anthropic_handler.py +++ b/tests/client/sampling/handlers/test_anthropic_handler.py @@ -242,5 +242,6 @@ def test_convert_messages_with_tool_result_content(): "type": "tool_result", "tool_use_id": "toolu_123", "content": "72F and sunny", + "is_error": False, } ]