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.
This commit is contained in:
Tin Chi Lo 2026-06-22 10:11:39 -07:00
parent 53593f697d
commit 8def4fd9ea
8 changed files with 271 additions and 142 deletions

View file

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

View file

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

View file

@ -935,16 +935,6 @@ print(response.choices[0].message.content)`}
<div>
<Text className="text-lg font-semibold mb-4">Connection Details</Text>
<div className="space-y-2">
<div>
<Text className="font-medium">URL:</Text>
<div className="flex items-center space-x-2 mt-1">
<Text className="text-sm break-all bg-gray-100 p-2 rounded flex-1">{selectedMcpServer.url}</Text>
<CopyOutlined
onClick={() => copyToClipboard(selectedMcpServer.url)}
className="cursor-pointer text-gray-500 hover:text-blue-500 flex-shrink-0"
/>
</div>
</div>
{selectedMcpServer.command && (
<div>
<Text className="font-medium">Command:</Text>

View file

@ -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>
<thead>
{table.getHeaderGroups().map((hg) => (
<tr key={hg.id}>
{hg.headers.map((h) => (
<th key={h.id}>{flexRender(h.column.columnDef.header, h.getContext())}</th>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map((row) => (
<tr key={row.id}>
{row.getVisibleCells().map((cell) => (
<td key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</td>
))}
</tr>
))}
</tbody>
</table>
);
}
describe("mcpHubColumns", () => {
it("renders the server row", () => {
render(<TestTable data={[mockServer]} />);
expect(screen.getByText("exa_test")).toBeInTheDocument();
});
it("keeps the non-sensitive columns", () => {
render(<TestTable data={[mockServer]} />);
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(<TestTable data={[mockServer]} />);
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(<TestTable data={[mockServer]} />);
expect(screen.queryByText(SERVER_URL)).not.toBeInTheDocument();
});
});

View file

@ -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 (
<div className="flex items-center space-x-2">
<Text className="text-xs truncate max-w-xs">{server.url}</Text>
<Tooltip title="Copy URL">
<CopyOutlined
onClick={() => copyToClipboard(server.url)}
className="cursor-pointer text-gray-500 hover:text-blue-500 text-xs flex-shrink-0"
/>
</Tooltip>
</div>
);
},
meta: {
className: "hidden lg:table-cell",
},
},
{
header: "Transport",
accessorKey: "transport",

View file

@ -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>
<thead>
{table.getHeaderGroups().map((hg) => (
<tr key={hg.id}>
{hg.headers.map((h) => (
<th key={h.id}>{flexRender(h.column.columnDef.header, h.getContext())}</th>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map((row) => (
<tr key={row.id}>
{row.getVisibleCells().map((cell) => (
<td key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</td>
))}
</tr>
))}
</tbody>
</table>
);
}
describe("publicMCPHubColumns", () => {
it("keeps the non-sensitive columns", () => {
render(<PublicMcpTestTable data={[mockMcpServer]} />);
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(<PublicMcpTestTable data={[mockMcpServer]} />);
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(<PublicMcpTestTable data={[mockMcpServer]} />);
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(<PublicModelHub />);
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();
});
});

View file

@ -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<MCPServerData>[] => [
{
header: "Server Name",
accessorKey: "server_name",
enableSorting: true,
cell: ({ row }) => (
<div className="overflow-hidden">
<Tooltip title={row.original.server_name}>
<Button
size="xs"
variant="light"
className="font-mono text-blue-500 bg-blue-50 hover:bg-blue-100 text-xs font-normal px-2 py-0.5 text-left"
onClick={() => showMcpModal(row.original)}
>
{row.original.server_name}
</Button>
</Tooltip>
</div>
),
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 (
<Tooltip title={description}>
<Text className="text-sm text-gray-700">{truncated}</Text>
</Tooltip>
);
},
size: 250,
},
{
header: "Transport",
accessorKey: "transport",
enableSorting: true,
cell: ({ row }) => {
const transport = row.original.transport;
return (
<Tag color="blue" className="text-xs uppercase">
{transport}
</Tag>
);
},
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 (
<Tag color={color} className="text-xs capitalize">
{authType}
</Tag>
);
},
size: 100,
},
];
const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded = false }) => {
const [modelHubData, setModelHubData] = useState<ModelGroupInfo[] | null>(null);
const [agentHubData, setAgentHubData] = useState<AgentCard[] | null>(null);
@ -889,94 +955,6 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
},
];
const publicMCPHubColumns = (): ColumnDef<MCPServerData>[] => [
{
header: "Server Name",
accessorKey: "server_name",
enableSorting: true,
cell: ({ row }) => (
<div className="overflow-hidden">
<Tooltip title={row.original.server_name}>
<Button
size="xs"
variant="light"
className="font-mono text-blue-500 bg-blue-50 hover:bg-blue-100 text-xs font-normal px-2 py-0.5 text-left"
onClick={() => showMcpModal(row.original)}
>
{row.original.server_name}
</Button>
</Tooltip>
</div>
),
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 (
<Tooltip title={description}>
<Text className="text-sm text-gray-700">{truncated}</Text>
</Tooltip>
);
},
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 (
<Tooltip title={url}>
<div className="flex items-center space-x-2">
<Text className="text-xs font-mono">{truncated}</Text>
<Copy
onClick={() => copyToClipboard(url)}
className="cursor-pointer text-gray-500 hover:text-blue-500 w-3 h-3"
/>
</div>
</Tooltip>
);
},
size: 200,
},
{
header: "Transport",
accessorKey: "transport",
enableSorting: true,
cell: ({ row }) => {
const transport = row.original.transport;
return (
<Tag color="blue" className="text-xs uppercase">
{transport}
</Tag>
);
},
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 (
<Tag color={color} className="text-xs capitalize">
{authType}
</Tag>
);
},
size: 100,
},
];
return (
<ThemeProvider accessToken={accessToken}>
<div className={isEmbedded ? "w-full" : "min-h-screen bg-white"}>
@ -1286,7 +1264,7 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
</div>
<ModelDataTable
columns={publicMCPHubColumns()}
columns={publicMCPHubColumns(showMcpModal)}
data={filteredMcpData}
isLoading={mcpLoading}
defaultSorting={[{ id: "server_name", desc: false }]}
@ -1891,18 +1869,6 @@ print(response.model_dump(mode='json', exclude_none=True))`;
<Text className="font-medium">Description:</Text>
<Text>{selectedMcpServer.mcp_info?.description || "-"}</Text>
</div>
<div className="col-span-2">
<Text className="font-medium">URL:</Text>
<a
href={selectedMcpServer.url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800 text-sm break-all flex items-center space-x-2"
>
<span>{selectedMcpServer.url}</span>
<ExternalLinkIcon className="w-4 h-4" />
</a>
</div>
</div>
</div>

View file

@ -26617,8 +26617,6 @@ export interface components {
* @enum {string}
*/
transport: "sse" | "http" | "stdio";
/** Url */
url?: string | null;
};
/**
* MCPSemanticFilterSettings