feat(mcp): add MCPToolsetsTab component for creating and managing toolsets

This commit is contained in:
Ishaan Jaffer 2026-03-21 19:01:12 -07:00
parent e8cdca5dc9
commit b9d8dd5fc5

View 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>
);
}