From fca470a64b50947102b300d034b3a3d734ae818b Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Tue, 18 Aug 2026 14:07:36 -0700 Subject: [PATCH] refactor(ui): move the MCP server forms and detail tabs off tremor (#37329) * refactor(ui): move the MCP server forms and detail tabs off tremor Swaps the tremor Button, TextInput, Title, Text and Tab primitives in the six MCP server components for the shadcn layer, and leaves the antd Modals, Forms and Selects alone for the antd pass. In the two files that mix both input libraries, antd's Input is imported as AntdInput so the shadcn Input keeps its canonical name. Two behaviours needed care. Base UI's Button forces type="button", so the create button in CreateMCPServer now carries an explicit type="submit"; Cancel and the OAuth authorize button stay non submitting, which also drops the accidental implicit submit they inherited from tremor. Every TabsContent gets keepMounted, because Base UI unmounts inactive panels while tremor only hid them, and a save started from the Cost Configuration tab reads fields that live in the Server Configuration panel. mcp_server_edit.test.tsx gains a regression test for that: drop keepMounted and the pending edit never reaches the update payload. One affordance is gone: password fields no longer draw tremor's built in reveal toggle, since the shadcn Input is a plain native input. Prunes the six no-restricted-imports suppressions these files no longer need. * fix(ui): keep the reveal toggle on the MCP secret fields tremor's TextInput drew its own show/hide button whenever the type was password, and the shadcn Input is a plain native input, so the straight prop pass-through silently deleted that affordance from five fields: the create modal's authentication value and the OAuth client id and secret in both the M2M and the interactive flow. Puts them on antd's Input.Password instead, which is what every sibling secret field in this directory already uses (TokenExchangeFormFields, IdJagFormFields, AwsSigV4Fields and the edit form), so the reveal survives and the five fields now match their neighbours instead of behaving differently inside the same form. Both files already import antd, so this adds no import and no suppression. * test(ui): pin the connect tab mount contract mcp_connect's per-card "limit tools to specific MCP servers" toggle lives in panel local state that feeds the rendered header block, so the panels have to stay mounted across a tab switch. Base UI unmounts an inactive panel unless keepMounted is set, and unlike the edit form there was no test holding that down. Toggles the header on from the LiteLLM Proxy panel, switches to Cursor and back, and asserts both the switch and the x-mcp-servers line in the curl example survived. Dropping keepMounted from that panel fails it. * fix(ui): keep the MCP tab strips underlined instead of segmented A bare tremor TabList is variant="line", so the connect strip and the server settings strip both drew an underlined tab on a full width divider. Converting them bare turned each into a filled segmented control, because the shadcn TabsList defaults to the pill. Both strips now use variant="line" with the divider recipe, and the connect strip gets back the grey rounded box tremor drew around its four tabs. --- ui/litellm-dashboard/eslint-suppressions.json | 12 ++-- .../_components/CreateMCPServer.tsx | 22 +++---- .../_components/MCPToolsetsTab.tsx | 30 +++++----- .../_components/OAuthFormFields.tsx | 36 +++++------ .../mcp-servers/_components/ToolTestPanel.tsx | 25 +++++--- .../_components/mcp_connect.test.tsx | 29 +++++++++ .../mcp-servers/_components/mcp_connect.tsx | 60 +++++++++---------- .../_components/mcp_server_edit.test.tsx | 47 +++++++++++++++ .../_components/mcp_server_edit.tsx | 31 ++++++---- 9 files changed, 187 insertions(+), 105 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_connect.test.tsx diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index d51704551e4..a8bae24e30e 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -623,7 +623,7 @@ "count": 1 }, "no-restricted-imports": { - "count": 2 + "count": 1 }, "react-hooks/set-state-in-effect": { "count": 4 @@ -669,7 +669,7 @@ "count": 1 }, "no-restricted-imports": { - "count": 2 + "count": 1 }, "react-hooks/set-state-in-effect": { "count": 1 @@ -685,7 +685,7 @@ "count": 1 }, "no-restricted-imports": { - "count": 2 + "count": 1 } }, "src/app/(dashboard)/mcp-servers/_components/OpenAPIFormSection.tsx": { @@ -736,7 +736,7 @@ "count": 3 }, "no-restricted-imports": { - "count": 2 + "count": 1 }, "react-hooks/set-state-in-effect": { "count": 1 @@ -760,7 +760,7 @@ "count": 1 }, "no-restricted-imports": { - "count": 2 + "count": 1 }, "react-hooks/static-components": { "count": 4 @@ -803,7 +803,7 @@ "count": 1 }, "no-restricted-imports": { - "count": 2 + "count": 1 }, "react-hooks/immutability": { "count": 1 diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/CreateMCPServer.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/CreateMCPServer.tsx index 33887c250ca..ed8d3bddc98 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/CreateMCPServer.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/CreateMCPServer.tsx @@ -1,7 +1,9 @@ import React, { useState } from "react"; -import { Modal, Tooltip, Form, Select, Input, InputNumber, Collapse } from "antd"; +import { Modal, Tooltip, Form, Select, Input as AntdInput, InputNumber, Collapse } from "antd"; import { InfoCircleOutlined } from "@ant-design/icons"; -import { Button, TextInput } from "@tremor/react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; import { createMCPServer, registerMCPServer, storeMCPOAuthUserCredential } from "@/components/networking"; import { setToken } from "@/utils/mcpTokenStore"; import { @@ -663,7 +665,7 @@ const CreateMCPServer: React.FC = ({ { validator: (_, value) => validateMCPServerName(value) }, ]} > - @@ -681,7 +683,7 @@ const CreateMCPServer: React.FC = ({ name="alias" rules={[{ required: false }, { validator: (_, value) => validateMCPServerName(value) }]} > - setAliasManuallyEdited(true)} @@ -698,7 +700,7 @@ const CreateMCPServer: React.FC = ({ }, ]} > - @@ -710,7 +712,7 @@ const CreateMCPServer: React.FC = ({ label={GitHub / Source URL} name="source_url" > - @@ -745,7 +747,7 @@ const CreateMCPServer: React.FC = ({ { validator: (_, value) => validateMCPServerUrl(value) }, ]} > - @@ -852,8 +854,7 @@ const CreateMCPServer: React.FC = ({ }, ]} > - @@ -957,7 +958,8 @@ const CreateMCPServer: React.FC = ({ - diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPToolsetsTab.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPToolsetsTab.tsx index 0bced76e24e..a7a56fe040f 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPToolsetsTab.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPToolsetsTab.tsx @@ -1,9 +1,9 @@ import React, { useState, useCallback } from "react"; -import { Button, Text, Title } from "@tremor/react"; +import { Button } from "@/components/ui/button"; +import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; import { Modal, Form, Input, message, Spin } from "antd"; -import { PlusIcon } from "@heroicons/react/outline"; import { SortingState } from "@tanstack/react-table"; -import { Inbox } from "lucide-react"; +import { Inbox, Plus } from "lucide-react"; import { useMCPToolsets } from "@/app/(dashboard)/hooks/mcpServers/useMCPToolsets"; import { useMCPServers } from "@/app/(dashboard)/hooks/mcpServers/useMCPServers"; import { useQueryClient } from "@tanstack/react-query"; @@ -212,7 +212,7 @@ function CreateToolsetModal({ open, onClose, onSave, accessToken, initialToolset {/* Left panel: Available Tools */}
- Available Tools +

Available Tools

{filteredServers.length === 0 ? ( - +

{mcpServers.length === 0 ? "No MCP servers configured" : "No servers match your search"} - +

) : ( filteredServers.map((server) => ( - +

Your Toolset ({selectedTools.length} tools) - +

{selectedTools.length === 0 ? ( - No tools added yet +

No tools added yet

) : ( selectedTools.map((tool, idx) => ( -
@@ -406,14 +407,15 @@ export function MCPToolsetsTab({ accessToken, userRole }: MCPToolsetsTabProps) {
- MCP Toolsets - +

MCP Toolsets

+

Curated collections of tools from one or more MCP servers. Assign toolsets to keys and teams via the MCP permissions dropdown. - +

{isAdmin && ( - )} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/OAuthFormFields.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/OAuthFormFields.tsx index 5650bd1d7e4..cbe6ac18d22 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/OAuthFormFields.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/OAuthFormFields.tsx @@ -1,7 +1,8 @@ import React from "react"; -import { Form, Input, InputNumber, Select, Tooltip } from "antd"; +import { Form, Input as AntdInput, InputNumber, Select, Tooltip } from "antd"; import { InfoCircleOutlined } from "@ant-design/icons"; -import { Button, TextInput } from "@tremor/react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; import { OAUTH_FLOW } from "@/components/mcp_tools/types"; import TokenEndpointAuthMethodField from "./TokenEndpointAuthMethodField"; @@ -44,7 +45,7 @@ const UpstreamResourceField: React.FC = () => ( label={} name={["credentials", "upstream_resource"]} > - + ); @@ -93,11 +94,7 @@ const OAuthFormFields: React.FC = ({ name={["credentials", "client_id"]} rules={requiredWhenCreating("Client ID is required for M2M OAuth")} > - + = ({ name={["credentials", "client_secret"]} rules={requiredWhenCreating("Client Secret is required for M2M OAuth")} > - @@ -117,7 +113,7 @@ const OAuthFormFields: React.FC = ({ name="token_url" rules={requiredWhenCreating("Token URL is required for M2M OAuth")} > - + = ({ } name={["credentials", "client_id"]} > - + = ({ } name={["credentials", "client_secret"]} > - + = ({ } name="issuer" > - + = ({ } name="authorization_url" > - + } name="token_url" > - + = ({ } name="registration_url" > - + = ({ }, ]} > - => @@ -335,10 +338,14 @@ export function ToolTestPanel({

Provider: {tool.mcp_info.server_name}

-
@@ -369,7 +376,7 @@ export function ToolTestPanel({ rules={[{ required: true, message: "Please enter input for this tool" }]} className="mb-3" > - @@ -466,7 +473,7 @@ export function ToolTestPanel({ )} {prop.type === "string" && !prop.enum && ( - form.submit()} disabled={isLoading} - variant="primary" + aria-busy={isLoading} className="w-full" - loading={isLoading} > + {isLoading && } {isLoading ? "Calling Tool..." : result || error ? "Call Again" : "Call Tool"}
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_connect.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_connect.test.tsx new file mode 100644 index 00000000000..54a32e87b07 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_connect.test.tsx @@ -0,0 +1,29 @@ +import React from "react"; +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, it, expect, vi } from "vitest"; +import MCPConnect from "./mcp_connect"; + +vi.mock("@/components/networking", () => ({ + getProxyBaseUrl: vi.fn().mockReturnValue("http://localhost:4000"), +})); + +const activePanel = () => screen.getByRole("tabpanel"); + +describe("MCPConnect (tab mount contract)", () => { + it("keeps the x-mcp-servers header toggle on after switching tabs away and back", async () => { + render(); + + await userEvent.click(screen.getByRole("tab", { name: "LiteLLM Proxy" })); + expect(within(activePanel()).queryByText(/"x-mcp-servers":/)).not.toBeInTheDocument(); + + await userEvent.click(screen.getByRole("switch")); + expect(within(activePanel()).getByText(/"x-mcp-servers": "Zapier_MCP,dev-group"/)).toBeInTheDocument(); + + await userEvent.click(screen.getByRole("tab", { name: "Cursor" })); + await userEvent.click(screen.getByRole("tab", { name: "LiteLLM Proxy" })); + + expect(screen.getByRole("switch")).toBeChecked(); + expect(within(activePanel()).getByText(/"x-mcp-servers": "Zapier_MCP,dev-group"/)).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_connect.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_connect.tsx index 74b77735377..e32b968c1d3 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_connect.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_connect.tsx @@ -2,7 +2,7 @@ import React, { useState } from "react"; import { Card, Typography, Space, Alert, Button, Switch, Form } from "antd"; -import { TabPanel, TabPanels, TabGroup, TabList, Tab, Title as TremorTitle, Text as TremorText } from "@tremor/react"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { CopyIcon, Code, Terminal, Globe, CheckIcon, ExternalLinkIcon, KeyIcon, ServerIcon, Zap } from "lucide-react"; import { getProxyBaseUrl } from "@/components/networking"; import { copyToClipboard as utilCopyToClipboard } from "@/utils/dataUtils"; @@ -458,57 +458,55 @@ const MCPConnect: React.FC = ({ currentServerAccessGroups = []
- Connect to your MCP client - +

Connect to your MCP client

+

Use tools directly from any MCP client with LiteLLM MCP. Enable your AI assistant to perform real-world tasks through a simple, secure connection. - +

- - -
- + + +
+ OpenAI API - - + + LiteLLM Proxy - - + + Cursor - - + + Streamable HTTP - +
- - - - - - - - - - - - - - - - +
+ + + + + + + + + + + + +
); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_edit.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_edit.test.tsx index 0aab19cb131..4e85aa41cfc 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_edit.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_edit.test.tsx @@ -2202,3 +2202,50 @@ describe("MCPServerEdit (dcr_bridge toggle)", () => { expect(payload.dcr_bridge).toBe(true); }); }); + +describe("MCPServerEdit (tab mount contract)", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + const plainServer = { + ...interactiveOAuthServer, + auth_type: "none", + max_concurrent_requests: 5, + }; + + it("carries pending server edits into a save triggered from the Cost Configuration tab", async () => { + vi.mocked(networking.updateMCPServer).mockResolvedValue({ + ...plainServer, + max_concurrent_requests: 2, + }); + + render( + , + ); + + fireEvent.change(screen.getByPlaceholderText("e.g. 10"), { target: { value: "2" } }); + + await userEvent.click(screen.getByRole("tab", { name: "Cost Configuration" })); + expect(await screen.findByTestId("mcp-cost-config")).toBeInTheDocument(); + + const costTabSaveButtons = screen.getAllByRole("button", { name: "Save Changes" }); + expect(costTabSaveButtons).toHaveLength(1); + await act(async () => { + fireEvent.click(costTabSaveButtons[0]); + }); + + await waitFor(() => { + expect(networking.updateMCPServer).toHaveBeenCalledTimes(1); + }); + + const [, payload] = vi.mocked(networking.updateMCPServer).mock.calls[0]; + expect(payload.max_concurrent_requests).toBe(2); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_edit.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_edit.tsx index 2bae6a7bfcc..5c3fe6d8cda 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_edit.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_edit.tsx @@ -1,7 +1,8 @@ import React, { useState, useEffect } from "react"; import { Form, Select, Button as AntdButton, Tooltip, Input, InputNumber, Alert } from "antd"; import { InfoCircleOutlined } from "@ant-design/icons"; -import { Button, TabGroup, TabList, Tab, TabPanels, TabPanel } from "@tremor/react"; +import { Button } from "@/components/ui/button"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { AUTH_TYPE, isClientForwardedTokenMode, @@ -994,13 +995,17 @@ const MCPServerEdit: React.FC = ({ }; return ( - - - Server Configuration - Cost Configuration - - - + + + + Server Configuration + + + Cost Configuration + + +
+
= ({
-
+ - +
@@ -1458,9 +1463,9 @@ const MCPServerEdit: React.FC = ({
- - - + + + ); };