mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-21 00:21:49 +00:00
feat(mcp): add MCPToolsetsTab component for creating and managing toolsets
This commit is contained in:
parent
e8cdca5dc9
commit
b9d8dd5fc5
1 changed files with 524 additions and 0 deletions
524
ui/litellm-dashboard/src/components/mcp_tools/MCPToolsetsTab.tsx
Normal file
524
ui/litellm-dashboard/src/components/mcp_tools/MCPToolsetsTab.tsx
Normal file
|
|
@ -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<ToolEntry[]>([]);
|
||||
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 (
|
||||
<div className="border border-gray-200 rounded-lg overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
className="w-full flex items-center justify-between px-4 py-3 bg-gray-50 hover:bg-gray-100 transition-colors"
|
||||
onClick={handleToggle}
|
||||
>
|
||||
<span className="text-sm font-medium text-gray-700 flex items-center gap-2">
|
||||
<span className="inline-block w-2 h-2 rounded-full bg-blue-500 flex-shrink-0" />
|
||||
{serverName}
|
||||
{selectedSet.size > 0 && (
|
||||
<span className="ml-1 text-xs text-purple-600 font-semibold">{selectedSet.size} selected</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-gray-400 text-xs">{expanded ? "▲" : "▼"}</span>
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="p-2">
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-3"><Spin size="small" /></div>
|
||||
) : tools.length === 0 ? (
|
||||
<p className="text-xs text-gray-400 px-2 py-2">No tools found for this server.</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
{tools.map((tool) => {
|
||||
const selected = selectedSet.has(tool.name);
|
||||
return (
|
||||
<button
|
||||
key={tool.name}
|
||||
type="button"
|
||||
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"
|
||||
}`}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className={`text-sm font-medium leading-tight ${selected ? "text-purple-800" : "text-gray-800"}`}>
|
||||
{tool.name}
|
||||
</p>
|
||||
{tool.description && (
|
||||
<p className="text-xs text-gray-400 mt-0.5 leading-tight line-clamp-2">{tool.description}</p>
|
||||
)}
|
||||
</div>
|
||||
{selected && <span className="text-purple-500 text-xs font-semibold ml-2 flex-shrink-0 mt-0.5">✓</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CreateToolsetModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (name: string, description: string | undefined, tools: MCPToolsetTool[]) => Promise<void>;
|
||||
accessToken: string | null;
|
||||
initialToolset?: MCPToolset;
|
||||
}
|
||||
|
||||
function CreateToolsetModal({ open, onClose, onSave, accessToken, initialToolset }: CreateToolsetModalProps) {
|
||||
const [form] = Form.useForm<ToolsetFormValues>();
|
||||
const [selectedTools, setSelectedTools] = useState<MCPToolsetTool[]>(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 (
|
||||
<Modal
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
title={initialToolset ? "Edit Toolset" : "New Toolset"}
|
||||
width={960}
|
||||
footer={null}
|
||||
forceRender
|
||||
>
|
||||
<Form form={form} layout="vertical" className="mt-2">
|
||||
<div className="flex gap-4 mb-4">
|
||||
<Form.Item
|
||||
label="Toolset Name"
|
||||
name="toolset_name"
|
||||
rules={[{ required: true, message: "Please enter a toolset name" }]}
|
||||
className="flex-1 mb-0"
|
||||
>
|
||||
<Input placeholder="e.g. github-linear-tools" />
|
||||
</Form.Item>
|
||||
<Form.Item label="Description" name="description" className="flex-1 mb-0">
|
||||
<Input placeholder="Optional description" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
</Form>
|
||||
|
||||
<div className="flex gap-4 mt-2" style={{ minHeight: 360 }}>
|
||||
{/* Left panel: Available Tools */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<Text className="text-sm font-semibold text-gray-700">Available Tools</Text>
|
||||
</div>
|
||||
<Input
|
||||
placeholder="Search MCP servers..."
|
||||
value={serverSearch}
|
||||
onChange={(e) => setServerSearch(e.target.value)}
|
||||
className="mb-2"
|
||||
allowClear
|
||||
/>
|
||||
<div className="space-y-2 overflow-y-auto" style={{ maxHeight: 300 }}>
|
||||
{filteredServers.length === 0 ? (
|
||||
<Text className="text-gray-400 text-sm">{mcpServers.length === 0 ? "No MCP servers configured" : "No servers match your search"}</Text>
|
||||
) : (
|
||||
filteredServers.map((server) => (
|
||||
<MCPToolList
|
||||
key={server.server_id}
|
||||
serverId={server.server_id}
|
||||
serverName={server.alias || server.server_name || server.server_id}
|
||||
accessToken={accessToken}
|
||||
selectedTools={selectedTools}
|
||||
onToggle={handleToggleTool}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="w-px bg-gray-200 flex-shrink-0" />
|
||||
|
||||
{/* Right panel: Your Toolset */}
|
||||
<div className="w-72 flex-shrink-0">
|
||||
<Text className="text-sm font-semibold text-gray-700 mb-2 block">
|
||||
Your Toolset{" "}
|
||||
<span className="text-xs font-normal text-gray-400">({selectedTools.length} tools)</span>
|
||||
</Text>
|
||||
<div className="space-y-1 overflow-y-auto" style={{ maxHeight: 340 }}>
|
||||
{selectedTools.length === 0 ? (
|
||||
<Text className="text-gray-400 text-sm">No tools added yet</Text>
|
||||
) : (
|
||||
selectedTools.map((tool, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
type="button"
|
||||
onClick={() => handleToggleTool(tool)}
|
||||
className="w-full flex items-center justify-between px-3 py-1.5 rounded-lg border border-purple-200 bg-purple-50 hover:bg-red-50 hover:border-red-200 group transition-colors"
|
||||
>
|
||||
<div className="min-w-0 text-left">
|
||||
<span className="text-xs font-medium text-purple-800 group-hover:text-red-600 truncate block">{tool.tool_name}</span>
|
||||
<span className="text-[10px] text-purple-400 truncate block">{tool.server_id.slice(0, 8)}…</span>
|
||||
</div>
|
||||
<span className="ml-2 text-purple-300 group-hover:text-red-400 text-xs flex-shrink-0">✕</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 mt-4 pt-4 border-t border-gray-200">
|
||||
<Button variant="secondary" onClick={onClose}>Cancel</Button>
|
||||
<Button onClick={handleSubmit} loading={saving}>
|
||||
{initialToolset ? "Save Changes" : "Create Toolset"}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function toolsetColumns(
|
||||
isAdmin: boolean,
|
||||
onEdit: (t: MCPToolset) => void,
|
||||
onDelete: (id: string) => void,
|
||||
proxyBaseUrl: string,
|
||||
): ColumnDef<MCPToolset>[] {
|
||||
return [
|
||||
{
|
||||
header: "Toolset ID",
|
||||
accessorKey: "toolset_id",
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs bg-gray-100 px-2 py-0.5 rounded text-gray-600">
|
||||
{row.original.toolset_id.slice(0, 8)}…
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: "Name",
|
||||
accessorKey: "toolset_name",
|
||||
cell: ({ row }) => {
|
||||
const url = `${proxyBaseUrl}/toolset/${row.original.toolset_name}/mcp`;
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-block w-2 h-2 rounded-full bg-purple-500 flex-shrink-0" />
|
||||
<span className="font-medium text-gray-900">{row.original.toolset_name}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-gray-400 hover:text-purple-600 font-mono truncate max-w-xs text-left transition-colors"
|
||||
onClick={() => navigator.clipboard.writeText(url)}
|
||||
title="Click to copy endpoint URL"
|
||||
>
|
||||
{url}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "Description",
|
||||
accessorKey: "description",
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm text-gray-500">{row.original.description || "—"}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: "Tools",
|
||||
accessorKey: "tools",
|
||||
cell: ({ row }) => {
|
||||
const tools = row.original.tools;
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1 max-w-xs">
|
||||
{tools.slice(0, 4).map((t, i) => (
|
||||
<span key={i} className="inline-flex items-center px-1.5 py-0.5 rounded bg-purple-50 border border-purple-200 text-purple-700 text-xs">
|
||||
{t.tool_name}
|
||||
</span>
|
||||
))}
|
||||
{tools.length > 4 && (
|
||||
<span className="text-xs text-gray-400 self-center">+{tools.length - 4} more</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "Created",
|
||||
accessorKey: "created_at",
|
||||
cell: ({ row }) => (
|
||||
<span className="text-xs text-gray-500">
|
||||
{row.original.created_at ? new Date(row.original.created_at).toLocaleDateString() : "—"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
...(isAdmin ? [{
|
||||
header: "",
|
||||
id: "actions",
|
||||
cell: ({ row }: { row: { original: MCPToolset } }) => (
|
||||
<div className="flex items-center gap-1 justify-end">
|
||||
<button
|
||||
type="button"
|
||||
className="p-1.5 rounded-lg hover:bg-gray-100 text-gray-400 hover:text-gray-700 transition-colors"
|
||||
onClick={() => onEdit(row.original)}
|
||||
>
|
||||
<PencilIcon className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="p-1.5 rounded-lg hover:bg-red-50 text-gray-400 hover:text-red-500 transition-colors"
|
||||
onClick={() => onDelete(row.original.toolset_id)}
|
||||
>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
} as ColumnDef<MCPToolset>] : []),
|
||||
];
|
||||
}
|
||||
|
||||
function ToolsetUsageGuide() {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const proxyBaseUrl = getProxyBaseUrl();
|
||||
|
||||
const snippet = `{
|
||||
"mcpServers": {
|
||||
"my-toolset": {
|
||||
"url": "${proxyBaseUrl}/toolset/<toolset-name>/mcp",
|
||||
"headers": { "x-litellm-api-key": "Bearer <your-api-key>" }
|
||||
}
|
||||
}
|
||||
}`;
|
||||
|
||||
const copy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(snippet);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-6 rounded-lg border border-gray-200 bg-gray-50 px-5 py-4">
|
||||
<p className="text-sm font-medium text-gray-700 mb-1">How toolsets work</p>
|
||||
<p className="text-sm text-gray-500 mb-3">
|
||||
Create a toolset, assign it to a key via <span className="font-medium text-gray-700">API Keys → Edit Key → MCP Servers</span>, then point your MCP client at the toolset URL. The client only sees the tools you picked.
|
||||
</p>
|
||||
<div className="text-xs text-gray-400 mb-1">Claude Code / Cursor config</div>
|
||||
<div className="relative">
|
||||
<pre className="bg-white border border-gray-200 rounded px-4 py-3 text-xs font-mono text-gray-700 overflow-x-auto leading-relaxed pr-14">
|
||||
{snippet}
|
||||
</pre>
|
||||
<button
|
||||
type="button"
|
||||
onClick={copy}
|
||||
className="absolute top-2 right-2 px-2 py-1 text-xs rounded border bg-white hover:bg-gray-50 text-gray-400 hover:text-gray-600 border-gray-200 transition-colors"
|
||||
>
|
||||
{copied ? "✓" : "copy"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MCPToolsetsTab({ accessToken, userRole }: MCPToolsetsTabProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const { data: toolsets = [], isLoading } = useMCPToolsets();
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [editToolset, setEditToolset] = useState<MCPToolset | null>(null);
|
||||
const [deleteId, setDeleteId] = useState<string | null>(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 (
|
||||
<div className="mt-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<Title>MCP Toolsets</Title>
|
||||
<Text className="text-gray-500 text-sm">
|
||||
Curated collections of tools from one or more MCP servers. Assign toolsets to keys and teams via the MCP permissions dropdown.
|
||||
</Text>
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<Button icon={PlusIcon} onClick={() => setCreateOpen(true)}>
|
||||
New Toolset
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ToolsetUsageGuide />
|
||||
|
||||
<DataTable
|
||||
data={toolsets}
|
||||
columns={columns}
|
||||
renderSubComponent={() => <div />}
|
||||
getRowCanExpand={() => false}
|
||||
isLoading={isLoading}
|
||||
noDataMessage="No toolsets yet. Click 'New Toolset' to create one."
|
||||
loadingMessage="Loading toolsets..."
|
||||
enableSorting={true}
|
||||
/>
|
||||
|
||||
<CreateToolsetModal
|
||||
open={createOpen}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
onSave={handleCreate}
|
||||
accessToken={accessToken}
|
||||
/>
|
||||
|
||||
{editToolset && (
|
||||
<CreateToolsetModal
|
||||
open={!!editToolset}
|
||||
onClose={() => setEditToolset(null)}
|
||||
onSave={handleUpdate}
|
||||
accessToken={accessToken}
|
||||
initialToolset={editToolset}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
open={!!deleteId}
|
||||
onCancel={() => setDeleteId(null)}
|
||||
onOk={handleDelete}
|
||||
okText="Delete"
|
||||
okButtonProps={{ danger: true, loading: deleting }}
|
||||
title="Delete Toolset"
|
||||
>
|
||||
<p>Are you sure you want to delete this toolset? Keys and teams using it will lose access to the scoped tools.</p>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue