diff --git a/apps/code/src/renderer/features/mcp-servers/components/parts/ServerDetailView.tsx b/apps/code/src/renderer/features/mcp-servers/components/parts/ServerDetailView.tsx index 905e9da2b..263783f9c 100644 --- a/apps/code/src/renderer/features/mcp-servers/components/parts/ServerDetailView.tsx +++ b/apps/code/src/renderer/features/mcp-servers/components/parts/ServerDetailView.tsx @@ -1,3 +1,4 @@ +import { groupMcpToolsByCapability } from "@features/mcp-servers/hooks/mcpToolGroups"; import { useMcpInstallationTools } from "@features/mcp-servers/hooks/useMcpInstallationTools"; import { ArrowClockwise, @@ -25,6 +26,7 @@ import { } from "@radix-ui/themes"; import type { McpApprovalState, + McpInstallationTool, McpRecommendedServer, McpServerInstallation, } from "@renderer/api/posthogClient"; @@ -50,6 +52,45 @@ interface ServerDetailViewProps { onUninstall: () => void; } +interface ToolGroupSectionProps { + title: string; + tools: McpInstallationTool[]; + onToolApprovalChange: ( + toolName: string, + approval_state: McpApprovalState, + ) => void; +} + +function ToolGroupSection({ + title, + tools, + onToolApprovalChange, +}: ToolGroupSectionProps) { + if (tools.length === 0) return null; + + return ( + + + + {title} + + + {tools.length} + + + {tools.map((tool) => ( + + onToolApprovalChange(tool.tool_name, approval_state) + } + /> + ))} + + ); +} + export function ServerDetailView({ installation, template, @@ -119,6 +160,21 @@ export function ServerDetailView({ return visibleTools.filter((t) => t.tool_name.toLowerCase().includes(term)); }, [visibleTools, toolSearch]); + const groupedTools = useMemo( + () => groupMcpToolsByCapability(filteredTools), + [filteredTools], + ); + + const handleToolApprovalChange = ( + toolName: string, + approval_state: McpApprovalState, + ) => { + setToolApproval({ + toolName, + approval_state, + }); + }; + const removedCount = tools.filter((t) => !!t.removed_at).length; return ( @@ -385,18 +441,18 @@ export function ServerDetailView({ ) : ( - filteredTools.map((tool) => ( - - setToolApproval({ - toolName: tool.tool_name, - approval_state, - }) - } + <> + - )) + + )} )} diff --git a/apps/code/src/renderer/features/mcp-servers/hooks/mcpToolGroups.test.ts b/apps/code/src/renderer/features/mcp-servers/hooks/mcpToolGroups.test.ts new file mode 100644 index 000000000..5a2a1c146 --- /dev/null +++ b/apps/code/src/renderer/features/mcp-servers/hooks/mcpToolGroups.test.ts @@ -0,0 +1,76 @@ +import type { McpInstallationTool } from "@renderer/api/posthogClient"; +import { describe, expect, it } from "vitest"; +import { getMcpToolGroup, groupMcpToolsByCapability } from "./mcpToolGroups"; + +function tool( + name: string, + overrides: Partial = {}, +): McpInstallationTool { + return { + id: `tool-${name}`, + tool_name: name, + display_name: name, + description: "", + input_schema: {}, + approval_state: "needs_approval", + last_seen_at: "2026-01-01T00:00:00Z", + removed_at: null, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + ...overrides, + }; +} + +describe("getMcpToolGroup", () => { + it.each(["get_ticket", "list_projects", "search_docs", "lookup_customer"])( + "classifies %s as read", + (toolName) => { + expect(getMcpToolGroup(tool(toolName))).toBe("read"); + }, + ); + + it.each(["create_ticket", "delete_file", "send_message", "run_query"])( + "classifies %s as write/delete", + (toolName) => { + expect(getMcpToolGroup(tool(toolName))).toBe("write_delete"); + }, + ); + + it("falls back to display name and description when the tool name is ambiguous", () => { + expect( + getMcpToolGroup( + tool("ticket", { + display_name: "Find ticket", + }), + ), + ).toBe("read"); + expect( + getMcpToolGroup( + tool("message", { + display_name: "Message", + description: "Send a message to the current channel", + }), + ), + ).toBe("write_delete"); + }); + + it("defaults ambiguous tools to write/delete for safety", () => { + expect(getMcpToolGroup(tool("ticket"))).toBe("write_delete"); + }); +}); + +describe("groupMcpToolsByCapability", () => { + it("groups tools while preserving their input order within each group", () => { + const tools = [ + tool("create_ticket"), + tool("get_ticket"), + tool("search_tickets"), + tool("update_ticket"), + ]; + + expect(groupMcpToolsByCapability(tools)).toEqual({ + read: [tools[1], tools[2]], + write_delete: [tools[0], tools[3]], + }); + }); +}); diff --git a/apps/code/src/renderer/features/mcp-servers/hooks/mcpToolGroups.ts b/apps/code/src/renderer/features/mcp-servers/hooks/mcpToolGroups.ts new file mode 100644 index 000000000..7fe7fafb2 --- /dev/null +++ b/apps/code/src/renderer/features/mcp-servers/hooks/mcpToolGroups.ts @@ -0,0 +1,73 @@ +import type { McpInstallationTool } from "@renderer/api/posthogClient"; + +export type McpToolGroup = "read" | "write_delete"; + +const READ_VERBS = new Set([ + "fetch", + "find", + "get", + "list", + "lookup", + "query", + "read", + "search", + "view", +]); + +const WRITE_DELETE_VERBS = new Set([ + "add", + "archive", + "assign", + "close", + "create", + "delete", + "edit", + "execute", + "remove", + "run", + "send", + "set", + "update", + "upload", +]); + +function firstVerb(value: string | null | undefined): string | null { + const [verb] = + value + ?.trim() + .toLowerCase() + .match(/[a-z]+/) ?? []; + return verb ?? null; +} + +function classifyVerb(verb: string | null): McpToolGroup | null { + if (!verb) return null; + if (READ_VERBS.has(verb)) return "read"; + if (WRITE_DELETE_VERBS.has(verb)) return "write_delete"; + return null; +} + +export function getMcpToolGroup(tool: McpInstallationTool): McpToolGroup { + return ( + classifyVerb(firstVerb(tool.tool_name)) ?? + classifyVerb(firstVerb(tool.display_name)) ?? + classifyVerb(firstVerb(tool.description)) ?? + "write_delete" + ); +} + +export function groupMcpToolsByCapability(tools: McpInstallationTool[]): { + read: McpInstallationTool[]; + write_delete: McpInstallationTool[]; +} { + return tools.reduce( + (groups, tool) => { + groups[getMcpToolGroup(tool)].push(tool); + return groups; + }, + { read: [], write_delete: [] } as { + read: McpInstallationTool[]; + write_delete: McpInstallationTool[]; + }, + ); +}