mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-07 08:26:10 +00:00
Merge 4982ead0e7 into e52f05566d
This commit is contained in:
commit
51b435e5fb
2 changed files with 175 additions and 4 deletions
|
|
@ -60,6 +60,23 @@ def _build_tool_result_message(tool_results: Sequence[Mapping[str, object]]) ->
|
|||
)
|
||||
|
||||
|
||||
def _has_client_side_tool(
|
||||
tool_use_blocks: Sequence[Mapping[str, object]],
|
||||
other_tools: Sequence[Mapping[str, object]] | None,
|
||||
tool_server_map: Mapping[str, str],
|
||||
) -> bool:
|
||||
client_tool_names: Final = tuple(
|
||||
t.get("name") for t in (other_tools or ()) if isinstance(t, dict) and isinstance(t.get("name"), str)
|
||||
) # kwargs-ok: extract client tool names tuple
|
||||
for block in tool_use_blocks:
|
||||
name = block.get("name") # kwargs-ok: extract block tool name
|
||||
if name in client_tool_names or (
|
||||
bool(tool_server_map) and name not in tool_server_map
|
||||
): # kwargs-ok: map membership test
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
async def anthropic_messages_with_mcp(
|
||||
max_tokens: int,
|
||||
messages: Sequence[Mapping[str, object]],
|
||||
|
|
@ -89,7 +106,7 @@ async def anthropic_messages_with_mcp(
|
|||
max_tokens=max_tokens,
|
||||
messages=list(messages),
|
||||
model=model,
|
||||
tools=list(tools) if tools else None,
|
||||
tools=list(tools) if tools else None, # kwargs-ok: pass tools list
|
||||
_skip_mcp_handler=True,
|
||||
**kwargs,
|
||||
)
|
||||
|
|
@ -144,6 +161,9 @@ async def anthropic_messages_with_mcp(
|
|||
if not tool_use_blocks:
|
||||
break
|
||||
|
||||
if _has_client_side_tool(tool_use_blocks, other_tools, tool_server_map):
|
||||
break
|
||||
|
||||
tool_results = await LiteLLM_Proxy_MCP_Handler._execute_tool_calls(
|
||||
tool_server_map=tool_server_map,
|
||||
tool_calls=list(tool_use_blocks),
|
||||
|
|
@ -157,12 +177,10 @@ async def anthropic_messages_with_mcp(
|
|||
request_tags=list(context.request_tags) if context.request_tags else None,
|
||||
)
|
||||
|
||||
# Every tool call was skipped, so there is nothing to feed back; a
|
||||
# tool_result message with empty content is rejected by Anthropic.
|
||||
if not tool_results:
|
||||
break
|
||||
|
||||
working_messages = (
|
||||
working_messages = ( # rebind-ok: append assistant and tool_result turns to working context
|
||||
*working_messages,
|
||||
{"role": "assistant", "content": list(_get_response_content(response))},
|
||||
_build_tool_result_message(tool_results),
|
||||
|
|
|
|||
153
tests/test_mcp_client_tool_passthrough.py
Normal file
153
tests/test_mcp_client_tool_passthrough.py
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from litellm.llms.anthropic.experimental_pass_through.messages.mcp_handler import (
|
||||
anthropic_messages_with_mcp,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_auto_execute_bypasses_client_side_tools():
|
||||
"""
|
||||
Ensure that if a response contains client-native tools (not present in tool_server_map),
|
||||
the auto-execution loop breaks early and passes the response back to the client.
|
||||
"""
|
||||
mock_mcp_references = [{"type": "mcp", "server_url": "http://localhost/mcp", "require_approval": "never"}]
|
||||
client_tools = [{"name": "Read", "description": "Client Read tool"}]
|
||||
|
||||
mock_mcp_tools = [SimpleNamespace(name="mcp_tool_1", description="MCP Tool", inputSchema={"type": "object"})]
|
||||
mock_tool_server_map = {"mcp_tool_1": "http://localhost/mcp"}
|
||||
|
||||
mock_anthropic_response = {
|
||||
"id": "msg_123",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "tool_use", "id": "call_mcp", "name": "mcp_tool_1", "input": {}},
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "call_client",
|
||||
"name": "Read",
|
||||
"input": {"file_path": "test.txt"},
|
||||
},
|
||||
],
|
||||
"stop_reason": "tool_use",
|
||||
}
|
||||
|
||||
mock_context = MagicMock()
|
||||
mock_context.user_api_key_auth = None
|
||||
mock_context.litellm_trace_id = "trace_123"
|
||||
mock_context.mcp_auth_header = None
|
||||
mock_context.mcp_server_auth_headers = None
|
||||
mock_context.request_tags = None
|
||||
mock_context.oauth2_headers = None
|
||||
mock_context.raw_headers = None
|
||||
mock_context.litellm_call_id = "call_123"
|
||||
|
||||
path_resolve = "litellm.responses.mcp.request_context.MCPRequestContext.resolve"
|
||||
path_parse = "litellm.responses.mcp.litellm_proxy_mcp_handler.LiteLLM_Proxy_MCP_Handler._parse_mcp_tools"
|
||||
path_process = (
|
||||
"litellm.responses.mcp.litellm_proxy_mcp_handler."
|
||||
"LiteLLM_Proxy_MCP_Handler._process_mcp_tools_without_openai_transform"
|
||||
)
|
||||
path_auto = "litellm.responses.mcp.litellm_proxy_mcp_handler.LiteLLM_Proxy_MCP_Handler._should_auto_execute_tools"
|
||||
path_exec = "litellm.responses.mcp.litellm_proxy_mcp_handler.LiteLLM_Proxy_MCP_Handler._execute_tool_calls"
|
||||
path_call = "litellm.llms.anthropic.experimental_pass_through.messages.mcp_handler._AnthropicMessagesCall"
|
||||
|
||||
with (
|
||||
patch(path_resolve, return_value=mock_context),
|
||||
patch(path_parse, return_value=(mock_mcp_references, client_tools)),
|
||||
patch(path_process, new_callable=AsyncMock) as mock_process,
|
||||
patch(path_auto, return_value=True),
|
||||
patch(path_exec, new_callable=AsyncMock) as mock_execute,
|
||||
patch(path_call) as mock_call,
|
||||
):
|
||||
mock_process.return_value = (mock_mcp_tools, mock_tool_server_map)
|
||||
mock_fn = AsyncMock(return_value=mock_anthropic_response)
|
||||
mock_call.return_value.fn = mock_fn
|
||||
|
||||
response = await anthropic_messages_with_mcp(
|
||||
max_tokens=100,
|
||||
messages=[{"role": "user", "content": "Read test.txt and run image_understand"}],
|
||||
model="claude-3-5-sonnet-20241022",
|
||||
tools=[*mock_mcp_references, *client_tools],
|
||||
)
|
||||
|
||||
mock_execute.assert_not_called()
|
||||
assert response == mock_anthropic_response
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_auto_execute_runs_server_side_mcp_tools():
|
||||
"""
|
||||
Ensure that if a response contains ONLY server-side MCP tools,
|
||||
auto-execution proceeds as expected and returns False for client-side tool check.
|
||||
"""
|
||||
mock_mcp_references = [{"type": "mcp", "server_url": "http://localhost/mcp", "require_approval": "never"}]
|
||||
|
||||
mock_mcp_tools = [SimpleNamespace(name="mcp_tool_1", description="MCP Tool", inputSchema={"type": "object"})]
|
||||
mock_tool_server_map = {"mcp_tool_1": "http://localhost/mcp"}
|
||||
|
||||
mock_anthropic_response = {
|
||||
"id": "msg_123",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "tool_use", "id": "call_mcp", "name": "mcp_tool_1", "input": {}},
|
||||
],
|
||||
"stop_reason": "tool_use",
|
||||
}
|
||||
|
||||
mock_context = MagicMock()
|
||||
mock_context.user_api_key_auth = None
|
||||
mock_context.litellm_trace_id = "trace_123"
|
||||
mock_context.mcp_auth_header = None
|
||||
mock_context.mcp_server_auth_headers = None
|
||||
mock_context.request_tags = None
|
||||
mock_context.oauth2_headers = None
|
||||
mock_context.raw_headers = None
|
||||
mock_context.litellm_call_id = "call_123"
|
||||
|
||||
path_resolve = "litellm.responses.mcp.request_context.MCPRequestContext.resolve"
|
||||
path_parse = "litellm.responses.mcp.litellm_proxy_mcp_handler.LiteLLM_Proxy_MCP_Handler._parse_mcp_tools"
|
||||
path_process = (
|
||||
"litellm.responses.mcp.litellm_proxy_mcp_handler."
|
||||
"LiteLLM_Proxy_MCP_Handler._process_mcp_tools_without_openai_transform"
|
||||
)
|
||||
path_auto = "litellm.responses.mcp.litellm_proxy_mcp_handler.LiteLLM_Proxy_MCP_Handler._should_auto_execute_tools"
|
||||
path_exec = "litellm.responses.mcp.litellm_proxy_mcp_handler.LiteLLM_Proxy_MCP_Handler._execute_tool_calls"
|
||||
path_call = "litellm.llms.anthropic.experimental_pass_through.messages.mcp_handler._AnthropicMessagesCall"
|
||||
|
||||
with (
|
||||
patch(path_resolve, return_value=mock_context),
|
||||
patch(path_parse, return_value=(mock_mcp_references, [])),
|
||||
patch(path_process, new_callable=AsyncMock) as mock_process,
|
||||
patch(path_auto, return_value=True),
|
||||
patch(path_exec, new_callable=AsyncMock) as mock_execute,
|
||||
patch(path_call) as mock_call,
|
||||
):
|
||||
mock_process.return_value = (mock_mcp_tools, mock_tool_server_map)
|
||||
mock_execute.return_value = [{"tool_call_id": "call_mcp", "result": "ok"}]
|
||||
|
||||
mock_final_response = {
|
||||
"id": "msg_124",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [{"type": "text", "text": "done"}],
|
||||
"stop_reason": "end_turn",
|
||||
}
|
||||
|
||||
mock_fn = AsyncMock(side_effect=[mock_anthropic_response, mock_final_response])
|
||||
mock_call.return_value.fn = mock_fn
|
||||
|
||||
response = await anthropic_messages_with_mcp(
|
||||
max_tokens=100,
|
||||
messages=[{"role": "user", "content": "run mcp_tool_1"}],
|
||||
model="claude-3-5-sonnet-20241022",
|
||||
tools=mock_mcp_references,
|
||||
)
|
||||
|
||||
mock_execute.assert_called_once()
|
||||
assert response == mock_final_response
|
||||
Loading…
Add table
Reference in a new issue