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 00000000000..358968df039
--- /dev/null
+++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPNetworkSettings.test.tsx
@@ -0,0 +1,92 @@
+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("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/OpenAPIQuickPicker.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/OpenAPIQuickPicker.test.tsx
new file mode 100644
index 00000000000..f6091f06376
--- /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/TruePassthroughWarning.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/TruePassthroughWarning.test.tsx
new file mode 100644
index 00000000000..18a32a384c5
--- /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/mcp_discovery.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_discovery.test.tsx
new file mode 100644
index 00000000000..4e2456ab7a4
--- /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_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 00000000000..a4547e4923f
--- /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_display.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_cost_display.test.tsx
new file mode 100644
index 00000000000..466341405c8
--- /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_view.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_view.test.tsx
new file mode 100644
index 00000000000..02d168bf7f4
--- /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_servers.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_servers.test.tsx
index d61bc23c757..f9f3d20ca15 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/components/ToolDetail.test.tsx b/ui/litellm-dashboard/src/components/ToolDetail.test.tsx
new file mode 100644
index 00000000000..db5047145c4
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/ToolDetail.test.tsx
@@ -0,0 +1,197 @@
+import { render, screen, waitFor } 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 { ToolDetail } from "./ToolDetail";
+import {
+ deleteToolPolicyOverride,
+ fetchToolDetail,
+ fetchToolPolicyOptions,
+ getToolUsageLogs,
+ keyListCall,
+ teamListCall,
+ updateToolPolicy,
+ type ToolDetailResponse,
+ type ToolPolicyOption,
+ type ToolPolicyOverrideRow,
+ type ToolRow,
+ type ToolUsageLogsResponse,
+} from "@/components/networking";
+
+vi.mock("@/components/networking", () => ({
+ deleteToolPolicyOverride: vi.fn(),
+ fetchToolDetail: vi.fn(),
+ fetchToolPolicyOptions: vi.fn(),
+ getToolUsageLogs: vi.fn(),
+ keyListCall: vi.fn(),
+ teamListCall: vi.fn(),
+ updateToolPolicy: vi.fn(),
+}));
+
+vi.mock("@/components/common_components/team_dropdown", () => ({
+ default: ({ onChange }: { onChange: (id: string) => void }) => (
+
+ ),
+}));
+
+vi.mock("@/components/GuardrailsMonitor/LogViewer", () => ({
+ LogViewer: ({ totalLogs }: { totalLogs: number }) => log viewer ({totalLogs})
,
+}));
+
+const detail = {
+ tool: {
+ tool_name: "search_docs",
+ input_policy: "untrusted",
+ output_policy: "trusted",
+ origin: "mcp",
+ call_count: 42,
+ user_agent: "litellm-python/1.0",
+ created_at: "2026-03-04T10:00:00Z",
+ },
+ overrides: [],
+} as unknown as ToolDetailResponse;
+
+const renderDetail = (onBack = vi.fn()) => {
+ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
+ return render(
+
+
+ ,
+ );
+};
+
+describe("ToolDetail", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.mocked(fetchToolDetail).mockResolvedValue(detail);
+ vi.mocked(fetchToolPolicyOptions).mockResolvedValue({ input_policies: [], output_policies: [] });
+ vi.mocked(teamListCall).mockResolvedValue({ data: [] });
+ vi.mocked(keyListCall).mockResolvedValue({ keys: [] });
+ vi.mocked(getToolUsageLogs).mockResolvedValue({ logs: [], total: 0 } as unknown as ToolUsageLogsResponse);
+ vi.mocked(updateToolPolicy).mockResolvedValue(undefined as unknown as ToolRow);
+ vi.mocked(deleteToolPolicyOverride).mockResolvedValue(
+ undefined as unknown as { deleted: boolean; tool_name: string },
+ );
+ });
+
+ it("shows the tool identity once loaded", async () => {
+ renderDetail();
+
+ expect(await screen.findByText("search_docs")).toBeInTheDocument();
+ expect(screen.getByText("mcp")).toBeInTheDocument();
+ expect(screen.getByText("42 calls")).toBeInTheDocument();
+ expect(screen.getByText("litellm-python/1.0")).toBeInTheDocument();
+ });
+
+ it("renders both policy panels with the tool's current policies", async () => {
+ renderDetail();
+
+ expect(await screen.findByText("Input Policy")).toBeInTheDocument();
+ expect(screen.getByText("Output Policy")).toBeInTheDocument();
+ expect(screen.getByText("untrusted")).toBeInTheDocument();
+ expect(screen.getByText("trusted")).toBeInTheDocument();
+ });
+
+ it("uses the policy option descriptions when the backend supplies them", async () => {
+ vi.mocked(fetchToolPolicyOptions).mockResolvedValue({
+ input_policies: [{ value: "untrusted", description: "Treat inputs as hostile" } as ToolPolicyOption],
+ output_policies: [{ value: "trusted", description: "Outputs may be chained" } as ToolPolicyOption],
+ });
+
+ renderDetail();
+
+ expect(await screen.findByText("Treat inputs as hostile")).toBeInTheDocument();
+ expect(screen.getByText("Outputs may be chained")).toBeInTheDocument();
+ });
+
+ it("returns to the list when Back is pressed", async () => {
+ const onBack = vi.fn();
+ renderDetail(onBack);
+
+ await userEvent.click(await screen.findByRole("button", { name: /Back to Tool Policies/ }));
+
+ expect(onBack).toHaveBeenCalled();
+ });
+
+ it("reports a failed detail load and still offers a way back", async () => {
+ vi.mocked(fetchToolDetail).mockRejectedValue(new Error("nope"));
+
+ renderDetail();
+
+ expect(await screen.findByText("Failed to load tool details.")).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: /Back to Tool Policies/ })).toBeInTheDocument();
+ });
+
+ it("hides the overrides panel when the tool has none", async () => {
+ renderDetail();
+
+ await screen.findByText("Input Policy");
+ expect(screen.queryByText("Blocked for team or key")).not.toBeInTheDocument();
+ });
+
+ it("lists existing overrides and removes the chosen one", async () => {
+ vi.mocked(fetchToolDetail).mockResolvedValue({
+ ...detail,
+ overrides: [
+ {
+ override_id: "o1",
+ team_id: "team-alpha",
+ key_hash: null,
+ key_alias: null,
+ } as unknown as ToolPolicyOverrideRow,
+ ],
+ });
+
+ renderDetail();
+
+ expect(await screen.findByText("Team: team-alpha")).toBeInTheDocument();
+ await userEvent.click(screen.getByRole("button", { name: "Remove" }));
+
+ await waitFor(() =>
+ expect(deleteToolPolicyOverride).toHaveBeenCalledWith("tok", "search_docs", {
+ team_id: "team-alpha",
+ key_hash: undefined,
+ }),
+ );
+ });
+
+ it("keeps the block button disabled until a team is chosen, then blocks that team", async () => {
+ renderDetail();
+
+ const blockButton = await screen.findByRole("button", { name: /Block for team/ });
+ expect(blockButton).toBeDisabled();
+
+ await userEvent.click(screen.getByRole("button", { name: "pick team" }));
+ await waitFor(() => expect(screen.getByRole("button", { name: /Block for team/ })).toBeEnabled());
+ await userEvent.click(screen.getByRole("button", { name: /Block for team/ }));
+
+ await waitFor(() =>
+ expect(updateToolPolicy).toHaveBeenCalledWith(
+ "tok",
+ "search_docs",
+ { input_policy: "blocked" },
+ { team_id: "team-1", key_hash: undefined, key_alias: undefined },
+ ),
+ );
+ });
+
+ it("switches the block scope to a key", async () => {
+ renderDetail();
+
+ await screen.findByText("Block for team or key");
+ await userEvent.click(screen.getByRole("radio", { name: "Key" }));
+
+ expect(await screen.findByRole("button", { name: /Block for key/ })).toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: "pick team" })).not.toBeInTheDocument();
+ });
+
+ it("passes the usage-log total through to the log viewer", async () => {
+ vi.mocked(getToolUsageLogs).mockResolvedValue({ logs: [], total: 7 } as unknown as ToolUsageLogsResponse);
+
+ renderDetail();
+
+ expect(await screen.findByText("log viewer (7)")).toBeInTheDocument();
+ });
+});
diff --git a/ui/litellm-dashboard/src/components/ToolPolicies/PolicySelect.test.tsx b/ui/litellm-dashboard/src/components/ToolPolicies/PolicySelect.test.tsx
index e65f79edd99..f2890e77347 100644
--- a/ui/litellm-dashboard/src/components/ToolPolicies/PolicySelect.test.tsx
+++ b/ui/litellm-dashboard/src/components/ToolPolicies/PolicySelect.test.tsx
@@ -30,12 +30,12 @@ describe("PolicySelect", () => {
it("should be disabled when saving is true", () => {
renderWithProviders();
expect(screen.getByRole("combobox")).toHaveAttribute("aria-expanded", "false");
- expect(screen.getByRole("combobox").closest(".ant-select")).toHaveClass("ant-select-disabled");
+ expect(screen.getByRole("combobox")).toBeDisabled();
});
it("should not be disabled when saving is false", () => {
renderWithProviders();
- expect(screen.getByRole("combobox").closest(".ant-select")).not.toHaveClass("ant-select-disabled");
+ expect(screen.getByRole("combobox")).toBeEnabled();
});
});
diff --git a/ui/litellm-dashboard/src/components/ToolPolicies/ToolPoliciesPanel.test.tsx b/ui/litellm-dashboard/src/components/ToolPolicies/ToolPoliciesPanel.test.tsx
index 721c215cfb1..0a0b1c09fbb 100644
--- a/ui/litellm-dashboard/src/components/ToolPolicies/ToolPoliciesPanel.test.tsx
+++ b/ui/litellm-dashboard/src/components/ToolPolicies/ToolPoliciesPanel.test.tsx
@@ -67,21 +67,27 @@ const row = (toolId: string): HTMLElement => {
const policySelect = (toolId: string, kind: "input" | "output"): HTMLElement =>
within(row(toolId)).getAllByRole("combobox")[kind === "input" ? 0 : 1];
-/** Exact selected-value text. Never assert with toHaveTextContent here: it substring-matches, so "untrusted" satisfies "trusted". */
-const policyValue = (toolId: string, kind: "input" | "output"): string =>
- policySelect(toolId, kind).closest(".ant-select")?.querySelector(".ant-select-selection-item")?.textContent ?? "";
+/**
+ * Exact selected-value text, read off the policy cell and stripped of anything
+ * that is not a letter (the control draws a status dot and a chevron around the
+ * label). Never assert with toHaveTextContent here: it substring-matches, so
+ * "untrusted" satisfies "trusted".
+ */
+const policyValue = (toolId: string, kind: "input" | "output"): string => {
+ const cell = policySelect(toolId, kind).closest("td");
+ return (cell?.textContent ?? "").replace(/[^a-z]/gi, "");
+};
const isSaving = (toolId: string, kind: "input" | "output"): boolean =>
- policySelect(toolId, kind).closest(".ant-select")?.classList.contains("ant-select-disabled") ?? false;
+ policySelect(toolId, kind).hasAttribute("disabled");
const chooseOption = async (user: ReturnType, trigger: HTMLElement, label: string) => {
await user.click(trigger);
+ // The label also renders in the trigger once selected, so take the last match:
+ // the popup is portalled after the table in document order.
const option = await waitFor(() => {
- const match = Array.from(document.querySelectorAll(".ant-select-item-option")).find(
- (element) => element.textContent === label,
- );
- if (match === undefined) throw new Error(`option ${label} not open`);
- return match as HTMLElement;
+ const matches = screen.getAllByText(label);
+ return matches[matches.length - 1];
});
await user.click(option);
};
diff --git a/ui/litellm-dashboard/src/components/ToolPolicies/ToolPoliciesTableColumns.test.tsx b/ui/litellm-dashboard/src/components/ToolPolicies/ToolPoliciesTableColumns.test.tsx
new file mode 100644
index 00000000000..bb4cd8a463a
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/ToolPolicies/ToolPoliciesTableColumns.test.tsx
@@ -0,0 +1,126 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, it, expect, vi } from "vitest";
+import { flexRender, getCoreRowModel, useReactTable, type ColumnDef } from "@tanstack/react-table";
+import { getToolPoliciesTableColumns } from "./ToolPoliciesTableColumns";
+import type { ToolRow } from "@/components/networking";
+
+const row: ToolRow = {
+ tool_name: "search_docs",
+ input_policy: "untrusted",
+ output_policy: "trusted",
+ call_count: 1234,
+ team_id: "team-alpha",
+ key_hash: "abc123def456",
+ key_alias: "prod-key",
+ user_agent: "litellm-python/1.0",
+ created_at: "2026-03-04T10:00:00Z",
+} as ToolRow;
+
+const defaultDeps = {
+ onSelectTool: vi.fn(),
+ savingInput: new Set(),
+ savingOutput: new Set(),
+ onInputPolicyChange: vi.fn(),
+ onOutputPolicyChange: vi.fn(),
+};
+
+// Renders the column definitions through a real TanStack table so each `cell`
+// renderer runs exactly as the DataTable runs it.
+function TableHarness({ columns, data }: { columns: ColumnDef[]; data: ToolRow[] }) {
+ const table = useReactTable({ columns, data, getCoreRowModel: getCoreRowModel() });
+ return (
+
+
+ {table.getRowModel().rows.map((r) => (
+
+ {r.getVisibleCells().map((cell) => (
+ | {flexRender(cell.column.columnDef.cell, cell.getContext())} |
+ ))}
+
+ ))}
+
+
+ );
+}
+
+const renderTable = (deps = {}, data: ToolRow[] = [row]) =>
+ render();
+
+describe("getToolPoliciesTableColumns", () => {
+ it("defines the expected columns in order", () => {
+ const columns = getToolPoliciesTableColumns(defaultDeps);
+
+ expect(columns.map((c) => c.id)).toEqual([
+ "created_at",
+ "tool_name",
+ "input_policy",
+ "output_policy",
+ "call_count",
+ "team_id",
+ "key_hash",
+ "key_alias",
+ "user_agent",
+ ]);
+ });
+
+ it("renders the row's identifying fields", () => {
+ renderTable();
+
+ expect(screen.getByText("search_docs")).toBeInTheDocument();
+ expect(screen.getByText("team-alpha")).toBeInTheDocument();
+ expect(screen.getByText("prod-key")).toBeInTheDocument();
+ expect(screen.getByText("litellm-python/1.0")).toBeInTheDocument();
+ });
+
+ it("formats the call count with thousands separators", () => {
+ renderTable();
+
+ expect(screen.getByText("1,234")).toBeInTheDocument();
+ });
+
+ it("renders a zero call count rather than a blank cell", () => {
+ renderTable({}, [{ ...row, call_count: undefined } as ToolRow]);
+
+ expect(screen.getByText("0")).toBeInTheDocument();
+ });
+
+ it("falls back to a dash for a missing key alias and user agent", () => {
+ renderTable({}, [{ ...row, key_alias: undefined, user_agent: undefined } as ToolRow]);
+
+ expect(screen.getAllByText("-").length).toBeGreaterThanOrEqual(2);
+ });
+
+ it("notifies the caller when the tool name is clicked", async () => {
+ const onSelectTool = vi.fn();
+ renderTable({ onSelectTool });
+
+ await userEvent.click(screen.getByText("search_docs"));
+
+ expect(onSelectTool).toHaveBeenCalledWith("search_docs");
+ });
+
+ it("renders a policy control for each direction, showing the row's current policies", () => {
+ renderTable();
+
+ expect(screen.getByText("untrusted")).toBeInTheDocument();
+ expect(screen.getByText("trusted")).toBeInTheDocument();
+ expect(screen.getAllByRole("combobox")).toHaveLength(2);
+ });
+
+ it("disables only the input policy control while that direction is saving", () => {
+ renderTable({ savingInput: new Set(["search_docs"]) });
+
+ const [input, output] = screen.getAllByRole("combobox");
+ expect(input).toBeDisabled();
+ expect(output).toBeEnabled();
+ });
+
+ it("disables only the output policy control while that direction is saving", () => {
+ renderTable({ savingOutput: new Set(["search_docs"]) });
+
+ const [input, output] = screen.getAllByRole("combobox");
+ expect(input).toBeEnabled();
+ expect(output).toBeDisabled();
+ });
+});