From 26c6978808ca4c9d99e8a87b66ac4f54229154e1 Mon Sep 17 00:00:00 2001 From: cursor Date: Fri, 24 Apr 2026 09:48:59 +0000 Subject: [PATCH] feat(ui): migrate mcp_tools lists/panels to shadcn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Migrate src/components/mcp_tools/mcp_servers.tsx from Tremor TabGroup/ Tab/TabList/TabPanels + antd Modal/Descriptions/Select/Tooltip/Typography to shadcn Tabs/AlertDialog/Select/Tooltip/Separator. Delete confirm uses AlertDialog with a dl/dt/dd Descriptions replacement. The Team / Access Group filter dropdowns become shadcn Radix Selects with aria-label. Update mcp_servers.test.tsx to drive the new Radix Select via getByRole('combobox', { name: 'Team' }) and getAllByRole('option') instead of .ant-select class selectors. - Migrate src/components/mcp_tools/mcp_tools.tsx from Tremor Card/Title/ Text + antd Input/Button to shadcn Card/Input/Button. Retains the loader/selection visual treatment with lucide icons. - Migrate src/components/mcp_tools/mcp_connect.tsx from antd Card/Alert/ Button/Switch/Form/Collapse + Tremor TabGroup/Tab/Title/Text to shadcn Card/Alert/Button/Switch/Tabs (Form.Item replaced with a plain Switch + Label pair — the connect screens have no form submit). - Migrate src/components/mcp_tools/mcp_discovery.tsx from antd Modal/ Input.Search/Typography to shadcn Dialog + Input with an inline search icon. Inline styles replaced with Tailwind classes, keeping the custom 2-column server grid and category pills. - Migrate src/components/mcp_tools/MCPToolsetsTab.tsx from antd Modal/ Form/Input/Typography/message/Spin + Tremor Button/Text/Title to shadcn Dialog/Input/Label/Button/AlertDialog and MessageManager for toasts. Form state moves to react-hook-form with register(). - Migrate src/components/mcp_tools/ToolTestPanel.tsx from antd Form.useForm + Form.Item + Tremor Button/TextInput to react-hook-form + Controller with shadcn Input/Textarea/Select/Label/Button. The ref-exposed boolean-select keeps its title attribute on the trigger so the existing ToolTestPanel.test.tsx getByTitle('True') assertion still matches. Categorical green palette retained per BLUEPRINT rule and added to the eslint overrides list for categorical-palette files (mcp_connect.tsx, mcp_tools.tsx, ToolTestPanel.tsx). - Migrate src/components/mcp_tools/mcp_tools.tsx. Categorical palette overrides recorded in .eslintrc.json. - MCPPermissionManagement.tsx is already in BLOCKERS.md from commit A as part of the antd Form cluster; no change required here. Co-authored-by: yuneng-jiang --- ui/litellm-dashboard/.eslintrc.json | 5 +- .../components/mcp_tools/MCPToolsetsTab.tsx | 442 +++++++----- .../components/mcp_tools/ToolTestPanel.tsx | 677 +++++++++--------- .../src/components/mcp_tools/mcp_connect.tsx | 424 ++++++----- .../components/mcp_tools/mcp_discovery.tsx | 368 ++++------ .../components/mcp_tools/mcp_servers.test.tsx | 25 +- .../src/components/mcp_tools/mcp_servers.tsx | 444 +++++++----- .../src/components/mcp_tools/mcp_tools.tsx | 275 ++++--- 8 files changed, 1372 insertions(+), 1288 deletions(-) diff --git a/ui/litellm-dashboard/.eslintrc.json b/ui/litellm-dashboard/.eslintrc.json index 5f0a43bf194..73e88197b19 100644 --- a/ui/litellm-dashboard/.eslintrc.json +++ b/ui/litellm-dashboard/.eslintrc.json @@ -143,7 +143,10 @@ "src/components/DeletedTeamsPage/DeletedTeamsPage.tsx", "src/components/molecules/models/columns.tsx", "src/components/common_components/MemberTable.tsx", - "src/components/price_data_reload.tsx" + "src/components/price_data_reload.tsx", + "src/components/mcp_tools/mcp_connect.tsx", + "src/components/mcp_tools/mcp_tools.tsx", + "src/components/mcp_tools/ToolTestPanel.tsx" ], "rules": { "litellm-ui/no-banned-ui-imports": "off", diff --git a/ui/litellm-dashboard/src/components/mcp_tools/MCPToolsetsTab.tsx b/ui/litellm-dashboard/src/components/mcp_tools/MCPToolsetsTab.tsx index dbc59d9310d..30a3a1dce5a 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/MCPToolsetsTab.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/MCPToolsetsTab.tsx @@ -1,8 +1,32 @@ import React, { useState, useCallback } from "react"; -// eslint-disable-next-line litellm-ui/no-banned-ui-imports -import { Button, Text, Title } from "@tremor/react"; -import { Modal, Form, Input, message, Spin, Card, Typography, Space } from "antd"; -import { Plus as PlusIcon, Pencil as PencilIcon, Trash2 as TrashIcon } from "lucide-react"; +import { useForm } from "react-hook-form"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { + Plus as PlusIcon, + Pencil as PencilIcon, + Trash2 as TrashIcon, + LoaderCircle, + X as XIcon, +} from "lucide-react"; import { ColumnDef } from "@tanstack/react-table"; import { useMCPToolsets } from "@/app/(dashboard)/hooks/mcpServers/useMCPToolsets"; import { useMCPServers } from "@/app/(dashboard)/hooks/mcpServers/useMCPServers"; @@ -15,10 +39,9 @@ import { listMCPTools, getProxyBaseUrl, } from "../networking"; +import MessageManager from "../molecules/message_manager"; import { MCPToolset, MCPToolsetTool } from "./types"; -const { Text: AntdText } = Typography; - interface MCPToolsetsTabProps { accessToken: string | null; userRole: string | null; @@ -47,15 +70,19 @@ function MCPToolList({ serverId, serverName, accessToken, selectedTools, onToggl const [loading, setLoading] = useState(false); const [expanded, setExpanded] = useState(false); - const selectedSet = new Set(selectedTools.filter((t) => t.server_id === serverId).map((t) => t.tool_name)); + const selectedSet = new Set( + selectedTools.filter((t) => t.server_id === serverId).map((t) => t.tool_name), + ); const fetchTools = useCallback(async () => { if (!accessToken || tools.length > 0) return; setLoading(true); try { const result = await listMCPTools(accessToken, serverId); - const toolList = Array.isArray(result) ? result : result?.tools ?? []; - setTools(toolList.map((t: any) => ({ name: t.name ?? t.tool_name ?? t, description: t.description ?? "" }))); + const toolList = Array.isArray(result) ? result : (result?.tools ?? []); + setTools( + toolList.map((t: any) => ({ name: t.name ?? t.tool_name ?? t, description: t.description ?? "" })), + ); } catch { setTools([]); } finally { @@ -69,27 +96,29 @@ function MCPToolList({ serverId, serverName, accessToken, selectedTools, onToggl }; return ( -
+
{expanded && (
{loading ? ( -
+
+ +
) : tools.length === 0 ? ( -

No tools found for this server.

+

No tools found for this server.

) : (
{tools.map((tool) => { @@ -101,19 +130,25 @@ function MCPToolList({ serverId, serverName, accessToken, selectedTools, onToggl onClick={() => onToggle({ server_id: serverId, tool_name: tool.name })} className={`flex items-start justify-between px-3 py-2 rounded-lg text-left transition-colors ${ selected - ? "bg-purple-50 border border-purple-300" - : "bg-white border border-gray-100 hover:bg-gray-50" + ? "bg-primary/10 border border-primary/40" + : "bg-background border border-border hover:bg-muted" }`} >
-

+

{tool.name}

{tool.description && ( -

{tool.description}

+

+ {tool.description} +

)}
- {selected && ✓} + {selected && ( + ✓ + )} ); })} @@ -133,8 +168,20 @@ interface CreateToolsetModalProps { initialToolset?: MCPToolset; } -function CreateToolsetModal({ open, onClose, onSave, accessToken, initialToolset }: CreateToolsetModalProps) { - const [form] = Form.useForm(); +function CreateToolsetModal({ + open, + onClose, + onSave, + accessToken, + initialToolset, +}: CreateToolsetModalProps) { + const form = useForm({ + defaultValues: { + toolset_name: initialToolset?.toolset_name || "", + description: initialToolset?.description || "", + }, + }); + const { register, reset, handleSubmit, formState } = form; const [selectedTools, setSelectedTools] = useState(initialToolset?.tools || []); const [saving, setSaving] = useState(false); const [serverSearch, setServerSearch] = useState(""); @@ -142,14 +189,14 @@ function CreateToolsetModal({ open, onClose, onSave, accessToken, initialToolset React.useEffect(() => { if (open) { - form.setFieldsValue({ + reset({ toolset_name: initialToolset?.toolset_name || "", description: initialToolset?.description || "", }); setSelectedTools(initialToolset?.tools || []); setServerSearch(""); } - }, [open, initialToolset]); + }, [open, initialToolset, reset]); const handleToggleTool = (tool: MCPToolsetTool) => { setSelectedTools((prev) => { @@ -160,8 +207,7 @@ function CreateToolsetModal({ open, onClose, onSave, accessToken, initialToolset }); }; - const handleSubmit = async () => { - const values = await form.validateFields(); + const onValidSubmit = async (values: ToolsetFormValues) => { setSaving(true); try { await onSave(values.toolset_name, values.description, selectedTools); @@ -181,100 +227,118 @@ function CreateToolsetModal({ open, onClose, onSave, accessToken, initialToolset }); return ( - -
-
- - - - - - -
-
+ !o && onClose()}> + + + {initialToolset ? "Edit Toolset" : "New Toolset"} + -
- {/* Left panel: Available Tools */} -
-
- Available Tools +
+
+
+ + + {formState.errors.toolset_name && ( +

+ {formState.errors.toolset_name.message as string} +

+ )} +
+
+ + +
- setServerSearch(e.target.value)} - className="mb-2" - allowClear - /> -
- {filteredServers.length === 0 ? ( - {mcpServers.length === 0 ? "No MCP servers configured" : "No servers match your search"} - ) : ( - filteredServers.map((server) => ( - - )) - )} + +
+
+
+ Available Tools +
+ setServerSearch(e.target.value)} + className="mb-2" + /> +
+ {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 + ) : ( + selectedTools.map((tool, idx) => ( + + )) + )} +
+
-
- {/* Divider */} -
- - {/* Right panel: Your Toolset */} -
- - Your Toolset{" "} - ({selectedTools.length} tools) - -
- {selectedTools.length === 0 ? ( - No tools added yet - ) : ( - selectedTools.map((tool, idx) => ( - - )) - )} -
-
-
- -
- - -
- + + + + + + +
); } @@ -289,7 +353,7 @@ function toolsetColumns( header: "Toolset ID", accessorKey: "toolset_id", cell: ({ row }) => ( - + {row.original.toolset_id.slice(0, 8)}… ), @@ -302,12 +366,12 @@ function toolsetColumns( return (
- - {row.original.toolset_name} + + {row.original.toolset_name}
- -
- ), - } as ColumnDef] : []), + ...(isAdmin + ? [ + { + header: "", + id: "actions", + cell: ({ row }: { row: { original: MCPToolset } }) => ( +
+ + +
+ ), + } as ColumnDef, + ] + : []), ]; } @@ -401,20 +472,22 @@ function ToolsetUsageGuide() { }; return ( -
-

How toolsets work

-

- Create a toolset, assign it to a key via API Keys → Edit Key → MCP Servers, then point your MCP client at the toolset URL. The client only sees the tools you picked. +

+

How toolsets work

+

+ Create a toolset, assign it to a key via{" "} + API Keys → Edit Key → MCP Servers, then point + your MCP client at the toolset URL. The client only sees the tools you picked.

-
Claude Code / Cursor config
+
Claude Code / Cursor config
-
+        
           {snippet}
         
@@ -436,14 +509,19 @@ export function MCPToolsetsTab({ accessToken, userRole }: MCPToolsetsTabProps) { const handleCreate = async (name: string, description: string | undefined, tools: MCPToolsetTool[]) => { if (!accessToken) return; await createMCPToolset(accessToken, { toolset_name: name, description, tools }); - message.success("Toolset created"); + MessageManager.success("Toolset created"); queryClient.invalidateQueries({ queryKey: ["mcpToolsets"] }); }; const handleUpdate = async (name: string, description: string | undefined, tools: MCPToolsetTool[]) => { if (!accessToken || !editToolset) return; - await updateMCPToolset(accessToken, { toolset_id: editToolset.toolset_id, toolset_name: name, description, tools }); - message.success("Toolset updated"); + await updateMCPToolset(accessToken, { + toolset_id: editToolset.toolset_id, + toolset_name: name, + description, + tools, + }); + MessageManager.success("Toolset updated"); queryClient.invalidateQueries({ queryKey: ["mcpToolsets"] }); setEditToolset(null); }; @@ -453,7 +531,7 @@ export function MCPToolsetsTab({ accessToken, userRole }: MCPToolsetsTabProps) { setDeleting(true); try { await deleteMCPToolset(accessToken, deleteId); - message.success("Toolset deleted"); + MessageManager.success("Toolset deleted"); queryClient.invalidateQueries({ queryKey: ["mcpToolsets"] }); setDeleteId(null); } finally { @@ -468,13 +546,15 @@ export function MCPToolsetsTab({ accessToken, userRole }: MCPToolsetsTabProps) {
- MCP Toolsets - - Curated collections of tools from one or more MCP servers. Assign toolsets to keys and teams via the MCP permissions dropdown. - +

MCP Toolsets

+

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

{isAdmin && ( - )} @@ -510,16 +590,30 @@ export function MCPToolsetsTab({ accessToken, userRole }: MCPToolsetsTabProps) { /> )} - setDeleteId(null)} - onOk={handleDelete} - okText="Delete" - okButtonProps={{ danger: true, loading: deleting }} - title="Delete Toolset" - > -

Are you sure you want to delete this toolset? Keys and teams using it will lose access to the scoped tools.

-
+ !o && setDeleteId(null)}> + + + Delete Toolset + + Are you sure you want to delete this toolset? Keys and teams using it will lose access to the + scoped tools. + + + + Cancel + { + e.preventDefault(); + handleDelete(); + }} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + disabled={deleting} + > + {deleting ? "Deleting..." : "Delete"} + + + +
); } diff --git a/ui/litellm-dashboard/src/components/mcp_tools/ToolTestPanel.tsx b/ui/litellm-dashboard/src/components/mcp_tools/ToolTestPanel.tsx index f94584d1964..5fbad48ab5a 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/ToolTestPanel.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/ToolTestPanel.tsx @@ -1,9 +1,24 @@ import React from "react"; -// eslint-disable-next-line litellm-ui/no-banned-ui-imports -import { Button, TextInput } from "@tremor/react"; +import { useForm, Controller } from "react-hook-form"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { Info as InfoCircleOutlined, X, Copy, Check, AlertCircle, Zap } from "lucide-react"; import { MCPTool, InputSchema, InputSchemaProperty } from "./types"; -import { Form, Select, Tooltip } from "antd"; -import { Info as InfoCircleOutlined } from "lucide-react"; import NotificationsManager from "../molecules/notifications_manager"; const isPlainObject = (value: unknown): value is Record => @@ -101,6 +116,38 @@ const getInitialValueForField = (prop: InputSchemaProperty): any => { return defaultValue; }; +function validateField( + key: string, + prop: InputSchemaProperty, + required: boolean, + value: any, +): string | true { + if (required && (value === undefined || value === null || value === "")) { + return `Please enter ${key}`; + } + if (prop.type === "object" || prop.type === "array") { + if ((value === undefined || value === null || value === "") && !required) { + return true; + } + try { + const parsed = typeof value === "string" ? JSON.parse(value) : value; + const isValidObject = + prop.type === "object" && + parsed !== null && + typeof parsed === "object" && + !Array.isArray(parsed); + const isValidArray = prop.type === "array" && Array.isArray(parsed); + if ((prop.type === "object" && isValidObject) || (prop.type === "array" && isValidArray)) { + return true; + } + return prop.type === "object" ? "Please enter a JSON object" : "Please enter a JSON array"; + } catch { + return "Invalid JSON"; + } + } + return true; +} + export function ToolTestPanel({ tool, onSubmit, @@ -116,15 +163,12 @@ export function ToolTestPanel({ error: Error | null; onClose: () => void; }) { - const [form] = Form.useForm(); const [viewMode, setViewMode] = React.useState<"formatted" | "json">("formatted"); const [startTime, setStartTime] = React.useState(null); const [duration, setDuration] = React.useState(null); - // Create a placeholder schema if we only have the "tool_input_schema" string const schema: InputSchema = React.useMemo(() => { if (typeof tool.inputSchema === "string") { - // Default schema with a single text field return { type: "object", properties: { @@ -139,7 +183,6 @@ export function ToolTestPanel({ return tool.inputSchema as InputSchema; }, [tool.inputSchema]); - // Check if this is a nested params structure and extract the actual parameters const actualSchema: InputSchema = React.useMemo(() => { if ( schema.properties && @@ -147,7 +190,6 @@ export function ToolTestPanel({ schema.properties.params.type === "object" && schema.properties.params.properties ) { - // This is a nested params structure, extract the actual parameters return { type: "object", properties: schema.properties.params.properties, @@ -157,27 +199,31 @@ export function ToolTestPanel({ return schema; }, [schema]); - React.useEffect(() => { - form.resetFields(); - - if (!actualSchema.properties) { - return; + const defaultValues = React.useMemo(() => { + const values: Record = {}; + if (actualSchema.properties) { + Object.entries(actualSchema.properties).forEach(([key, prop]) => { + values[key] = getInitialValueForField(prop); + }); } + return values; + }, [actualSchema]); - const initialValues: Record = {}; - Object.entries(actualSchema.properties).forEach(([key, prop]) => { - initialValues[key] = getInitialValueForField(prop); - }); + const form = useForm>({ + defaultValues, + mode: "onSubmit", + }); + const { control, handleSubmit, reset } = form; - form.setFieldsValue(initialValues); - }, [form, actualSchema, tool]); + React.useEffect(() => { + reset(defaultValues); + }, [defaultValues, reset, tool]); - const handleSubmit = (values: Record) => { + const handleFormSubmit = (values: Record) => { const start = Date.now(); setStartTime(start); setDuration(null); - // Convert form values to proper types based on schema const convertedValues: Record = {}; const schemaToUse = actualSchema; @@ -226,7 +272,6 @@ export function ToolTestPanel({ } }); - // If this was a nested params structure, wrap the values back in params const submitValues = schema.properties && schema.properties.params && @@ -238,7 +283,6 @@ export function ToolTestPanel({ onSubmit(submitValues); }; - // Track when result changes to calculate duration React.useEffect(() => { if (startTime && (result || error)) { const endTime = Date.now(); @@ -248,12 +292,10 @@ export function ToolTestPanel({ const copyToClipboard = async (text: string) => { try { - // Try modern clipboard API first if (navigator.clipboard && window.isSecureContext) { await navigator.clipboard.writeText(text); return true; } else { - // Fallback for non-secure contexts (like 0.0.0.0) const textArea = document.createElement("textarea"); textArea.value = text; textArea.style.position = "fixed"; @@ -297,7 +339,7 @@ export function ToolTestPanel({ return (
{/* Compact Header */} -
+
{tool.mcp_info.logo_url && ( // eslint-disable-next-line @next/next/no-img-element @@ -309,300 +351,311 @@ export function ToolTestPanel({ )}
-

Test Tool:

+

Test Tool:

- {tool.name} - - - + {tool.name} +
-

{tool.description}

-

Provider: {tool.mcp_info.server_name}

+

{tool.description}

+

Provider: {tool.mcp_info.server_name}

-
- {/* Two Column Layout - Always Side by Side */} + {/* Two Column Layout */}
{/* Left Column - Input Parameters */} -
-
+
+
-

Input Parameters

- - - +

Input Parameters

+ + + + + + Configure the input parameters for this tool call + +
-
+ {typeof tool.inputSchema === "string" ? ( -
- - Input * - - } +
+ + - - + rules={{ required: "Please enter input for this tool" }} + render={({ field, fieldState }) => ( + <> + + {fieldState.error && ( +

{fieldState.error.message}

+ )} + + )} + />
) : actualSchema.properties === undefined ? ( -
+
-

No Parameters Required

-

This tool can be called without any input parameters.

+

No Parameters Required

+

+ This tool can be called without any input parameters. +

) : (
{Object.entries(actualSchema.properties).map(([key, prop]) => { - const initialValue = getInitialValueForField(prop); + const required = !!actualSchema.required?.includes(key); const fieldKey = `${tool.name}-${key}`; return ( - - {key} {actualSchema.required?.includes(key) && *} +
+ + validateField(key, prop, required, value) }} + render={({ field, fieldState }) => { + const errorMessage = fieldState.error?.message; + if (prop.type === "string" && prop.enum) { + return ( + <> + + {errorMessage && ( +

{errorMessage}

+ )} + + ); + } + if (prop.type === "string") { + return ( + <> + + {errorMessage && ( +

{errorMessage}

+ )} + + ); + } + if (prop.type === "number" || prop.type === "integer") { + return ( + <> + { + const v = e.target.value; + field.onChange(v === "" ? "" : Number(v)); + }} + onBlur={field.onBlur} + /> + {errorMessage && ( +

{errorMessage}

+ )} + + ); + } + if (prop.type === "boolean") { + const valueStr = + field.value === true ? "true" : field.value === false ? "false" : ""; + const valueTitle = + field.value === true ? "True" : field.value === false ? "False" : undefined; + return ( + <> + + {errorMessage && ( +

{errorMessage}

+ )} + + ); + } + if (prop.type === "object" || prop.type === "array") { + return ( +
+