mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-30 06:43:27 +00:00
* fix(mcp): keep the gateway handler off the SDK path when no tool is an MCP tool completion(), responses() and anthropic messages imported the MCP gateway handler whenever tools were passed, and responses() did so on every call. Since the handler began importing the MCP catalog at module level in #42568, that import needs the optional mcp package, so a base SDK install failed with ModuleNotFoundError on any function-tool call and on every responses() call A new import-safe pre-check skips the handler unless some tool is a dict of type mcp, which is the only shape the handler's gateway decision can accept. MCP gateway tools still reach the handler as before Adds in-process regression tests for the three entry points and a function-tool completion to the base SDK install check * fix(mcp): leave one-shot tool iterables unread in the gateway pre-check responses() declares tools as an Iterable and the handler scans the same object a second time, so a pre-check that read a generator would hand the handler one tool fewer. The pre-check now scans only collections and reports anything else as possibly naming the gateway, which keeps the previous path The Anthropic messages regression test now mocks the httpx layer with respx and turns off the aiohttp transport, instead of replacing AsyncHTTPHandler * fix(mcp): scan only lists and tuples in the gateway pre-check The pre-check read any Collection, but a Collection that yields its items only once would still be exhausted by the handler's second scan. Only the list and tuple builtins are guaranteed to iterate the same way twice, so they are the only inputs read now. Everything else keeps the previous path and is reported as possibly naming the gateway The unread-input test now covers a generator, an iterator, a one-shot collection and a deque * fix(mcp): scan only exact list and tuple types in the gateway pre-check isinstance accepted list subclasses, and a subclass that iterates only once would still be consumed by the handler's second scan. The fast path now reads only objects whose type is exactly list or tuple. Every other input, subclasses included, keeps the previous path and is reported as possibly naming the gateway The unread-input test gains a one-shot list subclass and a tuple subclass * fix(sdk): decide MCP gateway routing without importing the proxy handler The pre-check now holds the handler's exact gateway test, so remote MCP tools, mcp 1.x installs and any tools iterable stay off the handler, and the base SDK check calls responses() with a remote MCP tool. * fix(sdk): send /mcp/<name> remote MCP URLs to the provider when the gateway registry cannot load A path-only /mcp/<name> match now counts as a gateway tool only when the MCP server registry imports, so SDK installs without the proxy extras pass remote MCP servers like Zapier to the provider. Also drops a dead test patch. * test(sdk): send a /mcp/<name> remote MCP tool in the base SDK install check * style(sdk): drop docstrings from the MCP gateway pre-check and its tests * test(mcp): cover /mcp/<name> gateway routing on the proxy and Messages tools on a base SDK install --------- Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
318 lines
11 KiB
Python
318 lines
11 KiB
Python
import json
|
|
import sys
|
|
from collections import deque
|
|
from collections.abc import Callable, Iterable, Mapping, Sequence
|
|
from importlib.abc import MetaPathFinder
|
|
from importlib.machinery import ModuleSpec
|
|
from types import ModuleType
|
|
from typing import Final
|
|
|
|
import httpx
|
|
import pytest
|
|
import respx
|
|
|
|
import litellm
|
|
from litellm.responses.mcp.tool_precheck import may_name_litellm_mcp_gateway
|
|
|
|
GATEWAY_REGISTRY_MODULE: Final = "litellm.proxy._experimental.mcp_server.mcp_server_manager"
|
|
GATEWAY_HANDLER_MODULES: Final = (
|
|
"litellm.responses.mcp.litellm_proxy_mcp_handler",
|
|
"litellm.responses.mcp.chat_completions_handler",
|
|
"litellm.llms.anthropic.pass_through.messages.mcp_handler",
|
|
)
|
|
|
|
FUNCTION_TOOL: Final = {
|
|
"type": "function",
|
|
"function": {"name": "noop", "description": "No operation", "parameters": {"type": "object", "properties": {}}},
|
|
}
|
|
RESPONSES_FUNCTION_TOOL: Final = {
|
|
"type": "function",
|
|
"name": "noop",
|
|
"description": "No operation",
|
|
"parameters": {"type": "object", "properties": {}},
|
|
}
|
|
ANTHROPIC_FUNCTION_TOOL: Final = {
|
|
"name": "noop",
|
|
"description": "No operation",
|
|
"input_schema": {"type": "object", "properties": {}},
|
|
}
|
|
ANTHROPIC_MESSAGE_PAYLOAD: Final = {
|
|
"id": "msg_1",
|
|
"type": "message",
|
|
"role": "assistant",
|
|
"model": "claude-sonnet-5-5",
|
|
"content": [{"type": "text", "text": "ok"}],
|
|
"stop_reason": "end_turn",
|
|
"stop_sequence": None,
|
|
"usage": {"input_tokens": 1, "output_tokens": 1},
|
|
}
|
|
GATEWAY_MCP_TOOL: Final = {"type": "mcp", "server_label": "litellm", "server_url": "litellm_proxy"}
|
|
GATEWAY_SERVER_MCP_TOOL: Final = {"type": "mcp", "server_label": "x", "server_url": "litellm_proxy/mcp/github"}
|
|
GATEWAY_PATH_MCP_TOOL: Final = {"type": "mcp", "server_label": "x", "server_url": "https://gw.example.com/mcp/github"}
|
|
REMOTE_MCP_TOOL: Final = {
|
|
"type": "mcp",
|
|
"server_label": "deepwiki",
|
|
"server_url": "https://mcp.deepwiki.com/sse",
|
|
"require_approval": "never",
|
|
}
|
|
|
|
RESPONSES_PAYLOAD: Final = {
|
|
"id": "resp_1",
|
|
"object": "response",
|
|
"created_at": 1734366691,
|
|
"status": "completed",
|
|
"model": "gpt-4o-mini",
|
|
"output": [
|
|
{
|
|
"type": "message",
|
|
"id": "msg_1",
|
|
"status": "completed",
|
|
"role": "assistant",
|
|
"content": [{"type": "output_text", "text": "Done.", "annotations": []}],
|
|
}
|
|
],
|
|
"parallel_tool_calls": True,
|
|
"usage": {
|
|
"input_tokens": 10,
|
|
"output_tokens": 5,
|
|
"total_tokens": 15,
|
|
"output_tokens_details": {"reasoning_tokens": 0},
|
|
},
|
|
"tools": [],
|
|
}
|
|
|
|
|
|
@pytest.fixture
|
|
def gateway_handler_unavailable(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
for module_name in GATEWAY_HANDLER_MODULES:
|
|
monkeypatch.setitem(sys.modules, module_name, None)
|
|
|
|
|
|
class _FailingImport(MetaPathFinder):
|
|
def __init__(self, module_name: str, error: ImportError) -> None:
|
|
self.module_name: Final = module_name
|
|
self.error: Final = error
|
|
|
|
def find_spec(
|
|
self, fullname: str, path: Sequence[str] | None, target: ModuleType | None = None
|
|
) -> ModuleSpec | None:
|
|
if fullname == self.module_name:
|
|
raise self.error
|
|
return None
|
|
|
|
|
|
@pytest.fixture(
|
|
params=[
|
|
ModuleNotFoundError("No module named 'mcp'"),
|
|
ImportError("cannot import name 'CacheableResult' from 'mcp.types'"),
|
|
ModuleNotFoundError("No module named 'fastapi'"),
|
|
],
|
|
ids=["mcp-missing", "mcp-1x", "proxy-extras-missing"],
|
|
)
|
|
def gateway_registry_unavailable(
|
|
request: pytest.FixtureRequest, monkeypatch: pytest.MonkeyPatch, gateway_handler_unavailable: None
|
|
) -> None:
|
|
monkeypatch.delitem(sys.modules, GATEWAY_REGISTRY_MODULE, raising=False)
|
|
monkeypatch.setattr(sys, "meta_path", [_FailingImport(GATEWAY_REGISTRY_MODULE, request.param), *sys.meta_path])
|
|
|
|
|
|
class _ToolList(list[Mapping[str, object]]):
|
|
pass
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"tools",
|
|
[[FUNCTION_TOOL], [REMOTE_MCP_TOOL], _ToolList([FUNCTION_TOOL]), (FUNCTION_TOOL, REMOTE_MCP_TOOL)],
|
|
ids=["function-tool", "remote-mcp-tool", "list-subclass", "tuple"],
|
|
)
|
|
@pytest.mark.usefixtures("gateway_handler_unavailable")
|
|
def test_completion_without_a_gateway_tool_does_not_import_the_gateway_handler(tools: Iterable[Mapping[str, object]]):
|
|
response: Final = litellm.completion(
|
|
model="openai/gpt-4o-mini",
|
|
messages=[{"role": "user", "content": "hi"}],
|
|
tools=tools,
|
|
mock_response="ok",
|
|
)
|
|
|
|
assert response.choices[0].message.content == "ok"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.usefixtures("gateway_handler_unavailable")
|
|
async def test_acompletion_with_function_tool_does_not_import_the_gateway_handler():
|
|
response: Final = await litellm.acompletion(
|
|
model="openai/gpt-4o-mini",
|
|
messages=[{"role": "user", "content": "hi"}],
|
|
tools=[FUNCTION_TOOL],
|
|
mock_response="ok",
|
|
)
|
|
|
|
assert response.choices[0].message.content == "ok"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"tool",
|
|
[GATEWAY_MCP_TOOL, GATEWAY_SERVER_MCP_TOOL, GATEWAY_PATH_MCP_TOOL],
|
|
ids=["litellm-proxy", "litellm-proxy-server", "gateway-path-url"],
|
|
)
|
|
@pytest.mark.usefixtures("gateway_handler_unavailable")
|
|
def test_completion_with_gateway_mcp_tool_still_reaches_the_handler(tool: Mapping[str, object]):
|
|
with pytest.raises(ModuleNotFoundError, match=r"litellm_proxy_mcp_handler|chat_completions_handler"):
|
|
litellm.completion(
|
|
model="openai/gpt-4o-mini",
|
|
messages=[{"role": "user", "content": "hi"}],
|
|
tools=[tool],
|
|
mock_response="ok",
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"tools",
|
|
[None, [RESPONSES_FUNCTION_TOOL], [REMOTE_MCP_TOOL]],
|
|
ids=["no-tools", "function-tool", "remote-mcp-tool"],
|
|
)
|
|
@pytest.mark.usefixtures("gateway_handler_unavailable")
|
|
def test_responses_without_a_gateway_tool_does_not_import_the_gateway_handler(
|
|
tools: list[Mapping[str, object]] | None, respx_mock: respx.MockRouter
|
|
):
|
|
upstream: Final = respx_mock.post("https://api.openai.com/v1/responses").mock(
|
|
return_value=httpx.Response(200, json=RESPONSES_PAYLOAD)
|
|
)
|
|
|
|
response: Final = litellm.responses(model="openai/gpt-4o-mini", input="hi", tools=tools, api_key="sk-test")
|
|
|
|
assert upstream.called
|
|
assert response.output[0].content[0].text == "Done."
|
|
|
|
|
|
@pytest.mark.usefixtures("gateway_handler_unavailable")
|
|
def test_responses_with_gateway_mcp_tool_still_reaches_the_handler():
|
|
with pytest.raises(litellm.APIConnectionError, match=r"litellm_proxy_mcp_handler"):
|
|
litellm.responses(model="openai/gpt-4o-mini", input="hi", tools=[GATEWAY_MCP_TOOL], api_key="sk-test")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.usefixtures("gateway_handler_unavailable")
|
|
async def test_anthropic_messages_with_function_tool_does_not_import_the_gateway_handler(
|
|
monkeypatch: pytest.MonkeyPatch, respx_mock: respx.MockRouter
|
|
):
|
|
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
|
upstream: Final = respx_mock.post("https://api.anthropic.com/v1/messages").mock(
|
|
return_value=httpx.Response(200, json=ANTHROPIC_MESSAGE_PAYLOAD)
|
|
)
|
|
|
|
response: Final = await litellm.anthropic_interface.messages.acreate(
|
|
model="anthropic/claude-sonnet-5-5",
|
|
messages=[{"role": "user", "content": "hi"}],
|
|
max_tokens=16,
|
|
tools=[ANTHROPIC_FUNCTION_TOOL],
|
|
api_key="sk-test",
|
|
)
|
|
|
|
assert upstream.called
|
|
assert response["content"][0]["text"] == "ok"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"tools, expected",
|
|
[
|
|
(None, False),
|
|
([], False),
|
|
([FUNCTION_TOOL], False),
|
|
([RESPONSES_FUNCTION_TOOL], False),
|
|
([{"type": "web_search"}], False),
|
|
(["not-a-mapping"], False),
|
|
([REMOTE_MCP_TOOL], False),
|
|
([{"type": "mcp", "server_label": "x"}], False),
|
|
([{"type": "mcp", "server_label": "x", "server_url": None}], False),
|
|
([{"server_url": "litellm_proxy"}], False),
|
|
([FUNCTION_TOOL, GATEWAY_MCP_TOOL], True),
|
|
([GATEWAY_SERVER_MCP_TOOL], True),
|
|
([GATEWAY_PATH_MCP_TOOL], True),
|
|
([REMOTE_MCP_TOOL, GATEWAY_PATH_MCP_TOOL], True),
|
|
],
|
|
ids=[
|
|
"none",
|
|
"empty",
|
|
"chat-function",
|
|
"responses-function",
|
|
"web-search",
|
|
"non-mapping",
|
|
"remote-mcp",
|
|
"mcp-without-url",
|
|
"mcp-null-url",
|
|
"gateway-url-without-mcp-type",
|
|
"gateway-among-functions",
|
|
"gateway-server",
|
|
"gateway-path-url",
|
|
"gateway-path-after-remote-mcp",
|
|
],
|
|
)
|
|
def test_may_name_litellm_mcp_gateway_matches_only_gateway_mcp_tools(tools: list[object] | None, expected: bool):
|
|
assert may_name_litellm_mcp_gateway(tools) is expected
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"tools, expected",
|
|
[
|
|
([GATEWAY_PATH_MCP_TOOL], False),
|
|
([FUNCTION_TOOL, GATEWAY_PATH_MCP_TOOL], False),
|
|
([GATEWAY_MCP_TOOL], True),
|
|
([GATEWAY_SERVER_MCP_TOOL, GATEWAY_PATH_MCP_TOOL], True),
|
|
],
|
|
ids=["gateway-path-url", "gateway-path-among-functions", "litellm-proxy", "litellm-proxy-server-and-path-url"],
|
|
)
|
|
@pytest.mark.usefixtures("gateway_registry_unavailable")
|
|
def test_proxy_path_url_names_the_gateway_only_when_its_registry_loads(tools: list[object], expected: bool):
|
|
assert may_name_litellm_mcp_gateway(tools) is expected
|
|
|
|
|
|
@pytest.mark.usefixtures("gateway_registry_unavailable")
|
|
def test_responses_sends_a_proxy_path_mcp_url_to_the_provider_without_the_gateway_registry(
|
|
respx_mock: respx.MockRouter,
|
|
):
|
|
upstream: Final = respx_mock.post("https://api.openai.com/v1/responses").mock(
|
|
return_value=httpx.Response(200, json=RESPONSES_PAYLOAD)
|
|
)
|
|
|
|
response: Final = litellm.responses(
|
|
model="openai/gpt-4o-mini", input="hi", tools=[GATEWAY_PATH_MCP_TOOL], api_key="sk-test"
|
|
)
|
|
|
|
assert response.output[0].content[0].text == "Done."
|
|
assert json.loads(upstream.calls.last.request.content)["tools"] == [GATEWAY_PATH_MCP_TOOL]
|
|
|
|
|
|
@pytest.mark.usefixtures("gateway_registry_unavailable")
|
|
def test_completion_sends_a_proxy_path_mcp_url_to_the_provider_without_the_gateway_registry():
|
|
response: Final = litellm.completion(
|
|
model="openai/gpt-4o-mini",
|
|
messages=[{"role": "user", "content": "hi"}],
|
|
tools=[GATEWAY_PATH_MCP_TOOL],
|
|
mock_response="ok",
|
|
)
|
|
|
|
assert response.choices[0].message.content == "ok"
|
|
|
|
|
|
class _ToolTuple(tuple[Mapping[str, object], ...]):
|
|
pass
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"make_tools",
|
|
[
|
|
lambda items: (item for item in items),
|
|
lambda items: iter(items),
|
|
lambda items: deque(items),
|
|
lambda items: _ToolList(items),
|
|
lambda items: _ToolTuple(items),
|
|
],
|
|
ids=["generator", "iterator", "deque", "list-subclass", "tuple-subclass"],
|
|
)
|
|
@pytest.mark.usefixtures("gateway_handler_unavailable")
|
|
def test_precheck_reads_every_iterable_shape_without_the_gateway_handler(
|
|
make_tools: Callable[[list[Mapping[str, object]]], Iterable[Mapping[str, object]]],
|
|
):
|
|
assert may_name_litellm_mcp_gateway(make_tools([FUNCTION_TOOL, REMOTE_MCP_TOOL])) is False
|
|
assert may_name_litellm_mcp_gateway(make_tools([FUNCTION_TOOL, GATEWAY_MCP_TOOL])) is True
|