Litellm anthropic mcp support (#11474)

* feat(anthropic/): initial commit adding working mcp tool call support

pass in mcp tool via `tools` and litellm will handle translating it to the right anthropic param

* feat(anthropic/): map openai mcp tool to anthropic mcp tool

allows usage within responses api

* fix(databricks/transformation.py): fix databricks linting error

* test(test_anthropic_chat_transformation.py): fix test

* test: update test

* fix(anthropic/chat/transformation.py): add dummy tool call
This commit is contained in:
Krish Dholakia 2025-06-07 15:11:55 -07:00 • committed by GitHub
parent 440a6aa3d2
commit e3c66418b8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 145 additions and 21 deletions

View file

@ -24,6 +24,7 @@ from litellm.types.llms.anthropic import (
AnthropicComputerTool,
AnthropicHostedTools,
AnthropicInputSchema,
AnthropicMcpServerTool,
AnthropicMessagesTool,
AnthropicMessagesToolChoice,
AnthropicSystemMessageContent,
@ -41,6 +42,7 @@ from litellm.types.llms.openai import (
ChatCompletionToolCallChunk,
ChatCompletionToolCallFunctionChunk,
ChatCompletionToolParam,
OpenAIMcpServerTool,
OpenAIWebSearchOptions,
)
from litellm.types.utils import CompletionTokensDetailsWrapper
@ -175,8 +177,9 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
def _map_tool_helper(
self, tool: ChatCompletionToolParam
) -> AllAnthropicToolsValues:
) -> Tuple[Optional[AllAnthropicToolsValues], Optional[AnthropicMcpServerTool]]:
returned_tool: Optional[AllAnthropicToolsValues] = None
mcp_server: Optional[AnthropicMcpServerTool] = None
if tool["type"] == "function" or tool["type"] == "custom":
_input_schema: dict = tool["function"].get(
@ -239,33 +242,77 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
returned_tool = AnthropicHostedTools(
type=tool["type"], name=function_name, **additional_tool_params # type: ignore
)
if returned_tool is None:
elif tool["type"] == "url": # mcp server tool
mcp_server = AnthropicMcpServerTool(**tool) # type: ignore
elif tool["type"] == "mcp":
mcp_server = self._map_openai_mcp_server_tool(
cast(OpenAIMcpServerTool, tool)
)
if returned_tool is None and mcp_server is None:
raise ValueError(f"Unsupported tool type: {tool['type']}")
## check if cache_control is set in the tool
_cache_control = tool.get("cache_control", None)
_cache_control_function = tool.get("function", {}).get("cache_control", None)
if _cache_control is not None:
returned_tool["cache_control"] = _cache_control
elif _cache_control_function is not None and isinstance(
_cache_control_function, dict
):
returned_tool["cache_control"] = ChatCompletionCachedContent(
**_cache_control_function # type: ignore
if returned_tool is not None:
if _cache_control is not None:
returned_tool["cache_control"] = _cache_control
elif _cache_control_function is not None and isinstance(
_cache_control_function, dict
):
returned_tool["cache_control"] = ChatCompletionCachedContent(
**_cache_control_function # type: ignore
)
return returned_tool, mcp_server
def _map_openai_mcp_server_tool(
self, tool: OpenAIMcpServerTool
) -> AnthropicMcpServerTool:
from litellm.types.llms.anthropic import AnthropicMcpServerToolConfiguration
allowed_tools = tool.get("allowed_tools", None)
tool_configuration: Optional[AnthropicMcpServerToolConfiguration] = None
if allowed_tools is not None:
tool_configuration = AnthropicMcpServerToolConfiguration(
allowed_tools=tool.get("allowed_tools", None),
)
return returned_tool
headers = tool.get("headers", {})
authorization_token: Optional[str] = None
if headers is not None:
bearer_token = headers.get("Authorization", None)
if bearer_token is not None:
authorization_token = bearer_token.replace("Bearer ", "")
def _map_tools(self, tools: List) -> List[AllAnthropicToolsValues]:
initial_tool = AnthropicMcpServerTool(
type="url",
url=tool["server_url"],
name=tool["server_label"],
)
if tool_configuration is not None:
initial_tool["tool_configuration"] = tool_configuration
if authorization_token is not None:
initial_tool["authorization_token"] = authorization_token
return initial_tool
def _map_tools(
self, tools: List
) -> Tuple[List[AllAnthropicToolsValues], List[AnthropicMcpServerTool]]:
anthropic_tools = []
mcp_servers = []
for tool in tools:
if "input_schema" in tool: # assume in anthropic format
anthropic_tools.append(tool)
else: # assume openai tool call
new_tool = self._map_tool_helper(tool)
new_tool, mcp_server_tool = self._map_tool_helper(tool)
anthropic_tools.append(new_tool)
return anthropic_tools
if new_tool is not None:
anthropic_tools.append(new_tool)
if mcp_server_tool is not None:
mcp_servers.append(mcp_server_tool)
return anthropic_tools, mcp_servers
def _map_stop_sequences(
self, stop: Optional[Union[str, List[str]]]
@ -389,10 +436,12 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
optional_params["max_tokens"] = value
if param == "tools":
# check if optional params already has tools
tool_value = self._map_tools(value)
anthropic_tools, mcp_servers = self._map_tools(value)
optional_params = self._add_tools_to_optional_params(
optional_params=optional_params, tools=tool_value
optional_params=optional_params, tools=anthropic_tools
)
if mcp_servers:
optional_params["mcp_servers"] = mcp_servers
if param == "tool_choice" or param == "parallel_tool_calls":
_tool_choice: Optional[
AnthropicMessagesToolChoice
@ -591,7 +640,7 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
and has_tool_call_blocks(messages)
):
if litellm.modify_params:
optional_params["tools"] = self._map_tools(
optional_params["tools"], _ = self._map_tools(
add_dummy_tool(custom_llm_provider="anthropic")
)
else:

View file

@ -12,7 +12,7 @@ from litellm.litellm_core_utils.prompt_templates.common_utils import (
)
from litellm.llms.base_llm.base_utils import BaseLLMModelInfo
from litellm.llms.base_llm.chat.transformation import BaseLLMException
from litellm.types.llms.anthropic import AllAnthropicToolsValues
from litellm.types.llms.anthropic import AllAnthropicToolsValues, AnthropicMcpServerTool
from litellm.types.llms.openai import AllMessageValues
@ -51,6 +51,15 @@ class AnthropicModelInfo(BaseLLMModelInfo):
file_ids = get_file_ids_from_messages(messages)
return len(file_ids) > 0
def is_mcp_server_used(
self, mcp_servers: Optional[List[AnthropicMcpServerTool]]
) -> bool:
if mcp_servers is None:
return False
if mcp_servers:
return True
return False
def is_computer_tool_used(
self, tools: Optional[List[AllAnthropicToolsValues]]
) -> bool:
@ -92,6 +101,7 @@ class AnthropicModelInfo(BaseLLMModelInfo):
prompt_caching_set: bool = False,
pdf_used: bool = False,
file_id_used: bool = False,
mcp_server_used: bool = False,
is_vertex_request: bool = False,
user_anthropic_beta_headers: Optional[List[str]] = None,
) -> dict:
@ -105,6 +115,9 @@ class AnthropicModelInfo(BaseLLMModelInfo):
if file_id_used:
betas.add("files-api-2025-04-14")
betas.add("code-execution-2025-05-22")
if mcp_server_used:
betas.add("mcp-client-2025-04-04")
headers = {
"anthropic-version": anthropic_version or "2023-06-01",
"x-api-key": api_key,
@ -143,6 +156,9 @@ class AnthropicModelInfo(BaseLLMModelInfo):
tools = optional_params.get("tools")
prompt_caching_set = self.is_cache_control_set(messages=messages)
computer_tool_used = self.is_computer_tool_used(tools=tools)
mcp_server_used = self.is_mcp_server_used(
mcp_servers=optional_params.get("mcp_servers")
)
pdf_used = self.is_pdf_used(messages=messages)
file_id_used = self.is_file_id_used(messages=messages)
user_anthropic_beta_headers = self._get_user_anthropic_beta_headers(
@ -156,6 +172,7 @@ class AnthropicModelInfo(BaseLLMModelInfo):
file_id_used=file_id_used,
is_vertex_request=optional_params.get("is_vertex_request", False),
user_anthropic_beta_headers=user_anthropic_beta_headers,
mcp_server_used=mcp_server_used,
)
headers = {**headers, **anthropic_headers}

View file

@ -184,7 +184,9 @@ class DatabricksConfig(DatabricksBase, OpenAILikeChatConfig, AnthropicConfig):
return tools
# if claude, convert to anthropic tool and then to databricks tool
anthropic_tools = self._map_tools(tools=tools)
anthropic_tools, _ = self._map_tools(
tools=tools
) # unclear how mcp tool calling on databricks works
databricks_tools = [
cast(DatabricksTool, self.convert_anthropic_tool_to_databricks_tool(tool))
for tool in anthropic_tools

View file

@ -72,6 +72,18 @@ AllAnthropicToolsValues = Union[
]
class AnthropicMcpServerToolConfiguration(TypedDict, total=False):
allowed_tools: Optional[List[str]]
class AnthropicMcpServerTool(TypedDict, total=False):
type: Required[Literal["url"]]
url: Required[str]
name: Required[str]
tool_configuration: AnthropicMcpServerToolConfiguration
authorization_token: str
class AnthropicMessagesTextParam(TypedDict, total=False):
type: Required[Literal["text"]]
text: Required[str]
@ -216,6 +228,7 @@ class AnthropicMessagesRequestOptionalParams(TypedDict, total=False):
tools: Optional[List[Union[AllAnthropicToolsValues, Dict]]]
top_k: Optional[int]
top_p: Optional[float]
mcp_servers: Optional[List[AnthropicMcpServerTool]]
class AnthropicMessagesRequest(AnthropicMessagesRequestOptionalParams, total=False):

View file

@ -1641,3 +1641,12 @@ class OpenAIRealtimeTurnDetection(TypedDict, total=False):
silence_duration_ms: int
threshold: int
type: str
class OpenAIMcpServerTool(TypedDict, total=False):
type: Required[Literal["mcp"]]
server_label: Required[str]
server_url: Required[str]
require_approval: str
allowed_tools: Optional[List[str]]
headers: Optional[Dict[str, str]]

View file

@ -422,7 +422,7 @@ def test_anthropic_tool_helper(cache_control_location):
else:
tool["cache_control"] = {"type": "ephemeral"}
tool = AnthropicConfig()._map_tool_helper(tool=tool)
tool, _ = AnthropicConfig()._map_tool_helper(tool=tool)
assert tool["cache_control"] == {"type": "ephemeral"}
@ -1273,3 +1273,37 @@ def test_anthropic_text_editor():
assert response is not None
@pytest.mark.parametrize("spec", ["anthropic", "openai"])
def test_anthropic_mcp_server_tool_use(spec: str):
litellm._turn_on_debug()
if spec == "anthropic":
tools = [
{
"type": "url",
"url": "https://mcp.deepwiki.com/mcp",
"name": "deepwiki-mcp",
}
]
elif spec == "openai":
tools=[
{
"type": "mcp",
"server_label": "deepwiki",
"server_url": "https://mcp.deepwiki.com/mcp",
"require_approval": "never",
},
]
params = {
"model": "anthropic/claude-sonnet-4-20250514",
"messages": [{"role": "user", "content": "Who won the World Cup in 2022?"}],
"tools": tools
}
try:
response = litellm.completion(**params)
except litellm.InternalServerError as e:
print(e)
assert response is not None

View file

@ -118,7 +118,7 @@ def test_map_tool_helper():
tool = {"type": "web_search_20250305", "name": "web_search", "max_uses": 5}
result = config._map_tool_helper(tool)
result, _ = config._map_tool_helper(tool)
assert result is not None
assert result["name"] == "web_search"
assert result["max_uses"] == 5