fix(mcp): align hub publication status and controls (#43241)

* fix(mcp): align hub publication status and controls

* refactor(mcp): keep hub visibility guard outside table rendering

---------

Co-authored-by: Joshua Valluru <326636767+joshua-berri@users.noreply.github.com>
This commit is contained in:
tin-berri 2026-09-26 16:53:02 -07:00 • committed by GitHub
parent 7244040908
commit 5d777c16d9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 720 additions and 164 deletions

View file

@ -0,0 +1,37 @@
# Publish MCP servers in the AI Hub
Set `litellm_settings.public_mcp_servers` to the concrete IDs of the servers you want listed in the public AI Hub. Pin `server_id` in each configuration entry so the publication list stays stable across deployments
```yaml
mcp_servers:
documentation:
server_id: documentation-mcp
url: https://mcp.example.com/mcp
transport: http
available_on_public_internet: true
litellm_settings:
public_mcp_hub_strict_whitelist: true
public_mcp_servers:
- documentation-mcp
```
Use `documentation-mcp`, the `server_id`, in the publication list. The configuration key `documentation`, display names, and aliases are not publication IDs. Database-created servers use the ID returned by `/v1/mcp/server`
The dashboard's **AI Hub > MCP Hub > Manage MCP Hub Visibility** dialog edits this same list. Its YAML example includes the selected server IDs. With database-backed configuration (`store_model_in_db: true`), a value declared in YAML is owned by that file: edit the file and reload, or remove that key from YAML to let the dashboard manage it in the database. File-backed deployments can save the list directly to their configuration file
To remove all explicit entries, save an empty selection in the dialog or configure:
```yaml
litellm_settings:
public_mcp_hub_strict_whitelist: true
public_mcp_servers: []
```
## Hub listing and network access
The **Hub listing** column in AI Hub identifies servers that appear in `/public/mcp_hub`. The dashboard derives this status from the current registry and publication settings. Setting `mcp_info.is_public` on a server does not publish it; that response field is derived metadata. `mcp_info.is_public_explicit` identifies registered servers included in the explicit publication list
Gateway cards and server details show **All Networks** when `available_on_public_internet` is enabled or the server is explicitly published in `public_mcp_servers`. They show **Internal Only** when both are false. The per-server flag defaults to `true`; explicit publication overrides a disabled flag for compatibility. Older proxies that omit the metadata needed to determine access show **Unknown**. These labels describe allowed client IPs; authentication and tool permissions still apply
The default `public_mcp_hub_strict_whitelist: true` lists only registered servers in `public_mcp_servers`. Legacy mode (`false`) additionally lists registered servers with `available_on_public_internet: true`. In legacy mode, clearing the explicit publication list leaves these automatically listed servers visible. Enable strict mode when the publication list should fully determine hub visibility

View file

@ -6799,6 +6799,16 @@ class MCPServerManager:
return server
return None
@staticmethod
def _is_public_mcp_server(server: MCPServer, public_ids: Container[str]) -> bool:
return server.server_id in public_ids or (
not litellm.public_mcp_hub_strict_whitelist and server.available_on_public_internet
)
def is_mcp_server_public(self, server_id: str) -> bool:
server: Final = self.registry.get(server_id) or self.config_mcp_servers.get(server_id)
return server is not None and self._is_public_mcp_server(server, litellm.public_mcp_servers or ())
def get_public_mcp_servers(self) -> list[MCPServer]:
"""
Return the MCP servers published to the AI Hub via /v1/mcp/make_public.
@ -6816,18 +6826,8 @@ class MCPServerManager:
deployments that relied on the OR-with-default semantics; will be
removed in a future release.
"""
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 [])
return [
server
for server in self.get_registry().values()
if server.available_on_public_internet or server.server_id in public_ids
]
public_ids: Final = frozenset(litellm.public_mcp_servers or ())
return [server for server in self.get_registry().values() if self._is_public_mcp_server(server, public_ids)]
def expand_permission_list(self, identifiers: list[str]) -> list[str]:
"""

View file

@ -650,7 +650,16 @@ if MCP_AVAILABLE:
if hasattr(redacted_server, "credentials"):
setattr(redacted_server, "credentials", _preserved_admin_config_credentials(redacted_server.credentials))
return redacted_server
is_public: Final = global_mcp_server_manager.is_mcp_server_public(redacted_server.server_id)
return redacted_server.model_copy(
update={
"mcp_info": {
**(redacted_server.mcp_info or {}),
"is_public": is_public,
"is_public_explicit": is_public and redacted_server.server_id in (litellm.public_mcp_servers or ()),
}
}
)
def _preserved_admin_config_credentials(
credentials: "MCPCredentials | str | None",
@ -832,10 +841,10 @@ if MCP_AVAILABLE:
sanitized.updated_at = None
# `mcp_info` is arbitrary metadata; keep only an explicit safe subset.
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": (sanitized.mcp_info or {}).get("is_public") is True,
"is_public_explicit": (sanitized.mcp_info or {}).get("is_public_explicit") is True,
}
return sanitized
@ -1260,14 +1269,6 @@ 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
# Annotate has_user_credential for BYOK servers (single batched query)
from litellm.proxy.proxy_server import prisma_client as _byok_prisma_client
@ -3041,9 +3042,6 @@ if MCP_AVAILABLE:
},
)
if litellm.public_mcp_servers is None:
litellm.public_mcp_servers = []
for server_id in request.mcp_server_ids:
server = global_mcp_server_manager.get_mcp_server_by_id(server_id=server_id)
if server is None:
@ -3052,16 +3050,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

@ -300,7 +300,19 @@ async def get_mcp_servers():
)
public_mcp_servers: Final = global_mcp_server_manager.get_public_mcp_servers()
return [MCPPublicServer.model_validate(server.model_dump()) for server in public_mcp_servers]
return [
MCPPublicServer.model_validate(
{
**server.model_dump(),
"mcp_info": {
**(server.mcp_info or {}),
"is_public": True,
"is_public_explicit": server.server_id in (litellm.public_mcp_servers or ()),
},
}
)
for server in public_mcp_servers
]
@router.get(

View file

@ -9566,6 +9566,60 @@ class TestGetPublicMCPServers:
manager.config_mcp_servers[s.server_id] = s
return manager
@pytest.mark.parametrize("registered_in", ("config", "database", "both", "neither"))
@pytest.mark.parametrize("public_ids", (None, [], ["server-id"], ["server-alias"], ["Server Name"]))
@pytest.mark.parametrize(
"strict,network_access,implicitly_public",
((True, True, False), (True, False, False), (False, True, True), (False, False, False)),
)
def test_public_status_agrees_with_hub_membership(
self,
registered_in: Literal["config", "database", "both", "neither"],
public_ids: list[str] | None,
strict: bool,
network_access: bool,
implicitly_public: bool,
) -> None:
manager: Final = MCPServerManager()
server: Final = MCPServer(
server_id="server-id",
name="server-alias",
alias="server-alias",
server_name="Server Name",
transport=MCPTransport.http,
available_on_public_internet=network_access,
mcp_info={"is_public": True, "description": "Preserve custom metadata"},
)
config_server: Final = (
server.model_copy(update={"available_on_public_internet": not network_access})
if registered_in == "both"
else server
)
manager.config_mcp_servers = (
{server.server_id: config_server} if registered_in in ("config", "both") else {}
)
manager.registry = {server.server_id: server} if registered_in in ("database", "both") else {}
original_server: Final = server.model_dump()
original_config_server: Final = config_server.model_dump()
expected_public: Final = registered_in != "neither" and (
public_ids == [server.server_id] or implicitly_public
)
with (
patch("litellm.public_mcp_servers", public_ids),
patch("litellm.public_mcp_hub_strict_whitelist", strict),
):
public_servers: Final = manager.get_public_mcp_servers()
assert manager.is_mcp_server_public(server.server_id) is expected_public
assert [item.server_id for item in public_servers] == (
[server.server_id] if expected_public else []
)
assert manager.is_mcp_server_public("server-alias") is False
assert manager.is_mcp_server_public("missing-server") is False
assert server.model_dump() == original_server
assert config_server.model_dump() == original_config_server
@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

View file

@ -33,6 +33,7 @@ from litellm.proxy._types import (
LiteLLM_ObjectPermissionTable,
LiteLLM_MCPServerTable,
LitellmUserRoles,
MakeMCPServersPublicRequest,
MCPTransport,
MCPUserCredentialResponse,
NewMCPServerRequest,
@ -154,6 +155,161 @@ def patch_proxy_general_settings(settings: dict):
)
@pytest.mark.asyncio
@pytest.mark.parametrize("from_db", (False, True))
@pytest.mark.parametrize(
"strict,explicit,expected_public",
((True, True, True), (True, False, False), (False, False, True)),
)
async def test_mcp_publication_list_and_detail_derive_current_status(
from_db: bool, strict: bool, explicit: bool, expected_public: bool
) -> None:
from litellm.proxy._experimental.mcp_server.mcp_server_manager import MCPServerManager
manager: Final = MCPServerManager()
server: Final = MCPServer(
server_id="publication-server",
name="publication-server",
transport=MCPTransport.http,
auth_type=MCPAuth.api_key,
available_on_public_internet=True,
mcp_info={
"is_public": not expected_public,
"is_public_explicit": not explicit,
"description": "Keep this description",
},
)
manager.registry = {server.server_id: server} if from_db else {}
manager.config_mcp_servers = {} if from_db else {server.server_id: server}
record: Final = manager._build_mcp_server_table(server)
original_metadata: Final = dict(server.mcp_info or {})
admin: Final = generate_mock_user_api_key_auth()
with (
patch("litellm.public_mcp_servers", [server.server_id] if explicit else []),
patch("litellm.public_mcp_hub_strict_whitelist", strict),
patch.object(mgmt_endpoints, "global_mcp_server_manager", manager),
patch.object(mgmt_endpoints, "get_prisma_client_or_throw", return_value=MagicMock()),
patch.object(mgmt_endpoints, "get_mcp_server", AsyncMock(return_value=record if from_db else None)),
patch("litellm.proxy.proxy_server.prisma_client", None),
patch("litellm.proxy.proxy_server.general_settings", {"user_mcp_management_mode": "view_all"}),
):
listing: Final = await mgmt_endpoints.fetch_all_mcp_servers(
user_api_key_dict=admin, team_id=None, connected_app_view=False
)
detail: Final = await mgmt_endpoints.fetch_mcp_server(
request=_make_mock_request(), server_id=server.server_id, user_api_key_dict=admin
)
assert len(listing) == 1
for projected in (listing[0], detail):
assert projected.mcp_info == {
"is_public": expected_public,
"is_public_explicit": explicit,
"description": "Keep this description",
}
assert bool(manager.get_public_mcp_servers()) is expected_public
assert server.mcp_info == original_metadata
assert record.mcp_info == original_metadata
@pytest.mark.parametrize("approval_status", ("pending_review", "rejected", "draft", "active"))
@pytest.mark.parametrize("strict", (False, True))
def test_mcp_publication_projection_excludes_unregistered_lifecycle_records(
approval_status: str, strict: bool
) -> None:
from litellm.proxy._experimental.mcp_server.mcp_server_manager import MCPServerManager
record: Final = LiteLLM_MCPServerTable(
server_id="unregistered-server",
transport=MCPTransport.http,
approval_status=approval_status,
credentials={"auth_value": "test-secret"},
available_on_public_internet=True,
mcp_info={"is_public": True, "is_public_explicit": True},
)
original: Final = record.model_dump()
with (
patch("litellm.public_mcp_servers", [record.server_id]),
patch("litellm.public_mcp_hub_strict_whitelist", strict),
patch.object(mgmt_endpoints, "global_mcp_server_manager", MCPServerManager()),
):
for project in (
mgmt_endpoints._redact_mcp_credentials,
mgmt_endpoints._sanitize_mcp_server_for_non_admin,
mgmt_endpoints._sanitize_mcp_server_for_virtual_key,
):
projected: Final = project(record)
assert projected.mcp_info == {"is_public": False, "is_public_explicit": False}
assert projected.credentials is None
assert record.model_dump() == original
@pytest.mark.asyncio
@pytest.mark.parametrize("previous_ids", (None, ["old-server"]))
@pytest.mark.parametrize(
"selected_ids,save_error,role,error_status",
(
(["new-server"], None, LitellmUserRoles.PROXY_ADMIN, None),
([], None, LitellmUserRoles.PROXY_ADMIN, None),
(["new-server"], HTTPException(400, "Owned by config file"), LitellmUserRoles.PROXY_ADMIN, 400),
(["new-server"], RuntimeError("Database write failed"), LitellmUserRoles.PROXY_ADMIN, 500),
(["missing-server"], None, LitellmUserRoles.PROXY_ADMIN, 404),
(["new-server"], None, LitellmUserRoles.INTERNAL_USER, 403),
),
)
async def test_mcp_publication_updates_runtime_only_after_successful_save(
previous_ids: list[str] | None,
selected_ids: list[str],
save_error: HTTPException | RuntimeError | None,
role: LitellmUserRoles,
error_status: int | None,
) -> None:
import litellm
from litellm.proxy._experimental.mcp_server.mcp_server_manager import MCPServerManager
manager: Final = MCPServerManager()
server: Final = generate_mock_mcp_server_config_record(server_id="new-server")
manager.config_mcp_servers = {server.server_id: server}
expected_config: Final = {"litellm_settings": {"drop_params": True, "public_mcp_servers": selected_ids}}
async def save_config(new_config: Mapping[str, object]) -> None:
assert litellm.public_mcp_servers is previous_ids
assert new_config == expected_config
if save_error is not None:
raise save_error
save: Final = AsyncMock(side_effect=save_config)
proxy_config: Final = SimpleNamespace(
get_config=AsyncMock(return_value={"litellm_settings": {"drop_params": True}}),
save_config=save,
)
request: Final = MakeMCPServersPublicRequest(mcp_server_ids=selected_ids)
caller: Final = generate_mock_user_api_key_auth(user_role=role)
with (
patch("litellm.public_mcp_servers", previous_ids),
patch("litellm.proxy.proxy_server.proxy_config", proxy_config),
patch(
"litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager",
manager,
),
):
if error_status is None:
response: Final = await mgmt_endpoints.make_mcp_servers_public(request, caller)
assert response["public_mcp_servers"] == selected_ids
assert litellm.public_mcp_servers == selected_ids
else:
with pytest.raises(HTTPException) as error:
await mgmt_endpoints.make_mcp_servers_public(request, caller)
assert error.value.status_code == error_status
assert litellm.public_mcp_servers is previous_ids
if error_status in (403, 404):
save.assert_not_awaited()
else:
save.assert_awaited_once_with(new_config=expected_config)
class TestMCPCredentialsTokenExchangeProfile:
"""token_exchange_profile must be a declared MCPCredentials field so the management API can
persist the entra_obo profile. An undeclared key is silently stripped by pydantic when the

View file

@ -1086,43 +1086,73 @@ def test_clean_display_name_passthrough_when_no_suffix():
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."""
@pytest.mark.parametrize(
"strict,explicit,expected_listed",
((True, True, True), (True, False, False), (False, True, True), (False, False, True)),
)
@pytest.mark.parametrize("stored_public", (None, False, True))
def test_public_mcp_hub_derives_publication_metadata_without_mutating_registry(
strict: bool,
explicit: bool,
expected_listed: bool,
stored_public: bool | None,
) -> None:
from litellm.proxy._experimental.mcp_server.mcp_server_manager import MCPServerManager
from litellm.types.mcp_server.mcp_server_manager import MCPServer
from litellm.proxy._types import MCPTransport
app = FastAPI()
app: Final = FastAPI()
app.include_router(router)
app.dependency_overrides[user_api_key_auth] = lambda: MagicMock()
client = TestClient(app)
client: Final = TestClient(app)
listed = MCPServer(
server: Final = MCPServer(
server_id="listed",
name="listed",
server_name="listed",
transport=MCPTransport.http,
available_on_public_internet=True,
mcp_info=(
{
"is_public": stored_public,
"is_public_explicit": not explicit,
"description": "Preserve custom metadata",
}
if stored_public is not None
else None
),
)
mock_manager = MagicMock()
mock_manager.get_public_mcp_servers.return_value = [listed]
unlisted: Final = MCPServer(
server_id="unlisted",
name="unlisted",
transport=MCPTransport.http,
available_on_public_internet=False,
mcp_info={"is_public": True, "is_public_explicit": True},
)
manager: Final = MCPServerManager()
manager.config_mcp_servers = {server.server_id: server}
manager.registry = {unlisted.server_id: unlisted}
original_registry: Final = {key: value.model_dump() for key, value in manager.get_registry().items()}
with (
patch("litellm.public_mcp_servers", ["listed"]),
patch("litellm.public_mcp_servers", [server.server_id] if explicit else []),
patch("litellm.public_mcp_hub_strict_whitelist", strict),
patch(
"litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager",
mock_manager,
manager,
),
):
response = client.get("/public/mcp_hub")
response: Final = 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()
data: Final = response.json()
assert [item["server_id"] for item in data] == ([server.server_id] if expected_listed else [])
if expected_listed:
assert data[0]["mcp_info"] == {
**({"description": "Preserve custom metadata"} if stored_public is not None else {}),
"is_public": True,
"is_public_explicit": explicit,
}
assert {key: value.model_dump() for key, value in manager.get_registry().items()} == original_registry
def test_public_mcp_hub_returns_empty_when_whitelist_unset():

View file

@ -217,12 +217,12 @@ const MCPPermissionManagement: React.FC<MCPPermissionManagementProps> = ({
<div>
<span className="text-sm font-medium text-foreground flex items-center">
Internal network only
<SimpleTooltip content="When on, only requests from within your internal network are accepted. Turn off to allow external clients (other clusters, ChatGPT, etc). API key authentication is always required regardless of this setting.">
<SimpleTooltip content="When on, requests must come from your internal network unless the server ID is explicitly published in public_mcp_servers. Turn off to allow public IPs. Authentication and access permissions still apply.">
<Info className="ml-2 size-4 text-info hover:text-info/80 cursor-help" />
</SimpleTooltip>
</span>
<p className="text-sm text-muted-foreground mt-1">
Turn on to restrict access to callers within your internal network only.
Turn on to restrict public IPs. Explicitly published server IDs remain accessible from public IPs.
</p>
</div>
<MountedFormField name="available_on_public_internet" defaultValue={true} className="mb-0">

View file

@ -126,3 +126,15 @@ describe("MCPServerCard per-user credentials", () => {
expect(screen.queryByRole("button", { name: "Set" })).not.toBeInTheDocument();
});
});
describe("MCPServerCard network access", () => {
it("shows effective network access without a hub listing badge", () => {
renderCard({
available_on_public_internet: false,
mcp_info: { server_name: "demo_server", is_public: true, is_public_explicit: true },
});
expect(screen.getByText("All Networks")).toBeInTheDocument();
expect(screen.queryByText(/^Hub:/)).not.toBeInTheDocument();
});
});

View file

@ -13,7 +13,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/comp
import { cn } from "@/lib/cva.config";
import { AUTH_TYPE, MCP_REACHABLE_DESCRIPTION, type MCPServer } from "@/components/mcp_tools/types";
import { Logo } from "@/components/molecules/logo/Logo";
import { getMaskedAndFullUrl } from "./utils";
import { getMaskedAndFullUrl, getMCPNetworkAccess } from "./utils";
interface MCPServerCardProps {
server: MCPServer;
@ -70,7 +70,7 @@ 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 networkAccess = getMCPNetworkAccess(server);
const accessGroups = (server.mcp_access_groups ?? []).filter((g): g is string => typeof g === "string");
const missing = missingUserFields ?? [];
@ -236,10 +236,17 @@ const MCPServerCard: FC<MCPServerCardProps> = ({
</TooltipContent>
</Tooltip>
)}
<Badge variant="outline">
<span className={cn("h-1.5 w-1.5 rounded-full", isPublic ? "bg-success" : "bg-warning")} />
{isPublic ? "Public" : "Internal"}
</Badge>
<Tooltip>
<TooltipTrigger
render={
<Badge variant="outline">
<span className={cn("h-1.5 w-1.5 rounded-full", networkAccess.dotClassName)} />
{networkAccess.label}
</Badge>
}
/>
<TooltipContent>{networkAccess.description}</TooltipContent>
</Tooltip>
{accessGroups.slice(0, 2).map((g) => (
<Tooltip key={g}>
<TooltipTrigger

View file

@ -120,14 +120,21 @@ describe("MCPServerView", () => {
});
it("shows the read-only settings summary before editing", async () => {
renderView({ allow_all_keys: true, available_on_public_internet: false });
renderView({
allow_all_keys: true,
available_on_public_internet: false,
mcp_info: { server_name: "demo server", is_public: true, is_public_explicit: true },
});
await userEvent.click(screen.getByRole("tab", { name: "Settings" }));
expect(await screen.findByText("MCP Server Settings")).toBeInTheDocument();
expect(screen.getByText("Allow All Keys")).toBeInTheDocument();
expect(screen.getByText("Enabled")).toBeInTheDocument();
expect(screen.getByText("Internal only")).toBeInTheDocument();
expect(screen.getByText("Network access")).toBeInTheDocument();
expect(screen.getByText("All Networks")).toBeInTheDocument();
expect(screen.queryByText("MCP Hub")).not.toBeInTheDocument();
expect(screen.queryByText("Listed")).not.toBeInTheDocument();
expect(screen.queryByText("edit form")).not.toBeInTheDocument();
});

View file

@ -13,7 +13,7 @@ import { MCPServerUserCredentialsPanel } from "./MCPServerUserCredentialsPanel";
import { getSecureItem } from "@/utils/secureStorage";
import { isProxyAdminRole, isProxyAdminTierRole } from "@/utils/roles";
import MCPServerCostDisplay from "./mcp_server_cost_display";
import { getMaskedAndFullUrl } from "./utils";
import { getMaskedAndFullUrl, getMCPNetworkAccess } from "./utils";
import { copyToClipboard as utilCopyToClipboard } from "@/utils/dataUtils";
import { CheckIcon, CopyIcon } from "lucide-react";
@ -68,6 +68,7 @@ export const MCPServerView: React.FC<MCPServerViewProps> = ({
const returningFromEditOAuth = isReturningFromEditOAuth(canEdit, mcpServer.server_id);
const [editing, setEditing] = useState(isEditing || returningFromEditOAuth);
const [showFullUrl, setShowFullUrl] = useState(false);
const networkAccess = getMCPNetworkAccess(mcpServer);
const [copiedStates, setCopiedStates] = useState<Record<string, boolean>>({});
const [selectedTabIndex, setSelectedTabIndex] = useState(returningFromEditOAuth ? 2 : initialTabIndex);
const canViewUserCredentials = userRole !== null && isProxyAdminTierRole(userRole);
@ -318,19 +319,13 @@ export const MCPServerView: React.FC<MCPServerViewProps> = ({
</div>
</div>
<div className="grid grid-cols-3 gap-4 py-3">
<p className="text-sm font-medium text-muted-foreground">Network Access</p>
<p className="text-sm font-medium text-muted-foreground">Network access</p>
<div className="col-span-2">
{mcpServer.available_on_public_internet ? (
<Badge variant="outline">
<span className="h-1.5 w-1.5 rounded-full bg-success" />
Public
</Badge>
) : (
<Badge variant="outline">
<span className="h-1.5 w-1.5 rounded-full bg-warning" />
Internal only
</Badge>
)}
<Badge variant="outline">
<span className={`h-1.5 w-1.5 rounded-full ${networkAccess.dotClassName}`} />
{networkAccess.label}
</Badge>
<p className="mt-2 text-xs text-muted-foreground">{networkAccess.description}</p>
</div>
</div>
{handleAuth(mcpServer.auth_type) === "oauth2" && (

View file

@ -3,11 +3,40 @@ import {
extractMCPToken,
maskUrl,
getMaskedAndFullUrl,
getMCPNetworkAccess,
validateMCPServerUrl,
validateMCPServerName,
normalizeToolOverrideMap,
} from "./utils";
describe("getMCPNetworkAccess", () => {
it.each([
{ publicIp: true, explicit: false, label: "All Networks" },
{ publicIp: false, explicit: true, label: "All Networks" },
{ publicIp: true, explicit: true, label: "All Networks" },
{ publicIp: false, explicit: false, label: "Internal Only" },
{ publicIp: true, explicit: undefined, label: "All Networks" },
{ publicIp: false, explicit: undefined, label: "Unknown" },
{ publicIp: undefined, explicit: false, label: "Unknown" },
])("reports $label for network=$publicIp and publication=$explicit", ({ publicIp, explicit, label }) => {
expect(
getMCPNetworkAccess({
available_on_public_internet: publicIp,
mcp_info: { server_name: "demo", is_public: true, is_public_explicit: explicit },
}).label,
).toBe(label);
});
it("explains when hub publication permits public IPs", () => {
expect(
getMCPNetworkAccess({
available_on_public_internet: false,
mcp_info: { server_name: "demo", is_public_explicit: true },
}).description,
).toContain("because this server is published in MCP Hub");
});
});
describe("extractMCPToken", () => {
it("should extract token after /mcp/", () => {
const result = extractMCPToken("https://example.com/mcp/abc123");

View file

@ -1,4 +1,37 @@
import { MCPEnvVar, MCPEnvVarScope } from "@/components/mcp_tools/types";
import { MCPEnvVar, MCPEnvVarScope, type MCPServer } from "@/components/mcp_tools/types";
export const getMCPNetworkAccess = (
server: Pick<MCPServer, "available_on_public_internet" | "mcp_info">,
): {
readonly label: "All Networks" | "Internal Only" | "Unknown";
readonly dotClassName: string;
readonly description: string;
} => {
const explicitlyPublished = server.mcp_info?.is_public_explicit;
if (server.available_on_public_internet === true || explicitlyPublished === true) {
return {
label: "All Networks",
dotClassName: "bg-success",
description:
server.available_on_public_internet === true
? "Allows requests from public and internal IPs. Authentication and access permissions still apply"
: "Allows requests from public and internal IPs because this server is published in MCP Hub. Authentication and access permissions still apply",
};
}
if (server.available_on_public_internet === false && explicitlyPublished === false) {
return {
label: "Internal Only",
dotClassName: "bg-warning",
description:
"Allows requests only from internal/private IP ranges. Authentication and access permissions still apply",
};
}
return {
label: "Unknown",
dotClassName: "bg-border",
description: "The proxy did not report enough network and publication settings to determine allowed client IPs",
};
};
export const extractMCPToken = (url: string): { token: string | null; baseUrl: string } => {
try {

View file

@ -1,4 +1,4 @@
import { render, screen } from "@testing-library/react";
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { DataTable } from "@/components/shared/DataTable";
@ -63,6 +63,23 @@ describe("getMCPHubTableColumns", () => {
expect(screen.getByText("Auth Type")).toBeInTheDocument();
});
it("shows hub membership separately from the network setting", () => {
renderTable(vi.fn(), [
{ ...mockServer, available_on_public_internet: false, mcp_info: { is_public: true } },
{
...mockServer,
server_id: "network-only",
server_name: "Network-only server",
available_on_public_internet: true,
mcp_info: { is_public: false },
},
]);
expect(screen.getByText("Hub listing")).toBeInTheDocument();
expect(within(screen.getByRole("row", { name: /exa_test/ })).getByText("Listed")).toBeInTheDocument();
expect(within(screen.getByRole("row", { name: /Network-only server/ })).getByText("Unlisted")).toBeInTheDocument();
});
it("does not expose a URL column", () => {
renderTable();
expect(screen.queryByText("URL")).not.toBeInTheDocument();

View file

@ -203,8 +203,8 @@ 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" />,
meta: { title: "Hub listing", skeleton: "badge", className: "hidden md:table-cell" },
header: ({ column }) => <DataTableSortHeader column={column} title="Hub listing" />,
size: 100,
enableSorting: true,
sortingFn: (rowA, rowB) => {
@ -214,7 +214,7 @@ export const getMCPHubTableColumns = ({ onServerClick }: MCPHubTableColumnsDeps)
},
cell: ({ row }) => {
const isPublic = row.original.mcp_info?.is_public === true;
return <StatusBadge tone={isPublic ? "success" : "neutral"} label={isPublic ? "Yes" : "No"} />;
return <StatusBadge tone={isPublic ? "success" : "neutral"} label={isPublic ? "Listed" : "Unlisted"} />;
},
},
{

View file

@ -1,5 +1,7 @@
import * as networking from "@/components/networking";
import userEvent from "@testing-library/user-event";
import { act } from "@testing-library/react";
import type { MCPServerData } from "./MCPHubTableColumns";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders, screen, waitFor } from "../../../tests/test-utils";
import ModelHubTable from "./ModelHubTable";
@ -18,6 +20,7 @@ vi.mock("@/components/networking", () => ({
getProxyBaseUrl: vi.fn(() => "http://localhost:4000"),
getAgentsList: vi.fn(),
fetchMCPServers: vi.fn(),
makeMCPPublicCall: vi.fn(),
getUiSettings: vi.fn(),
getClaudeCodePluginsList: vi.fn(() => Promise.resolve({ plugins: [] })),
}));
@ -202,13 +205,13 @@ describe("ModelHubTable", () => {
});
describe("hub tabs", () => {
const renderHub = async (agents: object[] = []) => {
const renderHub = async (agents: object[] = [], mcpServers: Promise<MCPServerData[]> = Promise.resolve([])) => {
vi.mocked(networking.modelHubCall).mockResolvedValue({
data: [{ model_group: "claude-opus-4-8", providers: ["anthropic"], mode: "chat" }],
});
vi.mocked(networking.getConfigFieldSetting).mockResolvedValue({ field_value: false });
vi.mocked(networking.getAgentsList).mockResolvedValue({ agents });
vi.mocked(networking.fetchMCPServers).mockResolvedValue([]);
vi.mocked(networking.fetchMCPServers).mockReturnValue(mcpServers);
vi.mocked(networking.getUiSettings).mockResolvedValue({ values: {} });
mockUseUISettings.mockReturnValue({ data: { values: {} }, isLoading: false });
@ -219,6 +222,29 @@ describe("ModelHubTable", () => {
return { user, search: await screen.findByPlaceholderText("Search model names...") };
};
it("requires a fresh MCP publication list before and after saving", async () => {
const servers = Promise.withResolvers<MCPServerData[]>();
const { user } = await renderHub([], servers.promise);
await user.click(screen.getByRole("tab", { name: "MCP Hub" }));
const manageVisibility = screen.getByRole("button", { name: "Manage MCP Hub Visibility" });
expect(manageVisibility).toBeDisabled();
await act(async () => servers.resolve([]));
expect(manageVisibility).toBeEnabled();
const refresh = Promise.withResolvers<MCPServerData[]>();
vi.mocked(networking.makeMCPPublicCall).mockResolvedValueOnce({});
vi.mocked(networking.fetchMCPServers).mockReturnValueOnce(refresh.promise);
await user.click(manageVisibility);
await user.click(screen.getByRole("button", { name: "Next" }));
await user.click(screen.getByRole("button", { name: "Save Publication List" }));
expect(networking.makeMCPPublicCall).toHaveBeenCalledWith("test-token", []);
expect(manageVisibility).toBeDisabled();
await act(async () => refresh.reject(new Error("Unable to reload the publication list")));
expect(manageVisibility).toBeDisabled();
});
it("keeps the model filter typed on the Model Hub tab after visiting another hub", async () => {
const { user, search } = await renderHub();

View file

@ -49,6 +49,10 @@ interface ModelHubTableProps {
userRole: string | null;
}
function isMCPHubVisibilityDisabled(isLoading: boolean, servers: readonly MCPServerData[] | null): boolean {
return isLoading || servers === null;
}
function HubEmptyState({ title, body }: { title: string; body: string }) {
return (
<div className="flex flex-col items-center gap-1 py-6">
@ -359,10 +363,14 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({ accessToken, publicPage,
if (accessToken) {
const fetchMcpData = async () => {
try {
setMcpLoading(true);
const response = await fetchMCPServers(accessToken);
setMcpHubData(response);
} catch (error) {
setMcpHubData(null);
console.error("Error refreshing MCP server data:", error);
} finally {
setMcpLoading(false);
}
};
fetchMcpData();
@ -567,7 +575,12 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({ accessToken, publicPage,
{/* Header with Make Public Button */}
{publicPage == false && canModify && (
<div className="flex justify-end mb-4">
<Button onClick={() => handleMakeMcpPublicPage()}>Select MCP Servers to Make Public</Button>
<Button
onClick={() => handleMakeMcpPublicPage()}
disabled={isMCPHubVisibilityDisabled(mcpLoading, mcpHubData)}
>
Manage MCP Hub Visibility
</Button>
</div>
)}

View file

@ -1,6 +1,8 @@
import { render, screen, fireEvent, act, waitFor } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import MakeMCPPublicForm from "./MakeMCPPublicForm";
import userEvent from "@testing-library/user-event";
import { toast } from "@/lib/toast";
import { MCPServerData } from "@/components/AIHub/MCPHubTableColumns";
// Mock the networking function
@ -8,6 +10,10 @@ vi.mock("../../networking", () => ({
makeMCPPublicCall: vi.fn(),
}));
vi.mock("@/lib/toast", () => ({
toast: { success: vi.fn(), fromError: vi.fn() },
}));
// Import the mocked function
import { makeMCPPublicCall } from "../../networking";
const mockMakeMCPPublicCall = vi.mocked(makeMCPPublicCall);
@ -28,7 +34,7 @@ describe("MakeMCPPublicForm", () => {
url: "http://example.com/server1",
transport: "http",
status: "active",
mcp_info: { is_public: false },
mcp_info: { is_public: false, is_public_explicit: false },
allowed_tools: ["tool-1", "tool-2"],
auth_type: "bearer",
credentials: {},
@ -50,7 +56,7 @@ describe("MakeMCPPublicForm", () => {
url: "http://example.com/server2",
transport: "websocket",
status: "inactive",
mcp_info: { is_public: true },
mcp_info: { is_public: true, is_public_explicit: true },
allowed_tools: [],
auth_type: "none",
credentials: {},
@ -80,16 +86,16 @@ describe("MakeMCPPublicForm", () => {
it("should render the component", () => {
render(<MakeMCPPublicForm {...mockProps} />);
expect(screen.getByText("Make MCP Servers Public")).toBeInTheDocument();
expect(screen.getByText("Select MCP Servers to Make Public")).toBeInTheDocument();
expect(screen.getByText("Manage MCP Hub Visibility")).toBeInTheDocument();
expect(screen.getByText("Select MCP Servers for the Hub")).toBeInTheDocument();
});
it("should initialize with correct state", () => {
render(<MakeMCPPublicForm {...mockProps} />);
// Check that the component renders with the correct title and content
expect(screen.getByText("Make MCP Servers Public")).toBeInTheDocument();
expect(screen.getByText("Select MCP Servers to Make Public")).toBeInTheDocument();
expect(screen.getByText("Manage MCP Hub Visibility")).toBeInTheDocument();
expect(screen.getByText("Select MCP Servers for the Hub")).toBeInTheDocument();
// Check that all server checkboxes are present
const checkboxes = screen.getAllByRole("checkbox");
@ -104,7 +110,7 @@ describe("MakeMCPPublicForm", () => {
render(<MakeMCPPublicForm {...mockProps} />);
// Initially on step 1
expect(screen.getByText("Select MCP Servers to Make Public")).toBeInTheDocument();
expect(screen.getByText("Select MCP Servers for the Hub")).toBeInTheDocument();
// Select all servers using the select all checkbox
const selectAllCheckbox = screen.getByRole("checkbox", { name: "Select All (2)" });
@ -123,7 +129,7 @@ describe("MakeMCPPublicForm", () => {
// Should move to step 2
await waitFor(() => {
expect(screen.getByText("Confirm Making MCP Servers Public")).toBeInTheDocument();
expect(screen.getByText("Confirm MCP Hub Publication")).toBeInTheDocument();
});
});
@ -145,10 +151,10 @@ describe("MakeMCPPublicForm", () => {
// Wait for navigation to complete
await waitFor(() => {
expect(screen.getByText("Confirm Making MCP Servers Public")).toBeInTheDocument();
expect(screen.getByText("Confirm MCP Hub Publication")).toBeInTheDocument();
});
const submitButton = screen.getByRole("button", { name: "Make Public" });
const submitButton = screen.getByRole("button", { name: "Save Publication List" });
await act(async () => {
fireEvent.click(submitButton);
});
@ -187,29 +193,105 @@ describe("MakeMCPPublicForm", () => {
expect(checkboxes[2]).not.toBeChecked();
});
it("should show error when no servers selected", async () => {
it("submits an empty publication list after the last server is deselected", async () => {
mockMakeMCPPublicCall.mockResolvedValueOnce({});
render(<MakeMCPPublicForm {...mockProps} />);
// Deselect all servers first
const checkboxes = screen.getAllByRole("checkbox");
await act(async () => {
fireEvent.click(checkboxes[0]); // Click select all to select all
});
await act(async () => {
fireEvent.click(checkboxes[0]); // Click select all again to deselect all
});
fireEvent.click(screen.getAllByRole("checkbox")[2]);
expect(screen.getByRole("button", { name: "Next" })).toBeEnabled();
fireEvent.click(screen.getByRole("button", { name: "Next" }));
fireEvent.click(screen.getByRole("button", { name: "Save Publication List" }));
// Try to go to next step
const nextButton = screen.getByRole("button", { name: "Next" });
await act(async () => {
fireEvent.click(nextButton);
});
// Should stay on same step
expect(screen.getByText("Select MCP Servers to Make Public")).toBeInTheDocument();
await waitFor(() => expect(mockMakeMCPPublicCall).toHaveBeenCalledWith("test-token", []));
expect(mockProps.onSuccess).toHaveBeenCalled();
});
it("should display empty state when no servers are available", () => {
it("keeps legacy listings separate from explicitly published selections", () => {
render(
<MakeMCPPublicForm
{...mockProps}
mcpHubData={[
{ ...mockProps.mcpHubData[0], mcp_info: { is_public: true, is_public_explicit: false } },
{ ...mockProps.mcpHubData[1], mcp_info: { is_public: true, is_public_explicit: true } },
]}
/>,
);
expect(screen.getAllByRole("checkbox")[1]).not.toBeChecked();
expect(screen.getAllByRole("checkbox")[2]).toBeChecked();
expect(screen.getByText("Listed by legacy mode")).toBeInTheDocument();
});
it.each([
{ mode: "all missing, stale true", info: { is_public: true }, mixed: false },
{ mode: "all missing, stale false", info: { is_public: false }, mixed: false },
{ mode: "mixed, stale true", info: { is_public: true }, mixed: true },
{ mode: "mixed, stale false", info: { is_public: false }, mixed: true },
{ mode: "null explicit status", info: { is_public: true, is_public_explicit: null }, mixed: true },
{ mode: "nonboolean explicit status", info: { is_public: true, is_public_explicit: "true" }, mixed: true },
])("blocks unknown explicit publication metadata: $mode", ({ info, mixed }) => {
const unknownServer = { ...mockProps.mcpHubData[0], mcp_info: info };
const catalog = mixed ? [unknownServer, mockProps.mcpHubData[1]] : [unknownServer];
render(<MakeMCPPublicForm {...mockProps} mcpHubData={catalog} />);
expect(screen.getByRole("alert")).toHaveTextContent("explicit publication status");
expect(screen.queryByRole("checkbox")).not.toBeInTheDocument();
expect(screen.queryByText("Configure in YAML")).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Copy code" })).not.toBeInTheDocument();
const nextButton = screen.getByRole("button", { name: "Next" });
expect(nextButton).toBeDisabled();
fireEvent.click(nextButton);
expect(screen.queryByText("Confirm MCP Hub Publication")).not.toBeInTheDocument();
expect(mockMakeMCPPublicCall).not.toHaveBeenCalled();
});
it.each([true, false])("blocks confirmation when explicit metadata disappears with stale listing %s", (listed) => {
const { rerender } = render(<MakeMCPPublicForm {...mockProps} />);
fireEvent.click(screen.getByRole("button", { name: "Next" }));
expect(screen.getByRole("button", { name: "Save Publication List" })).toBeEnabled();
const catalog = [{ ...mockProps.mcpHubData[0], mcp_info: { is_public: listed } }, mockProps.mcpHubData[1]];
rerender(<MakeMCPPublicForm {...mockProps} mcpHubData={catalog} />);
expect(screen.getByRole("alert")).toHaveTextContent("explicit publication status");
expect(screen.queryByText("Confirm MCP Hub Publication")).not.toBeInTheDocument();
const saveButton = screen.getByRole("button", { name: "Save Publication List" });
expect(saveButton).toBeDisabled();
fireEvent.click(saveButton);
expect(mockMakeMCPPublicCall).not.toHaveBeenCalled();
expect(screen.queryByRole("button", { name: "Copy code" })).not.toBeInTheDocument();
const refreshedCatalog = [
{ ...mockProps.mcpHubData[0], mcp_info: { is_public: true, is_public_explicit: true } },
{ ...mockProps.mcpHubData[1], mcp_info: { is_public: false, is_public_explicit: false } },
];
rerender(<MakeMCPPublicForm {...mockProps} mcpHubData={refreshedCatalog} />);
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Next" })).toBeEnabled();
expect(screen.getByRole("checkbox", { name: "Publish Test Server 1" })).toBeChecked();
expect(screen.getByRole("checkbox", { name: "Publish Test Server 2" })).not.toBeChecked();
});
it("copies publication YAML using the selected server IDs", async () => {
const user = userEvent.setup();
render(<MakeMCPPublicForm {...mockProps} />);
await user.click(screen.getByText("Configure in YAML"));
await user.click(screen.getByRole("button", { name: "Copy code" }));
expect(await navigator.clipboard.readText()).toBe(
'litellm_settings:\n public_mcp_hub_strict_whitelist: true\n public_mcp_servers:\n - "server-2"',
);
await user.click(screen.getByRole("checkbox", { name: "Publish Test Server 2" }));
await user.click(screen.getByRole("button", { name: "Copy code" }));
expect(await navigator.clipboard.readText()).toBe(
"litellm_settings:\n public_mcp_hub_strict_whitelist: true\n public_mcp_servers: []",
);
});
it("allows clearing publication IDs when the loaded server catalog is empty", async () => {
mockMakeMCPPublicCall.mockResolvedValueOnce({});
const emptyProps = {
...mockProps,
mcpHubData: [] as MCPServerData[],
@ -223,9 +305,13 @@ describe("MakeMCPPublicForm", () => {
const selectAllCheckbox = screen.getByRole("checkbox", { name: "Select All" });
expectDisabledControl(selectAllCheckbox);
// Next button should be disabled
const nextButton = screen.getByRole("button", { name: "Next" });
expect(nextButton).toBeDisabled();
expect(nextButton).toBeEnabled();
fireEvent.click(nextButton);
fireEvent.click(screen.getByRole("button", { name: "Save Publication List" }));
await waitFor(() => expect(mockMakeMCPPublicCall).toHaveBeenCalledWith("test-token", []));
expect(mockProps.onSuccess).toHaveBeenCalled();
});
it("should handle Cancel button functionality", async () => {
@ -252,7 +338,7 @@ describe("MakeMCPPublicForm", () => {
// Verify we're on step 1
await waitFor(() => {
expect(screen.getByText("Confirm Making MCP Servers Public")).toBeInTheDocument();
expect(screen.getByText("Confirm MCP Hub Publication")).toBeInTheDocument();
});
// Click Previous button
@ -262,7 +348,7 @@ describe("MakeMCPPublicForm", () => {
});
// Should go back to step 0
expect(screen.getByText("Select MCP Servers to Make Public")).toBeInTheDocument();
expect(screen.getByText("Select MCP Servers for the Hub")).toBeInTheDocument();
});
it("should handle individual server selection", async () => {
@ -322,8 +408,8 @@ describe("MakeMCPPublicForm", () => {
});
it("should handle submit error properly", async () => {
const errorMessage = "Network error";
mockMakeMCPPublicCall.mockRejectedValueOnce(new Error(errorMessage));
const error = new Error("Update litellm_settings.public_mcp_servers in your YAML configuration");
mockMakeMCPPublicCall.mockRejectedValueOnce(error);
render(<MakeMCPPublicForm {...mockProps} />);
@ -333,10 +419,10 @@ describe("MakeMCPPublicForm", () => {
});
await waitFor(() => {
expect(screen.getByText("Confirm Making MCP Servers Public")).toBeInTheDocument();
expect(screen.getByText("Confirm MCP Hub Publication")).toBeInTheDocument();
});
const submitButton = screen.getByRole("button", { name: "Make Public" });
const submitButton = screen.getByRole("button", { name: "Save Publication List" });
await act(async () => {
fireEvent.click(submitButton);
});
@ -346,6 +432,8 @@ describe("MakeMCPPublicForm", () => {
expect(mockMakeMCPPublicCall).toHaveBeenCalledWith("test-token", ["server-2"]);
});
expect(toast.fromError).toHaveBeenCalledWith(error);
// Should not call onSuccess or onClose on error
expect(mockProps.onSuccess).not.toHaveBeenCalled();
expect(mockProps.onClose).not.toHaveBeenCalled();
@ -366,10 +454,10 @@ describe("MakeMCPPublicForm", () => {
});
await waitFor(() => {
expect(screen.getByText("Confirm Making MCP Servers Public")).toBeInTheDocument();
expect(screen.getByText("Confirm MCP Hub Publication")).toBeInTheDocument();
});
const submitButton = screen.getByRole("button", { name: "Make Public" });
const submitButton = screen.getByRole("button", { name: "Save Publication List" });
await act(async () => {
fireEvent.click(submitButton);
});
@ -381,7 +469,7 @@ describe("MakeMCPPublicForm", () => {
expect(mockMakeMCPPublicCall).toHaveBeenCalledTimes(1);
expect(mockProps.onSuccess).not.toHaveBeenCalled();
expect(mockProps.onClose).not.toHaveBeenCalled();
expect(screen.getByText("Confirm Making MCP Servers Public")).toBeInTheDocument();
expect(screen.getByText("Confirm MCP Hub Publication")).toBeInTheDocument();
resolvePromise({});
await waitFor(() => {
@ -400,7 +488,7 @@ describe("MakeMCPPublicForm", () => {
// Modal should not be rendered
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
expect(screen.queryByText("Make MCP Servers Public")).not.toBeInTheDocument();
expect(screen.queryByText("Manage MCP Hub Visibility")).not.toBeInTheDocument();
});
it("should preselect already public servers when modal opens", () => {
@ -415,7 +503,7 @@ describe("MakeMCPPublicForm", () => {
url: "http://example.com/server1",
transport: "http",
status: "active",
mcp_info: { is_public: false }, // Not public
mcp_info: { is_public: false, is_public_explicit: false }, // Not public
allowed_tools: [],
auth_type: "bearer",
credentials: {},
@ -437,7 +525,7 @@ describe("MakeMCPPublicForm", () => {
url: "http://example.com/server2",
transport: "websocket",
status: "inactive",
mcp_info: { is_public: true }, // Already public
mcp_info: { is_public: true, is_public_explicit: true }, // Already public
allowed_tools: [],
auth_type: "none",
credentials: {},
@ -459,7 +547,7 @@ describe("MakeMCPPublicForm", () => {
url: "http://example.com/server3",
transport: "sse",
status: "healthy",
mcp_info: { is_public: true }, // Already public
mcp_info: { is_public: true, is_public_explicit: true }, // Already public
allowed_tools: [],
auth_type: "oauth",
credentials: {},

View file

@ -1,5 +1,6 @@
import React, { useState, useEffect } from "react";
import { Loader2 } from "lucide-react";
import CodeBlock from "@/components/CodeBlock";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
@ -29,6 +30,11 @@ interface MakeMCPPublicFormProps {
onSuccess: () => void;
}
interface PublicationSelection {
readonly catalog: MCPServerData[];
readonly serverIds: Set<string>;
}
const MakeMCPPublicForm: React.FC<MakeMCPPublicFormProps> = ({
visible,
onClose,
@ -37,21 +43,28 @@ const MakeMCPPublicForm: React.FC<MakeMCPPublicFormProps> = ({
onSuccess,
}) => {
const [currentStep, setCurrentStep] = useState(0);
const [selectedServers, setSelectedServers] = useState<Set<string>>(new Set());
const [selection, setSelection] = useState<PublicationSelection | null>(null);
const [loading, setLoading] = useState(false);
const selectedServers = selection?.serverIds ?? new Set<string>();
const hasPublicationMetadata = mcpHubData.every((server) => typeof server.mcp_info?.is_public_explicit === "boolean");
const canManagePublication = hasPublicationMetadata && selection?.catalog === mcpHubData;
const publicationYaml = [
"litellm_settings:",
" public_mcp_hub_strict_whitelist: true",
selectedServers.size === 0
? " public_mcp_servers: []"
: ` public_mcp_servers:\n${Array.from(selectedServers, (id) => ` - ${JSON.stringify(id)}`).join("\n")}`,
].join("\n");
const handleClose = () => {
setCurrentStep(0);
setSelectedServers(new Set());
setSelection(null);
onClose();
};
const handleNext = () => {
if (!canManagePublication) return;
if (currentStep === 0) {
if (selectedServers.size === 0) {
toast.fromError("Please select at least one MCP server to make public");
return;
}
setCurrentStep(1);
}
};
@ -69,37 +82,32 @@ const MakeMCPPublicForm: React.FC<MakeMCPPublicFormProps> = ({
} else {
newSelection.delete(serverId);
}
setSelectedServers(newSelection);
setSelection({ catalog: mcpHubData, serverIds: newSelection });
};
const handleSelectAll = (checked: boolean) => {
if (checked) {
const allServerIds = mcpHubData.map((server) => server.server_id);
setSelectedServers(new Set(allServerIds));
setSelection({ catalog: mcpHubData, serverIds: new Set(allServerIds) });
} else {
setSelectedServers(new Set());
setSelection({ catalog: mcpHubData, serverIds: new Set() });
}
};
// Initialize and preselect already public servers when modal opens
useEffect(() => {
if (visible && mcpHubData.length > 0) {
// Extract server IDs from servers that are already public
const publicServerIds = mcpHubData
.filter((server) => server.mcp_info?.is_public === true)
.map((server) => server.server_id);
// Preselect servers that are already public
setSelectedServers(new Set(publicServerIds));
}
}, [visible]); // Only re-run when modal visibility changes, not when mcpHubData updates
const handleSubmit = async () => {
if (selectedServers.size === 0) {
toast.fromError("Please select at least one MCP server to make public");
if (!visible || !hasPublicationMetadata) {
setSelection(null);
return;
}
const publicServerIds = mcpHubData
.filter((server) => server.mcp_info.is_public_explicit === true)
.map((server) => server.server_id);
setSelection({ catalog: mcpHubData, serverIds: new Set(publicServerIds) });
setCurrentStep(0);
}, [visible, mcpHubData, hasPublicationMetadata]);
const handleSubmit = async () => {
if (!canManagePublication) return;
setLoading(true);
try {
const serverIdsToMakePublic = Array.from(selectedServers);
@ -107,12 +115,12 @@ const MakeMCPPublicForm: React.FC<MakeMCPPublicFormProps> = ({
// Make batch API call for all servers
await makeMCPPublicCall(accessToken, serverIdsToMakePublic);
toast.success(`Successfully made ${serverIdsToMakePublic.length} MCP server(s) public!`);
toast.success("MCP Hub publication list updated");
handleClose();
onSuccess();
} catch (error) {
console.error("Error making MCP servers public:", error);
toast.fromError("Failed to make MCP servers public. Please try again.");
toast.fromError(error);
} finally {
setLoading(false);
}
@ -126,7 +134,7 @@ const MakeMCPPublicForm: React.FC<MakeMCPPublicFormProps> = ({
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">Select MCP Servers to Make Public</h3>
<h3 className="text-lg font-semibold">Select MCP Servers for the Hub</h3>
<div className="flex items-center space-x-2">
<label className="flex items-center gap-2 text-sm">
<Checkbox
@ -141,8 +149,13 @@ const MakeMCPPublicForm: React.FC<MakeMCPPublicFormProps> = ({
</div>
<p className="text-sm text-muted-foreground">
Select the MCP servers you want to be visible on the public model hub. Users will still require a valid
Virtual Key to use these servers.
Select the complete list of MCP servers to publish on the public hub. Uncheck a server to remove it from this
list, or uncheck all to clear it. Authentication and access permissions still apply
</p>
<p className="text-xs text-muted-foreground">
Legacy mode also lists servers with public IP access enabled. Set public_mcp_hub_strict_whitelist to true in
your configuration to use only the publication list
</p>
<div className="max-h-96 overflow-y-auto border rounded-lg p-4">
@ -160,16 +173,22 @@ const MakeMCPPublicForm: React.FC<MakeMCPPublicFormProps> = ({
className="flex items-center space-x-3 p-3 border rounded-lg hover:bg-accent"
>
<Checkbox
aria-label={`Publish ${server.server_name}`}
checked={selectedServers.has(server.server_id)}
onCheckedChange={(checked) => handleServerSelection(server.server_id, checked === true)}
/>
<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>
{server.mcp_info?.is_public_explicit === false ? "Listed by legacy mode" : "Listed"}
</Badge>
)}
<Badge variant="secondary">{server.transport}</Badge>
<Badge variant={statusVariant(server.status)}>{server.status || "unknown"}</Badge>
</div>
<p className="text-xs font-mono text-muted-foreground mt-1 break-all">{server.server_id}</p>
<p className="text-xs text-muted-foreground mt-1 break-words">
{server.description || server.url}
</p>
@ -193,6 +212,18 @@ const MakeMCPPublicForm: React.FC<MakeMCPPublicFormProps> = ({
</div>
</div>
<details className="rounded-lg border p-3">
<summary className="cursor-pointer text-sm font-medium">Configure in YAML</summary>
<div className="mt-3 space-y-3">
<p className="text-sm text-muted-foreground">
Merge these settings into your proxy configuration and reload it. Entries use the server IDs shown above,
not names or aliases. For servers defined in YAML, pin server_id in each existing mcp_servers entry so the
publication list stays stable
</p>
<CodeBlock code={publicationYaml} language="yaml" />
</div>
</details>
{selectedServers.size > 0 && (
<div className="bg-info/10 border border-info/20 rounded-lg p-3">
<p className="text-sm text-info">
@ -207,19 +238,20 @@ const MakeMCPPublicForm: React.FC<MakeMCPPublicFormProps> = ({
const renderStep2Content = () => {
return (
<div className="space-y-4">
<h3 className="text-lg font-semibold">Confirm Making MCP Servers Public</h3>
<h3 className="text-lg font-semibold">Confirm MCP Hub Publication</h3>
<div className="bg-warning/10 border border-warning/20 rounded-lg p-4">
<p className="text-sm text-warning">
<strong>Warning:</strong> Once you make these MCP servers public, anyone who can go to the{" "}
<code>/ui/model_hub_table</code> will be able to know they exist on the proxy.
Anyone who can open <code>/ui/model_hub_table</code> can discover published servers. Explicitly published
server IDs also allow requests from public IPs. Authentication and access permissions still apply
</p>
</div>
<div className="space-y-3">
<p className="font-medium">MCP Servers to be made public:</p>
<p className="font-medium">MCP servers in the publication list:</p>
<div className="max-h-48 overflow-y-auto border rounded-lg p-3">
<div className="space-y-2">
{selectedServers.size === 0 && <p className="text-sm">No explicitly published servers</p>}
{Array.from(selectedServers).map((serverId) => {
const server = mcpHubData.find((s) => s.server_id === serverId);
return (
@ -248,8 +280,8 @@ const MakeMCPPublicForm: React.FC<MakeMCPPublicFormProps> = ({
<div className="bg-info/10 border border-info/20 rounded-lg p-3">
<p className="text-sm text-info">
Total: <strong>{selectedServers.size}</strong> MCP server{selectedServers.size !== 1 ? "s" : ""} will be
made public
Saving replaces the publication list with <strong>{selectedServers.size}</strong> MCP server
{selectedServers.size !== 1 ? "s" : ""}. Legacy mode may still list servers with public IP access enabled
</p>
</div>
</div>
@ -257,6 +289,15 @@ const MakeMCPPublicForm: React.FC<MakeMCPPublicFormProps> = ({
};
const renderStepContent = () => {
if (!hasPublicationMetadata) {
return (
<div role="alert" className="rounded-lg border border-warning/20 bg-warning/10 p-4 text-sm">
This proxy does not provide explicit publication status for every MCP server. Update the proxy to manage
visibility here, or edit litellm_settings.public_mcp_servers in its existing configuration
</div>
);
}
if (!canManagePublication) return <p role="status">Loading publication settings</p>;
switch (currentStep) {
case 0:
return renderStep1Content();
@ -276,15 +317,15 @@ const MakeMCPPublicForm: React.FC<MakeMCPPublicFormProps> = ({
<div className="flex space-x-2">
{currentStep === 0 && (
<Button onClick={handleNext} disabled={selectedServers.size === 0}>
<Button onClick={handleNext} disabled={!canManagePublication}>
Next
</Button>
)}
{currentStep === 1 && (
<Button onClick={handleSubmit} disabled={loading}>
<Button onClick={handleSubmit} disabled={loading || !canManagePublication}>
{loading && <Loader2 className="size-4 animate-spin" />}
Make Public
Save Publication List
</Button>
)}
</div>
@ -296,7 +337,7 @@ const MakeMCPPublicForm: React.FC<MakeMCPPublicFormProps> = ({
<Dialog open={visible} onOpenChange={(open) => !open && handleClose()} disablePointerDismissal>
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-[1200px]">
<DialogHeader>
<DialogTitle>Make MCP Servers Public</DialogTitle>
<DialogTitle>Manage MCP Hub Visibility</DialogTitle>
</DialogHeader>
<div>

View file

@ -321,6 +321,8 @@ export interface MCPServerCostInfo {
// Define MCP provider info
export interface MCPInfo {
server_name: string;
is_public?: boolean;
is_public_explicit?: boolean;
description?: string;
logo_url?: string;
mcp_server_cost_info?: MCPServerCostInfo | null;