From 6969fa5e98c17891d85a7ee429d7f92dd3c11a35 Mon Sep 17 00:00:00 2001 From: michelligabriele Date: Tue, 12 May 2026 21:11:00 +0200 Subject: [PATCH 1/2] fix(mcp): gate /public/mcp_hub strictly on litellm.public_mcp_servers --- .../mcp_server/mcp_server_manager.py | 23 +++-- .../mcp_server/test_mcp_server_manager.py | 83 +++++++++++++++++++ .../public_endpoints/test_public_endpoints.py | 71 ++++++++++++++++ 3 files changed, 169 insertions(+), 8 deletions(-) diff --git a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py index 4901bc76d2f9..980cfe16b5c6 100644 --- a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py +++ b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py @@ -3160,15 +3160,22 @@ def get_mcp_server_by_id(self, server_id: str) -> Optional[MCPServer]: def get_public_mcp_servers(self) -> List[MCPServer]: """ - Get the public MCP servers (available_on_public_internet=True flag on server). - Also includes servers from litellm.public_mcp_servers for backwards compat. + Return the MCP servers published to the AI Hub via /v1/mcp/make_public. + + Mirrors /public/model_hub and /public/agent_hub: gates strictly on the + litellm.public_mcp_servers whitelist. Returns an empty list when no + servers have been published. The per-server available_on_public_internet + flag is unrelated — it governs IP-based access in + _is_server_accessible_from_ip, not hub visibility. """ - servers: List[MCPServer] = [] - public_ids = set(litellm.public_mcp_servers or []) - for server in self.get_registry().values(): - if server.available_on_public_internet or server.server_id in public_ids: - servers.append(server) - return servers + if litellm.public_mcp_servers is None: + return [] + public_ids = set(litellm.public_mcp_servers) + return [ + server + for server in self.get_registry().values() + if server.server_id in public_ids + ] def expand_permission_list(self, identifiers: List[str]) -> List[str]: """ diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py index b53420f00000..487b417eb4a7 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py @@ -3436,5 +3436,88 @@ async def test_update_server_noop_for_unregistered_pending(self): assert "never-seen" not in manager.registry +class TestGetPublicMCPServers: + """ + /public/mcp_hub strict-whitelist semantics — mirrors /public/model_hub + and /public/agent_hub. Regression test for the PR #20607 OR-with-default + behavior that made `litellm.public_mcp_servers` ignored by the hub. + """ + + def _make_server(self, server_id, available_on_public_internet=True): + return MCPServer( + server_id=server_id, + name=server_id, + server_name=server_id, + transport=MCPTransport.http, + available_on_public_internet=available_on_public_internet, + ) + + def _make_manager(self, servers): + manager = MCPServerManager() + for s in servers: + manager.config_mcp_servers[s.server_id] = s + return manager + + @patch("litellm.public_mcp_servers", None) + def test_returns_empty_when_whitelist_is_none(self): + """No /make_public call yet → hub returns nothing, regardless of + per-server flags.""" + manager = self._make_manager( + [ + self._make_server("a", available_on_public_internet=True), + self._make_server("b", available_on_public_internet=True), + ] + ) + assert manager.get_public_mcp_servers() == [] + + @patch("litellm.public_mcp_servers", []) + def test_returns_empty_when_whitelist_is_empty(self): + """Explicit empty whitelist → hub returns nothing.""" + manager = self._make_manager( + [self._make_server("a", available_on_public_internet=True)] + ) + assert manager.get_public_mcp_servers() == [] + + @patch("litellm.public_mcp_servers", ["a"]) + def test_returns_only_whitelisted_when_flag_defaults_to_true(self): + """ + Regression: prior to the fix, every server with + available_on_public_internet=True (the default) leaked into the hub + regardless of the whitelist. Whitelist must be authoritative. + """ + manager = self._make_manager( + [ + self._make_server("a", available_on_public_internet=True), + self._make_server("b", available_on_public_internet=True), + ] + ) + result = manager.get_public_mcp_servers() + assert [s.server_id for s in result] == ["a"] + + @patch("litellm.public_mcp_servers", ["a"]) + def test_does_not_leak_servers_via_internal_flag(self): + """ + available_on_public_internet is an IP-gating flag, not a hub flag. + A server with the flag True that is not in the whitelist must not + appear in the hub. + """ + manager = self._make_manager( + [ + self._make_server("a", available_on_public_internet=False), + self._make_server("b", available_on_public_internet=True), + ] + ) + result = manager.get_public_mcp_servers() + assert [s.server_id for s in result] == ["a"] + + @patch("litellm.public_mcp_servers", ["does-not-exist"]) + def test_stale_whitelist_id_returns_empty(self): + """Whitelist references an unknown server_id → no spurious results.""" + manager = self._make_manager( + [self._make_server("a", available_on_public_internet=True)] + ) + assert manager.get_public_mcp_servers() == [] + + if __name__ == "__main__": pytest.main([__file__]) diff --git a/tests/test_litellm/proxy/public_endpoints/test_public_endpoints.py b/tests/test_litellm/proxy/public_endpoints/test_public_endpoints.py index f82da59899b0..fbdbc48a0288 100644 --- a/tests/test_litellm/proxy/public_endpoints/test_public_endpoints.py +++ b/tests/test_litellm/proxy/public_endpoints/test_public_endpoints.py @@ -639,3 +639,74 @@ def test_clean_display_name_strips_suffix(): def test_clean_display_name_passthrough_when_no_suffix(): assert _clean_display_name("OpenAI") == "OpenAI" assert _clean_display_name("") == "" + + +def test_public_mcp_hub_returns_only_whitelisted_servers(): + """Regression: /public/mcp_hub must gate strictly on + litellm.public_mcp_servers, mirroring /public/model_hub and + /public/agent_hub. Servers with available_on_public_internet=True that + are not on the whitelist must not leak.""" + from litellm.types.mcp_server.mcp_server_manager import MCPServer + from litellm.proxy._types import MCPTransport + + app = FastAPI() + app.include_router(router) + app.dependency_overrides[user_api_key_auth] = lambda: MagicMock() + client = TestClient(app) + + listed = MCPServer( + server_id="listed", + name="listed", + server_name="listed", + transport=MCPTransport.http, + available_on_public_internet=True, + ) + hidden = MCPServer( + server_id="hidden", + name="hidden", + server_name="hidden", + transport=MCPTransport.http, + available_on_public_internet=True, + ) + + mock_manager = MagicMock() + mock_manager.get_public_mcp_servers.return_value = [listed] + + with ( + patch("litellm.public_mcp_servers", ["listed"]), + patch( + "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager", + mock_manager, + ), + ): + response = client.get("/public/mcp_hub") + + assert response.status_code == 200 + data = response.json() + assert [item["server_id"] for item in data] == ["listed"] + app.dependency_overrides.clear() + + +def test_public_mcp_hub_returns_empty_when_whitelist_unset(): + """When no servers have been published via /v1/mcp/make_public, the + hub returns an empty list (matches /public/agent_hub behavior).""" + app = FastAPI() + app.include_router(router) + app.dependency_overrides[user_api_key_auth] = lambda: MagicMock() + client = TestClient(app) + + mock_manager = MagicMock() + mock_manager.get_public_mcp_servers.return_value = [] + + with ( + patch("litellm.public_mcp_servers", None), + patch( + "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager", + mock_manager, + ), + ): + response = client.get("/public/mcp_hub") + + assert response.status_code == 200 + assert response.json() == [] + app.dependency_overrides.clear() From 02817bc92e65964e4cf26285ab466f2adf79c1e7 Mon Sep 17 00:00:00 2001 From: michelligabriele Date: Tue, 12 May 2026 22:05:15 +0200 Subject: [PATCH 2/2] fix(mcp): add public_mcp_hub_strict_whitelist flag (default True) for migration --- litellm/__init__.py | 1 + .../mcp_server/mcp_server_manager.py | 25 +++++++-- .../mcp_server/test_mcp_server_manager.py | 52 +++++++++++++++++++ .../public_endpoints/test_public_endpoints.py | 7 --- 4 files changed, 73 insertions(+), 12 deletions(-) diff --git a/litellm/__init__.py b/litellm/__init__.py index e1b367fb2340..fc7e9e59882e 100644 --- a/litellm/__init__.py +++ b/litellm/__init__.py @@ -426,6 +426,7 @@ False # If false (default), converts all 'system' role messages to 'assistant' for GitHub Copilot compatibility. Set to true to disable this behavior. ) public_mcp_servers: Optional[List[str]] = None +public_mcp_hub_strict_whitelist: bool = True public_model_groups: Optional[List[str]] = None public_agent_groups: Optional[List[str]] = None # Supports both old format (Dict[str, str]) and new format (Dict[str, Dict[str, Any]]) diff --git a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py index 980cfe16b5c6..b1928d05a301 100644 --- a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py +++ b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py @@ -3162,19 +3162,34 @@ def get_public_mcp_servers(self) -> List[MCPServer]: """ Return the MCP servers published to the AI Hub via /v1/mcp/make_public. - Mirrors /public/model_hub and /public/agent_hub: gates strictly on the + Default (litellm.public_mcp_hub_strict_whitelist=True): mirrors + /public/model_hub and /public/agent_hub — gates strictly on the litellm.public_mcp_servers whitelist. Returns an empty list when no servers have been published. The per-server available_on_public_internet flag is unrelated — it governs IP-based access in _is_server_accessible_from_ip, not hub visibility. + + Legacy (litellm.public_mcp_hub_strict_whitelist=False): preserves the + pre-fix behavior where any server with available_on_public_internet=True + is also included. Intended as a one-release migration window for + deployments that relied on the OR-with-default semantics; will be + removed in a future release. """ - if litellm.public_mcp_servers is None: - return [] - public_ids = set(litellm.public_mcp_servers) + if litellm.public_mcp_hub_strict_whitelist: + if litellm.public_mcp_servers is None: + return [] + public_ids = set(litellm.public_mcp_servers) + return [ + server + for server in self.get_registry().values() + if server.server_id in public_ids + ] + + public_ids = set(litellm.public_mcp_servers or []) return [ server for server in self.get_registry().values() - if server.server_id in public_ids + if server.available_on_public_internet or server.server_id in public_ids ] def expand_permission_list(self, identifiers: List[str]) -> List[str]: diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py index 487b417eb4a7..51f81ecf47a4 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py @@ -3519,5 +3519,57 @@ def test_stale_whitelist_id_returns_empty(self): assert manager.get_public_mcp_servers() == [] +class TestGetPublicMCPServersLegacyMode: + """ + Legacy migration knob: litellm.public_mcp_hub_strict_whitelist=False + preserves the pre-fix OR-with-default semantics for one release so + operators that relied on the old behavior have a window to call + /v1/mcp/make_public before /public/mcp_hub goes empty. + """ + + def _make_server(self, server_id, available_on_public_internet=True): + return MCPServer( + server_id=server_id, + name=server_id, + server_name=server_id, + transport=MCPTransport.http, + available_on_public_internet=available_on_public_internet, + ) + + def _make_manager(self, servers): + manager = MCPServerManager() + for s in servers: + manager.config_mcp_servers[s.server_id] = s + return manager + + @patch("litellm.public_mcp_hub_strict_whitelist", False) + @patch("litellm.public_mcp_servers", None) + def test_legacy_returns_default_flag_servers_when_whitelist_is_none(self): + """Legacy mode + no whitelist → every server with the default + available_on_public_internet=True appears (old behavior).""" + manager = self._make_manager( + [ + self._make_server("a", available_on_public_internet=True), + self._make_server("b", available_on_public_internet=False), + ] + ) + result = manager.get_public_mcp_servers() + assert [s.server_id for s in result] == ["a"] + + @patch("litellm.public_mcp_hub_strict_whitelist", False) + @patch("litellm.public_mcp_servers", ["b"]) + def test_legacy_unions_whitelist_and_default_flag(self): + """Legacy mode unions the whitelist with any + available_on_public_internet=True server.""" + manager = self._make_manager( + [ + self._make_server("a", available_on_public_internet=True), + self._make_server("b", available_on_public_internet=False), + ] + ) + result = manager.get_public_mcp_servers() + assert sorted(s.server_id for s in result) == ["a", "b"] + + if __name__ == "__main__": pytest.main([__file__]) diff --git a/tests/test_litellm/proxy/public_endpoints/test_public_endpoints.py b/tests/test_litellm/proxy/public_endpoints/test_public_endpoints.py index fbdbc48a0288..525a121e054d 100644 --- a/tests/test_litellm/proxy/public_endpoints/test_public_endpoints.py +++ b/tests/test_litellm/proxy/public_endpoints/test_public_endpoints.py @@ -661,13 +661,6 @@ def test_public_mcp_hub_returns_only_whitelisted_servers(): transport=MCPTransport.http, available_on_public_internet=True, ) - hidden = MCPServer( - server_id="hidden", - name="hidden", - server_name="hidden", - transport=MCPTransport.http, - available_on_public_internet=True, - ) mock_manager = MagicMock() mock_manager.get_public_mcp_servers.return_value = [listed]