diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/proxySettings/useDocBaseUrl.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/proxySettings/useDocBaseUrl.test.ts new file mode 100644 index 000000000000..b17f71516d79 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/proxySettings/useDocBaseUrl.test.ts @@ -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); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/proxySettings/useDocBaseUrl.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/proxySettings/useDocBaseUrl.ts new file mode 100644 index 000000000000..88a3683136aa --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/proxySettings/useDocBaseUrl.ts @@ -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; +} + +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()); +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPToolsetTableColumns.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPToolsetTableColumns.test.tsx index b29320cb5357..b2c52e00da0b 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPToolsetTableColumns.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPToolsetTableColumns.test.tsx @@ -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", @@ -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( { 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", () => { @@ -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")); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPToolsetTableColumns.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPToolsetTableColumns.tsx index 525700d3c492..c7221eb085c1 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPToolsetTableColumns.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPToolsetTableColumns.tsx @@ -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"; @@ -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 ( void copyToClipboard(toolsetEndpointUrl(toolset.toolset_name), "Endpoint URL copied")} + onClick={() => + void copyToClipboard(toolsetEndpointUrl(docBaseUrl, toolset.toolset_name), "Endpoint URL copied") + } > Copy endpoint URL @@ -90,6 +92,7 @@ function ToolsetRowActions({ toolset, isAdmin, onEditClick, onDeleteClick }: Too interface MCPToolsetTableColumnsDeps { isAdmin: boolean; serverPrefixById: Map; + docBaseUrl: string; onEditClick: (toolset: MCPToolset) => void; onDeleteClick: (toolsetId: string) => void; } @@ -97,6 +100,7 @@ interface MCPToolsetTableColumnsDeps { export const getMCPToolsetTableColumns = ({ isAdmin, serverPrefixById, + docBaseUrl, onEditClick, onDeleteClick, }: MCPToolsetTableColumnsDeps): ColumnDef[] => [ @@ -120,7 +124,7 @@ export const getMCPToolsetTableColumns = ({ cell: ({ row }) => ( onEditClick(row.original) : undefined} /> @@ -185,6 +189,7 @@ export const getMCPToolsetTableColumns = ({ diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPToolsetsTab.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPToolsetsTab.test.tsx new file mode 100644 index 000000000000..09deb04b6821 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPToolsetsTab.test.tsx @@ -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( + + + , + ); +} + +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//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//mcp`))).toBeInTheDocument(); + expect(screen.getByText(`${PROXY_BASE_URL}/toolset/github-tools/mcp`)).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPToolsetsTab.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPToolsetsTab.tsx index 0bced76e24eb..f4087bd84686 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPToolsetsTab.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPToolsetsTab.tsx @@ -8,13 +8,8 @@ import { useMCPToolsets } from "@/app/(dashboard)/hooks/mcpServers/useMCPToolset 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"; @@ -302,12 +297,12 @@ function ToolsetsEmptyState() { function ToolsetUsageGuide() { const [copied, setCopied] = useState(false); - const proxyBaseUrl = getProxyBaseUrl(); + const docBaseUrl = useDocBaseUrl(); const snippet = `{ "mcpServers": { "my-toolset": { - "url": "${proxyBaseUrl}/toolset//mcp", + "url": "${docBaseUrl}/toolset//mcp", "headers": { "x-litellm-api-key": "Bearer " } } } @@ -358,6 +353,7 @@ export function MCPToolsetsTab({ accessToken, userRole }: MCPToolsetsTabProps) { 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; @@ -396,11 +392,12 @@ export function MCPToolsetsTab({ accessToken, userRole }: MCPToolsetsTabProps) { const deps = { isAdmin, serverPrefixById, + docBaseUrl, onEditClick: setEditToolset, onDeleteClick: setDeleteId, }; return getMCPToolsetTableColumns(deps); - }, [isAdmin, serverPrefixById]); + }, [isAdmin, serverPrefixById, docBaseUrl]); return (
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_connect.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_connect.test.tsx new file mode 100644 index 000000000000..f4957f049fc8 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_connect.test.tsx @@ -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(); + + 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(); + + expect(screen.getAllByText("http://proxy.internal:4000/mcp").length).toBeGreaterThan(0); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_connect.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_connect.tsx index 7bdfd9c6b8fd..fcdbc03bcb66 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_connect.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_connect.tsx @@ -4,7 +4,7 @@ import React, { useState } from "react"; import { Card, Typography, Space, Alert, Button, Switch, Form, Collapse } from "antd"; import { TabPanel, TabPanels, TabGroup, TabList, Tab, Title as TremorTitle, Text as TremorText } from "@tremor/react"; import { CopyIcon, Code, Terminal, Globe, CheckIcon, ExternalLinkIcon, KeyIcon, ServerIcon, Zap } from "lucide-react"; -import { getProxyBaseUrl } from "@/components/networking"; +import useDocBaseUrl from "@/app/(dashboard)/hooks/proxySettings/useDocBaseUrl"; import { copyToClipboard as utilCopyToClipboard } from "@/utils/dataUtils"; const { Title, Text } = Typography; @@ -115,7 +115,7 @@ interface MCPConnectProps { } const MCPConnect: React.FC = ({ currentServerAccessGroups = [] }) => { - const proxyBaseUrl = getProxyBaseUrl(); + const docBaseUrl = useDocBaseUrl(); const [copiedStates, setCopiedStates] = useState>({}); const [serverHeaders, setServerHeaders] = useState>({ openai: [], @@ -236,7 +236,7 @@ const MCPConnect: React.FC = ({ currentServerAccessGroups = [] title="MCP Server Information" description="Connection details for your LiteLLM MCP server" > - + = ({ currentServerAccessGroups = [] accessGroups={["dev-group"]} > = ({ currentServerAccessGroups = [] title="MCP Server Information" description="Connection details for your LiteLLM MCP server" > - + = ({ currentServerAccessGroups = [] { "type": "mcp", "server_label": "litellm", - "server_url": "${proxyBaseUrl}/mcp", + "server_url": "${docBaseUrl}/mcp", "require_approval": "never", "headers": { "x-litellm-api-key": "Bearer YOUR_LITELLM_API_KEY", @@ -406,7 +406,7 @@ const MCPConnect: React.FC = ({ currentServerAccessGroups = [] code={`{ "mcpServers": { "Zapier_MCP": { - "url": "${proxyBaseUrl}/mcp", + "url": "${docBaseUrl}/mcp", "headers": { "x-litellm-api-key": "Bearer YOUR_LITELLM_API_KEY", "x-mcp-servers": "Zapier_MCP,dev-group" @@ -450,7 +450,7 @@ const MCPConnect: React.FC = ({ currentServerAccessGroups = [] appropriate transport method.
- +