feat(ui): migrate mcp_server_management to shadcn

- MCPServerSelector: antd Select mode=multiple \u2192 shadcn Popover +
  chip-style multi-select (same pattern as VectorStoreSelector /
  GuardrailSelector). Merges three lists (access groups / servers /
  toolsets) into a single searchable popover with a categorical emerald
  / blue / purple dot + label per item. Added to .eslintrc.json overrides.
- MCPToolPermissions: @tremor Text + antd Spin/Radio.Group \u2192 shadcn
  Checkbox/Skeleton/ToggleGroup + plain Tailwind. View-mode picker uses
  a ToggleGroup with 'Risk Groups' / 'Flat List' options. Flat-list
  tool rows use shadcn Checkbox.

Gates: TS \u2713 | Vitest 3/3 (existing MCPToolPermissions.test) \u2713 | Build \u2713.

Co-authored-by: yuneng-jiang <yuneng-berri@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-04-23 10:02:19 +00:00
parent eaa075ee8e
commit 276b1682e2
No known key found for this signature in database
3 changed files with 255 additions and 138 deletions

View file

@ -37,7 +37,8 @@
"src/components/cache_dashboard.tsx",
"src/components/Settings/AdminSettings/MCPSemanticFilterSettings/MCPSemanticFilterTestPanel.tsx",
"src/components/mcp_hub_table_columns.tsx",
"src/components/model_hub_table_columns.tsx"
"src/components/model_hub_table_columns.tsx",
"src/components/mcp_server_management/MCPServerSelector.tsx"
],
"rules": {
"litellm-ui/no-banned-ui-imports": "off",

View file

@ -1,11 +1,23 @@
import { useMCPAccessGroups } from "@/app/(dashboard)/hooks/mcpServers/useMCPAccessGroups";
import { useMCPServers } from "@/app/(dashboard)/hooks/mcpServers/useMCPServers";
import { useMCPToolsets } from "@/app/(dashboard)/hooks/mcpServers/useMCPToolsets";
import { Select } from "antd";
import React from "react";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
import React, { useMemo, useState } from "react";
interface MCPServerSelectorProps {
onChange: (selected: { servers: string[]; accessGroups: string[]; toolsets: string[] }) => void;
onChange: (selected: {
servers: string[];
accessGroups: string[];
toolsets: string[];
}) => void;
value?: {
servers: string[];
accessGroups: string[];
@ -20,121 +32,212 @@ interface MCPServerSelectorProps {
const TOOLSET_PREFIX = "toolset:";
/**
* Multi-select that combines three lists:
* - MCP Access Groups (emerald dot)
* - MCP Servers (blue dot)
* - Toolsets (purple dot)
*
* Selected items become chips; popover shows the three categories together
* with an inline search input.
*/
const MCPServerSelector: React.FC<MCPServerSelectorProps> = ({
onChange,
value,
className,
accessToken,
placeholder = "Select MCP servers",
disabled = false,
teamId,
}) => {
const { data: mcpServers = [], isLoading: serversLoading } = useMCPServers(teamId);
const { data: accessGroups = [], isLoading: groupsLoading } = useMCPAccessGroups();
const { data: toolsets = [], isLoading: toolsetsLoading } = useMCPToolsets();
const { data: mcpServers = [] } = useMCPServers(teamId);
const { data: accessGroups = [] } = useMCPAccessGroups();
const { data: toolsets = [] } = useMCPToolsets();
const loading = serversLoading || groupsLoading || toolsetsLoading;
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const accessGroupSet = new Set(accessGroups);
const accessGroupSet = useMemo(
() => new Set(accessGroups),
[accessGroups],
);
// Combine options: access groups (green) + servers (blue) + toolsets (purple)
const options = [
...accessGroups.map((group) => ({
label: group,
value: group,
type: "accessGroup" as const,
searchText: `${group} Access Group`,
})),
...mcpServers.map((server) => ({
label: `${server.server_name || server.server_id} (${server.server_id})`,
value: server.server_id,
type: "server" as const,
searchText: `${server.server_name || server.server_id} ${server.server_id} MCP Server`,
})),
...toolsets.map((toolset) => ({
label: toolset.toolset_name,
value: `${TOOLSET_PREFIX}${toolset.toolset_id}`,
type: "toolset" as const,
searchText: `${toolset.toolset_name} ${toolset.toolset_id} Toolset`,
})),
];
const options = useMemo(
() => [
...accessGroups.map((group) => ({
label: group,
value: group,
type: "accessGroup" as const,
searchText: `${group} Access Group`,
})),
...mcpServers.map((server) => ({
label: `${server.server_name || server.server_id} (${server.server_id})`,
value: server.server_id,
type: "server" as const,
searchText: `${server.server_name || server.server_id} ${server.server_id} MCP Server`,
})),
...toolsets.map((toolset) => ({
label: toolset.toolset_name,
value: `${TOOLSET_PREFIX}${toolset.toolset_id}`,
type: "toolset" as const,
searchText: `${toolset.toolset_name} ${toolset.toolset_id} Toolset`,
})),
],
[accessGroups, mcpServers, toolsets],
);
const colorByType: Record<string, string> = {
accessGroup: "#52c41a",
server: "#1890ff",
toolset: "#722ed1",
const selectedValues = useMemo(
() => [
...(value?.servers || []),
...(value?.accessGroups || []),
...(value?.toolsets || []).map((id) => `${TOOLSET_PREFIX}${id}`),
],
[value],
);
const filteredOptions = useMemo(
() =>
options
.filter((o) => !selectedValues.includes(o.value))
.filter((o) =>
query
? o.searchText.toLowerCase().includes(query.toLowerCase())
: true,
),
[options, selectedValues, query],
);
const dotByType: Record<string, string> = {
accessGroup: "bg-emerald-500",
server: "bg-blue-500",
toolset: "bg-purple-500",
};
const labelByType: Record<string, string> = {
accessGroup: "Access Group",
server: "MCP Server",
toolset: "Toolset",
};
// Flatten value for Select — prefix toolset IDs
const selectedValues = [
...(value?.servers || []),
...(value?.accessGroups || []),
...(value?.toolsets || []).map((id) => `${TOOLSET_PREFIX}${id}`),
];
const textByType: Record<string, string> = {
accessGroup: "text-emerald-700 dark:text-emerald-400",
server: "text-blue-700 dark:text-blue-400",
toolset: "text-purple-700 dark:text-purple-400",
};
// Handle selection
const handleChange = (selected: string[]) => {
const toolsetsSelected = selected
const applyChange = (next: string[]) => {
const toolsetsSelected = next
.filter((v) => v.startsWith(TOOLSET_PREFIX))
.map((v) => v.slice(TOOLSET_PREFIX.length));
const rest = selected.filter((v) => !v.startsWith(TOOLSET_PREFIX));
const rest = next.filter((v) => !v.startsWith(TOOLSET_PREFIX));
const servers = rest.filter((v) => !accessGroupSet.has(v));
const accessGroupsSelected = rest.filter((v) => accessGroupSet.has(v));
onChange({ servers, accessGroups: accessGroupsSelected, toolsets: toolsetsSelected });
onChange({
servers,
accessGroups: accessGroupsSelected,
toolsets: toolsetsSelected,
});
};
const labelFor = (v: string) => {
const opt = options.find((o) => o.value === v);
return opt ? opt.label : v;
};
const typeFor = (v: string) => {
const opt = options.find((o) => o.value === v);
return opt?.type ?? "server";
};
return (
<div>
<Select
mode="multiple"
placeholder={placeholder}
onChange={handleChange}
value={selectedValues}
loading={loading}
className={className}
allowClear
showSearch
style={{ width: "100%" }}
disabled={disabled}
filterOption={(input, option) => {
const searchText = options.find((opt) => opt.value === option?.value)?.searchText || "";
return searchText.toLowerCase().includes(input.toLowerCase());
}}
>
{options.map((opt) => (
<Select.Option key={opt.value} value={opt.value} label={opt.label}>
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<span
style={{
display: "inline-block",
width: 8,
height: 8,
borderRadius: "50%",
background: colorByType[opt.type],
flexShrink: 0,
}}
/>
<span style={{ flex: 1 }}>{opt.label}</span>
<span
style={{
color: colorByType[opt.type],
fontSize: "12px",
fontWeight: 500,
opacity: 0.8,
}}
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
disabled={disabled}
className={cn(
"min-h-9 w-full flex flex-wrap items-center gap-1 rounded-md border border-input bg-background px-2 py-1 text-sm text-left disabled:opacity-50",
className,
)}
>
{selectedValues.length === 0 ? (
<span className="text-muted-foreground px-1">{placeholder}</span>
) : (
selectedValues.map((v) => (
<Badge
key={v}
variant="secondary"
className="gap-1 inline-flex items-center"
>
{labelByType[opt.type]}
</span>
<span
className={cn(
"w-1.5 h-1.5 rounded-full inline-block",
dotByType[typeFor(v)],
)}
/>
{labelFor(v)}
<span
role="button"
tabIndex={0}
onClick={(e) => {
e.stopPropagation();
applyChange(selectedValues.filter((s) => s !== v));
}}
className="inline-flex items-center"
aria-label={`Remove ${labelFor(v)}`}
>
<X size={12} />
</span>
</Badge>
))
)}
</button>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-[var(--radix-popover-trigger-width)] p-2"
>
<Input
autoFocus
placeholder="Search servers, groups, toolsets…"
value={query}
onChange={(e) => setQuery(e.target.value)}
className="h-8 mb-2"
/>
<div className="max-h-72 overflow-y-auto">
{filteredOptions.length === 0 ? (
<div className="py-2 px-3 text-sm text-muted-foreground">
No matches
</div>
</Select.Option>
))}
</Select>
</div>
) : (
filteredOptions.map((opt) => (
<button
key={opt.value}
type="button"
className="w-full text-left px-2 py-1.5 text-sm rounded hover:bg-accent flex items-center gap-2"
onClick={() =>
applyChange([...selectedValues, opt.value])
}
>
<span
className={cn(
"w-2 h-2 rounded-full inline-block shrink-0",
dotByType[opt.type],
)}
/>
<span className="flex-1 truncate">{opt.label}</span>
<span
className={cn(
"text-xs font-medium opacity-80",
textByType[opt.type],
)}
>
{labelByType[opt.type]}
</span>
</button>
))
)}
</div>
</PopoverContent>
</Popover>
);
};

View file

@ -1,8 +1,12 @@
import React, { useEffect, useRef, useState, useMemo } from "react";
import { listMCPTools } from "../networking";
import { MCPTool, MCPServer } from "../mcp_tools/types";
import { Text } from "@tremor/react";
import { Spin, Radio } from "antd";
import { Checkbox } from "@/components/ui/checkbox";
import { Skeleton } from "@/components/ui/skeleton";
import {
ToggleGroup,
ToggleGroupItem,
} from "@/components/ui/toggle-group";
import { useMCPServers } from "../../app/(dashboard)/hooks/mcpServers/useMCPServers";
import McpCrudPermissionPanel from "../mcp_tools/McpCrudPermissionPanel";
import { classifyToolOp } from "../../utils/mcpToolCrudClassification";
@ -117,34 +121,42 @@ const MCPToolPermissions: React.FC<MCPToolPermissionsProps> = ({
const viewMode = viewModes[server.server_id] ?? "crud";
return (
<div key={server.server_id} className="border rounded-lg bg-gray-50">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b bg-white rounded-t-lg">
<div
key={server.server_id}
className="border border-border rounded-lg bg-muted"
>
<div className="flex items-center justify-between p-4 border-b border-border bg-background rounded-t-lg">
<div>
<Text className="font-semibold text-gray-900">{serverName}</Text>
{server.description && <Text className="text-sm text-gray-500">{server.description}</Text>}
<p className="font-semibold text-foreground">{serverName}</p>
{server.description && (
<p className="text-sm text-muted-foreground">
{server.description}
</p>
)}
</div>
<div className="flex items-center gap-3">
{!disabled && tools.length > 0 && (
<Radio.Group
<ToggleGroup
type="single"
size="sm"
value={viewMode}
onChange={(e) =>
setViewModes((prev) => ({ ...prev, [server.server_id]: e.target.value }))
}
size="small"
optionType="button"
buttonStyle="solid"
options={[
{ label: "Risk Groups", value: "crud" },
{ label: "Flat List", value: "flat" },
]}
/>
onValueChange={(v) => {
if (!v) return;
setViewModes((prev) => ({
...prev,
[server.server_id]: v as "crud" | "flat",
}));
}}
>
<ToggleGroupItem value="crud">Risk Groups</ToggleGroupItem>
<ToggleGroupItem value="flat">Flat List</ToggleGroupItem>
</ToggleGroup>
)}
{!disabled && (
<>
<button
type="button"
className="text-sm text-blue-600 hover:text-blue-700 font-medium"
className="text-sm text-primary hover:underline font-medium"
onClick={() => handleSelectAll(server.server_id)}
disabled={isLoading}
>
@ -152,7 +164,7 @@ const MCPToolPermissions: React.FC<MCPToolPermissionsProps> = ({
</button>
<button
type="button"
className="text-sm text-blue-600 hover:text-blue-700 font-medium"
className="text-sm text-primary hover:underline font-medium"
onClick={() => handleDeselectAll(server.server_id)}
disabled={isLoading}
>
@ -163,60 +175,62 @@ const MCPToolPermissions: React.FC<MCPToolPermissionsProps> = ({
</div>
</div>
{/* Tools */}
<div className="p-4">
{/* Loading */}
{isLoading && (
<div className="flex items-center justify-center py-8">
<Spin size="large" />
<Text className="ml-3 text-gray-500">Loading tools...</Text>
<div className="flex items-center justify-center py-8 gap-3">
<Skeleton className="h-8 w-8 rounded-full" />
<p className="text-muted-foreground">Loading tools...</p>
</div>
)}
{/* Error */}
{error && !isLoading && (
<div className="p-4 bg-red-50 border border-red-200 rounded-lg text-center">
<Text className="text-red-600 font-medium">Unable to load tools</Text>
<Text className="text-sm text-red-500 mt-1">{error}</Text>
<div className="p-4 bg-destructive/10 border border-destructive/30 rounded-lg text-center">
<p className="text-destructive font-medium">
Unable to load tools
</p>
<p className="text-sm text-destructive mt-1">{error}</p>
</div>
)}
{/* CRUD grouped view */}
{!isLoading && !error && tools.length > 0 && viewMode === "crud" && (
<McpCrudPermissionPanel
tools={tools}
value={!toolPermissions[server.server_id] ? undefined : selectedTools}
onChange={(allowed) => handleCrudPanelChange(server.server_id, allowed)}
value={
!toolPermissions[server.server_id] ? undefined : selectedTools
}
onChange={(allowed) =>
handleCrudPanelChange(server.server_id, allowed)
}
readOnly={disabled}
/>
)}
{/* Flat list view */}
{!isLoading && !error && tools.length > 0 && viewMode === "flat" && (
<div className="space-y-2">
{tools.map((tool) => {
const isSelected = selectedTools.includes(tool.name);
return (
<div key={tool.name} className="flex items-start gap-2">
<input
type="checkbox"
<Checkbox
checked={isSelected}
onChange={() => {
if (disabled) return;
const next = isSelected
? selectedTools.filter((n) => n !== tool.name)
: [...selectedTools, tool.name];
handleCrudPanelChange(server.server_id, next);
}}
disabled={disabled}
className="mt-0.5"
onCheckedChange={(next) => {
if (disabled) return;
const nextList = next
? [...selectedTools, tool.name]
: selectedTools.filter((n) => n !== tool.name);
handleCrudPanelChange(server.server_id, nextList);
}}
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<Text className="font-medium text-gray-900">{tool.name}</Text>
<Text className="text-sm text-gray-500">
<span className="font-medium text-foreground">
{tool.name}
</span>
<span className="text-sm text-muted-foreground">
- {tool.description || "No description"}
</Text>
</span>
</div>
</div>
</div>
@ -225,10 +239,9 @@ const MCPToolPermissions: React.FC<MCPToolPermissionsProps> = ({
</div>
)}
{/* Empty State */}
{!isLoading && !error && tools.length === 0 && (
<div className="text-center py-6">
<Text className="text-gray-500">No tools available</Text>
<p className="text-muted-foreground">No tools available</p>
</div>
)}
</div>