mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-04 02:31:27 +00:00
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:
parent
eaa075ee8e
commit
276b1682e2
3 changed files with 255 additions and 138 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue