diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index abd44de2de27..ef0a88d669dc 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -838,15 +838,7 @@ "count": 1 } }, - "src/app/(dashboard)/mcp-servers/_components/MCPLogoSelector.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/app/(dashboard)/mcp-servers/_components/MCPNetworkSettings.tsx": { - "no-restricted-imports": { - "count": 1 - }, "react-hooks/immutability": { "count": 2 } @@ -861,11 +853,6 @@ "count": 1 } }, - "src/app/(dashboard)/mcp-servers/_components/MCPServerCard.tsx": { - "no-restricted-imports": { - "count": 2 - } - }, "src/app/(dashboard)/mcp-servers/_components/MCPSubmissionsTab.tsx": { "react-hooks/set-state-in-effect": { "count": 1 @@ -904,9 +891,6 @@ } }, "src/app/(dashboard)/mcp-servers/_components/OpenAPIQuickPicker.tsx": { - "no-restricted-imports": { - "count": 1 - }, "react-hooks/set-state-in-effect": { "count": 1 } @@ -947,11 +931,6 @@ "count": 1 } }, - "src/app/(dashboard)/mcp-servers/_components/TruePassthroughWarning.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/app/(dashboard)/mcp-servers/_components/UserEnvVarsModal.tsx": { "no-nested-ternary": { "count": 2 @@ -999,9 +978,6 @@ }, "no-nested-ternary": { "count": 3 - }, - "no-restricted-imports": { - "count": 2 } }, "src/app/(dashboard)/mcp-servers/_components/mcp_discovery.tsx": { @@ -1011,9 +987,6 @@ "local/no-complex-jsx-arrow": { "count": 1 }, - "no-restricted-imports": { - "count": 1 - }, "react-hooks/set-state-in-effect": { "count": 2 } @@ -1021,17 +994,11 @@ "src/app/(dashboard)/mcp-servers/_components/mcp_server_cost_config.tsx": { "local/filename-pascal-case": { "count": 1 - }, - "no-restricted-imports": { - "count": 2 } }, "src/app/(dashboard)/mcp-servers/_components/mcp_server_cost_display.tsx": { "local/filename-pascal-case": { "count": 1 - }, - "no-restricted-imports": { - "count": 1 } }, "src/app/(dashboard)/mcp-servers/_components/mcp_server_edit.tsx": { @@ -1057,9 +1024,6 @@ "src/app/(dashboard)/mcp-servers/_components/mcp_server_view.tsx": { "local/filename-pascal-case": { "count": 1 - }, - "no-restricted-imports": { - "count": 2 } }, "src/app/(dashboard)/mcp-servers/_components/mcp_servers.tsx": { @@ -1072,9 +1036,6 @@ "no-nested-ternary": { "count": 1 }, - "no-restricted-imports": { - "count": 2 - }, "react-hooks/set-state-in-effect": { "count": 2 } @@ -1082,9 +1043,6 @@ "src/app/(dashboard)/mcp-servers/_components/mcp_tool_configuration.tsx": { "local/filename-pascal-case": { "count": 1 - }, - "no-restricted-imports": { - "count": 2 } }, "src/app/(dashboard)/mcp-servers/_components/mcp_tools.tsx": { @@ -1097,9 +1055,6 @@ "no-nested-ternary": { "count": 1 }, - "no-restricted-imports": { - "count": 2 - }, "react-hooks/set-state-in-effect": { "count": 2 } @@ -1864,9 +1819,6 @@ "local/filename-pascal-case": { "count": 1 }, - "no-restricted-imports": { - "count": 1 - }, "react-hooks/set-state-in-effect": { "count": 1 } @@ -2435,11 +2387,6 @@ "count": 1 } }, - "src/components/Settings/AdminSettings/MCPSemanticFilterSettings/MCPSemanticFilterTestPanel.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/components/Settings/AdminSettings/PluginSettings/PluginSettings.tsx": { "no-restricted-imports": { "count": 1 @@ -2605,20 +2552,7 @@ } }, "src/components/ToolDetail.tsx": { - "no-restricted-imports": { - "count": 1 - }, "unused-imports/no-unused-imports": { - "count": 2 - } - }, - "src/components/ToolPolicies/PolicySelect.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "src/components/ToolPolicies/ToolPoliciesTableColumns.tsx": { - "no-restricted-imports": { "count": 1 } }, diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPLogoSelector.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPLogoSelector.tsx index a67a0dc882d9..1cb8ed6f5b5f 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPLogoSelector.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPLogoSelector.tsx @@ -1,6 +1,8 @@ import React from "react"; -import { Input, Tooltip } from "antd"; -import { InfoCircleOutlined, LinkOutlined } from "@ant-design/icons"; +import { Info, Link as LinkIcon } from "lucide-react"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group"; +import { cn } from "@/lib/cva.config"; import { Logo } from "@/components/molecules/logo/Logo"; import githubLogo from "../../../../../public/assets/logos/github.svg"; import slackLogo from "../../../../../public/assets/logos/slack.svg"; @@ -61,72 +63,83 @@ const MCPLogoSelector: React.FC = ({ value, onChange }) => }; return ( -
-
- Logo - - - -
+ +
+
+ Logo + + } + /> + + Select a well-known logo or paste a URL to any image. The logo is shown on the admin and chat pages. + + +
- {/* Preview */} - {value && ( -
- -
-
{value}
+ {/* Preview */} + {value && ( +
+ +
+
{value}
+
+
- + )} + + {/* Well-known logo grid */} +
+ {WELL_KNOWN_LOGOS.map((logo) => { + const isSelected = value === logo.url; + return ( + + handleSelect(logo.url)} + className={cn( + "flex size-10 cursor-pointer items-center justify-center rounded-lg border p-2 transition-all", + isSelected ? "border-primary bg-accent shadow-xs" : "border-border hover:bg-accent", + )} + > + {logo.name} + + } + /> + {logo.name} + + ); + })}
- )} - {/* Well-known logo grid */} -
- {WELL_KNOWN_LOGOS.map((logo) => { - const isSelected = value === logo.url; - return ( - - - - ); - })} + {/* Custom URL input */} + + + + + { + const v = e.target.value.trim(); + onChange?.(v || undefined); + }} + /> +
- - {/* Custom URL input */} - } - placeholder="Or paste a custom logo URL..." - value={value && !selectedWellKnown ? value : ""} - onChange={(e) => { - const v = e.target.value.trim(); - onChange?.(v || undefined); - }} - className="rounded-lg" - size="small" - /> -
+ ); }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPNetworkSettings.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPNetworkSettings.test.tsx new file mode 100644 index 000000000000..9526f5de074e --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPNetworkSettings.test.tsx @@ -0,0 +1,106 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import MCPNetworkSettings from "./MCPNetworkSettings"; +import { + getGeneralSettingsCall, + updateConfigFieldSetting, + deleteConfigFieldSetting, + fetchMCPClientIp, +} from "@/components/networking"; + +vi.mock("@/components/networking", () => ({ + getGeneralSettingsCall: vi.fn(), + updateConfigFieldSetting: vi.fn(), + deleteConfigFieldSetting: vi.fn(), + fetchMCPClientIp: vi.fn(), +})); + +const renderSettings = () => render(); + +describe("MCPNetworkSettings", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getGeneralSettingsCall).mockResolvedValue([]); + vi.mocked(fetchMCPClientIp).mockResolvedValue(null); + vi.mocked(updateConfigFieldSetting).mockResolvedValue(undefined); + vi.mocked(deleteConfigFieldSetting).mockResolvedValue(undefined); + }); + + it("renders the stored private ranges once settings load", async () => { + vi.mocked(getGeneralSettingsCall).mockResolvedValue([ + { field_name: "mcp_internal_ip_ranges", field_value: ["10.0.0.0/8", "192.168.0.0/16"] }, + ]); + + renderSettings(); + + expect(await screen.findByText("10.0.0.0/8")).toBeInTheDocument(); + expect(screen.getByText("192.168.0.0/16")).toBeInTheDocument(); + }); + + it("ignores unrelated config fields", async () => { + vi.mocked(getGeneralSettingsCall).mockResolvedValue([ + { field_name: "some_other_setting", field_value: ["should-not-show"] }, + ]); + + renderSettings(); + + await screen.findByText("Private IP Ranges"); + expect(screen.queryByText("should-not-show")).not.toBeInTheDocument(); + }); + + it("suggests the caller's /24 range from the detected client IP", async () => { + vi.mocked(fetchMCPClientIp).mockResolvedValue("203.0.113.45"); + + renderSettings(); + + expect(await screen.findByText("203.0.113.45")).toBeInTheDocument(); + expect(screen.getByText("203.0.113.0/24")).toBeInTheDocument(); + }); + + it("exposes the suggested range as a control a keyboard user can reach and activate", async () => { + vi.mocked(fetchMCPClientIp).mockResolvedValue("203.0.113.45"); + + renderSettings(); + const suggested = await screen.findByRole("button", { name: /203\.0\.113\.0\/24/ }); + + suggested.focus(); + expect(suggested).toHaveFocus(); + + await userEvent.keyboard("{Enter}"); + + await waitFor(() => expect(screen.queryByText("Suggested range:")).not.toBeInTheDocument()); + }); + + it("adds the suggested range to the list when clicked, and stops suggesting it", async () => { + vi.mocked(fetchMCPClientIp).mockResolvedValue("203.0.113.45"); + + renderSettings(); + await userEvent.click(await screen.findByText("203.0.113.0/24")); + + await waitFor(() => expect(screen.queryByText("Suggested range:")).not.toBeInTheDocument()); + expect(screen.getByText("203.0.113.0/24")).toBeInTheDocument(); + }); + + it("saves the configured ranges", async () => { + vi.mocked(getGeneralSettingsCall).mockResolvedValue([ + { field_name: "mcp_internal_ip_ranges", field_value: ["10.0.0.0/8"] }, + ]); + + renderSettings(); + await userEvent.click(await screen.findByRole("button", { name: /Save/ })); + + await waitFor(() => + expect(updateConfigFieldSetting).toHaveBeenCalledWith("tok", "mcp_internal_ip_ranges", ["10.0.0.0/8"]), + ); + expect(deleteConfigFieldSetting).not.toHaveBeenCalled(); + }); + + it("clears the setting instead of saving an empty list", async () => { + renderSettings(); + await userEvent.click(await screen.findByRole("button", { name: /Save/ })); + + await waitFor(() => expect(deleteConfigFieldSetting).toHaveBeenCalledWith("tok", "mcp_internal_ip_ranges")); + expect(updateConfigFieldSetting).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPNetworkSettings.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPNetworkSettings.tsx index 7ab240389f32..8b4d2a586527 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPNetworkSettings.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPNetworkSettings.tsx @@ -1,6 +1,10 @@ import React, { useState, useEffect } from "react"; -import { Select, Button, Card, Typography, Spin, Tag } from "antd"; -import { SaveOutlined, PlusOutlined } from "@ant-design/icons"; +import { Save, Plus, X } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; import { DeprecationBanner } from "@/components/DeprecationBanner"; import { getGeneralSettingsCall, @@ -9,8 +13,6 @@ import { fetchMCPClientIp, } from "@/components/networking"; -const { Text } = Typography; - interface MCPNetworkSettingsProps { accessToken: string | null; } @@ -29,6 +31,7 @@ const MCPNetworkSettings: React.FC = ({ accessToken }) const [saving, setSaving] = useState(false); const [privateRanges, setPrivateRanges] = useState([]); const [currentIp, setCurrentIp] = useState(null); + const [rangeDraft, setRangeDraft] = useState(""); useEffect(() => { loadSettings(); @@ -82,10 +85,22 @@ const MCPNetworkSettings: React.FC = ({ accessToken }) } }; + // Commas separate entries, matching the old tokenised input. + const commitDraft = () => { + const added = rangeDraft + .split(",") + .map((r) => r.trim()) + .filter((r) => r !== "" && !privateRanges.includes(r)); + if (added.length > 0) { + setPrivateRanges([...privateRanges, ...added]); + } + setRangeDraft(""); + }; + if (loading) { return (
- +
); } @@ -96,55 +111,76 @@ const MCPNetworkSettings: React.FC = ({ accessToken })
- Private IP Ranges -

+

Private IP Ranges

+

Define which IP ranges are part of your private network. Callers from these IPs can see all MCP servers. Callers from any other IP can only see servers marked "Available on Public Internet".

- + {currentIp && ( -
- +
+

Your current IP: {currentIp} - +

{suggestedRange && !privateRanges.includes(suggestedRange) && ( -
- Suggested range: - } +
+

Suggested range:

+
)}
)} -
- Your Private Network Ranges +
+

Your Private Network Ranges

- setRangeDraft(e.target.value)} + onBlur={commitDraft} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === ",") { + e.preventDefault(); + commitDraft(); + } + }} /> -

+

Enter CIDR ranges (e.g., 10.0.0.0/8). When empty, standard private IP ranges are used.

-
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPServerCard.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPServerCard.tsx index c7dd6e47f76a..de53d34c3ac6 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPServerCard.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPServerCard.tsx @@ -1,19 +1,20 @@ import { type FC, type KeyboardEvent, type MouseEvent } from "react"; -import { Dropdown, Tooltip, Typography, Tag } from "antd"; -import type { MenuProps } from "antd"; +import { Check, CircleAlert, Ellipsis, Trash2, Zap } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; import { - CheckOutlined, - DeleteOutlined, - ExclamationCircleFilled, - MoreOutlined, - ThunderboltOutlined, -} from "@ant-design/icons"; + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { cn } from "@/lib/cva.config"; import { AUTH_TYPE, type MCPServer } from "@/components/mcp_tools/types"; import { Logo } from "@/components/molecules/logo/Logo"; import { getMaskedAndFullUrl } from "./utils"; -const { Text } = Typography; - interface MCPServerCardProps { server: MCPServer; // Per-user env-var fields this user still needs to fill in for this server. @@ -73,8 +74,8 @@ const MCPServerCard: FC = ({ const needsAttention = missing.length > 0; const cardClass = needsAttention - ? "border-2 border-red-300 bg-red-50/40 hover:border-red-400 hover:shadow-md" - : "border border-gray-200 bg-white hover:border-gray-300 hover:shadow-md"; + ? "border-2 border-destructive/40 bg-destructive/5 hover:border-destructive/60 hover:shadow-md" + : "border border-border bg-card hover:shadow-md"; const url = server.url || ""; const { maskedUrl } = url ? getMaskedAndFullUrl(url) : { maskedUrl: "" }; @@ -105,174 +106,198 @@ const MCPServerCard: FC = ({ } }; - const menuItems: MenuProps["items"] = []; - if (onRecheckHealth) { - menuItems.push({ - key: "test-connection", - label: "Test Connection", - icon: , - disabled: isRechecking, - onClick: ({ domEvent }) => { - domEvent.stopPropagation(); - onRecheckHealth(); - }, - }); - } - if (onDelete) { - if (menuItems.length > 0) { - menuItems.push({ key: "divider", type: "divider" }); - } - menuItems.push({ - key: "delete", - label: "Delete", - icon: , - danger: true, - onClick: ({ domEvent }) => { - domEvent.stopPropagation(); - onDelete(); - }, - }); - } + const hasMenu = !!onRecheckHealth || !!onDelete; // Card uses role="button" + nested + } + /> + + {onRecheckHealth && ( + { + stop(e); + onRecheckHealth(); + }} + > + + Test Connection + + )} + {onRecheckHealth && onDelete && } + {onDelete && ( + { + stop(e); + onDelete(); + }} + > + + Delete + + )} + + + )}
- {menuItems.length > 0 && ( - - - - )} -
- {subtitle ? ( - - - {subtitle} - - - ) : ( - // Defensive placeholder: keep the row even when no identifier is - // available so the tag row stays vertically aligned across the grid. -
- )} - -
- - {displayTransport.toUpperCase()} - {authType} - {oauthFlowUnset && ( - - - - - OAuth flow not set - - + {subtitle ? ( + + {subtitle}

} /> + {subtitleTooltip}
+ ) : ( + // Defensive placeholder: keep the row even when no identifier is + // available so the badge row stays vertically aligned across the grid. +
)} - - - + +
+ + {displayTransport.toUpperCase()} + {authType} + {oauthFlowUnset && ( + + + + OAuth flow not set + + } + /> + + This OAuth server has no flow set (Machine-to-Machine vs Interactive). Open it and choose an OAuth Flow + Type so LiteLLM authenticates it as you intend. + + + )} + + {isPublic ? "Public" : "Internal"} - - - {accessGroups.slice(0, 2).map((g) => ( - - {g} - - ))} - {accessGroups.length > 2 && ( - - +{accessGroups.length - 2} - - )} -
+ + {accessGroups.slice(0, 2).map((g) => ( + + + {g} + + } + /> + {g} + + ))} + {accessGroups.length > 2 && ( + + +{accessGroups.length - 2}} /> + {accessGroups.slice(2).join(", ")} + + )} +
- {(server.is_byok || needsAttention) && ( -
- {server.is_byok && } - {needsAttention && ( -
- -
Missing user fields:
+ {(server.is_byok || needsAttention) && ( +
+ {server.is_byok && } + {needsAttention && ( +
+ + + + {missing.length} user field + {missing.length === 1 ? "" : "s"} missing + + } + /> + +
Missing user fields:
    {missing.map((m) => (
  • • {m}
  • ))}
-
- } - > - - - {missing.length} user field - {missing.length === 1 ? "" : "s"} missing - - - {onOpenFillFields && ( - - )} -
- )} -
- )} -
+ +
+ {onOpenFillFields && ( + + )} +
+ )} +
+ )} +
+ ); }; @@ -297,46 +322,45 @@ const HealthChip: FC = ({ }) => { if (isLoadingHealth || isRechecking) { return ( - - - - Checking - - + + + Checking + ); } - const tooltip = ( -
-
Health: {status}
- {lastCheck &&
Last check: {new Date(lastCheck).toLocaleString()}
} - {error && ( -
-
Error
-
{error}
-
- )} - {!lastCheck && !error &&
No health data
} - {onRecheck &&
Click to recheck
} -
- ); return ( - - { - e.stopPropagation(); - onRecheck(); - } - : undefined + + { + e.stopPropagation(); + onRecheck(); + } + : undefined + } + > + + {status.charAt(0).toUpperCase() + status.slice(1)} + } - > - - - {status.charAt(0).toUpperCase() + status.slice(1)} - - + /> + +
Health: {status}
+ {lastCheck &&
Last check: {new Date(lastCheck).toLocaleString()}
} + {error && ( +
+
Error
+
{error}
+
+ )} + {!lastCheck && !error &&
No health data
} + {onRecheck &&
Click to recheck
} +
); }; @@ -350,22 +374,22 @@ const ByokRow: FC = ({ connected, onConnect }) => { if (connected) { return (
- BYOK credential + BYOK credential
- - Connected - + + Connected + {onConnect && ( - + )}
@@ -373,20 +397,19 @@ const ByokRow: FC = ({ connected, onConnect }) => { } return (
- BYOK credential + BYOK credential {onConnect ? ( - + ) : ( - + )}
); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/OpenAPIQuickPicker.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/OpenAPIQuickPicker.test.tsx new file mode 100644 index 000000000000..f6091f06376c --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/OpenAPIQuickPicker.test.tsx @@ -0,0 +1,84 @@ +import { render, screen, waitFor, fireEvent } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import OpenAPIQuickPicker, { type OpenAPIRegistryEntry } from "./OpenAPIQuickPicker"; +import { fetchOpenAPIRegistry } from "@/components/networking"; + +vi.mock("@/components/networking", () => ({ + fetchOpenAPIRegistry: vi.fn(), +})); + +const stripe: OpenAPIRegistryEntry = { + name: "stripe", + title: "Stripe", + description: "Payments API", + icon_url: "https://cdn.example.com/stripe.svg", + spec_url: "https://example.com/stripe.json", +}; + +const github: OpenAPIRegistryEntry = { + name: "github", + title: "GitHub", + description: "Code hosting API", + icon_url: "https://cdn.example.com/github.svg", + spec_url: "https://example.com/github.json", +}; + +describe("OpenAPIQuickPicker", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders one selectable entry per registry API", async () => { + vi.mocked(fetchOpenAPIRegistry).mockResolvedValue({ apis: [stripe, github] }); + + render(); + + expect(await screen.findByRole("button", { name: /Stripe/ })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /GitHub/ })).toBeInTheDocument(); + expect(screen.getByText("Popular APIs")).toBeInTheDocument(); + }); + + it("passes the whole registry entry to onSelect when one is clicked", async () => { + vi.mocked(fetchOpenAPIRegistry).mockResolvedValue({ apis: [stripe, github] }); + const onSelect = vi.fn(); + + render(); + await userEvent.click(await screen.findByRole("button", { name: /Stripe/ })); + + expect(onSelect).toHaveBeenCalledWith(stripe); + }); + + it("renders nothing when the registry is empty", async () => { + vi.mocked(fetchOpenAPIRegistry).mockResolvedValue({ apis: [] }); + + const { container } = render(); + + await waitFor(() => expect(container).toBeEmptyDOMElement()); + }); + + it("renders nothing when the registry fetch fails", async () => { + vi.mocked(fetchOpenAPIRegistry).mockRejectedValue(new Error("boom")); + + const { container } = render(); + + await waitFor(() => expect(container).toBeEmptyDOMElement()); + }); + + it("does not fetch without an access token", () => { + render(); + + expect(fetchOpenAPIRegistry).not.toHaveBeenCalled(); + }); + + it("falls back to a letter avatar when the icon fails to load", async () => { + vi.mocked(fetchOpenAPIRegistry).mockResolvedValue({ apis: [stripe] }); + + render(); + + fireEvent.error(await screen.findByAltText("Stripe")); + + await waitFor(() => expect(screen.queryByAltText("Stripe")).not.toBeInTheDocument()); + expect(screen.getByText("S")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/OpenAPIQuickPicker.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/OpenAPIQuickPicker.tsx index 0aec81fdf4b5..d8e153a9699a 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/OpenAPIQuickPicker.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/OpenAPIQuickPicker.tsx @@ -1,5 +1,6 @@ import React, { useEffect, useState } from "react"; -import { Spin } from "antd"; +import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; +import { cn } from "@/lib/cva.config"; import { fetchOpenAPIRegistry } from "@/components/networking"; export interface OpenAPIKeyTool { @@ -49,9 +50,9 @@ const OpenAPIQuickPicker: React.FC = ({ accessToken, se if (loading) { return (
- Popular APIs + Popular APIs
- +
); @@ -61,7 +62,7 @@ const OpenAPIQuickPicker: React.FC = ({ accessToken, se return (
- Popular APIs + Popular APIs
{apis.map((api) => { @@ -73,32 +74,30 @@ const OpenAPIQuickPicker: React.FC = ({ accessToken, se type="button" title={api.description} onClick={() => onSelect(api)} - className={`flex flex-col items-center gap-1.5 p-3 rounded-lg border transition-all cursor-pointer - ${ - isSelected - ? "border-blue-500 bg-blue-50 shadow-xs" - : "border-gray-200 hover:border-blue-300 hover:bg-gray-50" - }`} + className={cn( + "flex cursor-pointer flex-col items-center gap-1.5 rounded-lg border p-3 transition-all", + isSelected ? "border-primary bg-accent shadow-xs" : "border-border hover:bg-accent", + )} > {imgFailed ? ( - + {api.title.charAt(0)} ) : ( {api.title} handleImgError(api.name)} /> )} - {api.title} + {api.title} ); })}
-

+

Select an API to pre-fill the spec URL and OAuth 2.0 settings, or enter your own spec URL below.

diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/TruePassthroughWarning.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/TruePassthroughWarning.test.tsx new file mode 100644 index 000000000000..18a32a384c56 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/TruePassthroughWarning.test.tsx @@ -0,0 +1,23 @@ +import { render, screen } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import TruePassthroughWarning from "./TruePassthroughWarning"; +import { AUTH_TYPE } from "@/components/mcp_tools/types"; + +describe("TruePassthroughWarning", () => { + it("warns when auth type is true_passthrough", () => { + render(); + + expect(screen.getByText("True Passthrough disables LiteLLM authentication for this server")).toBeInTheDocument(); + expect(screen.getByText(/Anyone who can reach the gateway can call this server/)).toBeInTheDocument(); + }); + + it("renders nothing for any other auth type", () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it("renders nothing when no auth type is set", () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/TruePassthroughWarning.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/TruePassthroughWarning.tsx index 9c57cbd7d145..b017fc4f1b4a 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/TruePassthroughWarning.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/TruePassthroughWarning.tsx @@ -1,5 +1,6 @@ import React from "react"; -import { Alert } from "antd"; +import { TriangleAlert } from "lucide-react"; +import { Alert, AlertDescription, AlertTitle } from "@/components/shared/Alert"; import { AUTH_TYPE } from "@/components/mcp_tools/types"; /** @@ -10,12 +11,15 @@ import { AUTH_TYPE } from "@/components/mcp_tools/types"; export default function TruePassthroughWarning({ authType }: { authType?: string | null }) { if (authType !== AUTH_TYPE.TRUE_PASSTHROUGH) return null; return ( - + + + True Passthrough disables LiteLLM authentication for this server + + Anyone who can reach the gateway can call this server without a LiteLLM key. The caller's Authorization + header is forwarded to the upstream verbatim, per-key and per-team rate limits and spend tracking do not apply, + and the upstream is fully responsible for authenticating callers. Choose OAuth Delegate instead if callers + should still authenticate to LiteLLM. + + ); } diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_connection_status.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_connection_status.tsx index 469f372409c1..cb02fedab662 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_connection_status.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_connection_status.tsx @@ -1,7 +1,10 @@ import React from "react"; -import { Button, Spin, Alert, Collapse } from "antd"; -import { CheckCircleOutlined, ExclamationCircleOutlined, ReloadOutlined, ToolOutlined } from "@ant-design/icons"; -import { Card, Title, Text } from "@tremor/react"; +import { CircleCheck, CircleAlert, RefreshCw, Wrench, Info } from "lucide-react"; +import { Alert, AlertDescription, AlertTitle } from "@/components/shared/Alert"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; +import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; interface MCPConnectionStatusProps { formValues: Record; @@ -31,27 +34,26 @@ const MCPConnectionStatus: React.FC = ({ } return ( - +
- - Connection Status + +

Connection Status

{!canFetchTools && (formValues.url || formValues.spec_path) && ( -
- - Complete required fields to test connection -
- Fill in URL, Transport, and Authentication to test MCP server connection +
+ +

Complete required fields to test connection

+

Fill in URL, Transport, and Authentication to test MCP server connection

)} {canFetchTools && (
-
+
- +

{isLoadingTools ? "Testing connection to MCP server..." : tools.length > 0 @@ -61,97 +63,84 @@ const MCPConnectionStatus: React.FC = ({ ? "Ready to submit" : "Connection failed" : "Ready to test connection"} - -
- Server: {formValues.url || formValues.spec_path} +

+

Server: {formValues.url || formValues.spec_path}

{isLoadingTools && ( -
- - Connecting... +
+ +

Connecting...

)} {!isLoadingTools && !toolsError && tools.length > 0 && ( -
- - Connected +
+ +

Connected

)} {toolsError && !isPreviewForbidden && ( -
- - Failed +
+ +

Failed

)}
{isLoadingTools && ( -
- - Testing connection and loading tools... +
+ +

Testing connection and loading tools...

)} {toolsError && isPreviewForbidden && ( - + + + Tool preview unavailable + {toolsError} + )} {toolsError && !isPreviewForbidden && ( - -
{toolsError}
- {toolsErrorStackTrace && ( - - {toolsErrorStackTrace} - - ), - }, - ]} - style={{ marginTop: "12px" }} + + + Connection Failed + +
{toolsError}
+ {toolsErrorStackTrace && ( + + + Stack Trace + + } /> - )} -
- } - type="error" - showIcon - action={ - - } - /> +
+ )} {!isLoadingTools && tools.length === 0 && !toolsError && ( -
- - Connection successful! -
- No tools found for this MCP server +
+ +

Connection successful!

+

No tools found for this MCP server

)}
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_discovery.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_discovery.test.tsx new file mode 100644 index 000000000000..4e2456ab7a40 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_discovery.test.tsx @@ -0,0 +1,118 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import MCPDiscovery from "./mcp_discovery"; +import { fetchDiscoverableMCPServers } from "@/components/networking"; +import type { DiscoverableMCPServer } from "@/components/mcp_tools/types"; + +vi.mock("@/components/networking", () => ({ + fetchDiscoverableMCPServers: vi.fn(), +})); + +const githubServer = { + name: "github", + title: "GitHub", + description: "Code hosting", + category: "Developer Tools", + icon_url: "", +} as DiscoverableMCPServer; + +const slackServer = { + name: "slack", + title: "Slack", + description: "Team chat", + category: "Communication", + icon_url: "", +} as DiscoverableMCPServer; + +const defaultProps = { + isVisible: true, + onClose: vi.fn(), + onSelectServer: vi.fn(), + onCustomServer: vi.fn(), + accessToken: "tok", +}; + +describe("MCPDiscovery", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(fetchDiscoverableMCPServers).mockResolvedValue({ + servers: [githubServer, slackServer], + categories: ["Developer Tools", "Communication"], + }); + }); + + // Each category name renders twice: once as a filter pill (a button) and once + // as the heading of its group. Only the heading is not a button. + const groupHeading = (category: string) => screen.getAllByText(category).filter((el) => el.tagName !== "BUTTON"); + + it("lists every discoverable server grouped under its category", async () => { + render(); + + expect(await screen.findByText("GitHub")).toBeInTheDocument(); + expect(screen.getByText("Slack")).toBeInTheDocument(); + expect(groupHeading("Developer Tools")).toHaveLength(1); + expect(groupHeading("Communication")).toHaveLength(1); + expect(screen.getByText("Add MCP Server")).toBeInTheDocument(); + }); + + it("filters the list down to the chosen category", async () => { + render(); + await screen.findByText("GitHub"); + + await userEvent.click(screen.getByRole("button", { name: "Communication" })); + + await waitFor(() => expect(screen.queryByText("GitHub")).not.toBeInTheDocument()); + expect(screen.getByText("Slack")).toBeInTheDocument(); + }); + + it("filters the list by the search term", async () => { + render(); + await screen.findByText("GitHub"); + + await userEvent.type(screen.getByPlaceholderText("Search servers..."), "chat"); + + await waitFor(() => expect(screen.queryByText("GitHub")).not.toBeInTheDocument()); + expect(screen.getByText("Slack")).toBeInTheDocument(); + }); + + it("hands the picked server back to the caller", async () => { + const onSelectServer = vi.fn(); + render(); + + await userEvent.click(await screen.findByText("GitHub")); + + expect(onSelectServer).toHaveBeenCalledWith(githubServer); + }); + + it("offers a custom-server escape hatch", async () => { + const onCustomServer = vi.fn(); + render(); + + await userEvent.click(await screen.findByRole("button", { name: "+ Custom Server" })); + + expect(onCustomServer).toHaveBeenCalled(); + }); + + it("surfaces a fetch failure", async () => { + vi.mocked(fetchDiscoverableMCPServers).mockRejectedValue(new Error("registry down")); + + render(); + + expect(await screen.findByText(/Failed to load servers: registry down/)).toBeInTheDocument(); + }); + + it("offers the custom-server link when nothing matches", async () => { + vi.mocked(fetchDiscoverableMCPServers).mockResolvedValue({ servers: [], categories: [] }); + + render(); + + expect(await screen.findByText(/No servers found/)).toBeInTheDocument(); + }); + + it("does not fetch while hidden", () => { + render(); + + expect(fetchDiscoverableMCPServers).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_discovery.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_discovery.tsx index 6fcff011ba6d..b2c181a91126 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_discovery.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_discovery.tsx @@ -1,13 +1,15 @@ import React, { useState, useMemo, useEffect } from "react"; -import { Modal, Input, Typography } from "antd"; +import { Search } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group"; +import { Skeleton } from "@/components/ui/skeleton"; +import { cn } from "@/lib/cva.config"; import { fetchDiscoverableMCPServers } from "@/components/networking"; import { DiscoverableMCPServer, DiscoverMCPServersResponse } from "@/components/mcp_tools/types"; import { mcpLogoImg } from "./create_mcp_server"; import { resolveLogoSrc } from "@/lib/assetPaths"; -const { Search } = Input; -const { Text } = Typography; - interface MCPDiscoveryProps { isVisible: boolean; onClose: () => void; @@ -16,12 +18,21 @@ interface MCPDiscoveryProps { accessToken: string | null; } -const INITIAL_COLORS = ["#3B82F6", "#10B981", "#F59E0B", "#EF4444", "#8B5CF6", "#EC4899", "#06B6D4", "#84CC16"]; +const INITIAL_COLORS = [ + "bg-blue-500", + "bg-emerald-500", + "bg-amber-500", + "bg-red-500", + "bg-violet-500", + "bg-pink-500", + "bg-cyan-500", + "bg-lime-500", +]; function getInitialAvatar(name: string) { const initial = name.charAt(0).toUpperCase(); const colorIndex = name.split("").reduce((acc, ch) => acc + ch.charCodeAt(0), 0) % INITIAL_COLORS.length; - return { initial, backgroundColor: INITIAL_COLORS[colorIndex] }; + return { initial, backgroundClass: INITIAL_COLORS[colorIndex] }; } const MCPDiscovery: React.FC = ({ @@ -91,214 +102,126 @@ const MCPDiscovery: React.FC = ({ }, [filteredServers]); return ( - -
- MCP Logo -

Add MCP Server

+ !open && onClose()}> + + +
+
+ MCP Logo + Add MCP Server +
+ +
+
+ +
+ {/* Filter pills */} +
+ {["All", ...categories].map((cat) => { + const isSelected = selectedCategory === cat; + return ( + + ); + })}
- -
- } - open={isVisible} - onCancel={onClose} - footer={null} - width={1000} - className="top-8" - styles={{ - body: { padding: "24px", maxHeight: "70vh", overflowY: "auto" }, - header: { padding: "24px 24px 0 24px", border: "none" }, - }} - > - {/* Filter pills */} -
- {["All", ...categories].map((cat) => { - const isSelected = selectedCategory === cat; - return ( - - ); - })} -
- - {/* Search */} - setSearchQuery(e.target.value)} - style={{ marginBottom: 16 }} - allowClear - /> - {/* Loading skeleton */} - {loading && ( -
- {Array.from({ length: 8 }).map((_, i) => ( -
+ + + + setSearchQuery(e.target.value)} /> - ))} -
- )} - - {error && ( -
- Failed to load servers: {error} -
- )} - - {!loading && !error && filteredServers.length === 0 && ( -
- - No servers found.{" "} - - Add a custom server - - -
- )} + + + {/* Loading skeleton */} + {loading && ( +
+ {Array.from({ length: 8 }).map((_, i) => ( + + ))} +
+ )} - {/* Server list grouped by category — 2 columns */} - {!loading && - !error && - Object.entries(groupedServers).map(([category, categoryServers]) => ( -
-
- {category} + {error && ( +
+

Failed to load servers: {error}

-
- {categoryServers.map((server) => { - const avatar = getInitialAvatar(server.title || server.name); - return ( -
onSelectServer(server)} - style={{ - display: "flex", - alignItems: "center", - padding: "8px 10px", - borderRadius: 6, - cursor: "pointer", - transition: "background 0.1s ease", - }} - onMouseEnter={(e) => { - e.currentTarget.style.background = "#f9fafb"; - }} - onMouseLeave={(e) => { - e.currentTarget.style.background = "transparent"; - }} - > - {server.icon_url ? ( - {server.title} { - const target = e.currentTarget; - target.style.display = "none"; - const next = target.nextElementSibling as HTMLElement; - if (next) next.style.display = "flex"; - }} - /> - ) : null} -
- {avatar.initial} -
- - {server.title || server.name} - - -
- ); - })} + )} + + {!loading && !error && filteredServers.length === 0 && ( +
+

+ No servers found.{" "} + +

-
- ))} - + )} + + {/* Server list grouped by category — 2 columns */} + {!loading && + !error && + Object.entries(groupedServers).map(([category, categoryServers]) => ( +
+
+ {category} +
+
+ {categoryServers.map((server) => { + const avatar = getInitialAvatar(server.title || server.name); + return ( +
onSelectServer(server)} + className="flex cursor-pointer items-center rounded-md px-2.5 py-2 transition-colors hover:bg-accent" + > + {server.icon_url ? ( + {server.title} { + const target = e.currentTarget; + target.style.display = "none"; + const next = target.nextElementSibling as HTMLElement; + if (next) next.style.display = "flex"; + }} + /> + ) : null} +
+ {avatar.initial} +
+ {server.title || server.name} + +
+ ); + })} +
+
+ ))} +
+ +
); }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_cost_config.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_cost_config.test.tsx new file mode 100644 index 000000000000..a4547e4923f4 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_cost_config.test.tsx @@ -0,0 +1,87 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, it, expect, vi } from "vitest"; +import MCPServerCostConfig from "./mcp_server_cost_config"; + +const tools = [ + { name: "search", description: "Search the index" }, + { name: "fetch", description: "Fetch a document" }, +]; + +describe("MCPServerCostConfig", () => { + it("renders the default cost field with the current value", () => { + render(); + + expect(screen.getByText("Cost Configuration")).toBeInTheDocument(); + expect(screen.getByPlaceholderText("0.0000")).toHaveValue("0.0200"); + }); + + it("reports the edited default cost as a number", async () => { + const onChange = vi.fn(); + render(); + + await userEvent.type(screen.getByPlaceholderText("0.0000"), "0.5"); + + expect(onChange).toHaveBeenLastCalledWith({ default_cost_per_query: 0.5 }); + }); + + it("disables the default cost field when disabled", () => { + render(); + + expect(screen.getByPlaceholderText("0.0000")).toBeDisabled(); + }); + + it("hides the per-tool section when the server exposes no tools", () => { + render(); + + expect(screen.queryByText("Available Tools")).not.toBeInTheDocument(); + }); + + it("offers a per-tool override for every tool once tools are loaded", async () => { + render(); + + await userEvent.click(screen.getByText("Available Tools")); + + expect(screen.getByText("search")).toBeInTheDocument(); + expect(screen.getByText("Search the index")).toBeInTheDocument(); + expect(screen.getByText("fetch")).toBeInTheDocument(); + expect(screen.getAllByPlaceholderText("Use default")).toHaveLength(2); + }); + + it("merges a per-tool override into the existing cost map", async () => { + const onChange = vi.fn(); + render( + , + ); + + await userEvent.click(screen.getByText("Available Tools")); + await userEvent.type(screen.getAllByPlaceholderText("Use default")[0], "3"); + + expect(onChange).toHaveBeenLastCalledWith({ + default_cost_per_query: 0.01, + tool_name_to_cost_per_query: { fetch: 0.2, search: 3 }, + }); + }); + + it("summarises the configured costs", () => { + render( + , + ); + + expect(screen.getByText("• Default cost: $0.0100 per query")).toBeInTheDocument(); + expect(screen.getByText("• search: $0.2500 per query")).toBeInTheDocument(); + }); + + it("shows no summary when nothing is configured", () => { + render(); + + expect(screen.queryByText("Cost Summary:")).not.toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_cost_config.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_cost_config.tsx index 89c41693a4cd..e0d44718ec5e 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_cost_config.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_cost_config.tsx @@ -1,7 +1,10 @@ -import React from "react"; -import { Tooltip, InputNumber, Collapse, Badge } from "antd"; -import { InfoCircleOutlined, DollarOutlined, ToolOutlined } from "@ant-design/icons"; -import { Card, Title, Text } from "@tremor/react"; +import React, { useState } from "react"; +import { Info, DollarSign, Wrench } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Card } from "@/components/ui/card"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; +import { InputGroup, InputGroupAddon, InputGroupInput, InputGroupText } from "@/components/ui/input-group"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { MCPServerCostInfo } from "@/components/mcp_tools/types"; interface MCPServerCostConfigProps { @@ -11,6 +14,47 @@ interface MCPServerCostConfigProps { disabled?: boolean; } +interface CostInputProps { + value: number | null | undefined; + placeholder: string; + disabled?: boolean; + className?: string; + onChange: (cost: number | null) => void; +} + +/** + * Costs are shown to four decimal places when idle, but the field keeps the raw + * keystrokes while it is being edited so partial input like "0." survives. + */ +const CostInput: React.FC = ({ value, placeholder, disabled, className, onChange }) => { + const [draft, setDraft] = useState(null); + const display = draft ?? (value === null || value === undefined ? "" : value.toFixed(4)); + + const handleChange = (next: string) => { + setDraft(next); + const parsed = Number(next); + onChange(next.trim() === "" || Number.isNaN(parsed) ? null : parsed); + }; + + return ( + + + $ + + setDraft(value === null || value === undefined ? "" : String(value))} + onBlur={() => setDraft(null)} + onChange={(e) => handleChange(e.target.value)} + /> + + ); +}; + const MCPServerCostConfig: React.FC = ({ value = {}, onChange, @@ -37,124 +81,126 @@ const MCPServerCostConfig: React.FC = ({ }; return ( - -
-
- - Cost Configuration - - - -
- -
-
- - - - Set a default cost for all tool calls to this server - + + +
+
+ +

Cost Configuration

+ + } + /> + + Configure costs for this MCP server's tool calls. Set a default rate and per-tool overrides. + +
- {tools.length > 0 && ( -
-
- {(value.default_cost_per_query || - (value.tool_name_to_cost_per_query && Object.keys(value.tool_name_to_cost_per_query).length > 0)) && ( -
- Cost Summary: -
- {value.default_cost_per_query && ( - - • Default cost: ${value.default_cost_per_query.toFixed(4)} per query - - )} - {value.tool_name_to_cost_per_query && - Object.entries(value.tool_name_to_cost_per_query).map( - ([toolName, cost]) => - cost !== null && - cost !== undefined && ( - - • {toolName}: ${cost.toFixed(4)} per query - - ), + {tools.length > 0 && ( +
+ + + + + Available Tools + {tools.length} + + } + /> + +
+ {tools.map((tool, index) => ( +
+
+

{tool.name}

+ {tool.description && ( +

{tool.description}

+ )} +
+
+ handleToolCostChange(tool.name, cost)} + /> +
+
+ ))} +
+
+
+
+ )} +
+ + {(value.default_cost_per_query || + (value.tool_name_to_cost_per_query && Object.keys(value.tool_name_to_cost_per_query).length > 0)) && ( +
+

Cost Summary:

+
+ {value.default_cost_per_query && ( +

+ • Default cost: ${value.default_cost_per_query.toFixed(4)} per query +

)} + {value.tool_name_to_cost_per_query && + Object.entries(value.tool_name_to_cost_per_query).map( + ([toolName, cost]) => + cost !== null && + cost !== undefined && ( +

+ • {toolName}: ${cost.toFixed(4)} per query +

+ ), + )} +
-
- )} -
-
+ )} +
+ + ); }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_cost_display.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_cost_display.test.tsx new file mode 100644 index 000000000000..466341405c89 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_cost_display.test.tsx @@ -0,0 +1,48 @@ +import { render, screen } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import MCPServerCostDisplay from "./mcp_server_cost_display"; + +describe("MCPServerCostDisplay", () => { + it("explains that calls are free when no cost config exists", () => { + render(); + + expect( + screen.getByText("No cost configuration set for this server. Tool calls will be charged at $0.00 per tool call."), + ).toBeInTheDocument(); + }); + + it("treats a config with only a null default cost as unconfigured", () => { + render(); + + expect(screen.getByText(/No cost configuration set for this server/)).toBeInTheDocument(); + }); + + it("shows a zero default cost rather than falling back to the empty state", () => { + render(); + + expect(screen.getByText("Default Cost per Query")).toBeInTheDocument(); + expect(screen.getByText("$0.0000")).toBeInTheDocument(); + }); + + it("renders the default cost to four decimal places and summarises it", () => { + render(); + + expect(screen.getByText("$0.0125")).toBeInTheDocument(); + expect(screen.getByText("• Default cost: $0.0125 per query")).toBeInTheDocument(); + }); + + it("lists each tool-specific cost and counts them in the summary", () => { + render( + , + ); + + expect(screen.getByText("search")).toBeInTheDocument(); + expect(screen.getByText("$0.5000 per query")).toBeInTheDocument(); + expect(screen.getByText("fetch")).toBeInTheDocument(); + expect(screen.getByText("$0.2500 per query")).toBeInTheDocument(); + expect(screen.queryByText("skipped")).not.toBeInTheDocument(); + expect(screen.getByText("• 3 tool(s) with custom pricing")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_cost_display.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_cost_display.tsx index f26f7ba23202..30fd269447e3 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_cost_display.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_cost_display.tsx @@ -1,5 +1,4 @@ import React from "react"; -import { Text } from "@tremor/react"; import { MCPServerCostInfo } from "@/components/mcp_tools/types"; interface MCPServerCostDisplayProps { @@ -15,12 +14,12 @@ const MCPServerCostDisplay: React.FC = ({ costConfig if (!hasCostConfig) { return ( -
+
-
- +
+

No cost configuration set for this server. Tool calls will be charged at $0.00 per tool call. - +

@@ -28,28 +27,28 @@ const MCPServerCostDisplay: React.FC = ({ costConfig } return ( -
+
{hasDefaultCost && costConfig?.default_cost_per_query !== undefined && costConfig?.default_cost_per_query !== null && (
- Default Cost per Query -
${costConfig.default_cost_per_query.toFixed(4)}
+

Default Cost per Query

+
${costConfig.default_cost_per_query.toFixed(4)}
)} {hasToolCosts && costConfig?.tool_name_to_cost_per_query && (
- Tool-Specific Costs +

Tool-Specific Costs

{Object.entries(costConfig.tool_name_to_cost_per_query).map( ([toolName, cost]) => cost !== null && cost !== undefined && ( -
- {toolName} - ${cost.toFixed(4)} per query +
+

{toolName}

+

${cost.toFixed(4)} per query

), )} @@ -57,20 +56,20 @@ const MCPServerCostDisplay: React.FC = ({ costConfig
)} -
- Cost Summary: +
+

Cost Summary:

{hasDefaultCost && costConfig?.default_cost_per_query !== undefined && costConfig?.default_cost_per_query !== null && ( - +

• Default cost: ${costConfig.default_cost_per_query.toFixed(4)} per query - +

)} {hasToolCosts && costConfig?.tool_name_to_cost_per_query && ( - +

• {Object.keys(costConfig.tool_name_to_cost_per_query).length} tool(s) with custom pricing - +

)}
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_view.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_view.test.tsx new file mode 100644 index 000000000000..02d168bf7f45 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_view.test.tsx @@ -0,0 +1,152 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { MCPServerView } from "./mcp_server_view"; +import type { MCPServer } from "@/components/mcp_tools/types"; + +vi.mock(".", () => ({ + MCPToolsViewer: () =>
tools viewer
, +})); + +vi.mock("./mcp_server_edit", () => ({ + default: () =>
edit form
, + EDIT_OAUTH_UI_STATE_KEY: "litellm-mcp-oauth-edit-state", +})); + +const baseServer = { + server_id: "srv-1", + server_name: "demo server", + alias: "demo_alias", + description: "A demo MCP server", + transport: "http", + url: "https://example.com/mcp", + auth_type: "api_key", +} as MCPServer; + +const renderView = (overrides: Partial = {}, props: Record = {}) => + render( + , + ); + +describe("MCPServerView", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + // Name, alias and description each label the header and a Settings row, so + // only the server id is unique to the header. + it("shows the server identity in the header", () => { + renderView(); + + expect(screen.getByText("srv-1")).toBeInTheDocument(); + expect(screen.getAllByText("demo server").length).toBeGreaterThan(0); + expect(screen.getAllByText("A demo MCP server").length).toBeGreaterThan(0); + expect(screen.getAllByText("demo_alias").length).toBeGreaterThan(0); + }); + + it("falls back to a placeholder name when the server has neither name nor alias", () => { + renderView({ server_name: undefined, alias: undefined }); + + expect(screen.getByText("Unnamed Server")).toBeInTheDocument(); + }); + + // "Transport" and "Authentication" label both an Overview card and a Settings + // row, so only Overview-exclusive labels identify the Overview panel. + it("summarises the connection on the Overview tab", () => { + renderView(); + + expect(screen.getByText("Host URL")).toBeInTheDocument(); + expect(screen.getByText("Cost Configuration")).toBeInTheDocument(); + expect(screen.getAllByText("HTTP").length).toBeGreaterThan(0); + expect(screen.getAllByText("https://example.com/mcp").length).toBeGreaterThan(0); + }); + + it("offers a Settings tab to proxy admins only", () => { + renderView(); + expect(screen.getByRole("tab", { name: "Settings" })).toBeInTheDocument(); + }); + + it("hides the Settings tab from non-admins", () => { + renderView({}, { isProxyAdmin: false }); + expect(screen.queryByRole("tab", { name: "Settings" })).not.toBeInTheDocument(); + }); + + it("opens the tools viewer on the MCP Tools tab", async () => { + renderView(); + + await userEvent.click(screen.getByRole("tab", { name: "MCP Tools" })); + + expect(await screen.findByText("tools viewer")).toBeInTheDocument(); + }); + + it("shows the read-only settings summary before editing", async () => { + renderView({ allow_all_keys: true, available_on_public_internet: false }); + + await userEvent.click(screen.getByRole("tab", { name: "Settings" })); + + expect(await screen.findByText("MCP Server Settings")).toBeInTheDocument(); + expect(screen.getByText("Allow All Keys")).toBeInTheDocument(); + expect(screen.getByText("Enabled")).toBeInTheDocument(); + expect(screen.getByText("Internal only")).toBeInTheDocument(); + expect(screen.queryByText("edit form")).not.toBeInTheDocument(); + }); + + it("swaps in the edit form when Edit Settings is pressed", async () => { + renderView(); + + await userEvent.click(screen.getByRole("tab", { name: "Settings" })); + await userEvent.click(await screen.findByRole("button", { name: "Edit Settings" })); + + expect(await screen.findByText("edit form")).toBeInTheDocument(); + }); + + it("opens straight into the edit form when isEditing is set", async () => { + renderView({}, { isEditing: true }); + + await userEvent.click(screen.getByRole("tab", { name: "Settings" })); + + expect(await screen.findByText("edit form")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Edit Settings" })).not.toBeInTheDocument(); + }); + + it("opens on the tab named by initialTabIndex", async () => { + renderView({}, { initialTabIndex: 1 }); + + expect(await screen.findByText("tools viewer")).toBeInTheDocument(); + }); + + it("returns to the server list when Back is pressed", async () => { + const onBack = vi.fn(); + renderView({}, { onBack }); + + await userEvent.click(screen.getByRole("button", { name: /Back to All Servers/ })); + + expect(onBack).toHaveBeenCalled(); + }); + + it("lists the allowed tools, or says all tools are enabled", async () => { + renderView({ allowed_tools: ["search", "fetch"] }); + await userEvent.click(screen.getByRole("tab", { name: "Settings" })); + + expect(await screen.findByText("search")).toBeInTheDocument(); + expect(screen.getByText("fetch")).toBeInTheDocument(); + expect(screen.queryByText("All tools enabled")).not.toBeInTheDocument(); + }); + + it("says all tools are enabled when no allowlist is stored", async () => { + renderView({ allowed_tools: [] }); + await userEvent.click(screen.getByRole("tab", { name: "Settings" })); + + expect(await screen.findByText("All tools enabled")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_view.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_view.tsx index 49df8206c8f1..736327409d6d 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_view.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_view.tsx @@ -1,6 +1,9 @@ import React, { useState } from "react"; -import { ArrowLeftIcon, EyeIcon, EyeOffIcon } from "@heroicons/react/outline"; -import { Title, Card, Button, Text, Grid, TabGroup, TabList, TabPanel, TabPanels, Tab, Icon } from "@tremor/react"; +import { ArrowLeft, Eye, EyeOff } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { MCPServer, handleTransport, handleAuth } from "@/components/mcp_tools/types"; // TODO: Move Tools viewer from index file @@ -11,7 +14,6 @@ import MCPServerCostDisplay from "./mcp_server_cost_display"; import { getMaskedAndFullUrl } from "./utils"; import { copyToClipboard as utilCopyToClipboard } from "@/utils/dataUtils"; import { CheckIcon, CopyIcon } from "lucide-react"; -import { Button as AntdButton } from "antd"; interface MCPServerViewProps { mcpServer: MCPServer; @@ -86,335 +88,306 @@ export const MCPServerView: React.FC = ({ } }; - const getTransportBadge = (transport: string) => { - const label = transport.toUpperCase(); - return ( - - {label} - - ); - }; + const getTransportBadge = (transport: string) => {transport.toUpperCase()}; - const getAuthBadge = (authType: string) => { - return ( - - {authType} - - ); - }; + const getAuthBadge = (authType: string) => {authType}; return ( -
+
-
- {mcpServer.server_name || mcpServer.alias || "Unnamed Server"} - : } +

{mcpServer.server_name || mcpServer.alias || "Unnamed Server"}

+ {mcpServer.alias && mcpServer.server_name && mcpServer.alias !== mcpServer.server_name && ( - + {mcpServer.alias} - + )}
-
- {mcpServer.server_id} - : } +
+

{mcpServer.server_id}

+
- {mcpServer.description && {mcpServer.description}} + {mcpServer.description &&

{mcpServer.description}

}
- {/* TODO: magic number for index */} - - - {[ - Overview, - MCP Tools, - ...(isProxyAdmin ? [Settings] : []), - ]} - - - - {/* Overview Panel */} - - - - Transport -
- {getTransportBadge( - handleTransport(mcpServer.transport ?? undefined, mcpServer.spec_path ?? undefined), - )} -
-
- - - Authentication -
{getAuthBadge(handleAuth(mcpServer.auth_type ?? undefined))}
-
+ setSelectedTabIndex(Number(v))}> + + + Overview + + + MCP Tools + + {isProxyAdmin && ( + + Settings + + )} + - - Host URL -
- - {renderUrlWithToggle(mcpServer.url, showFullUrl)} - - {/* Only proxy admins may reveal the raw URL — non-admins - receive a sanitized server object from the backend - with `url=null`, but hide the toggle anyway as - defense-in-depth in case the URL ever leaks back - into the response. */} - {hasToken && isProxyAdmin && ( - - )} -
-
-
- - Cost Configuration + {/* Overview Panel */} + +
+ +

Transport

- + {getTransportBadge(handleTransport(mcpServer.transport ?? undefined, mcpServer.spec_path ?? undefined))}
- - {/* Tool Panel */} - - - + +

Authentication

+
{getAuthBadge(handleAuth(mcpServer.auth_type ?? undefined))}
+
- {/* Settings Panel */} - - -
- MCP Server Settings - {editing ? null : ( - )}
- {editing ? ( - setEditing(false)} - onSuccess={handleSuccess} - availableAccessGroups={availableAccessGroups} - /> - ) : ( -
-
- Server Name -
- {mcpServer.server_name || } -
+ +
+ +

Cost Configuration

+
+ +
+
+ + + {/* Tool Panel */} + + + + + {/* Settings Panel */} + + +
+

MCP Server Settings

+ {editing ? null : ( + + )} +
+ {editing ? ( + setEditing(false)} + onSuccess={handleSuccess} + availableAccessGroups={availableAccessGroups} + /> + ) : ( +
+
+

Server Name

+
+ {mcpServer.server_name || }
-
- Alias -
- {mcpServer.alias || } -
+
+
+

Alias

+
+ {mcpServer.alias || }
-
- Description -
- {mcpServer.description || } -
+
+
+

Description

+
+ {mcpServer.description || }
-
- URL -
- {renderUrlWithToggle(mcpServer.url, showFullUrl)} - {hasToken && ( - - )} -
+
+
+

URL

+
+ {renderUrlWithToggle(mcpServer.url, showFullUrl)} + {hasToken && ( + + )}
-
- Transport -
- {getTransportBadge(handleTransport(mcpServer.transport, mcpServer.spec_path))} -
+
+
+

Transport

+
+ {getTransportBadge(handleTransport(mcpServer.transport, mcpServer.spec_path))}
-
- Authentication -
{getAuthBadge(handleAuth(mcpServer.auth_type))}
+
+
+

Authentication

+
{getAuthBadge(handleAuth(mcpServer.auth_type))}
+
+
+

Extra Headers

+
+ {mcpServer.extra_headers && mcpServer.extra_headers.length > 0 ? ( + mcpServer.extra_headers.join(", ") + ) : ( + + )}
-
- Extra Headers -
- {mcpServer.extra_headers && mcpServer.extra_headers.length > 0 ? ( - mcpServer.extra_headers.join(", ") - ) : ( - - )} -
+
+
+

Allow All Keys

+
+ {mcpServer.allow_all_keys ? ( + + + Enabled + + ) : ( + Disabled + )}
-
- Allow All Keys -
- {mcpServer.allow_all_keys ? ( - - - Enabled - - ) : ( - - Disabled - - )} -
+
+
+

Network Access

+
+ {mcpServer.available_on_public_internet ? ( + + + Public + + ) : ( + + + Internal only + + )}
-
- Network Access +
+ {handleAuth(mcpServer.auth_type) === "oauth2" && ( +
+

Delegate Auth to Upstream

- {mcpServer.available_on_public_internet ? ( - - - Public - + {mcpServer.delegate_auth_to_upstream ? ( + + + Enabled (PKCE passthrough) + ) : ( - - - Internal only - + Disabled )}
- {handleAuth(mcpServer.auth_type) === "oauth2" && ( -
- Delegate Auth to Upstream + )} + {handleAuth(mcpServer.auth_type) !== "oauth2" && + Array.isArray(mcpServer.extra_headers) && + mcpServer.extra_headers.some((h) => typeof h === "string" && h.toLowerCase() === "authorization") && ( +
+

OAuth Pass-through

- {mcpServer.delegate_auth_to_upstream ? ( - - - Enabled (PKCE passthrough) - + {mcpServer.oauth_passthrough ? ( + + + Enabled + ) : ( - - Disabled - + Disabled )}
)} - {handleAuth(mcpServer.auth_type) !== "oauth2" && - Array.isArray(mcpServer.extra_headers) && - mcpServer.extra_headers.some( - (h) => typeof h === "string" && h.toLowerCase() === "authorization", - ) && ( -
- OAuth Pass-through -
- {mcpServer.oauth_passthrough ? ( - - - Enabled - - ) : ( - - Disabled - - )} -
+
+

Access Groups

+
+ {mcpServer.mcp_access_groups && mcpServer.mcp_access_groups.length > 0 ? ( +
+ {mcpServer.mcp_access_groups.map((group: any, index: number) => ( + + {typeof group === "string" ? group : group?.name ?? ""} + + ))}
+ ) : ( + )} -
- Access Groups -
- {mcpServer.mcp_access_groups && mcpServer.mcp_access_groups.length > 0 ? ( -
- {mcpServer.mcp_access_groups.map((group: any, index: number) => ( - - {typeof group === "string" ? group : group?.name ?? ""} - - ))} -
- ) : ( - - )} -
-
- Allowed Tools -
- {mcpServer.allowed_tools && mcpServer.allowed_tools.length > 0 ? ( -
- {mcpServer.allowed_tools.map((tool: string, index: number) => ( - - {tool} - - ))} -
- ) : ( - - All tools enabled - - )} -
+
+
+

Allowed Tools

+
+ {mcpServer.allowed_tools && mcpServer.allowed_tools.length > 0 ? ( +
+ {mcpServer.allowed_tools.map((tool: string, index: number) => ( + + {tool} + + ))} +
+ ) : ( + All tools enabled + )}
-
- Cost -
- -
+
+
+

Cost

+
+
- )} - - - - +
+ )} + + +
); }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_servers.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_servers.test.tsx index d61bc23c757f..f9f3d20ca155 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_servers.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_servers.test.tsx @@ -1,5 +1,6 @@ import React from "react"; -import { render, waitFor, screen, fireEvent, act } from "@testing-library/react"; +import { render, waitFor, screen, act, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { describe, it, expect, vi, beforeEach } from "vitest"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import MCPServers from "./mcp_servers"; @@ -307,36 +308,15 @@ describe("MCPServers", () => { expect(screen.getByText("Team B Server")).toBeInTheDocument(); expect(screen.getByText("Team A Server 2")).toBeInTheDocument(); - // Find the team select dropdown by looking for the "Team" label + // Find the team select by its "Team" label, then the combobox it labels const teamLabel = screen.getByText("Team"); - const teamSelectContainer = teamLabel.closest("div")?.querySelector(".ant-select"); - expect(teamSelectContainer).toBeTruthy(); + const teamSelect = within(teamLabel.parentElement!).getByRole("combobox"); - // Open the dropdown by clicking on the selector - const selectSelector = teamSelectContainer?.querySelector(".ant-select-selector"); - expect(selectSelector).toBeTruthy(); + await userEvent.click(teamSelect); - act(() => { - fireEvent.mouseDown(selectSelector!); - }); - - // Wait for dropdown to open - await waitFor( - () => { - const dropdownOptions = document.querySelectorAll(".ant-select-item-option"); - expect(dropdownOptions.length).toBeGreaterThan(0); - }, - { timeout: 5000 }, - ); - - // Find and click on "Team A" option - const dropdownOptions = document.querySelectorAll(".ant-select-item-option"); - const teamAOption = Array.from(dropdownOptions).find((option) => option.textContent?.includes("Team A")); - expect(teamAOption).toBeTruthy(); - - act(() => { - fireEvent.click(teamAOption!); - }); + // Pick the "Team A" option once the listbox opens + const teamAOption = await screen.findByText("Team A"); + await userEvent.click(teamAOption); // Wait for filtering to complete await waitFor(() => { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_servers.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_servers.tsx index f186fef22da2..79bc6a9bb379 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_servers.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_servers.tsx @@ -1,8 +1,21 @@ import { isAdminRole } from "@/utils/roles"; -import { QuestionCircleOutlined, SearchOutlined } from "@ant-design/icons"; -import { Button, Tab, TabGroup, TabList, TabPanel, TabPanels, Text, Title } from "@tremor/react"; +import { CircleHelp, Search } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; import NewBadge from "@/components/common_components/NewBadge"; -import { Descriptions, Empty, Input, Modal, Select, Spin, Tooltip, Typography } from "antd"; import React, { useEffect, useState, useMemo, useCallback } from "react"; import { useQuery } from "@tanstack/react-query"; import { useMCPServers } from "@/app/(dashboard)/hooks/mcpServers/useMCPServers"; @@ -75,7 +88,6 @@ const compareServers = (a: MCPServer, b: MCPServer, sort: SortKey): number => { } }; -const { Text: AntdText, Title: AntdTitle } = Typography; const EDIT_OAUTH_UI_STATE_KEY = "litellm-mcp-oauth-edit-state"; // Server id stashed by the Tools tab before an OBO OAuth redirect, read once at @@ -95,8 +107,6 @@ const readToolsOAuthServerId = (): string | null => { } }; -const { Option } = Select; - const MCPServers: React.FC = ({ accessToken, userRole, userID }) => { const { data: mcpServers, isLoading: isLoadingServers, refetch } = useMCPServers(); @@ -240,6 +250,15 @@ const MCPServers: React.FC = ({ accessToken, userRole, userID }) }, [serversWithHealth]); // Get unique MCP access groups from all servers + const teamSelectItems = React.useMemo( + () => ({ + all: isInternalUser ? "All Available Servers" : "All Servers", + personal: "Personal", + ...Object.fromEntries(uniqueTeams.map((team) => [team.team_id, team.team_alias || team.team_id])), + }), + [isInternalUser, uniqueTeams], + ); + const uniqueMcpAccessGroups = React.useMemo(() => { if (!serversWithHealth) return []; return Array.from( @@ -251,6 +270,14 @@ const MCPServers: React.FC = ({ accessToken, userRole, userID }) ); }, [serversWithHealth]); + const accessGroupSelectItems = React.useMemo( + () => ({ + all: "All Access Groups", + ...Object.fromEntries(uniqueMcpAccessGroups.map((group) => [group, group])), + }), + [uniqueMcpAccessGroups], + ); + // Filtering logic for both team and access group const filterServers = useCallback( (teamId: string, group: string) => { @@ -390,131 +417,135 @@ const MCPServers: React.FC = ({ accessToken, userRole, userID }) } return ( -
- -
- - This action is permanent and cannot be undone. All associated configurations will be removed. - - - {serverToDelete && ( -
- - {serverToDelete.server_name && ( - Name}> - - {serverToDelete.server_name} - - - )} - ID}> - - {serverToDelete.server_id} - - - {serverToDelete.url && ( - URL}> - - {serverToDelete.url} - - - )} - + +
+ !open && cancelDelete()}> + + + Delete MCP Server? + +
+

+ This action is permanent and cannot be undone. All associated configurations will be removed. +

+ + {serverToDelete && ( +
+ {serverToDelete.server_name && ( +
+
Name
+
{serverToDelete.server_name}
+
+ )} +
+
ID
+
{serverToDelete.server_id}
+
+ {serverToDelete.url && ( +
+
URL
+
{serverToDelete.url}
+
+ )} +
+ )}
- )} -
- - { - setModalVisible(false); - setPrefillData(null); - setDiscoveryVisible(true); - }} - /> -
-
-
- MCP Servers - {filteredServers.length > 0 && ( - - {filteredServers.length} - + + Cancel + + + + + { + setModalVisible(false); + setPrefillData(null); + setDiscoveryVisible(true); + }} + /> +
+
+
+

MCP Servers

+ {filteredServers.length > 0 && {filteredServers.length}} +
+

Configure and manage your MCP servers

+
+
+ {isAdminRole(userRole) && ( + + )} + {!isAdminRole(userRole) && ( + )}
- Configure and manage your MCP servers
-
- {isAdminRole(userRole) && ( - - )} - {!isAdminRole(userRole) && ( - - )} -
-
- setDiscoveryVisible(false)} - onSelectServer={(server: DiscoverableMCPServer) => { - setPrefillData(server); - setDiscoveryVisible(false); - setModalVisible(true); - }} - onCustomServer={() => { - setPrefillData(null); - setDiscoveryVisible(false); - setModalVisible(true); - }} - accessToken={accessToken} - /> - - -
- All Servers - Toolsets - Connect - {isAdminRole(userRole) && Semantic Filter} - {isAdminRole(userRole) && Network Settings} + setDiscoveryVisible(false)} + onSelectServer={(server: DiscoverableMCPServer) => { + setPrefillData(server); + setDiscoveryVisible(false); + setModalVisible(true); + }} + onCustomServer={() => { + setPrefillData(null); + setDiscoveryVisible(false); + setModalVisible(true); + }} + accessToken={accessToken} + /> + + + + All Servers + + + Toolsets + + + Connect + + {isAdminRole(userRole) && ( + + Semantic Filter + + )} + {isAdminRole(userRole) && ( + + Network Settings + + )} {isAdminRole(userRole) && ( - + Submitted MCPs - + )} -
-
- - + + {selectedServerId ? ( = ({ accessToken, userRole, userID })
-
+
- Team - handleTeamChange(v ?? "all")} + > + + + + + {isInternalUser ? "All Available Servers" : "All Servers"} - - - - {uniqueTeams.map((team) => ( - - ))} + + Personal + {uniqueTeams.map((team) => ( + + {team.team_alias || team.team_id} + + ))} +
-
+
- +

Access Group - - + + + } + /> + + An MCP Access Group is a set of users or teams that have permission to access specific MCP + servers. Use access groups to control and organize who can connect to which servers. + - +

- } - placeholder="Search by name, alias, URL, or ID" - value={searchQuery} - onChange={(e) => setSearchQuery(e.target.value)} - style={{ maxWidth: 320 }} - /> + + + + + setSearchQuery(e.target.value)} + /> +
- Sort +

Sort

-
+
{displayedServers.length} of {filteredServers.length} servers
{isLoadingServers ? ( -
- +
+ +

Loading MCP servers...

) : displayedServers.length === 0 ? ( -
- +
+

+ {filteredServers.length === 0 + ? "No MCP servers configured. Click '+ Add New MCP Server' to get started." + : "No servers match the current filters or search."} +

) : (
= ({ accessToken, userRole, userID })
)} - - + + - - + + - + {isAdminRole(userRole) && ( - + - + )} {isAdminRole(userRole) && ( - + - + )} {isAdminRole(userRole) && ( - + - + )} - - - - {byokModalServer && ( - setByokModalServer(null)} - onSuccess={(_serverId) => { - refetch(); - setByokModalServer(null); + + + {byokModalServer && ( + setByokModalServer(null)} + onSuccess={(_serverId) => { + refetch(); + setByokModalServer(null); + }} + /> + )} + + {/* Per-user env-var fill modal — backed by /v1/mcp/server/{id}/user-env-vars */} + { + setEnvVarsModalServer(null); + setDeepLinkServerId(null); + }} + onSaved={() => { + // Refresh the bulk status so the red "N user fields missing" footer + // on each card clears once the user has filled in their values. + refetchEnvVarStatus(); }} /> - )} - - {/* Per-user env-var fill modal — backed by /v1/mcp/server/{id}/user-env-vars */} - { - setEnvVarsModalServer(null); - setDeepLinkServerId(null); - }} - onSaved={() => { - // Refresh the bulk status so the red "N user fields missing" footer - // on each card clears once the user has filled in their values. - refetchEnvVarStatus(); - }} - /> -
+
+ ); }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_tool_configuration.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_tool_configuration.tsx index 60c4c264c3ce..274bdf63e32a 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_tool_configuration.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_tool_configuration.tsx @@ -1,7 +1,14 @@ import React, { useEffect, useMemo, useRef, useState } from "react"; -import { Card, Title, Text } from "@tremor/react"; -import { ToolOutlined, CheckCircleOutlined, SearchOutlined, EditOutlined } from "@ant-design/icons"; -import { Badge, Spin, Checkbox, Input, Radio } from "antd"; +import { Wrench, CircleCheck, Search, Pencil } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Input } from "@/components/ui/input"; +import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group"; +import { Textarea } from "@/components/ui/textarea"; +import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; +import { cn } from "@/lib/cva.config"; import McpCrudPermissionPanel from "@/components/mcp_tools/McpCrudPermissionPanel"; import { TOOL_DISPLAY_NAME_PATTERN } from "./utils"; @@ -67,86 +74,74 @@ const ToolRow: React.FC = ({ return (
-
onToggle(tool.name)}> +
onToggle(tool.name)}>
- onToggle(tool.name)} /> + onToggle(tool.name)} />
- {toolNameToDisplayName[tool.name] || tool.name} - - {isEnabled ? "Enabled" : "Disabled"} - - {toolNameToDisplayName[tool.name] && ( - - Custom name - - )} +

{toolNameToDisplayName[tool.name] || tool.name}

+ {isEnabled ? "Enabled" : "Disabled"} + {toolNameToDisplayName[tool.name] && Custom name}
{(toolNameToDescription[tool.name] || tool.description) && ( - +

{toolNameToDescription[tool.name] || tool.description} - +

)} - +

{isEnabled ? "✓ Users can call this tool" : "✗ Users cannot call this tool"} - +

- + +
{isEditExpanded && (
e.stopPropagation()} >
- Display Name +

Display Name

onDisplayNameChange(tool.name, e.target.value)} - status={isDisplayNameInvalid ? "error" : undefined} + aria-invalid={isDisplayNameInvalid || undefined} /> {isDisplayNameInvalid ? ( - +

Only letters, digits, underscores, and hyphens are allowed (no spaces). - +

) : ( - +

Override how this tool's name appears to users. Leave blank to use original. - +

)}
- Description - Description

+