feat(ui): group MCP tools by CRUD risk category in tool permission panels

Adds a CRUD-classification layer to the MCP tool allowlist UI so admins
can allow/block an entire risk category (Read / Create / Update / Delete)
with a single toggle instead of managing a flat list of individual tools.

- New `mcpToolCrudClassification.ts` utility: regex-based classifier that
  buckets tool names/descriptions into read/create/update/delete/unknown
- New `McpCrudPermissionPanel` component: collapsible sections per CRUD
  group, group-level Switch toggle, individual tool checkboxes, risk badges
  (green Safe / yellow Medium / red High Risk)
- `mcp_tool_configuration.tsx`: adds "Risk Groups / Flat List" radio toggle;
  defaults to the CRUD-grouped view, flat list is still accessible
- `MCPToolPermissions.tsx` (key/team assignment): replaces flat checkboxes
  with the CRUD panel; adds per-server view toggle; delete tools are blocked
  by default for newly-added servers (safer default for key/team scoping)

No backend or schema changes — uses existing `allowed_tools` and
`mcp_tool_permissions` fields.
This commit is contained in:
Ishaan Jaffer 2026-03-11 18:01:08 -07:00
parent 50e68dc387
commit f8137174a4
5 changed files with 594 additions and 145 deletions

View file

@ -7997,6 +7997,80 @@
"supports_response_schema": true,
"supports_tool_choice": true
},
"black_forest_labs/flux-kontext-pro": {
"litellm_provider": "black_forest_labs",
"mode": "image_edit",
"output_cost_per_image": 0.04,
"source": "https://bfl.ai/pricing",
"supported_endpoints": [
"/v1/images/edits",
"/v1/images/generations"
]
},
"black_forest_labs/flux-kontext-max": {
"litellm_provider": "black_forest_labs",
"mode": "image_edit",
"output_cost_per_image": 0.08,
"source": "https://bfl.ai/pricing",
"supported_endpoints": [
"/v1/images/edits",
"/v1/images/generations"
]
},
"black_forest_labs/flux-pro-1.0-fill": {
"litellm_provider": "black_forest_labs",
"mode": "image_edit",
"output_cost_per_image": 0.05,
"source": "https://bfl.ai/pricing",
"supported_endpoints": [
"/v1/images/edits"
]
},
"black_forest_labs/flux-pro-1.0-expand": {
"litellm_provider": "black_forest_labs",
"mode": "image_edit",
"output_cost_per_image": 0.05,
"source": "https://bfl.ai/pricing",
"supported_endpoints": [
"/v1/images/edits"
]
},
"black_forest_labs/flux-pro-1.1": {
"litellm_provider": "black_forest_labs",
"mode": "image_generation",
"output_cost_per_image": 0.04,
"source": "https://bfl.ai/pricing",
"supported_endpoints": [
"/v1/images/generations"
]
},
"black_forest_labs/flux-pro-1.1-ultra": {
"litellm_provider": "black_forest_labs",
"mode": "image_generation",
"output_cost_per_image": 0.06,
"source": "https://bfl.ai/pricing",
"supported_endpoints": [
"/v1/images/generations"
]
},
"black_forest_labs/flux-dev": {
"litellm_provider": "black_forest_labs",
"mode": "image_generation",
"output_cost_per_image": 0.025,
"source": "https://bfl.ai/pricing",
"supported_endpoints": [
"/v1/images/generations"
]
},
"black_forest_labs/flux-pro": {
"litellm_provider": "black_forest_labs",
"mode": "image_generation",
"output_cost_per_image": 0.05,
"source": "https://bfl.ai/pricing",
"supported_endpoints": [
"/v1/images/generations"
]
},
"cerebras/llama-3.3-70b": {
"input_cost_per_token": 8.5e-07,
"litellm_provider": "cerebras",

View file

@ -2,9 +2,11 @@ import React, { useEffect, useState, useMemo } from "react";
import { listMCPTools } from "../networking";
import { MCPTool, MCPServer } from "../mcp_tools/types";
import { Text } from "@tremor/react";
import { Spin, Checkbox } from "antd";
import { Spin, Radio } from "antd";
import { XIcon } from "lucide-react";
import { useMCPServers } from "../../app/(dashboard)/hooks/mcpServers/useMCPServers";
import McpCrudPermissionPanel from "../mcp_tools/McpCrudPermissionPanel";
import { classifyToolOp } from "../../utils/mcpToolCrudClassification";
interface MCPToolPermissionsProps {
accessToken: string;
@ -25,6 +27,7 @@ const MCPToolPermissions: React.FC<MCPToolPermissionsProps> = ({
const [serverTools, setServerTools] = useState<Record<string, MCPTool[]>>({});
const [loadingTools, setLoadingTools] = useState<Record<string, boolean>>({});
const [toolErrors, setToolErrors] = useState<Record<string, string>>({});
const [viewModes, setViewModes] = useState<Record<string, "crud" | "flat">>({});
// Filter servers based on selectedServers
const servers = useMemo(() => {
@ -32,7 +35,7 @@ const MCPToolPermissions: React.FC<MCPToolPermissionsProps> = ({
return allServers.filter((server: MCPServer) => selectedServers.includes(server.server_id));
}, [allServers, selectedServers]);
// Fetch tools for a specific server
// Fetch tools for a specific server; applies delete-blocked-by-default for new servers.
const fetchToolsForServer = async (serverId: string) => {
setLoadingTools((prev) => ({ ...prev, [serverId]: true }));
setToolErrors((prev) => ({ ...prev, [serverId]: "" }));
@ -44,7 +47,16 @@ const MCPToolPermissions: React.FC<MCPToolPermissionsProps> = ({
setToolErrors((prev) => ({ ...prev, [serverId]: response.message || "Failed to fetch tools" }));
setServerTools((prev) => ({ ...prev, [serverId]: [] }));
} else {
setServerTools((prev) => ({ ...prev, [serverId]: response.tools || [] }));
const fetchedTools: MCPTool[] = response.tools || [];
setServerTools((prev) => ({ ...prev, [serverId]: fetchedTools }));
// For servers that have no permissions stored yet, block delete tools by default.
if (!toolPermissions[serverId] && fetchedTools.length > 0) {
const nonDeleteTools = fetchedTools
.filter((t) => classifyToolOp(t.name, t.description || "") !== "delete")
.map((t) => t.name);
onChange({ ...toolPermissions, [serverId]: nonDeleteTools });
}
}
} catch (err) {
console.error(`Error fetching tools for server ${serverId}:`, err);
@ -64,35 +76,17 @@ const MCPToolPermissions: React.FC<MCPToolPermissionsProps> = ({
});
}, [servers]);
// Handle tool selection
const handleToolToggle = (serverId: string, toolName: string) => {
const currentTools = toolPermissions[serverId] || [];
const newTools = currentTools.includes(toolName)
? currentTools.filter((name) => name !== toolName)
: [...currentTools, toolName];
const updatedPermissions = {
...toolPermissions,
[serverId]: newTools,
};
onChange(updatedPermissions);
const handleCrudPanelChange = (serverId: string, allowed: string[] | undefined) => {
onChange({ ...toolPermissions, [serverId]: allowed ?? [] });
};
const handleSelectAll = (serverId: string) => {
const tools = serverTools[serverId] || [];
const newPermissions = {
...toolPermissions,
[serverId]: tools.map((t) => t.name),
};
onChange(newPermissions);
onChange({ ...toolPermissions, [serverId]: tools.map((t) => t.name) });
};
const handleDeselectAll = (serverId: string) => {
const newPermissions = {
...toolPermissions,
[serverId]: [],
};
onChange(newPermissions);
onChange({ ...toolPermissions, [serverId]: [] });
};
if (selectedServers.length === 0) {
@ -117,22 +111,41 @@ const MCPToolPermissions: React.FC<MCPToolPermissionsProps> = ({
{server.description && <Text className="text-sm text-gray-500">{server.description}</Text>}
</div>
<div className="flex items-center gap-3">
<button
type="button"
className="text-sm text-blue-600 hover:text-blue-700 font-medium"
onClick={() => handleSelectAll(server.server_id)}
disabled={disabled || isLoading}
>
Select All
</button>
<button
type="button"
className="text-sm text-blue-600 hover:text-blue-700 font-medium"
onClick={() => handleDeselectAll(server.server_id)}
disabled={disabled || isLoading}
>
Deselect All
</button>
{!disabled && tools.length > 0 && (
<Radio.Group
value={viewModes[server.server_id] ?? "crud"}
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" },
]}
/>
)}
{!disabled && (
<>
<button
type="button"
className="text-sm text-blue-600 hover:text-blue-700 font-medium"
onClick={() => handleSelectAll(server.server_id)}
disabled={isLoading}
>
Select All
</button>
<button
type="button"
className="text-sm text-blue-600 hover:text-blue-700 font-medium"
onClick={() => handleDeselectAll(server.server_id)}
disabled={isLoading}
>
Deselect All
</button>
</>
)}
<button
type="button"
className="text-gray-400 hover:text-gray-600"
@ -147,8 +160,6 @@ const MCPToolPermissions: React.FC<MCPToolPermissionsProps> = ({
{/* Tools */}
<div className="p-4">
<Text className="text-sm font-medium text-gray-700 mb-3">Available Tools</Text>
{/* Loading */}
{isLoading && (
<div className="flex items-center justify-center py-8">
@ -165,18 +176,36 @@ const MCPToolPermissions: React.FC<MCPToolPermissionsProps> = ({
</div>
)}
{/* Tool List - Compact */}
{!isLoading && !error && tools.length > 0 && (
{/* CRUD grouped view */}
{!isLoading && !error && tools.length > 0 && (viewModes[server.server_id] ?? "crud") === "crud" && (
<McpCrudPermissionPanel
tools={tools}
value={selectedTools.length === 0 && !toolPermissions[server.server_id] ? undefined : selectedTools}
onChange={(allowed) => handleCrudPanelChange(server.server_id, allowed)}
readOnly={disabled}
blockDeleteByDefault
/>
)}
{/* Flat list view */}
{!isLoading && !error && tools.length > 0 && (viewModes[server.server_id] ?? "crud") === "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">
<Checkbox
<input
type="checkbox"
checked={isSelected}
onChange={() => handleToolToggle(server.server_id, tool.name)}
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"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">

View file

@ -0,0 +1,246 @@
/**
* McpCrudPermissionPanel
*
* Displays MCP tools grouped by CRUD operation risk category.
* Lets admins toggle an entire category (Read / Create / Update / Delete)
* or individual tools within a category.
*
* The component is a drop-in replacement for a flat tool checkbox list.
* Output is the same `string[]` of allowed tool names that the backend accepts.
*/
import React, { useMemo, useState } from "react";
import { Checkbox, Switch } from "antd";
import { Text } from "@tremor/react";
import { ChevronDownIcon, ChevronRightIcon } from "lucide-react";
import {
CrudOp,
MCPToolEntry,
CRUD_GROUP_META,
classifyToolOp,
groupToolsByCrud,
} from "../../utils/mcpToolCrudClassification";
interface McpCrudPermissionPanelProps {
/** List of tools available on this MCP server. */
tools: MCPToolEntry[];
/**
* Currently allowed tool names.
* `undefined` means "allow all" (no restriction stored yet).
* An empty array means "allow none".
*/
value: string[] | undefined;
/** Called whenever the allowed set changes. */
onChange: (allowed: string[] | undefined) => void;
readOnly?: boolean;
/**
* When true, "delete" operations are blocked by default when the panel
* first renders without an existing value (useful for key/team assignment
* where the safer default is to deny destructive ops).
*/
blockDeleteByDefault?: boolean;
}
const CRUD_ORDER: CrudOp[] = ["read", "create", "update", "delete", "unknown"];
const RISK_BADGE: Record<string, string> = {
low: "bg-green-100 text-green-800",
medium: "bg-yellow-100 text-yellow-800",
high: "bg-red-100 text-red-800 font-semibold",
unknown: "bg-gray-100 text-gray-700",
};
const GROUP_BORDER: Record<CrudOp, string> = {
read: "border-green-200",
create: "border-blue-200",
update: "border-yellow-200",
delete: "border-red-300",
unknown: "border-gray-200",
};
const GROUP_HEADER_BG: Record<CrudOp, string> = {
read: "bg-green-50",
create: "bg-blue-50",
update: "bg-yellow-50",
delete: "bg-red-50",
unknown: "bg-gray-50",
};
// ---------------------------------------------------------------------------
const McpCrudPermissionPanel: React.FC<McpCrudPermissionPanelProps> = ({
tools,
value,
onChange,
readOnly = false,
blockDeleteByDefault = false,
}) => {
const [collapsed, setCollapsed] = useState<Record<CrudOp, boolean>>({
read: false,
create: false,
update: false,
delete: false,
unknown: true,
});
const grouped = useMemo(() => groupToolsByCrud(tools), [tools]);
/**
* Derive the effective allowed set:
* - `undefined` all tools allowed
* - We materialise it to a Set<string> for fast lookups.
*/
const effectiveAllowed: Set<string> = useMemo(() => {
if (value === undefined) {
return new Set(tools.map((t) => t.name));
}
return new Set(value);
}, [value, tools]);
const isToolAllowed = (name: string) => effectiveAllowed.has(name);
const isGroupFullyAllowed = (op: CrudOp) => {
const group = grouped[op];
return group.length > 0 && group.every((t) => effectiveAllowed.has(t.name));
};
const isGroupPartiallyAllowed = (op: CrudOp) => {
const group = grouped[op];
if (group.length === 0) return false;
const allowedCount = group.filter((t) => effectiveAllowed.has(t.name)).length;
return allowedCount > 0 && allowedCount < group.length;
};
const toggleTool = (toolName: string) => {
if (readOnly) return;
const next = new Set(effectiveAllowed);
if (next.has(toolName)) {
next.delete(toolName);
} else {
next.add(toolName);
}
onChange(Array.from(next));
};
const toggleGroup = (op: CrudOp, enable: boolean) => {
if (readOnly) return;
const next = new Set(effectiveAllowed);
for (const tool of grouped[op]) {
if (enable) {
next.add(tool.name);
} else {
next.delete(tool.name);
}
}
onChange(Array.from(next));
};
const toggleCollapse = (op: CrudOp) => {
setCollapsed((prev) => ({ ...prev, [op]: !prev[op] }));
};
if (tools.length === 0) return null;
return (
<div className="space-y-3">
{CRUD_ORDER.map((op) => {
const group = grouped[op];
if (group.length === 0) return null;
const meta = CRUD_GROUP_META[op];
const fullyAllowed = isGroupFullyAllowed(op);
const partial = isGroupPartiallyAllowed(op);
const isCollapsed = collapsed[op];
return (
<div key={op} className={`rounded-lg border ${GROUP_BORDER[op]} overflow-hidden`}>
{/* Group header */}
<div className={`flex items-center justify-between px-4 py-3 ${GROUP_HEADER_BG[op]}`}>
<button
type="button"
className="flex items-center gap-2 flex-1 text-left"
onClick={() => toggleCollapse(op)}
>
{isCollapsed ? (
<ChevronRightIcon className="w-4 h-4 text-gray-500 flex-shrink-0" />
) : (
<ChevronDownIcon className="w-4 h-4 text-gray-500 flex-shrink-0" />
)}
<span className="font-semibold text-gray-900 text-sm">{meta.label}</span>
<span className={`text-xs px-2 py-0.5 rounded-full ${RISK_BADGE[meta.risk]}`}>
{meta.risk === "high" ? "High Risk" : meta.risk === "medium" ? "Medium Risk" : meta.risk === "low" ? "Safe" : "Unclassified"}
</span>
<span className="text-xs text-gray-500 ml-1">
{group.filter((t) => effectiveAllowed.has(t.name)).length}/{group.length} allowed
</span>
</button>
{!readOnly && (
<div className="flex items-center gap-2 ml-4">
<Text className="text-xs text-gray-500">
{fullyAllowed ? "All on" : partial ? "Partial" : "All off"}
</Text>
<Switch
size="small"
checked={fullyAllowed}
indeterminate={partial}
onChange={(checked) => toggleGroup(op, checked)}
/>
</div>
)}
</div>
{/* Description row */}
{!isCollapsed && (
<div className="px-4 pt-2 pb-1 text-xs text-gray-500 bg-white border-b border-gray-100">
{meta.description}
</div>
)}
{/* Tool list */}
{!isCollapsed && (
<div className="bg-white divide-y divide-gray-50">
{group.map((tool) => {
const allowed = isToolAllowed(tool.name);
return (
<div
key={tool.name}
className={`flex items-start gap-3 px-4 py-2.5 cursor-pointer transition-colors hover:bg-gray-50 ${
allowed ? "" : "opacity-60"
}`}
onClick={() => toggleTool(tool.name)}
>
<Checkbox
checked={allowed}
onChange={() => toggleTool(tool.name)}
disabled={readOnly}
onClick={(e) => e.stopPropagation()}
/>
<div className="flex-1 min-w-0">
<Text className="font-medium text-gray-900 text-sm">{tool.name}</Text>
{tool.description && (
<Text className="text-xs text-gray-500 mt-0.5 leading-snug">{tool.description}</Text>
)}
</div>
<span
className={`text-xs px-1.5 py-0.5 rounded flex-shrink-0 ${
allowed ? "bg-green-100 text-green-700" : "bg-gray-100 text-gray-500"
}`}
>
{allowed ? "on" : "off"}
</span>
</div>
);
})}
</div>
)}
</div>
);
})}
</div>
);
};
export default McpCrudPermissionPanel;
export { classifyToolOp, groupToolsByCrud };

View file

@ -1,8 +1,9 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import { Card, Title, Text } from "@tremor/react";
import { ToolOutlined, CheckCircleOutlined, SearchOutlined, EditOutlined } from "@ant-design/icons";
import { Badge, Spin, Checkbox, Input } from "antd";
import { Badge, Spin, Checkbox, Input, Radio } from "antd";
import { useTestMCPConnection } from "../../hooks/useTestMCPConnection";
import McpCrudPermissionPanel from "./McpCrudPermissionPanel";
interface KeyTool {
name: string;
@ -163,6 +164,7 @@ const MCPToolConfiguration: React.FC<MCPToolConfigurationProps> = ({
const hasInitializedRef = useRef(false);
const previousSuggestedToolNamesRef = useRef<string>("");
const [expandedTools, setExpandedTools] = useState<Set<string>>(new Set());
const [viewMode, setViewMode] = useState<"crud" | "flat">("crud");
// Use external tool state when provided (avoids duplicate fetch with MCPConnectionStatus).
// Fall back to internal hook when used standalone (e.g., edit flow).
@ -367,6 +369,19 @@ const MCPToolConfiguration: React.FC<MCPToolConfigurationProps> = ({
/>
)}
</div>
{tools.length > 0 && (
<Radio.Group
value={viewMode}
onChange={(e) => setViewMode(e.target.value)}
size="small"
optionType="button"
buttonStyle="solid"
options={[
{ label: "Risk Groups", value: "crud" },
{ label: "Flat List", value: "flat" },
]}
/>
)}
</div>
{/* Description */}
@ -436,104 +451,118 @@ const MCPToolConfiguration: React.FC<MCPToolConfigurationProps> = ({
</Text>
</div>
{/* Search bar */}
<Input
placeholder="Search tools by name or description..."
prefix={<SearchOutlined className="text-gray-400" />}
value={toolSearchTerm}
onChange={(e) => setToolSearchTerm(e.target.value)}
allowClear
className="rounded-lg"
size="large"
/>
{/* CRUD grouped view */}
{viewMode === "crud" && (
<McpCrudPermissionPanel
tools={tools}
value={allowedTools}
onChange={(allowed) => onAllowedToolsChange(allowed ?? [])}
/>
)}
{/* Tool list with checkboxes */}
{filteredTools.length === 0 ? (
<div className="text-center py-6 text-gray-400 border rounded-lg border-dashed">
<SearchOutlined className="text-2xl mb-2" />
<Text>No tools found matching &quot;{toolSearchTerm}&quot;</Text>
</div>
) : (
<div className="space-y-2">
{pinnedFiltered.length > 0 && (
<>
<div className="flex items-center justify-between px-1">
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
Suggested tools
</p>
<div className="flex gap-2">
<button
type="button"
onClick={handleEnableSuggested}
className="text-xs text-blue-600 hover:text-blue-700"
>
Enable all
</button>
<button
type="button"
onClick={handleDisableSuggested}
className="text-xs text-gray-500 hover:text-gray-700"
>
Disable all
</button>
</div>
</div>
{pinnedFiltered.map((tool) => (
<ToolRow
key={tool.name}
tool={tool}
isEnabled={allowedTools.includes(tool.name)}
isEditExpanded={expandedTools.has(tool.name)}
toolNameToDisplayName={toolNameToDisplayName}
toolNameToDescription={toolNameToDescription}
onToggle={handleToolToggle}
onToggleExpand={handleToggleEditExpanded}
onDisplayNameChange={handleDisplayNameChange}
onDescriptionChange={handleDescriptionChange}
/>
))}
</>
{/* Flat list view */}
{viewMode === "flat" && (
<>
{/* Search bar */}
<Input
placeholder="Search tools by name or description..."
prefix={<SearchOutlined className="text-gray-400" />}
value={toolSearchTerm}
onChange={(e) => setToolSearchTerm(e.target.value)}
allowClear
className="rounded-lg"
size="large"
/>
{/* Tool list with checkboxes */}
{filteredTools.length === 0 ? (
<div className="text-center py-6 text-gray-400 border rounded-lg border-dashed">
<SearchOutlined className="text-2xl mb-2" />
<Text>No tools found matching &quot;{toolSearchTerm}&quot;</Text>
</div>
) : (
<div className="space-y-2">
{pinnedFiltered.length > 0 && (
<>
<div className="flex items-center justify-between px-1">
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
Suggested tools
</p>
<div className="flex gap-2">
<button
type="button"
onClick={handleEnableSuggested}
className="text-xs text-blue-600 hover:text-blue-700"
>
Enable all
</button>
<button
type="button"
onClick={handleDisableSuggested}
className="text-xs text-gray-500 hover:text-gray-700"
>
Disable all
</button>
</div>
</div>
{pinnedFiltered.map((tool) => (
<ToolRow
key={tool.name}
tool={tool}
isEnabled={allowedTools.includes(tool.name)}
isEditExpanded={expandedTools.has(tool.name)}
toolNameToDisplayName={toolNameToDisplayName}
toolNameToDescription={toolNameToDescription}
onToggle={handleToolToggle}
onToggleExpand={handleToggleEditExpanded}
onDisplayNameChange={handleDisplayNameChange}
onDescriptionChange={handleDescriptionChange}
/>
))}
</>
)}
{restFiltered.length > 0 && (
<>
<div className="flex items-center justify-between px-1 pt-2">
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
{pinnedFiltered.length > 0 ? "All tools" : "Tools"}
</p>
<div className="flex gap-2">
<button
type="button"
onClick={handleEnableRest}
className="text-xs text-blue-600 hover:text-blue-700"
>
Enable all
</button>
<button
type="button"
onClick={handleDisableRest}
className="text-xs text-gray-500 hover:text-gray-700"
>
Disable all
</button>
</div>
</div>
{restFiltered.map((tool) => (
<ToolRow
key={tool.name}
tool={tool}
isEnabled={allowedTools.includes(tool.name)}
isEditExpanded={expandedTools.has(tool.name)}
toolNameToDisplayName={toolNameToDisplayName}
toolNameToDescription={toolNameToDescription}
onToggle={handleToolToggle}
onToggleExpand={handleToggleEditExpanded}
onDisplayNameChange={handleDisplayNameChange}
onDescriptionChange={handleDescriptionChange}
/>
))}
</>
)}
</div>
)}
{restFiltered.length > 0 && (
<>
<div className="flex items-center justify-between px-1 pt-2">
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
{pinnedFiltered.length > 0 ? "All tools" : "Tools"}
</p>
<div className="flex gap-2">
<button
type="button"
onClick={handleEnableRest}
className="text-xs text-blue-600 hover:text-blue-700"
>
Enable all
</button>
<button
type="button"
onClick={handleDisableRest}
className="text-xs text-gray-500 hover:text-gray-700"
>
Disable all
</button>
</div>
</div>
{restFiltered.map((tool) => (
<ToolRow
key={tool.name}
tool={tool}
isEnabled={allowedTools.includes(tool.name)}
isEditExpanded={expandedTools.has(tool.name)}
toolNameToDisplayName={toolNameToDisplayName}
toolNameToDescription={toolNameToDescription}
onToggle={handleToolToggle}
onToggleExpand={handleToggleEditExpanded}
onDisplayNameChange={handleDisplayNameChange}
onDescriptionChange={handleDescriptionChange}
/>
))}
</>
)}
</div>
</>
)}
</div>
)}

View file

@ -0,0 +1,71 @@
export type CrudOp = "read" | "create" | "update" | "delete" | "unknown";
const DELETE_RE = /\b(delete|remove|destroy|purge|drop|clear|erase|unlink|disconnect)\b/i;
const CREATE_RE = /\b(create|add|insert|new|post|submit|register|make|generate|write|upload)\b/i;
const UPDATE_RE = /\b(update|edit|modify|change|patch|put|set|rename|move|transform)\b/i;
const READ_RE = /\b(get|read|list|fetch|search|find|query|retrieve|show|view|check|describe|info)\b/i;
export interface MCPToolEntry {
name: string;
description?: string;
}
export function classifyToolOp(name: string, description = ""): CrudOp {
const text = (name + " " + description).toLowerCase();
if (DELETE_RE.test(text)) return "delete";
if (UPDATE_RE.test(text)) return "update";
if (CREATE_RE.test(text)) return "create";
if (READ_RE.test(text)) return "read";
return "unknown";
}
export function groupToolsByCrud(tools: MCPToolEntry[]): Record<CrudOp, MCPToolEntry[]> {
const groups: Record<CrudOp, MCPToolEntry[]> = {
read: [],
create: [],
update: [],
delete: [],
unknown: [],
};
for (const tool of tools) {
const op = classifyToolOp(tool.name, tool.description);
groups[op].push(tool);
}
return groups;
}
export const CRUD_GROUP_META: Record<
CrudOp,
{ label: string; description: string; risk: "low" | "medium" | "high" | "unknown"; color: string }
> = {
read: {
label: "Read",
description: "Safe operations — fetch, list, search. No side effects.",
risk: "low",
color: "green",
},
create: {
label: "Create",
description: "Add new resources — insert, upload, register.",
risk: "medium",
color: "blue",
},
update: {
label: "Update",
description: "Modify existing resources — edit, patch, rename.",
risk: "medium",
color: "yellow",
},
delete: {
label: "Delete",
description: "Destructive operations — remove, purge, destroy.",
risk: "high",
color: "red",
},
unknown: {
label: "Other",
description: "Operations that could not be automatically classified.",
risk: "unknown",
color: "gray",
},
};