mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-08 03:08:45 +00:00
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:
parent
53593f697d
commit
8def4fd9ea
8 changed files with 271 additions and 142 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
2
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
2
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -26617,8 +26617,6 @@ export interface components {
|
|||
* @enum {string}
|
||||
*/
|
||||
transport: "sse" | "http" | "stdio";
|
||||
/** Url */
|
||||
url?: string | null;
|
||||
};
|
||||
/**
|
||||
* MCPSemanticFilterSettings
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue