diff --git a/litellm/responses/mcp/litellm_proxy_mcp_handler.py b/litellm/responses/mcp/litellm_proxy_mcp_handler.py index 92b0f81b0f1..e7096a48f60 100644 --- a/litellm/responses/mcp/litellm_proxy_mcp_handler.py +++ b/litellm/responses/mcp/litellm_proxy_mcp_handler.py @@ -68,7 +68,7 @@ class LiteLLM_Proxy_MCP_Handler: def _encode_lazymcp_tool_server_map_value( mcp_servers: Optional[List[str]], toolset_id: Optional[str] ) -> str: - payload = {"mcp_servers": mcp_servers or [], "toolset_id": toolset_id} + payload = {"mcp_servers": mcp_servers, "toolset_id": toolset_id} return f"{LITELLM_PROXY_LAZYMCP_TOOL_SERVER_MAP_PREFIX}{json.dumps(payload, sort_keys=True)}" @staticmethod @@ -88,6 +88,8 @@ class LiteLLM_Proxy_MCP_Handler: if not isinstance(decoded, dict): return {"mcp_servers": [], "toolset_id": None} mcp_servers = decoded.get("mcp_servers") + if mcp_servers is None and "mcp_servers" in decoded: + return decoded if not isinstance(mcp_servers, list): decoded["mcp_servers"] = [] return decoded @@ -886,7 +888,7 @@ class LiteLLM_Proxy_MCP_Handler: set_auth_context, ) - lazy_mcp_servers = lazymcp_scope.get("mcp_servers") or None + lazy_mcp_servers = lazymcp_scope.get("mcp_servers") if not isinstance(lazy_mcp_servers, list): lazy_mcp_servers = None lazy_toolset_id = lazymcp_scope.get("toolset_id") diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py index 79c46f241da..870584dda03 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py @@ -378,6 +378,60 @@ async def test_lazymcp_call_uses_unavailable_server_error_when_server_missing(): assert payload["error"] == "MCP server is not available for this request." +@pytest.mark.asyncio +async def test_lazymcp_call_empty_scope_cannot_execute_allowed_server(): + try: + from mcp.types import Tool as MCPTool + + from litellm.proxy._experimental.mcp_server.server import ( + _lazymcp_call, + set_auth_context, + ) + except ImportError: + pytest.skip("MCP server not available") + + allowed_server = MCPServer( + server_id="allowed-server", + name="github", + alias="github", + server_name="github", + transport=MCPTransport.http, + ) + visible_tool = MCPTool( + name="github-search", + description="Search GitHub", + inputSchema={"type": "object"}, + ) + + set_auth_context(UserAPIKeyAuth(api_key="sk-test", user_id="user"), mcp_servers=[]) + with ( + patch( + "litellm.proxy._experimental.mcp_server.server.global_mcp_server_manager.get_allowed_mcp_servers", + AsyncMock(return_value=["allowed-server"]), + ), + patch( + "litellm.proxy._experimental.mcp_server.server.global_mcp_server_manager.get_mcp_server_by_id", + MagicMock(return_value=allowed_server), + ), + patch( + "litellm.proxy._experimental.mcp_server.server.global_mcp_server_manager._get_tools_from_server", + AsyncMock(return_value=[visible_tool]), + ) as tools_mock, + patch( + "litellm.proxy._experimental.mcp_server.server.call_mcp_tool", + AsyncMock(), + ) as call_mock, + ): + result = await _lazymcp_call( + {"server": "github", "tool": "github-search", "arguments": {}} + ) + + payload = json.loads(result.content[0].text) + assert payload["error"] == "MCP server is not available for this request." + tools_mock.assert_not_awaited() + call_mock.assert_not_awaited() + + def test_lazymcp_cache_get_set_and_invalidate_paths(monkeypatch): try: from litellm.proxy._experimental.mcp_server import server as mcp_server_module diff --git a/tests/test_litellm/responses/mcp/test_litellm_proxy_mcp_handler.py b/tests/test_litellm/responses/mcp/test_litellm_proxy_mcp_handler.py index 5b6d0fb5876..d3ff974820b 100644 --- a/tests/test_litellm/responses/mcp/test_litellm_proxy_mcp_handler.py +++ b/tests/test_litellm/responses/mcp/test_litellm_proxy_mcp_handler.py @@ -254,6 +254,9 @@ def test_decode_lazymcp_tool_server_map_value_handles_invalid_payloads(): assert LiteLLM_Proxy_MCP_Handler._decode_lazymcp_tool_server_map_value( 'lazymcp:{"mcp_servers":"github"}' ) == {"mcp_servers": []} + assert LiteLLM_Proxy_MCP_Handler._decode_lazymcp_tool_server_map_value( + LiteLLM_Proxy_MCP_Handler._encode_lazymcp_tool_server_map_value(None, "toolset") + ) == {"mcp_servers": None, "toolset_id": "toolset"} def test_should_use_litellm_mcp_gateway_matches_proxy_urls(): @@ -573,6 +576,48 @@ async def test_execute_tool_calls_passes_lazymcp_route_scope(monkeypatch): assert captured["mcp_servers"] == ["github"] +@pytest.mark.asyncio +async def test_execute_tool_calls_preserves_empty_lazymcp_scope(monkeypatch): + proxy_module = types.SimpleNamespace(proxy_logging_obj=object()) + monkeypatch.setitem(sys.modules, "litellm.proxy.proxy_server", proxy_module) + + captured = {} + + def fake_set_auth_context(**kwargs): + captured.update(kwargs) + + async def fake_lazymcp_tool_call(_name, _arguments): + return _DummyMCPResult() + + monkeypatch.setattr( + "litellm.proxy._experimental.mcp_server.server.set_auth_context", + fake_set_auth_context, + ) + monkeypatch.setattr( + "litellm.proxy._experimental.mcp_server.server.lazymcp_tool_call", + fake_lazymcp_tool_call, + ) + tool_server_map_value = ( + LiteLLM_Proxy_MCP_Handler._encode_lazymcp_tool_server_map_value([], None) + ) + + await LiteLLM_Proxy_MCP_Handler._execute_tool_calls( + tool_server_map={"mcp_call": tool_server_map_value}, + tool_calls=[ + { + "id": "call-lazy-empty-scope", + "function": { + "name": "mcp_call", + "arguments": '{"server":"github","tool":"search","arguments":{}}', + }, + } + ], + user_api_key_auth=None, + ) + + assert captured["mcp_servers"] == [] + + @pytest.mark.asyncio async def test_execute_tool_calls_passes_lazymcp_client_ip_and_scoped_permissions( monkeypatch,