From 9e027d1375199660989d3d30307ad111b56d6ed1 Mon Sep 17 00:00:00 2001 From: moe-berri Date: Thu, 24 Sep 2026 15:38:26 -0700 Subject: [PATCH] fix(mcp): keep hub publication state consistent --- litellm/proxy/README.md | 23 ++++ .../mcp_management_endpoints.py | 24 ++-- .../mcp_server/test_mcp_server_manager.py | 18 +++ .../test_mcp_management_endpoints.py | 115 ++++++++++++++++++ .../_components/MCPServerCard.test.tsx | 25 ++++ .../mcp-servers/_components/MCPServerCard.tsx | 8 +- .../AIHub/MCPHubTableColumns.test.tsx | 1 + .../components/AIHub/MCPHubTableColumns.tsx | 6 +- .../AIHub/forms/MakeMCPPublicForm.tsx | 25 +++- 9 files changed, 225 insertions(+), 20 deletions(-) diff --git a/litellm/proxy/README.md b/litellm/proxy/README.md index 900dea5ea29..24422cfcfc0 100644 --- a/litellm/proxy/README.md +++ b/litellm/proxy/README.md @@ -30,6 +30,29 @@ print(response) [**See how to call Huggingface,Bedrock,TogetherAI,Anthropic, etc.**](https://docs.litellm.ai/docs/simple_proxy) +## Publish MCP servers in the AI Hub + +Pin each server's `server_id` and list that ID under `litellm_settings.public_mcp_servers` in your proxy config + +```yaml +mcp_servers: + example: + server_id: example-hub-id + url: https://example.invalid/mcp + transport: http + auth_type: none + +litellm_settings: + public_mcp_servers: + - example-hub-id +``` + +Replace the example URL with your MCP endpoint, start or restart the proxy with `litellm --config config.yaml`, and check `GET /public/mcp_hub`. The publication list uses server IDs, not YAML map names. A pinned ID stays stable when the URL, transport, authentication or alias changes + +The default strict hub mode lists only these IDs. An empty or absent list publishes no servers. The legacy `public_mcp_hub_strict_whitelist: false` setting also lists internet-accessible servers. `available_on_public_internet` controls network access policy separately from hub listing; publishing does not remove authentication or tool permissions + +When `public_mcp_servers` is declared in YAML, edit that file and restart to change the list. Dashboard or `/v1/mcp/make_public` requests that change this config-owned value are rejected. To manage publication through the dashboard instead, remove the key from YAML and restart + --- diff --git a/litellm/proxy/management_endpoints/mcp_management_endpoints.py b/litellm/proxy/management_endpoints/mcp_management_endpoints.py index aa218f42023..f98d129c755 100644 --- a/litellm/proxy/management_endpoints/mcp_management_endpoints.py +++ b/litellm/proxy/management_endpoints/mcp_management_endpoints.py @@ -833,7 +833,7 @@ if MCP_AVAILABLE: is_public = False if isinstance(sanitized.mcp_info, dict): is_public = bool(sanitized.mcp_info.get("is_public")) - sanitized.mcp_info = {"is_public": True} if is_public else None + sanitized.mcp_info = {"is_public": is_public} return sanitized @@ -1258,13 +1258,11 @@ if MCP_AVAILABLE: for server in redacted_mcp_servers: server.connected_app_reachable = server.server_id in reachable_ids - # augment the mcp servers with public status - if litellm.public_mcp_servers is not None: - for server in redacted_mcp_servers: - if server.server_id in litellm.public_mcp_servers: - if server.mcp_info is None: - server.mcp_info = {} - server.mcp_info["is_public"] = True + public_server_ids: Final = frozenset( + server.server_id for server in global_mcp_server_manager.get_public_mcp_servers() + ) + for server in redacted_mcp_servers: + server.mcp_info = {**(server.mcp_info or {}), "is_public": server.server_id in public_server_ids} # Annotate has_user_credential for BYOK servers (single batched query) from litellm.proxy.proxy_server import prisma_client as _byok_prisma_client @@ -3026,8 +3024,9 @@ if MCP_AVAILABLE: }, ) - if litellm.public_mcp_servers is None: - litellm.public_mcp_servers = [] + proxy_config.reject_config_owned_writes( + section_name="litellm_settings", changed_keys={"public_mcp_servers": request.mcp_server_ids} + ) for server_id in request.mcp_server_ids: server = global_mcp_server_manager.get_mcp_server_by_id(server_id=server_id) @@ -3037,16 +3036,15 @@ if MCP_AVAILABLE: detail=f"MCP Server with ID {server_id} not found", ) - litellm.public_mcp_servers = request.mcp_server_ids - # Update config with new settings if "litellm_settings" not in config or config["litellm_settings"] is None: config["litellm_settings"] = {} - config["litellm_settings"]["public_mcp_servers"] = litellm.public_mcp_servers + config["litellm_settings"]["public_mcp_servers"] = request.mcp_server_ids # Save the updated config await proxy_config.save_config(new_config=config) + litellm.public_mcp_servers = request.mcp_server_ids verbose_proxy_logger.debug( "Updated public mcp servers to: %s by user: %s", litellm.public_mcp_servers, user_api_key_dict.user_id 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 cd5dae1269a..a65540ec95f 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 @@ -5,6 +5,7 @@ import json import logging import os import sys +from collections.abc import Callable from datetime import datetime from pathlib import Path from typing import Any, Dict, Final, Literal, Optional @@ -12235,6 +12236,23 @@ class TestConfigServerIdPinning: } } + @pytest.mark.asyncio + @pytest.mark.parametrize("public_ids", [["docs-prod-1"], ["docs_server"], [], None]) + async def test_publication_uses_pinned_id_after_fresh_config_load( + self, + config_only_mcp_manager_factory: Callable[[], MCPServerManager], + public_ids: list[str] | None, + ) -> None: + with patch("litellm.public_mcp_servers", public_ids), patch("litellm.public_mcp_hub_strict_whitelist", True): + for url in ("https://example.invalid/mcp", "https://updated.example.invalid/mcp"): + manager: Final = config_only_mcp_manager_factory() + await manager.load_servers_from_config(self._config(server_id="docs-prod-1", url=url)) + + assert [server.server_id for server in manager.get_public_mcp_servers()] == ( + ["docs-prod-1"] if public_ids == ["docs-prod-1"] else [] + ) + assert manager.config_mcp_servers["docs-prod-1"].url == url + @pytest.mark.asyncio async def test_derived_id_churns_when_connection_fields_change(self, config_only_mcp_manager_factory): """The behavior the pin exists to escape: editing the url mints a brand-new id.""" diff --git a/tests/test_litellm/proxy/management_endpoints/test_mcp_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_mcp_management_endpoints.py index 557e753a76f..1165361ea45 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_mcp_management_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_mcp_management_endpoints.py @@ -4,6 +4,7 @@ import types import json import logging from contextlib import ExitStack +from collections.abc import Mapping from datetime import datetime, timedelta from types import SimpleNamespace from typing import Final, List, Optional, cast @@ -33,6 +34,120 @@ from litellm.types.mcp import MCPAuth, MCPCredentials from litellm.types.mcp_server.mcp_server_manager import MCPServer +@pytest.mark.asyncio +@pytest.mark.parametrize("view", ["admin", "team", "member", "virtual_key"]) +@pytest.mark.parametrize( + "strict,public_ids,expected", + [(True, ["listed"], {"listed"}), (True, None, set()), (True, [], set()), + (False, None, {"internet"}), (False, ["listed"], {"internet", "listed"})], +) +async def test_list_publication_matches_hub_without_mutating_source( + monkeypatch: pytest.MonkeyPatch, + view: str, + strict: bool, + public_ids: list[str] | None, + expected: set[str], +) -> None: + import litellm + from litellm.proxy._experimental.mcp_server.mcp_server_manager import MCPServerManager + + manager: Final = MCPServerManager() + manager.config_mcp_servers = { + server_id: MCPServer( + server_id=server_id, name=server_id, transport=MCPTransport.http, + available_on_public_internet=server_id == "internet", + mcp_info={"is_public": server_id != "listed", "description": "preserve me"}, + ) + for server_id in ("listed", "internet", "internal") + } + records: Final = [manager._build_mcp_server_table(server) for server in manager.get_registry().values()] + original: Final = [record.model_dump() for record in records] + monkeypatch.setattr(litellm, "public_mcp_servers", public_ids) + monkeypatch.setattr(litellm, "public_mcp_hub_strict_whitelist", strict) + monkeypatch.setattr(mgmt_endpoints, "global_mcp_server_manager", manager) + monkeypatch.setattr(mgmt_endpoints, "_resolve_accessible_mcp_servers", AsyncMock(return_value=records)) + monkeypatch.setattr( + mgmt_endpoints, "_get_team_scoped_mcp_server_list", + AsyncMock(side_effect=lambda _: mgmt_endpoints._redact_mcp_credentials_list(records)), + ) + + result: Final = await mgmt_endpoints.fetch_all_mcp_servers( + user_api_key_dict=UserAPIKeyAuth( + user_role=LitellmUserRoles.INTERNAL_USER if view in ("member", "virtual_key") else LitellmUserRoles.PROXY_ADMIN, + allowed_routes=["/v1/mcp/server"] if view == "virtual_key" else [], + ), + team_id="test-team" if view == "team" else None, + ) + + assert {server.server_id for server in manager.get_public_mcp_servers()} == expected + assert {server.server_id for server in result if server.mcp_info["is_public"]} == expected + assert all(server.mcp_info["is_public"] is (server.server_id in expected) for server in result) + if view == "virtual_key": + assert all(server.mcp_info == {"is_public": server.server_id in expected} for server in result) + else: + assert all(server.mcp_info["description"] == "preserve me" for server in result) + assert [record.model_dump() for record in records] == original + assert manager.config_mcp_servers["internet"].mcp_info["is_public"] is True + assert manager.config_mcp_servers["listed"].mcp_info["is_public"] is False + + +@pytest.mark.asyncio +@pytest.mark.parametrize("initial", [None, ["original"]]) +@pytest.mark.parametrize("outcome", ["config_owned", "save_failed", "missing_server", "forbidden", "success"]) +async def test_make_public_commits_runtime_only_after_persistence( + monkeypatch: pytest.MonkeyPatch, initial: list[str] | None, outcome: str +) -> None: + import litellm + from litellm.proxy import proxy_server + from litellm.proxy._experimental.mcp_server import mcp_server_manager + from litellm.proxy._types import MakeMCPServersPublicRequest + + manager: Final = mcp_server_manager.MCPServerManager() + manager.config_mcp_servers = { + "selected": MCPServer(server_id="selected", name="selected", transport=MCPTransport.http) + } + config: Final = proxy_server.ProxyConfig() + config._load_yaml_settings_stores( + {"litellm_settings": {"public_mcp_servers": initial}} if outcome == "config_owned" else {} + ) + + async def save_config(new_config: Mapping[str, Mapping[str, object]]) -> None: + assert litellm.public_mcp_servers == initial + assert new_config["litellm_settings"]["public_mcp_servers"] == ["selected"] + if outcome == "save_failed": + raise RuntimeError("persistence unavailable") + + save: Final = AsyncMock(side_effect=save_config) + monkeypatch.setattr(proxy_server, "proxy_config", SimpleNamespace( + get_config=AsyncMock(return_value={"litellm_settings": {"public_mcp_servers": initial}}), + reject_config_owned_writes=config.reject_config_owned_writes, + save_config=save, + )) + monkeypatch.setattr(mcp_server_manager, "global_mcp_server_manager", manager) + monkeypatch.setattr(litellm, "public_mcp_servers", initial) + request: Final = MakeMCPServersPublicRequest( + mcp_server_ids=["missing"] if outcome == "missing_server" else ["selected"] + ) + auth: Final = UserAPIKeyAuth( + user_role=LitellmUserRoles.INTERNAL_USER if outcome == "forbidden" else LitellmUserRoles.PROXY_ADMIN + ) + + if outcome == "success": + response: Final = await mgmt_endpoints.make_mcp_servers_public(request, auth) + assert response["public_mcp_servers"] == ["selected"] + assert litellm.public_mcp_servers == ["selected"] + save.assert_awaited_once() + else: + with pytest.raises(HTTPException) as exc: + await mgmt_endpoints.make_mcp_servers_public(request, auth) + assert exc.value.status_code == { + "config_owned": 400, "save_failed": 500, "missing_server": 404, "forbidden": 403, + }[outcome] + assert litellm.public_mcp_servers == initial + if outcome != "save_failed": + save.assert_not_awaited() + + def generate_mock_mcp_server_db_record( server_id: Optional[str] = None, alias: str = "Test DB Server", diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPServerCard.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPServerCard.test.tsx index 71c2e107774..d3a3ef59d27 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPServerCard.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPServerCard.test.tsx @@ -18,6 +18,31 @@ function renderCard(overrides: Partial) { render(); } +describe("MCPServerCard publication", () => { + it.each([true, false])("separates hub membership from internet access (%s)", (internetAccessible) => { + const { rerender } = render( + , + ); + const networkLabel = internetAccessible ? "Internet-accessible" : "Internal network"; + expect(screen.getByText(networkLabel)).toBeInTheDocument(); + expect(screen.getByText("Not listed in hub")).toBeInTheDocument(); + expect(screen.queryByText("Listed in hub")).not.toBeInTheDocument(); + + rerender( + , + ); + expect(screen.getByText(networkLabel)).toBeInTheDocument(); + expect(screen.getByText("Listed in hub")).toBeInTheDocument(); + expect(screen.queryByText("Not listed in hub")).not.toBeInTheDocument(); + }); +}); + describe("MCPServerCard OAuth flow indicator", () => { it("shows the 'OAuth flow not set' badge for an oauth2 server with no oauth2_flow", () => { renderCard({ auth_type: "oauth2", oauth2_flow: null }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPServerCard.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPServerCard.tsx index 42fb95d5951..08f2a98d6ad 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPServerCard.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPServerCard.tsx @@ -69,7 +69,8 @@ const MCPServerCard: FC = ({ server.auth_type === AUTH_TYPE.OAUTH2 && !server.oauth2_flow && !server.delegate_auth_to_upstream; const status = server.status || "unknown"; const healthTone = HEALTH_TONE[status] ?? HEALTH_TONE.unknown; - const isPublic = server.available_on_public_internet; + const isInternetAccessible = server.available_on_public_internet; + const isListedInHub = server.mcp_info?.is_public === true; const accessGroups = (server.mcp_access_groups ?? []).filter((g): g is string => typeof g === "string"); const missing = missingUserFields ?? []; @@ -236,9 +237,10 @@ const MCPServerCard: FC = ({ )} - - {isPublic ? "Public" : "Internal"} + + {isInternetAccessible ? "Internet-accessible" : "Internal network"} + {isListedInHub ? "Listed in hub" : "Not listed in hub"} {accessGroups.slice(0, 2).map((g) => ( { expect(screen.getByText("Server Name")).toBeInTheDocument(); expect(screen.getByText("Transport")).toBeInTheDocument(); expect(screen.getByText("Auth Type")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Listed in hub" })).toBeInTheDocument(); }); it("does not expose a URL column", () => { diff --git a/ui/litellm-dashboard/src/components/AIHub/MCPHubTableColumns.tsx b/ui/litellm-dashboard/src/components/AIHub/MCPHubTableColumns.tsx index 6a1ede11201..2751ed7bfbc 100644 --- a/ui/litellm-dashboard/src/components/AIHub/MCPHubTableColumns.tsx +++ b/ui/litellm-dashboard/src/components/AIHub/MCPHubTableColumns.tsx @@ -197,9 +197,9 @@ export const getMCPHubTableColumns = ({ onServerClick }: MCPHubTableColumnsDeps) { id: "is_public", accessorFn: (row) => row.mcp_info?.is_public === true, - meta: { title: "Public", skeleton: "badge", className: "hidden md:table-cell" }, - header: ({ column }) => , - size: 100, + meta: { title: "Listed in hub", skeleton: "badge", className: "hidden md:table-cell" }, + header: ({ column }) => , + size: 140, enableSorting: true, sortingFn: (rowA, rowB) => { const publicA = rowA.original.mcp_info?.is_public === true ? 1 : 0; diff --git a/ui/litellm-dashboard/src/components/AIHub/forms/MakeMCPPublicForm.tsx b/ui/litellm-dashboard/src/components/AIHub/forms/MakeMCPPublicForm.tsx index 8287cf47f1a..09152922dd6 100644 --- a/ui/litellm-dashboard/src/components/AIHub/forms/MakeMCPPublicForm.tsx +++ b/ui/litellm-dashboard/src/components/AIHub/forms/MakeMCPPublicForm.tsx @@ -145,6 +145,29 @@ const MakeMCPPublicForm: React.FC = ({ Virtual Key to use these servers.

+
+ Publish from config.yaml +

+ Pin a server_id and list that ID under litellm_settings.public_mcp_servers, then restart the proxy. Use the + ID, not the YAML server name. In the default strict mode, an empty or absent list publishes no servers. + Internet accessibility is separate from hub listing +

+
{`mcp_servers:
+  example:
+    server_id: example-hub-id
+    url: https://example.invalid/mcp
+    transport: http
+    auth_type: none
+
+litellm_settings:
+  public_mcp_servers:
+    - example-hub-id`}
+

+ When this setting is in YAML, change it there. Dashboard changes are rejected. To manage it here, remove + public_mcp_servers from YAML and restart +

+
+
{mcpHubData.length === 0 ? ( @@ -166,7 +189,7 @@ const MakeMCPPublicForm: React.FC = ({

{server.server_name}

- {isPublic && Public} + {isPublic && Listed in hub} {server.transport} {server.status || "unknown"}