mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
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:
parent
28fe9fabae
commit
fd93079af4
5 changed files with 379 additions and 36 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -101,4 +101,6 @@ STABILIZATION_TODO.md
|
|||
**/test-results
|
||||
**/playwright-report
|
||||
**/*.storageState.json
|
||||
**/coverage
|
||||
**/coverage
|
||||
ui/litellm-dashboard/out
|
||||
litellm/proxy/_experimental/out
|
||||
|
|
|
|||
|
|
@ -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 "{toolSearchTerm}"</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>
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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",
|
||||
},
|
||||
};
|
||||
|
|
@ -14,7 +14,7 @@
|
|||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue