fix(mcp): keep hub publication state consistent

This commit is contained in:
moe-berri 2026-09-24 15:38:26 -07:00
parent 4aa3ff47fe
commit 9e027d1375
9 changed files with 225 additions and 20 deletions

View file

@ -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
---

View file

@ -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

View file

@ -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."""

View file

@ -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",

View file

@ -18,6 +18,31 @@ function renderCard(overrides: Partial<MCPServer>) {
render(<MCPServerCard server={{ ...baseServer, ...overrides } as MCPServer} onClick={vi.fn()} />);
}
describe("MCPServerCard publication", () => {
it.each([true, false])("separates hub membership from internet access (%s)", (internetAccessible) => {
const { rerender } = render(
<MCPServerCard
server={{ ...baseServer, available_on_public_internet: internetAccessible, mcp_info: { is_public: false } }}
onClick={vi.fn()}
/>,
);
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(
<MCPServerCard
server={{ ...baseServer, available_on_public_internet: internetAccessible, mcp_info: { is_public: true } }}
onClick={vi.fn()}
/>,
);
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 });

View file

@ -69,7 +69,8 @@ const MCPServerCard: FC<MCPServerCardProps> = ({
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<MCPServerCardProps> = ({
</Tooltip>
)}
<Badge variant="outline">
<span className={cn("h-1.5 w-1.5 rounded-full", isPublic ? "bg-success" : "bg-warning")} />
{isPublic ? "Public" : "Internal"}
<span className={cn("h-1.5 w-1.5 rounded-full", isInternetAccessible ? "bg-success" : "bg-warning")} />
{isInternetAccessible ? "Internet-accessible" : "Internal network"}
</Badge>
<Badge variant="outline">{isListedInHub ? "Listed in hub" : "Not listed in hub"}</Badge>
{accessGroups.slice(0, 2).map((g) => (
<Tooltip key={g}>
<TooltipTrigger

View file

@ -52,6 +52,7 @@ describe("getMCPHubTableColumns", () => {
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", () => {

View file

@ -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 }) => <DataTableSortHeader column={column} title="Public" />,
size: 100,
meta: { title: "Listed in hub", skeleton: "badge", className: "hidden md:table-cell" },
header: ({ column }) => <DataTableSortHeader column={column} title="Listed in hub" />,
size: 140,
enableSorting: true,
sortingFn: (rowA, rowB) => {
const publicA = rowA.original.mcp_info?.is_public === true ? 1 : 0;

View file

@ -145,6 +145,29 @@ const MakeMCPPublicForm: React.FC<MakeMCPPublicFormProps> = ({
Virtual Key to use these servers.
</p>
<details className="rounded-lg border p-3 text-sm">
<summary className="cursor-pointer font-medium">Publish from config.yaml</summary>
<p className="mt-2 text-muted-foreground">
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
</p>
<pre className="mt-2 overflow-x-auto rounded bg-muted p-3 text-xs">{`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`}</pre>
<p className="mt-2 text-muted-foreground">
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
</p>
</details>
<div className="max-h-96 overflow-y-auto border rounded-lg p-4">
<div className="space-y-3">
{mcpHubData.length === 0 ? (
@ -166,7 +189,7 @@ const MakeMCPPublicForm: React.FC<MakeMCPPublicFormProps> = ({
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-2">
<p className="font-medium break-words">{server.server_name}</p>
{isPublic && <Badge>Public</Badge>}
{isPublic && <Badge>Listed in hub</Badge>}
<Badge variant="secondary">{server.transport}</Badge>
<Badge variant={statusVariant(server.status)}>{server.status || "unknown"}</Badge>
</div>