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 ( +
+