fix(mcp): gate /public/mcp_hub strictly on litellm.public_mcp_servers (#27764)

* fix(mcp): gate /public/mcp_hub strictly on litellm.public_mcp_servers

* fix(mcp): add public_mcp_hub_strict_whitelist flag (default True) for migration
This commit is contained in:
michelligabriele 2026-06-04 13:56:59 +02:00 committed by GitHub
parent be7b9319d2
commit 9196098e9e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 229 additions and 7 deletions

View File

@ -444,6 +444,7 @@ disable_copilot_system_to_assistant: bool = (
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]])

View File

@ -3540,15 +3540,37 @@ class MCPServerManager:
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.
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.
"""
servers: List[MCPServer] = []
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 [])
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
return [
server
for server in self.get_registry().values()
if server.available_on_public_internet or server.server_id in public_ids
]
def expand_permission_list(self, identifiers: List[str]) -> List[str]:
"""

View File

@ -3984,5 +3984,140 @@ class TestApprovalStatusGate:
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() == []
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__])

View File

@ -703,3 +703,67 @@ 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,
)
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()