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