From b9d8dd5fc5ce42602d19e54350452724c905d822 Mon Sep 17 00:00:00 2001 From: Ishaan Jaffer Date: Sat, 21 Mar 2026 19:01:12 -0700 Subject: [PATCH] feat(mcp): add MCPToolsetsTab component for creating and managing toolsets --- .../components/mcp_tools/MCPToolsetsTab.tsx | 524 ++++++++++++++++++ 1 file changed, 524 insertions(+) create mode 100644 ui/litellm-dashboard/src/components/mcp_tools/MCPToolsetsTab.tsx diff --git a/ui/litellm-dashboard/src/components/mcp_tools/MCPToolsetsTab.tsx b/ui/litellm-dashboard/src/components/mcp_tools/MCPToolsetsTab.tsx new file mode 100644 index 00000000000..d6f91f99957 --- /dev/null +++ b/ui/litellm-dashboard/src/components/mcp_tools/MCPToolsetsTab.tsx @@ -0,0 +1,524 @@ +import React, { useState, useCallback } from "react"; +import { Button, Text, Title } from "@tremor/react"; +import { Modal, Form, Input, message, Spin, Card, Typography, Space } from "antd"; +import { PlusIcon, PencilIcon, TrashIcon } from "@heroicons/react/outline"; +import { ColumnDef } from "@tanstack/react-table"; +import { useMCPToolsets } from "@/app/(dashboard)/hooks/mcpServers/useMCPToolsets"; +import { useMCPServers } from "@/app/(dashboard)/hooks/mcpServers/useMCPServers"; +import { useQueryClient } from "@tanstack/react-query"; +import { DataTable } from "../view_logs/table"; +import { + createMCPToolset, + updateMCPToolset, + deleteMCPToolset, + listMCPTools, + getProxyBaseUrl, +} from "../networking"; +import { MCPToolset, MCPToolsetTool } from "./types"; + +const { Text: AntdText } = Typography; + +interface MCPToolsetsTabProps { + accessToken: string | null; + userRole: string | null; +} + +interface ToolsetFormValues { + toolset_name: string; + description?: string; +} + +interface MCPToolListProps { + serverId: string; + serverName: string; + accessToken: string | null; + selectedTools: MCPToolsetTool[]; + onToggle: (tool: MCPToolsetTool) => void; +} + +interface ToolEntry { + name: string; + description?: string; +} + +function MCPToolList({ serverId, serverName, accessToken, selectedTools, onToggle }: MCPToolListProps) { + const [tools, setTools] = useState([]); + 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 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 ?? "" }))); + } catch { + setTools([]); + } finally { + setLoading(false); + } + }, [accessToken, serverId, tools.length]); + + const handleToggle = () => { + if (!expanded) fetchTools(); + setExpanded(!expanded); + }; + + return ( +
+ + {expanded && ( +
+ {loading ? ( +
+ ) : tools.length === 0 ? ( +

No tools found for this server.

+ ) : ( +
+ {tools.map((tool) => { + const selected = selectedSet.has(tool.name); + return ( + + ); + })} +
+ )} +
+ )} +
+ ); +} + +interface CreateToolsetModalProps { + open: boolean; + onClose: () => void; + onSave: (name: string, description: string | undefined, tools: MCPToolsetTool[]) => Promise; + accessToken: string | null; + initialToolset?: MCPToolset; +} + +function CreateToolsetModal({ open, onClose, onSave, accessToken, initialToolset }: CreateToolsetModalProps) { + const [form] = Form.useForm(); + const [selectedTools, setSelectedTools] = useState(initialToolset?.tools || []); + const [saving, setSaving] = useState(false); + const [serverSearch, setServerSearch] = useState(""); + const { data: mcpServers = [] } = useMCPServers(); + + React.useEffect(() => { + if (open) { + form.setFieldsValue({ + toolset_name: initialToolset?.toolset_name || "", + description: initialToolset?.description || "", + }); + setSelectedTools(initialToolset?.tools || []); + setServerSearch(""); + } + }, [open, initialToolset]); + + const handleToggleTool = (tool: MCPToolsetTool) => { + setSelectedTools((prev) => { + const exists = prev.some((t) => t.server_id === tool.server_id && t.tool_name === tool.tool_name); + return exists + ? prev.filter((t) => !(t.server_id === tool.server_id && t.tool_name === tool.tool_name)) + : [...prev, tool]; + }); + }; + + const handleSubmit = async () => { + const values = await form.validateFields(); + setSaving(true); + try { + await onSave(values.toolset_name, values.description, selectedTools); + onClose(); + } finally { + setSaving(false); + } + }; + + const filteredServers = mcpServers.filter((s) => { + const q = serverSearch.toLowerCase(); + return ( + !q || + (s.alias || "").toLowerCase().includes(q) || + (s.server_name || "").toLowerCase().includes(q) + ); + }); + + return ( + +
+
+ + + + + + +
+
+ +
+ {/* Left panel: Available Tools */} +
+
+ Available Tools +
+ 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) => ( + + )) + )} +
+
+ + {/* Divider */} +
+ + {/* Right panel: Your Toolset */} +
+ + Your Toolset{" "} + ({selectedTools.length} tools) + +
+ {selectedTools.length === 0 ? ( + No tools added yet + ) : ( + selectedTools.map((tool, idx) => ( + + )) + )} +
+
+
+ +
+ + +
+ + ); +} + +function toolsetColumns( + isAdmin: boolean, + onEdit: (t: MCPToolset) => void, + onDelete: (id: string) => void, + proxyBaseUrl: string, +): ColumnDef[] { + return [ + { + header: "Toolset ID", + accessorKey: "toolset_id", + cell: ({ row }) => ( + + {row.original.toolset_id.slice(0, 8)}… + + ), + }, + { + header: "Name", + accessorKey: "toolset_name", + cell: ({ row }) => { + const url = `${proxyBaseUrl}/toolset/${row.original.toolset_name}/mcp`; + return ( +
+
+ + {row.original.toolset_name} +
+ +
+ ); + }, + }, + { + header: "Description", + accessorKey: "description", + cell: ({ row }) => ( + {row.original.description || "—"} + ), + }, + { + header: "Tools", + accessorKey: "tools", + cell: ({ row }) => { + const tools = row.original.tools; + return ( +
+ {tools.slice(0, 4).map((t, i) => ( + + {t.tool_name} + + ))} + {tools.length > 4 && ( + +{tools.length - 4} more + )} +
+ ); + }, + }, + { + header: "Created", + accessorKey: "created_at", + cell: ({ row }) => ( + + {row.original.created_at ? new Date(row.original.created_at).toLocaleDateString() : "—"} + + ), + }, + ...(isAdmin ? [{ + header: "", + id: "actions", + cell: ({ row }: { row: { original: MCPToolset } }) => ( +
+ + +
+ ), + } as ColumnDef] : []), + ]; +} + +function ToolsetUsageGuide() { + const [copied, setCopied] = useState(false); + const proxyBaseUrl = getProxyBaseUrl(); + + const snippet = `{ + "mcpServers": { + "my-toolset": { + "url": "${proxyBaseUrl}/toolset//mcp", + "headers": { "x-litellm-api-key": "Bearer " } + } + } +}`; + + const copy = async () => { + try { + await navigator.clipboard.writeText(snippet); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } catch { + // ignore + } + }; + + 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. +

+
Claude Code / Cursor config
+
+
+          {snippet}
+        
+ +
+
+ ); +} + +export function MCPToolsetsTab({ accessToken, userRole }: MCPToolsetsTabProps) { + const queryClient = useQueryClient(); + const { data: toolsets = [], isLoading } = useMCPToolsets(); + const [createOpen, setCreateOpen] = useState(false); + const [editToolset, setEditToolset] = useState(null); + const [deleteId, setDeleteId] = useState(null); + const [deleting, setDeleting] = useState(false); + + const isAdmin = userRole === "Admin" || userRole === "proxy_admin"; + + 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"); + 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"); + queryClient.invalidateQueries({ queryKey: ["mcpToolsets"] }); + setEditToolset(null); + }; + + const handleDelete = async () => { + if (!accessToken || !deleteId) return; + setDeleting(true); + try { + await deleteMCPToolset(accessToken, deleteId); + message.success("Toolset deleted"); + queryClient.invalidateQueries({ queryKey: ["mcpToolsets"] }); + setDeleteId(null); + } finally { + setDeleting(false); + } + }; + + const proxyBaseUrl = getProxyBaseUrl(); + const columns = toolsetColumns(isAdmin, setEditToolset, setDeleteId, proxyBaseUrl); + + return ( +
+
+
+ MCP Toolsets + + Curated collections of tools from one or more MCP servers. Assign toolsets to keys and teams via the MCP permissions dropdown. + +
+ {isAdmin && ( + + )} +
+ + + +
} + getRowCanExpand={() => false} + isLoading={isLoading} + noDataMessage="No toolsets yet. Click 'New Toolset' to create one." + loadingMessage="Loading toolsets..." + enableSorting={true} + /> + + setCreateOpen(false)} + onSave={handleCreate} + accessToken={accessToken} + /> + + {editToolset && ( + setEditToolset(null)} + onSave={handleUpdate} + accessToken={accessToken} + initialToolset={editToolset} + /> + )} + + 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.

+
+
+ ); +}