mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-30 01:52:18 +00:00
fix(mcp): keep hub publication state consistent
This commit is contained in:
parent
4aa3ff47fe
commit
9e027d1375
9 changed files with 225 additions and 20 deletions
|
|
@ -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
|
||||
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue