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
+ 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 +
+{server.server_name}
- {isPublic &&