Add AnthropicSamplingHandler (#2617)

* 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]

* Update default model

* Update sampling docs to cover both OpenAI and Anthropic handlers

* Use AsyncAnthropic, fix falsy value handling, handle tool_choice none

* Propagate isError to Anthropic, join multiple text blocks, fix docs
This commit is contained in:
Jeremiah Lowin 2025-12-14 16:07:22 -05:00 committed by GitHub
commit c91c43e280
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 726 additions and 26 deletions

View file

@ -177,11 +177,15 @@ client = Client(
)
```
## Using the OpenAI Handler
## Built-in Handlers
<VersionBadge version="2.14.0" />
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.
For full-featured sampling with tool support, use the built-in OpenAI handler. It handles message conversion, tool calls, and response formatting automatically:
### OpenAI Handler
<VersionBadge version="2.11.0" />
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,47 @@ client = Client(
```
<Note>
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]`.
</Note>
### Anthropic Handler
<VersionBadge version="2.14.1" />
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 AsyncAnthropic
client = Client(
"my_mcp_server.py",
sampling_handler=AnthropicSamplingHandler(
default_model="claude-sonnet-4-5",
client=AsyncAnthropic(), # Uses ANTHROPIC_API_KEY env var
),
)
```
<Note>
Install the Anthropic handler with `pip install fastmcp[anthropic]`.
</Note>
### 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.
<Tip>
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.
</Tip>

View file

@ -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.
<Note>
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]`.
</Note>
```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",
)
```

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

@ -0,0 +1,387 @@
"""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 AsyncAnthropic, 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 AsyncAnthropic
from fastmcp import FastMCP
from fastmcp.client.sampling.handlers.anthropic import AnthropicSamplingHandler
handler = AnthropicSamplingHandler(
default_model="claude-sonnet-4-5",
client=AsyncAnthropic(),
)
server = FastMCP(sampling_handler=handler)
```
"""
def __init__(
self, default_model: ModelParam, client: AsyncAnthropic | None = None
) -> None:
self.client: AsyncAnthropic = client or AsyncAnthropic()
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
# Returns None if mode is "none", signaling tools should be omitted
anthropic_tool_choice: ToolChoiceParam | NotGiven = NOT_GIVEN
if params.toolChoice:
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 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 if params.stopSequences is not None else 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,
is_error=item.isError if item.isError else False,
)
)
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,
is_error=content.isError if content.isError else False,
)
],
)
)
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")
# 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=text),
role="assistant",
model=message.model,
)
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
) -> 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 | 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 - return None to signal
# that tools should be omitted from the request entirely
return None
else:
raise ValueError(f"Unsupported tool_choice mode: {tool_choice.mode!r}")
@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,
)

View file

@ -0,0 +1,247 @@
from unittest.mock import MagicMock
import pytest
from anthropic import AsyncAnthropic
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=AsyncAnthropic)
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=AsyncAnthropic)
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 is not None
assert result["type"] == "auto"
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"
def test_convert_tool_choice_none():
result = AnthropicSamplingHandler._convert_tool_choice_to_anthropic(
MagicMock(mode="none")
)
# 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 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",
"is_error": False,
}
]

29
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,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" },