feat(ui): add CRUD categorization for MCP tools

Implement automatic CRUD categorization to organize MCP tools by operation type
(Create, Read, Update, Delete, Other), making it easier for admins to review
and configure tools.

Features:
- Automatic categorization based on tool names and descriptions
- Color-coded category headers (green/blue/yellow/red/gray)
- Category counts showing enabled/disabled ratio
- Search functionality across all tools
- Handles camelCase and snake_case tool names
- Tools always grouped by default for better UX

Changes:
- Add tool_crud_categorization.ts with categorization logic
- Add tool_crud_categorization.test.ts with 13 test cases
- Update mcp_tool_configuration.tsx to display grouped tools
- Update .gitignore to exclude build artifacts
- Auto-update tsconfig.json for Next.js compatibility

Addresses feedback from #product-qa-feedback:
Reviewing 31+ tools in a flat list is overwhelming - now organized
into clear CRUD categories for easier review.

Co-authored-by: Ishaan Jaff <ishaan-jaff@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-03-05 01:47:24 +00:00
parent 28fe9fabae
commit fd93079af4
5 changed files with 379 additions and 36 deletions

4
.gitignore vendored
View file

@ -101,4 +101,6 @@ STABILIZATION_TODO.md
**/test-results
**/playwright-report
**/*.storageState.json
**/coverage
**/coverage
ui/litellm-dashboard/out
litellm/proxy/_experimental/out

View file

@ -3,6 +3,12 @@ import { Card, Title, Text } from "@tremor/react";
import { ToolOutlined, CheckCircleOutlined, SearchOutlined } from "@ant-design/icons";
import { Badge, Spin, Checkbox, Input } from "antd";
import { useTestMCPConnection } from "../../hooks/useTestMCPConnection";
import {
categorizeTools,
groupToolsByCategory,
CRUD_CATEGORY_ORDER,
CRUD_CATEGORY_COLORS,
} from "./tool_crud_categorization";
interface MCPToolConfigurationProps {
accessToken: string | null;
@ -32,8 +38,9 @@ const MCPToolConfiguration: React.FC<MCPToolConfigurationProps> = ({
enabled: true,
});
// Filter tools based on search term
const filteredTools = tools.filter((tool) => {
// Categorize and filter tools based on search term
const categorizedTools = categorizeTools(tools);
const filteredTools = categorizedTools.filter((tool) => {
const searchLower = toolSearchTerm.toLowerCase();
return (
tool.name.toLowerCase().includes(searchLower) ||
@ -41,6 +48,9 @@ const MCPToolConfiguration: React.FC<MCPToolConfigurationProps> = ({
);
});
// Group filtered tools by category (always grouped)
const groupedTools = groupToolsByCategory(filteredTools);
// Auto-select tools when tools are first loaded or when tools list changes
useEffect(() => {
// Check if the tools list has actually changed by comparing tool names
@ -205,7 +215,7 @@ const MCPToolConfiguration: React.FC<MCPToolConfigurationProps> = ({
size="large"
/>
{/* Tool list with checkboxes */}
{/* Tool list grouped by CRUD category */}
<div className="space-y-2">
{filteredTools.length === 0 ? (
<div className="text-center py-6 text-gray-400 border rounded-lg border-dashed">
@ -213,39 +223,73 @@ const MCPToolConfiguration: React.FC<MCPToolConfigurationProps> = ({
<Text>No tools found matching &quot;{toolSearchTerm}&quot;</Text>
</div>
) : (
filteredTools.map((tool, index) => (
<div
key={index}
className={`p-4 rounded-lg border transition-colors cursor-pointer ${
allowedTools.includes(tool.name)
? "bg-blue-50 border-blue-300 hover:border-blue-400"
: "bg-gray-50 border-gray-200 hover:border-gray-300"
}`}
onClick={() => handleToolToggle(tool.name)}
>
<div className="flex items-start gap-3">
<Checkbox checked={allowedTools.includes(tool.name)} onChange={() => handleToolToggle(tool.name)} />
<div className="flex-1">
<div className="flex items-center gap-2">
<Text className="font-medium text-gray-900">{tool.name}</Text>
<span
className={`px-2 py-0.5 text-xs rounded-full font-medium ${
allowedTools.includes(tool.name) ? "bg-green-100 text-green-800" : "bg-red-100 text-red-800"
}`}
>
{allowedTools.includes(tool.name) ? "Enabled" : "Disabled"}
</span>
CRUD_CATEGORY_ORDER.map((category) => {
const categoryTools = groupedTools[category];
if (categoryTools.length === 0) return null;
const categoryColors = CRUD_CATEGORY_COLORS[category];
const enabledCount = categoryTools.filter((tool) => allowedTools.includes(tool.name)).length;
return (
<div key={category} className="space-y-2">
<div className={`flex items-center gap-2 px-3 py-2 rounded-lg border ${categoryColors.bg} ${categoryColors.border}`}>
<Text className={`font-semibold ${categoryColors.text}`}>{category}</Text>
<Badge
count={categoryTools.length}
style={{ backgroundColor: category === "Create" ? "#52c41a" : category === "Read" ? "#1890ff" : category === "Update" ? "#faad14" : category === "Delete" ? "#ff4d4f" : "#8c8c8c" }}
/>
<Text className={`text-xs ${categoryColors.text} ml-auto`}>
{enabledCount}/{categoryTools.length} enabled
</Text>
</div>
<div className="space-y-2 pl-4">
{categoryTools.map((tool, index) => (
<div
key={index}
className={`p-4 rounded-lg border transition-colors cursor-pointer ${
allowedTools.includes(tool.name)
? "bg-blue-50 border-blue-300 hover:border-blue-400"
: "bg-gray-50 border-gray-200 hover:border-gray-300"
}`}
onClick={() => handleToolToggle(tool.name)}
>
<div className="flex items-start gap-3">
<Checkbox
checked={allowedTools.includes(tool.name)}
onChange={() => handleToolToggle(tool.name)}
/>
<div className="flex-1">
<div className="flex items-center gap-2">
<Text className="font-medium text-gray-900">{tool.name}</Text>
<span
className={`px-2 py-0.5 text-xs rounded-full font-medium ${
allowedTools.includes(tool.name)
? "bg-green-100 text-green-800"
: "bg-red-100 text-red-800"
}`}
>
{allowedTools.includes(tool.name) ? "Enabled" : "Disabled"}
</span>
<span className={`px-2 py-0.5 text-xs rounded-full font-medium ${categoryColors.badge}`}>
{category}
</span>
</div>
{tool.description && (
<Text className="text-gray-500 text-sm block mt-1">{tool.description}</Text>
)}
<Text className="text-gray-400 text-xs block mt-1">
{allowedTools.includes(tool.name)
? "✓ Users can call this tool"
: "✗ Users cannot call this tool"}
</Text>
</div>
</div>
</div>
))}
</div>
{tool.description && <Text className="text-gray-500 text-sm block mt-1">{tool.description}</Text>}
<Text className="text-gray-400 text-xs block mt-1">
{allowedTools.includes(tool.name)
? "✓ Users can call this tool"
: "✗ Users cannot call this tool"}
</Text>
</div>
</div>
</div>
))
);
})
)}
</div>
</div>

View file

@ -0,0 +1,148 @@
import { describe, it, expect } from "vitest";
import {
categorizeTool,
categorizeTools,
groupToolsByCategory,
type CategorizedTool,
} from "./tool_crud_categorization";
describe("tool_crud_categorization", () => {
describe("categorizeTool", () => {
it("should categorize Create operations", () => {
expect(categorizeTool("createUser")).toBe("Create");
expect(categorizeTool("addComment")).toBe("Create");
expect(categorizeTool("insertRecord")).toBe("Create");
expect(categorizeTool("postMessage")).toBe("Create");
expect(categorizeTool("registerAccount")).toBe("Create");
});
it("should categorize Read operations", () => {
expect(categorizeTool("getUser")).toBe("Read");
expect(categorizeTool("fetchData")).toBe("Read");
expect(categorizeTool("listItems")).toBe("Read");
expect(categorizeTool("searchRecords")).toBe("Read");
expect(categorizeTool("findUser")).toBe("Read");
expect(categorizeTool("queryDatabase")).toBe("Read");
expect(categorizeTool("atlassianUserInfo")).toBe("Read");
expect(categorizeTool("getConfluencePage")).toBe("Read");
});
it("should categorize Update operations", () => {
expect(categorizeTool("updateUser")).toBe("Update");
expect(categorizeTool("editProfile")).toBe("Update");
expect(categorizeTool("modifySettings")).toBe("Update");
expect(categorizeTool("patchResource")).toBe("Update");
expect(categorizeTool("changePassword")).toBe("Update");
});
it("should categorize Delete operations", () => {
expect(categorizeTool("deleteUser")).toBe("Delete");
expect(categorizeTool("removeItem")).toBe("Delete");
expect(categorizeTool("dropTable")).toBe("Delete");
expect(categorizeTool("clearCache")).toBe("Delete");
expect(categorizeTool("destroySession")).toBe("Delete");
});
it("should categorize as Other when no match", () => {
expect(categorizeTool("calculateSum")).toBe("Other");
expect(categorizeTool("processPayment")).toBe("Other");
expect(categorizeTool("validateInput")).toBe("Other");
});
it("should use description for categorization", () => {
expect(categorizeTool("userTool", "Gets user information")).toBe("Read");
expect(categorizeTool("dataTool", "Creates new records")).toBe("Create");
expect(categorizeTool("toolName", "Updates existing data")).toBe("Update");
expect(categorizeTool("actionTool", "Deletes old files")).toBe("Delete");
});
it("should be case insensitive", () => {
expect(categorizeTool("GETUSER")).toBe("Read");
expect(categorizeTool("CreateRecord")).toBe("Create");
expect(categorizeTool("UpdateItem")).toBe("Update");
expect(categorizeTool("DeleteFile")).toBe("Delete");
});
});
describe("categorizeTools", () => {
it("should categorize multiple tools", () => {
const tools = [
{ name: "getUser", description: "Get user info" },
{ name: "createPost", description: "Create a new post" },
{ name: "updateProfile" },
{ name: "deleteComment" },
];
const categorized = categorizeTools(tools);
expect(categorized).toHaveLength(4);
expect(categorized[0].category).toBe("Read");
expect(categorized[1].category).toBe("Create");
expect(categorized[2].category).toBe("Update");
expect(categorized[3].category).toBe("Delete");
});
it("should handle empty array", () => {
const categorized = categorizeTools([]);
expect(categorized).toHaveLength(0);
});
});
describe("groupToolsByCategory", () => {
it("should group tools by CRUD category", () => {
const tools: CategorizedTool[] = [
{ name: "getUser", category: "Read" },
{ name: "createPost", category: "Create" },
{ name: "updateProfile", category: "Update" },
{ name: "deleteComment", category: "Delete" },
{ name: "fetchData", category: "Read" },
{ name: "addItem", category: "Create" },
{ name: "processTask", category: "Other" },
];
const grouped = groupToolsByCategory(tools);
expect(grouped.Read).toHaveLength(2);
expect(grouped.Create).toHaveLength(2);
expect(grouped.Update).toHaveLength(1);
expect(grouped.Delete).toHaveLength(1);
expect(grouped.Other).toHaveLength(1);
});
it("should handle empty categories", () => {
const tools: CategorizedTool[] = [
{ name: "getUser", category: "Read" },
];
const grouped = groupToolsByCategory(tools);
expect(grouped.Read).toHaveLength(1);
expect(grouped.Create).toHaveLength(0);
expect(grouped.Update).toHaveLength(0);
expect(grouped.Delete).toHaveLength(0);
expect(grouped.Other).toHaveLength(0);
});
it("should handle empty array", () => {
const grouped = groupToolsByCategory([]);
expect(grouped.Read).toHaveLength(0);
expect(grouped.Create).toHaveLength(0);
expect(grouped.Update).toHaveLength(0);
expect(grouped.Delete).toHaveLength(0);
expect(grouped.Other).toHaveLength(0);
});
});
describe("real-world MCP tool examples", () => {
it("should categorize Confluence/Atlassian tools correctly", () => {
expect(categorizeTool("atlassianUserInfo")).toBe("Read");
expect(categorizeTool("getAccessibleAtlassianResources")).toBe("Read");
expect(categorizeTool("getConfluencePage")).toBe("Read");
expect(categorizeTool("searchConfluenceUsingCql")).toBe("Read");
expect(categorizeTool("getConfluenceSpaces")).toBe("Read");
expect(categorizeTool("getPagesInConfluenceSpace")).toBe("Read");
expect(categorizeTool("getConfluencePageFooterComments")).toBe("Read");
});
});
});

View file

@ -0,0 +1,149 @@
export type CRUDCategory = "Create" | "Read" | "Update" | "Delete" | "Other";
interface ToolCategoryKeywords {
Create: string[];
Read: string[];
Update: string[];
Delete: string[];
}
const CRUD_KEYWORDS: ToolCategoryKeywords = {
Create: [
"create",
"add",
"insert",
"new",
"post",
"register",
"init",
"initialize",
"setup",
"make",
"generate",
"build",
"construct",
],
Read: [
"get",
"fetch",
"list",
"search",
"find",
"read",
"query",
"view",
"retrieve",
"show",
"describe",
"check",
"load",
"export",
"download",
"info",
],
Update: [
"update",
"edit",
"modify",
"patch",
"change",
"alter",
"replace",
"rename",
],
Delete: [
"delete",
"remove",
"drop",
"clear",
"destroy",
"purge",
"revoke",
"cancel",
"unregister",
],
};
function splitCamelCase(text: string): string {
return text.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/([A-Z])([A-Z][a-z])/g, "$1 $2");
}
export function categorizeTool(toolName: string, description?: string): CRUDCategory {
const normalizedName = splitCamelCase(toolName).toLowerCase();
const searchText = `${normalizedName} ${description || ""}`.toLowerCase();
for (const [category, keywords] of Object.entries(CRUD_KEYWORDS)) {
for (const keyword of keywords) {
const pattern = new RegExp(`\\b${keyword}`, "i");
if (pattern.test(searchText)) {
return category as CRUDCategory;
}
}
}
return "Other";
}
export interface CategorizedTool {
name: string;
description?: string;
category: CRUDCategory;
}
export function categorizeTools(tools: Array<{ name: string; description?: string }>): CategorizedTool[] {
return tools.map((tool) => ({
...tool,
category: categorizeTool(tool.name, tool.description),
}));
}
export function groupToolsByCategory(tools: CategorizedTool[]): Record<CRUDCategory, CategorizedTool[]> {
const grouped: Record<CRUDCategory, CategorizedTool[]> = {
Create: [],
Read: [],
Update: [],
Delete: [],
Other: [],
};
tools.forEach((tool) => {
grouped[tool.category].push(tool);
});
return grouped;
}
export const CRUD_CATEGORY_ORDER: CRUDCategory[] = ["Create", "Read", "Update", "Delete", "Other"];
export const CRUD_CATEGORY_COLORS: Record<CRUDCategory, { bg: string; border: string; text: string; badge: string }> = {
Create: {
bg: "bg-green-50",
border: "border-green-300",
text: "text-green-700",
badge: "bg-green-100 text-green-800",
},
Read: {
bg: "bg-blue-50",
border: "border-blue-300",
text: "text-blue-700",
badge: "bg-blue-100 text-blue-800",
},
Update: {
bg: "bg-yellow-50",
border: "border-yellow-300",
text: "text-yellow-700",
badge: "bg-yellow-100 text-yellow-800",
},
Delete: {
bg: "bg-red-50",
border: "border-red-300",
text: "text-red-700",
badge: "bg-red-100 text-red-800",
},
Other: {
bg: "bg-gray-50",
border: "border-gray-300",
text: "text-gray-700",
badge: "bg-gray-100 text-gray-800",
},
};

View file

@ -14,7 +14,7 @@
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{