Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { groupMcpToolsByCapability } from "@features/mcp-servers/hooks/mcpToolGroups";
import { useMcpInstallationTools } from "@features/mcp-servers/hooks/useMcpInstallationTools";
import {
ArrowClockwise,
Expand Down Expand Up @@ -25,6 +26,7 @@ import {
} from "@radix-ui/themes";
import type {
McpApprovalState,
McpInstallationTool,
McpRecommendedServer,
McpServerInstallation,
} from "@renderer/api/posthogClient";
Expand All @@ -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 (
<Flex direction="column" gap="2">
<Flex align="center" gap="2">
<Text color="gray" className="font-medium text-[13px]">
{title}
</Text>
<Badge color="gray" variant="soft" size="1">
{tools.length}
</Badge>
</Flex>
{tools.map((tool) => (
<ToolRow
key={tool.tool_name}
tool={tool}
onChange={(approval_state) =>
onToolApprovalChange(tool.tool_name, approval_state)
}
/>
))}
</Flex>
);
}

export function ServerDetailView({
installation,
template,
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -385,18 +441,18 @@ export function ServerDetailView({
</Text>
</Flex>
) : (
filteredTools.map((tool) => (
<ToolRow
key={tool.tool_name}
tool={tool}
onChange={(approval_state) =>
setToolApproval({
toolName: tool.tool_name,
approval_state,
})
}
<>
<ToolGroupSection
title="Read tools"
tools={groupedTools.read}
onToolApprovalChange={handleToolApprovalChange}
/>
))
<ToolGroupSection
title="Write / delete tools"
tools={groupedTools.write_delete}
onToolApprovalChange={handleToolApprovalChange}
/>
</>
)}
Comment on lines 443 to 456
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 OnceAndOnlyOnce: duplicate handler

The identical onToolApprovalChange callback is defined twice — once for each ToolGroupSection. Extract it to a useCallback (or a plain const before the return) so both sections share a single definition.

Suggested change
) : (
filteredTools.map((tool) => (
<ToolRow
key={tool.tool_name}
tool={tool}
onChange={(approval_state) =>
<>
<ToolGroupSection
title="Read tools"
tools={groupedTools.read}
onToolApprovalChange={(toolName, approval_state) =>
setToolApproval({
toolName: tool.tool_name,
toolName,
approval_state,
})
}
/>
))
<ToolGroupSection
title="Write / delete tools"
tools={groupedTools.write_delete}
onToolApprovalChange={(toolName, approval_state) =>
setToolApproval({
toolName,
approval_state,
})
}
/>
</>
)}
) : (
<>
<ToolGroupSection
title="Read tools"
tools={groupedTools.read}
onToolApprovalChange={(toolName, approval_state) =>
setToolApproval({ toolName, approval_state })
}
/>
<ToolGroupSection
title="Write / delete tools"
tools={groupedTools.write_delete}
onToolApprovalChange={(toolName, approval_state) =>
setToolApproval({ toolName, approval_state })
}
/>
</>
)}
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/code/src/renderer/features/mcp-servers/components/parts/ServerDetailView.tsx
Line: 433-456

Comment:
**OnceAndOnlyOnce: duplicate handler**

The identical `onToolApprovalChange` callback is defined twice — once for each `ToolGroupSection`. Extract it to a `useCallback` (or a plain `const` before the return) so both sections share a single definition.

```suggestion
              ) : (
                <>
                  <ToolGroupSection
                    title="Read tools"
                    tools={groupedTools.read}
                    onToolApprovalChange={(toolName, approval_state) =>
                      setToolApproval({ toolName, approval_state })
                    }
                  />
                  <ToolGroupSection
                    title="Write / delete tools"
                    tools={groupedTools.write_delete}
                    onToolApprovalChange={(toolName, approval_state) =>
                      setToolApproval({ toolName, approval_state })
                    }
                  />
                </>
              )}
```

How can I resolve this? If you propose a fix, please make it concise.

</Flex>
)}
Expand Down
Original file line number Diff line number Diff line change
@@ -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> = {},
): 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]],
});
});
});
73 changes: 73 additions & 0 deletions apps/code/src/renderer/features/mcp-servers/hooks/mcpToolGroups.ts
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is IMO the wrong way of going about it. MCP tools expose whether they actually are read only, destructive, etc. via tool annotations. We should use that instead: https://modelcontextprotocol.io/specification/2025-11-25/schema#toolannotations

Original file line number Diff line number Diff line change
@@ -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[];
},
);
}