Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { resolveDocBaseUrl } from "./useDocBaseUrl";

const FALLBACK = "http://proxy.internal:4000";
const DOC_BASE = "https://gateway.public.example.com";

describe("resolveDocBaseUrl", () => {
it("prefers the doc base url over the fallback when it is set", () => {
expect(resolveDocBaseUrl(DOC_BASE, FALLBACK)).toBe(DOC_BASE);
});

it("falls back when the doc base url is undefined", () => {
expect(resolveDocBaseUrl(undefined, FALLBACK)).toBe(FALLBACK);
});

it("falls back when the doc base url is null", () => {
expect(resolveDocBaseUrl(null, FALLBACK)).toBe(FALLBACK);
});

it("falls back when the doc base url is an empty string", () => {
expect(resolveDocBaseUrl("", FALLBACK)).toBe(FALLBACK);
});

it("falls back when the doc base url is whitespace only", () => {
expect(resolveDocBaseUrl(" ", FALLBACK)).toBe(FALLBACK);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
"use client";

import { getProxyBaseUrl } from "@/components/networking";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import useProxySettings from "@/app/(dashboard)/hooks/proxySettings/useProxySettings";

export function resolveDocBaseUrl(docBaseUrl: string | null | undefined, fallback: string): string {
return docBaseUrl && docBaseUrl.trim() ? docBaseUrl : fallback;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Unnormalized documentation base URL

If LITELLM_UI_API_DOC_BASE_URL contains surrounding whitespace or a trailing slash, this returns the raw value and the changed consumers append paths directly, producing copied endpoints such as https://gateway.example.com/mcp or https://gateway.example.com//toolset/name/mcp that clients can reject or route incorrectly.

Suggested change
return docBaseUrl && docBaseUrl.trim() ? docBaseUrl : fallback;
return docBaseUrl?.trim().replace(/\/+$/, "") || fallback;

Knowledge Base Used: Admin dashboard (ui/litellm-dashboard)

}

export default function useDocBaseUrl(): string {
const { accessToken } = useAuthorized();
const { LITELLM_UI_API_DOC_BASE_URL } = useProxySettings(accessToken);
return resolveDocBaseUrl(LITELLM_UI_API_DOC_BASE_URL, getProxyBaseUrl());
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,7 @@ import { DataTable } from "@/components/shared/DataTable";
import { MCPToolset } from "@/components/mcp_tools/types";
import { getMCPToolsetTableColumns } from "./MCPToolsetTableColumns";

vi.mock("@/components/networking", () => ({
getProxyBaseUrl: () => "http://localhost:4000",
}));
const DOC_BASE_URL = "https://gateway.public.example.com";

const mockToolset: MCPToolset = {
toolset_id: "ts-1",
Expand All @@ -29,7 +27,7 @@ const serverPrefixById = new Map([
]);

function renderTable({ isAdmin = true, onEditClick = vi.fn(), onDeleteClick = vi.fn() } = {}) {
const deps = { isAdmin, serverPrefixById, onEditClick, onDeleteClick };
const deps = { isAdmin, serverPrefixById, docBaseUrl: DOC_BASE_URL, onEditClick, onDeleteClick };
render(
<DataTable
data={[mockToolset]}
Expand All @@ -46,7 +44,7 @@ describe("getMCPToolsetTableColumns", () => {
it("renders the toolset with its endpoint url as subtitle", () => {
renderTable();
expect(screen.getByText("github-tools")).toBeInTheDocument();
expect(screen.getByText("http://localhost:4000/toolset/github-tools/mcp")).toBeInTheDocument();
expect(screen.getByText(`${DOC_BASE_URL}/toolset/github-tools/mcp`)).toBeInTheDocument();
});

it("renders server-prefixed tool chips capped at four with an overflow count", () => {
Expand Down Expand Up @@ -77,7 +75,7 @@ describe("getMCPToolsetTableColumns", () => {

await user.click(screen.getByTestId("toolset-actions-ts-1"));
await user.click(await screen.findByTestId("toolset-action-copy-url"));
expect(await window.navigator.clipboard.readText()).toBe("http://localhost:4000/toolset/github-tools/mcp");
expect(await window.navigator.clipboard.readText()).toBe(`${DOC_BASE_URL}/toolset/github-tools/mcp`);

await user.click(screen.getByTestId("toolset-actions-ts-1"));
await user.click(await screen.findByTestId("toolset-action-copy-id"));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/cva.config";
import { getProxyBaseUrl } from "@/components/networking";
import { MCPToolset } from "@/components/mcp_tools/types";
import { copyToClipboard } from "@/utils/dataUtils";

Expand All @@ -29,18 +28,19 @@ export function displayToolName(serverPrefix: string | undefined, toolName: stri
return serverPrefix ? `${serverPrefix}${MCP_TOOL_PREFIX_SEPARATOR}${toolName}` : toolName;
}

export function toolsetEndpointUrl(toolsetName: string): string {
return `${getProxyBaseUrl()}/toolset/${toolsetName}/mcp`;
export function toolsetEndpointUrl(baseUrl: string, toolsetName: string): string {
return `${baseUrl}/toolset/${toolsetName}/mcp`;
}

interface ToolsetRowActionsProps {
toolset: MCPToolset;
isAdmin: boolean;
docBaseUrl: string;
onEditClick: (toolset: MCPToolset) => void;
onDeleteClick: (toolsetId: string) => void;
}

function ToolsetRowActions({ toolset, isAdmin, onEditClick, onDeleteClick }: ToolsetRowActionsProps) {
function ToolsetRowActions({ toolset, isAdmin, docBaseUrl, onEditClick, onDeleteClick }: ToolsetRowActionsProps) {
return (
<DropdownMenu>
<DropdownMenuTrigger
Expand All @@ -53,7 +53,9 @@ function ToolsetRowActions({ toolset, isAdmin, onEditClick, onDeleteClick }: Too
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuItem
data-testid="toolset-action-copy-url"
onClick={() => void copyToClipboard(toolsetEndpointUrl(toolset.toolset_name), "Endpoint URL copied")}
onClick={() =>
void copyToClipboard(toolsetEndpointUrl(docBaseUrl, toolset.toolset_name), "Endpoint URL copied")
}
>
<Link2 />
Copy endpoint URL
Expand Down Expand Up @@ -90,13 +92,15 @@ function ToolsetRowActions({ toolset, isAdmin, onEditClick, onDeleteClick }: Too
interface MCPToolsetTableColumnsDeps {
isAdmin: boolean;
serverPrefixById: Map<string, string>;
docBaseUrl: string;
onEditClick: (toolset: MCPToolset) => void;
onDeleteClick: (toolsetId: string) => void;
}

export const getMCPToolsetTableColumns = ({
isAdmin,
serverPrefixById,
docBaseUrl,
onEditClick,
onDeleteClick,
}: MCPToolsetTableColumnsDeps): ColumnDef<MCPToolset>[] => [
Expand All @@ -120,7 +124,7 @@ export const getMCPToolsetTableColumns = ({
cell: ({ row }) => (
<IdentityCell
title={row.original.toolset_name}
subtitle={toolsetEndpointUrl(row.original.toolset_name)}
subtitle={toolsetEndpointUrl(docBaseUrl, row.original.toolset_name)}
className="max-w-80"
onClick={isAdmin ? () => onEditClick(row.original) : undefined}
/>
Expand Down Expand Up @@ -185,6 +189,7 @@ export const getMCPToolsetTableColumns = ({
<ToolsetRowActions
toolset={row.original}
isAdmin={isAdmin}
docBaseUrl={docBaseUrl}
onEditClick={onEditClick}
onDeleteClick={onDeleteClick}
/>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import React from "react";
import { render, screen } from "@testing-library/react";
import { describe, it, expect, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { MCPToolsetsTab } from "./MCPToolsetsTab";
import useProxySettings from "@/app/(dashboard)/hooks/proxySettings/useProxySettings";

const DOC_BASE_URL = "https://gateway.public.example.com";
const PROXY_BASE_URL = "http://proxy.internal:4000";

vi.mock("@/components/networking", () => ({
getProxyBaseUrl: () => PROXY_BASE_URL,
createMCPToolset: vi.fn(),
updateMCPToolset: vi.fn(),
deleteMCPToolset: vi.fn(),
listMCPTools: vi.fn(),
}));

vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
default: () => ({ accessToken: "sk-test" }),
}));

vi.mock("@/app/(dashboard)/hooks/proxySettings/useProxySettings", () => ({
default: vi.fn(),
}));

vi.mock("@/app/(dashboard)/hooks/mcpServers/useMCPToolsets", () => ({
useMCPToolsets: () => ({
data: [
{
toolset_id: "ts-1",
toolset_name: "github-tools",
description: "GitHub helpers",
tools: [{ server_id: "srv-1", tool_name: "create_issue" }],
created_at: "2026-01-01T00:00:00Z",
},
],
isLoading: false,
}),
}));

vi.mock("@/app/(dashboard)/hooks/mcpServers/useMCPServers", () => ({
useMCPServers: () => ({ data: [{ server_id: "srv-1", alias: "github", server_name: "github" }] }),
}));

const mockedUseProxySettings = vi.mocked(useProxySettings);

function renderTab(docBaseUrl: string | null) {
mockedUseProxySettings.mockReturnValue({
PROXY_BASE_URL,
PROXY_LOGOUT_URL: "",
LITELLM_UI_API_DOC_BASE_URL: docBaseUrl,
});
render(
<QueryClientProvider client={new QueryClient()}>
<MCPToolsetsTab accessToken="sk-test" userRole="Admin" />
</QueryClientProvider>,
);
}

describe("MCPToolsetsTab", () => {
it("builds the usage guide and row endpoint urls from LITELLM_UI_API_DOC_BASE_URL when set", () => {
renderTab(DOC_BASE_URL);

expect(screen.getByText(new RegExp(`${DOC_BASE_URL}/toolset/<toolset-name>/mcp`))).toBeInTheDocument();
expect(screen.getByText(`${DOC_BASE_URL}/toolset/github-tools/mcp`)).toBeInTheDocument();
expect(screen.queryAllByText(/proxy\.internal/)).toHaveLength(0);
});

it("falls back to the proxy base url when LITELLM_UI_API_DOC_BASE_URL is unset", () => {
renderTab(null);

expect(screen.getByText(new RegExp(`${PROXY_BASE_URL}/toolset/<toolset-name>/mcp`))).toBeInTheDocument();
expect(screen.getByText(`${PROXY_BASE_URL}/toolset/github-tools/mcp`)).toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,8 @@
import { useMCPServers } from "@/app/(dashboard)/hooks/mcpServers/useMCPServers";
import { useQueryClient } from "@tanstack/react-query";
import { DataTable } from "@/components/shared/DataTable";
import {
createMCPToolset,
updateMCPToolset,
deleteMCPToolset,
listMCPTools,
getProxyBaseUrl,
} from "@/components/networking";
import { createMCPToolset, updateMCPToolset, deleteMCPToolset, listMCPTools } from "@/components/networking";
import useDocBaseUrl from "@/app/(dashboard)/hooks/proxySettings/useDocBaseUrl";
import { MCPToolset, MCPToolsetTool } from "@/components/mcp_tools/types";
import { displayToolName, getMCPToolsetTableColumns } from "./MCPToolsetTableColumns";

Expand Down Expand Up @@ -54,7 +49,7 @@
try {
const result = await listMCPTools(accessToken, serverId);
const toolList = Array.isArray(result) ? result : result?.tools ?? [];
setTools(toolList.map((t: any) => ({ name: t.name ?? t.tool_name ?? t, description: t.description ?? "" })));

Check warning on line 52 in ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPToolsetsTab.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type
} catch {
setTools([]);
} finally {
Expand Down Expand Up @@ -156,7 +151,7 @@
setSelectedTools(initialToolset?.tools || []);
setServerSearch("");
}
}, [open, initialToolset]);

Check warning on line 154 in ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPToolsetsTab.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

React Hook React.useEffect has a missing dependency: 'form'. Either include it or remove the dependency array

const handleToggleTool = (tool: MCPToolsetTool) => {
setSelectedTools((prev) => {
Expand Down Expand Up @@ -302,12 +297,12 @@

function ToolsetUsageGuide() {
const [copied, setCopied] = useState(false);
const proxyBaseUrl = getProxyBaseUrl();
const docBaseUrl = useDocBaseUrl();

const snippet = `{
"mcpServers": {
"my-toolset": {
"url": "${proxyBaseUrl}/toolset/<toolset-name>/mcp",
"url": "${docBaseUrl}/toolset/<toolset-name>/mcp",
"headers": { "x-litellm-api-key": "Bearer <your-api-key>" }
}
}
Expand Down Expand Up @@ -358,6 +353,7 @@
const [deleting, setDeleting] = useState(false);

const isAdmin = userRole === "Admin" || userRole === "proxy_admin";
const docBaseUrl = useDocBaseUrl();

const handleCreate = async (name: string, description: string | undefined, tools: MCPToolsetTool[]) => {
if (!accessToken) return;
Expand All @@ -368,7 +364,7 @@

const handleUpdate = async (name: string, description: string | undefined, tools: MCPToolsetTool[]) => {
if (!accessToken || !editToolset) return;
await updateMCPToolset(accessToken, { toolset_id: editToolset.toolset_id, toolset_name: name, description, tools });

Check warning on line 367 in ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPToolsetsTab.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Object literal with 4 properties passed inline as an argument; assign it to a named variable first
message.success("Toolset updated");
queryClient.invalidateQueries({ queryKey: ["mcpToolsets"] });
setEditToolset(null);
Expand Down Expand Up @@ -396,11 +392,12 @@
const deps = {
isAdmin,
serverPrefixById,
docBaseUrl,
onEditClick: setEditToolset,
onDeleteClick: setDeleteId,
};
return getMCPToolsetTableColumns(deps);
}, [isAdmin, serverPrefixById]);
}, [isAdmin, serverPrefixById, docBaseUrl]);

return (
<div className="mt-4">
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import React from "react";
import { render, screen } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import MCPConnect from "./mcp_connect";
import useProxySettings from "@/app/(dashboard)/hooks/proxySettings/useProxySettings";

vi.mock("@/components/networking", () => ({
getProxyBaseUrl: () => "http://proxy.internal:4000",
}));

vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
default: () => ({ accessToken: "sk-test" }),
}));

vi.mock("@/app/(dashboard)/hooks/proxySettings/useProxySettings", () => ({
default: vi.fn(),
}));

const mockedUseProxySettings = vi.mocked(useProxySettings);

const proxySettings = (docBaseUrl: string | null) => ({
PROXY_BASE_URL: "http://proxy.internal:4000",
PROXY_LOGOUT_URL: "",
LITELLM_UI_API_DOC_BASE_URL: docBaseUrl,
});

describe("MCPConnect", () => {
beforeEach(() => {
vi.clearAllMocks();
});

it("renders the Server URL from LITELLM_UI_API_DOC_BASE_URL when it is set", () => {
mockedUseProxySettings.mockReturnValue(proxySettings("https://gateway.public.example.com"));

render(<MCPConnect />);

expect(screen.getAllByText("https://gateway.public.example.com/mcp").length).toBeGreaterThan(0);
expect(screen.queryAllByText(/proxy\.internal/)).toHaveLength(0);
});

it("renders the Server URL from the proxy base url when LITELLM_UI_API_DOC_BASE_URL is unset", () => {
mockedUseProxySettings.mockReturnValue(proxySettings(null));

render(<MCPConnect />);

expect(screen.getAllByText("http://proxy.internal:4000/mcp").length).toBeGreaterThan(0);
});
});
Loading
Loading