From 8def4fd9eacd3907d6d423e2a0b8826de7a98f24 Mon Sep 17 00:00:00 2001 From: Tin Chi Lo Date: Mon, 22 Jun 2026 10:11:39 -0700 Subject: [PATCH] fix(mcp): stop exposing MCP server URLs on the AI Hub and public hub API The AI Hub MCP Hub listed each MCP server's upstream URL in a table column and in the server Details modal, on both the authenticated dashboard and the public hub. The unauthenticated GET /public/mcp_hub endpoint also returned the url field via MCPPublicServer, so the upstream address was readable by any client even with the column gone. These surfaces are for end users discovering available servers, so the gateway-internal endpoint should not be exposed there. Drop the URL column from both MCP Hub tables and the URL field from both detail modals, and remove url from MCPPublicServer so /public/mcp_hub no longer serialises it; schema.d.ts is updated to match. The public hub MCPServerData interface no longer declares url since the response omits it. Admin surfaces that configure the endpoint (the MCP server management page, the submissions review tab, the make-public form) and the authenticated /v1/mcp/server endpoint are untouched. publicMCPHubColumns is lifted to a module-level export so both hub column sets get a mutation-killing regression test, public_model_hub.test.tsx covers the details modal hiding the url, and test_public_endpoints.py asserts /public/mcp_hub never returns url even when the server has one. --- litellm/types/mcp.py | 1 - .../public_endpoints/test_public_endpoints.py | 41 +++++ .../src/components/AIHub/ModelHubTable.tsx | 10 - .../components/mcp_hub_table_columns.test.tsx | 81 +++++++++ .../src/components/mcp_hub_table_columns.tsx | 24 --- .../src/components/public_model_hub.test.tsx | 82 ++++++++- .../src/components/public_model_hub.tsx | 172 +++++++----------- ui/litellm-dashboard/src/lib/http/schema.d.ts | 2 - 8 files changed, 271 insertions(+), 142 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/mcp_hub_table_columns.test.tsx diff --git a/litellm/types/mcp.py b/litellm/types/mcp.py index 21d4da82041d..94e4c68f5e26 100644 --- a/litellm/types/mcp.py +++ b/litellm/types/mcp.py @@ -69,7 +69,6 @@ class MCPPublicServer(BaseModel): name: str alias: Optional[str] = None server_name: Optional[str] = None - url: Optional[str] = None transport: MCPTransportType spec_path: Optional[str] = None auth_type: Optional[MCPAuthType] = None diff --git a/tests/test_litellm/proxy/public_endpoints/test_public_endpoints.py b/tests/test_litellm/proxy/public_endpoints/test_public_endpoints.py index 48d0b1deadda..1ca28e1c4e81 100644 --- a/tests/test_litellm/proxy/public_endpoints/test_public_endpoints.py +++ b/tests/test_litellm/proxy/public_endpoints/test_public_endpoints.py @@ -819,3 +819,44 @@ def test_public_mcp_hub_returns_empty_when_whitelist_unset(): assert response.status_code == 200 assert response.json() == [] app.dependency_overrides.clear() + + +def test_public_mcp_hub_does_not_expose_upstream_url(): + """Regression: /public/mcp_hub is unauthenticated, so the gateway-internal + upstream url must never appear in its response even when the server has one.""" + from litellm.types.mcp_server.mcp_server_manager import MCPServer + from litellm.proxy._types import MCPTransport + + app = FastAPI() + app.include_router(router) + app.dependency_overrides[user_api_key_auth] = lambda: MagicMock() + client = TestClient(app) + + secret_url = "https://internal-only.example.com/mcp" + server = MCPServer( + server_id="listed", + name="listed", + server_name="listed", + url=secret_url, + transport=MCPTransport.http, + available_on_public_internet=True, + ) + + mock_manager = MagicMock() + mock_manager.get_public_mcp_servers.return_value = [server] + + with ( + patch("litellm.public_mcp_servers", ["listed"]), + patch( + "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager", + mock_manager, + ), + ): + response = client.get("/public/mcp_hub") + + assert response.status_code == 200 + data = response.json() + assert [item["server_id"] for item in data] == ["listed"] + assert all("url" not in item for item in data) + assert secret_url not in response.text + app.dependency_overrides.clear() diff --git a/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.tsx b/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.tsx index 5d171139ab59..56999cd1a835 100644 --- a/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.tsx +++ b/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.tsx @@ -935,16 +935,6 @@ print(response.choices[0].message.content)`}
Connection Details
-
- URL: -
- {selectedMcpServer.url} - copyToClipboard(selectedMcpServer.url)} - className="cursor-pointer text-gray-500 hover:text-blue-500 flex-shrink-0" - /> -
-
{selectedMcpServer.command && (
Command: diff --git a/ui/litellm-dashboard/src/components/mcp_hub_table_columns.test.tsx b/ui/litellm-dashboard/src/components/mcp_hub_table_columns.test.tsx new file mode 100644 index 000000000000..ae48f140abdf --- /dev/null +++ b/ui/litellm-dashboard/src/components/mcp_hub_table_columns.test.tsx @@ -0,0 +1,81 @@ +import { render, screen } from "@testing-library/react"; +import { vi } from "vitest"; +import { flexRender, getCoreRowModel, useReactTable } from "@tanstack/react-table"; +import { mcpHubColumns, MCPServerData } from "./mcp_hub_table_columns"; + +const SERVER_URL = "https://mcp.exa.ai/mcp"; + +const mockServer: MCPServerData = { + server_id: "server-1", + server_name: "exa_test", + description: "Fast, intelligent web search and web crawling", + url: SERVER_URL, + transport: "http", + auth_type: "none", + created_at: "2026-01-01T00:00:00Z", + created_by: "admin", + updated_at: "2026-01-01T00:00:00Z", + updated_by: "admin", + teams: [], + mcp_access_groups: [], + allowed_tools: [], + extra_headers: [], + mcp_info: {}, + static_headers: {}, + status: "active", + args: [], + env: {}, +}; + +function TestTable({ data }: { data: MCPServerData[] }) { + const columns = mcpHubColumns(vi.fn(), vi.fn(), false); + const table = useReactTable({ data, columns, getCoreRowModel: getCoreRowModel() }); + + return ( + + + {table.getHeaderGroups().map((hg) => ( + + {hg.headers.map((h) => ( + + ))} + + ))} + + + {table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + ))} + + ))} + +
{flexRender(h.column.columnDef.header, h.getContext())}
{flexRender(cell.column.columnDef.cell, cell.getContext())}
+ ); +} + +describe("mcpHubColumns", () => { + it("renders the server row", () => { + render(); + expect(screen.getByText("exa_test")).toBeInTheDocument(); + }); + + it("keeps the non-sensitive columns", () => { + render(); + expect(screen.getByText("Server Name")).toBeInTheDocument(); + expect(screen.getByText("Transport")).toBeInTheDocument(); + expect(screen.getByText("Auth Type")).toBeInTheDocument(); + }); + + it("does not expose a URL column header", () => { + render(); + expect(screen.queryByText("URL")).not.toBeInTheDocument(); + expect(mcpHubColumns(vi.fn(), vi.fn(), false).some((c) => c.header === "URL")).toBe(false); + }); + + it("does not render the server url anywhere in the table", () => { + render(); + expect(screen.queryByText(SERVER_URL)).not.toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/mcp_hub_table_columns.tsx b/ui/litellm-dashboard/src/components/mcp_hub_table_columns.tsx index 528780b4ef50..7cf0d48a49f3 100644 --- a/ui/litellm-dashboard/src/components/mcp_hub_table_columns.tsx +++ b/ui/litellm-dashboard/src/components/mcp_hub_table_columns.tsx @@ -78,30 +78,6 @@ export const mcpHubColumns = ( className: "hidden md:table-cell", }, }, - { - header: "URL", - accessorKey: "url", - enableSorting: true, - sortingFn: "alphanumeric", - cell: ({ row }) => { - const server = row.original; - - return ( -
- {server.url} - - copyToClipboard(server.url)} - className="cursor-pointer text-gray-500 hover:text-blue-500 text-xs flex-shrink-0" - /> - -
- ); - }, - meta: { - className: "hidden lg:table-cell", - }, - }, { header: "Transport", accessorKey: "transport", diff --git a/ui/litellm-dashboard/src/components/public_model_hub.test.tsx b/ui/litellm-dashboard/src/components/public_model_hub.test.tsx index 9979fce5ebd4..9d1e31804ef9 100644 --- a/ui/litellm-dashboard/src/components/public_model_hub.test.tsx +++ b/ui/litellm-dashboard/src/components/public_model_hub.test.tsx @@ -1,6 +1,7 @@ import { describe, it, expect, vi, beforeAll, beforeEach } from "vitest"; -import { render, screen, waitFor } from "@testing-library/react"; -import PublicModelHub from "./public_model_hub"; +import { render, screen, waitFor, fireEvent } from "@testing-library/react"; +import { flexRender, getCoreRowModel, useReactTable } from "@tanstack/react-table"; +import PublicModelHub, { publicMCPHubColumns, MCPServerData } from "./public_model_hub"; vi.mock("next/navigation", () => ({ useRouter: vi.fn(() => ({ @@ -186,3 +187,80 @@ describe("PublicModelHub", () => { }); }); }); + +const PUBLIC_SERVER_URL = "https://mcp.exa.ai/mcp"; + +const mockMcpServer: MCPServerData = { + server_id: "server-1", + name: "exa_test", + server_name: "exa_test", + url: PUBLIC_SERVER_URL, + transport: "http", + auth_type: "none", + mcp_info: { server_name: "exa_test", description: "Fast, intelligent web search and web crawling" }, +}; + +function PublicMcpTestTable({ data }: { data: MCPServerData[] }) { + const columns = publicMCPHubColumns(vi.fn()); + const table = useReactTable({ data, columns, getCoreRowModel: getCoreRowModel() }); + + return ( + + + {table.getHeaderGroups().map((hg) => ( + + {hg.headers.map((h) => ( + + ))} + + ))} + + + {table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + ))} + + ))} + +
{flexRender(h.column.columnDef.header, h.getContext())}
{flexRender(cell.column.columnDef.cell, cell.getContext())}
+ ); +} + +describe("publicMCPHubColumns", () => { + it("keeps the non-sensitive columns", () => { + render(); + expect(screen.getByText("Server Name")).toBeInTheDocument(); + expect(screen.getByText("Transport")).toBeInTheDocument(); + expect(screen.getByText("Auth Type")).toBeInTheDocument(); + }); + + it("does not expose a URL column header", () => { + render(); + expect(screen.queryByText("URL")).not.toBeInTheDocument(); + expect(publicMCPHubColumns(vi.fn()).some((c) => c.header === "URL")).toBe(false); + }); + + it("does not render the server url anywhere in the table", () => { + render(); + expect(screen.queryByText(PUBLIC_SERVER_URL)).not.toBeInTheDocument(); + }); +}); + +describe("public hub MCP details modal", () => { + it("does not show the upstream url when a server is opened", async () => { + const networkingModule = await import("./networking"); + vi.mocked(networkingModule.mcpHubPublicServersCall).mockResolvedValue([mockMcpServer]); + + render(); + + fireEvent.click(await screen.findByRole("tab", { name: /MCP Hub/i })); + fireEvent.click(await screen.findByRole("button", { name: "exa_test" })); + + // "Server Overview" only exists inside the opened MCP details modal, + // so finding it proves the modal rendered and the url assertion is not vacuous. + await screen.findByText("Server Overview"); + expect(screen.queryByText(PUBLIC_SERVER_URL)).not.toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/public_model_hub.tsx b/ui/litellm-dashboard/src/components/public_model_hub.tsx index 62d8f2644ece..c91b783b0473 100644 --- a/ui/litellm-dashboard/src/components/public_model_hub.tsx +++ b/ui/litellm-dashboard/src/components/public_model_hub.tsx @@ -74,12 +74,11 @@ interface AgentCard { [key: string]: any; } -interface MCPServerData { +export interface MCPServerData { server_id: string; name: string; alias?: string | null; server_name: string; - url: string; transport: string; spec_path?: string | null; auth_type: string; @@ -96,6 +95,73 @@ interface PublicModelHubProps { isEmbedded?: boolean; // When true, hides navbar and adjusts layout for embedding in dashboard } +export const publicMCPHubColumns = (showMcpModal: (server: MCPServerData) => void): ColumnDef[] => [ + { + header: "Server Name", + accessorKey: "server_name", + enableSorting: true, + cell: ({ row }) => ( +
+ + + +
+ ), + size: 150, + }, + { + header: "Description", + accessorKey: "mcp_info.description", + enableSorting: false, + cell: ({ row }) => { + const description = String(row.original.mcp_info?.description ?? "-"); + const truncated = description.length > 80 ? description.substring(0, 80) + "..." : description; + return ( + + {truncated} + + ); + }, + size: 250, + }, + { + header: "Transport", + accessorKey: "transport", + enableSorting: true, + cell: ({ row }) => { + const transport = row.original.transport; + return ( + + {transport} + + ); + }, + size: 100, + }, + { + header: "Auth Type", + accessorKey: "auth_type", + enableSorting: true, + cell: ({ row }) => { + const authType = row.original.auth_type; + const color = authType === "none" ? "gray" : "green"; + return ( + + {authType} + + ); + }, + size: 100, + }, +]; + const PublicModelHub: React.FC = ({ accessToken, isEmbedded = false }) => { const [modelHubData, setModelHubData] = useState(null); const [agentHubData, setAgentHubData] = useState(null); @@ -889,94 +955,6 @@ const PublicModelHub: React.FC = ({ accessToken, isEmbedded }, ]; - const publicMCPHubColumns = (): ColumnDef[] => [ - { - header: "Server Name", - accessorKey: "server_name", - enableSorting: true, - cell: ({ row }) => ( -
- - - -
- ), - size: 150, - }, - { - header: "Description", - accessorKey: "mcp_info.description", - enableSorting: false, - cell: ({ row }) => { - const description = String(row.original.mcp_info?.description ?? "-"); - const truncated = description.length > 80 ? description.substring(0, 80) + "..." : description; - return ( - - {truncated} - - ); - }, - size: 250, - }, - { - header: "URL", - accessorKey: "url", - enableSorting: false, - cell: ({ row }) => { - const url = row.original.url ?? ""; - const truncated = url.length > 40 ? url.substring(0, 40) + "..." : url; - return ( - -
- {truncated} - copyToClipboard(url)} - className="cursor-pointer text-gray-500 hover:text-blue-500 w-3 h-3" - /> -
-
- ); - }, - size: 200, - }, - { - header: "Transport", - accessorKey: "transport", - enableSorting: true, - cell: ({ row }) => { - const transport = row.original.transport; - return ( - - {transport} - - ); - }, - size: 100, - }, - { - header: "Auth Type", - accessorKey: "auth_type", - enableSorting: true, - cell: ({ row }) => { - const authType = row.original.auth_type; - const color = authType === "none" ? "gray" : "green"; - return ( - - {authType} - - ); - }, - size: 100, - }, - ]; - return (
@@ -1286,7 +1264,7 @@ const PublicModelHub: React.FC = ({ accessToken, isEmbedded
Description: {selectedMcpServer.mcp_info?.description || "-"}
-
- URL: - - {selectedMcpServer.url} - - -
diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 63e788b59c89..ffb08884b939 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -26617,8 +26617,6 @@ export interface components { * @enum {string} */ transport: "sse" | "http" | "stdio"; - /** Url */ - url?: string | null; }; /** * MCPSemanticFilterSettings