From f2d531737adce76ddb84e3c4adeef02cc3f43ec8 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Thu, 23 Jul 2026 22:29:09 -0700 Subject: [PATCH 1/3] test(ui): pin mcp-servers, tag-management and tool-policies behaviour before the shadcn migration Rewrite the two markup-coupled assertions off antd class selectors and onto role/text queries, and add characterisation tests for the nine route-owned components that had none. Both rewritten tests and all nine new ones are green against the current antd and Tremor components, so the migration that follows can be judged by tests it never touched. --- .../_components/MCPNetworkSettings.test.tsx | 92 ++++++++ .../_components/OpenAPIQuickPicker.test.tsx | 84 ++++++++ .../TruePassthroughWarning.test.tsx | 23 ++ .../_components/mcp_discovery.test.tsx | 118 +++++++++++ .../mcp_server_cost_config.test.tsx | 87 ++++++++ .../mcp_server_cost_display.test.tsx | 48 +++++ .../_components/mcp_server_view.test.tsx | 152 ++++++++++++++ .../_components/mcp_servers.test.tsx | 36 +--- .../src/components/ToolDetail.test.tsx | 197 ++++++++++++++++++ .../ToolPolicies/PolicySelect.test.tsx | 4 +- .../ToolPolicies/ToolPoliciesPanel.test.tsx | 24 ++- .../ToolPoliciesTableColumns.test.tsx | 126 +++++++++++ 12 files changed, 952 insertions(+), 39 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPNetworkSettings.test.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/OpenAPIQuickPicker.test.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/TruePassthroughWarning.test.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_discovery.test.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_cost_config.test.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_cost_display.test.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_view.test.tsx create mode 100644 ui/litellm-dashboard/src/components/ToolDetail.test.tsx create mode 100644 ui/litellm-dashboard/src/components/ToolPolicies/ToolPoliciesTableColumns.test.tsx 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..358968df0398 --- /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 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/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/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_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_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_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_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/components/ToolDetail.test.tsx b/ui/litellm-dashboard/src/components/ToolDetail.test.tsx new file mode 100644 index 000000000000..db5047145c45 --- /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 e65f79edd992..f2890e773474 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 721c215cfb1f..0a0b1c09fbbe 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 000000000000..bb4cd8a463a9 --- /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(); + }); +}); From 428d23249a4cb56d2ea6aac7a0a292e7212a5d06 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Thu, 23 Jul 2026 23:30:14 -0700 Subject: [PATCH 2/3] refactor(ui): migrate mcp-servers, tag-management and tool-policies to shadcn Replaces antd and Tremor with shadcn primitives across the 18 files these three routes exclusively own. Markup only: no behaviour, data flow or copy changed, and no shared or form-bearing component is touched, so the blast radius stops at these pages. The 12 tests covering these components are unchanged from the previous commit and still pass, which is the evidence that the rewrite preserved behaviour. Also prunes the six antd no-restricted-imports suppressions these files no longer need. --- ui/litellm-dashboard/eslint-suppressions.json | 66 --- .../_components/MCPLogoSelector.tsx | 139 +++-- .../_components/MCPNetworkSettings.tsx | 93 ++- .../mcp-servers/_components/MCPServerCard.tsx | 447 ++++++++------- .../_components/OpenAPIQuickPicker.tsx | 27 +- .../_components/TruePassthroughWarning.tsx | 20 +- .../_components/mcp_connection_status.tsx | 139 +++-- .../mcp-servers/_components/mcp_discovery.tsx | 339 +++++------ .../_components/mcp_server_cost_config.tsx | 272 +++++---- .../_components/mcp_server_cost_display.tsx | 35 +- .../_components/mcp_server_view.tsx | 537 +++++++++--------- .../mcp-servers/_components/mcp_servers.tsx | 496 +++++++++------- .../_components/mcp_tool_configuration.tsx | 249 ++++---- .../mcp-servers/_components/mcp_tools.tsx | 232 ++++---- .../tag-management/_components/index.tsx | 32 +- .../MCPSemanticFilterTestPanel.tsx | 258 ++++----- .../src/components/ToolDetail.tsx | 150 ++--- .../components/ToolPolicies/PolicySelect.tsx | 81 +-- .../ToolPolicies/ToolPoliciesTableColumns.tsx | 11 +- 19 files changed, 1776 insertions(+), 1847 deletions(-) diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index ec1e3ac05bab..1add58c52219 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.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPNetworkSettings.tsx index 7ab240389f32..9226bb36a11e 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,75 @@ 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:

+ } onClick={() => addSuggestedRange(suggestedRange)} > + {suggestedRange} -
+
)}
)} -
- 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.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.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.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.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.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.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.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

+