diff --git a/packages/core/src/mcp-gateway/gatewayServers.ts b/packages/core/src/mcp-gateway/gatewayServers.ts index cb70f85949..2944c69be5 100644 --- a/packages/core/src/mcp-gateway/gatewayServers.ts +++ b/packages/core/src/mcp-gateway/gatewayServers.ts @@ -7,6 +7,7 @@ import type { McpResolvedToolPolicy, } from "@posthog/api-client/posthog-client"; import { formatRelativeTimeShort, getLocalDayDiff } from "@posthog/shared"; +import { isLikelyMutatingToolName } from "../mcp-servers/toolDerivation"; /** The rail lists the servers the caller has connected, under the rail search. */ export function railConnectedServers( @@ -281,12 +282,7 @@ export const AUDIT_DECISION_LABELS: Record = { blocked: "Blocked", }; -// Mirrors the backend's destructive-tool heuristic; only used to seed the -// per-tool defaults when sharing a server with an agent. -const DESTRUCTIVE_TOOL_RE = - /delete|update|post|write|create|run-migration|close|drop|send/; - /** Default policy offered when granting an agent access to a tool. */ export function defaultAgentGrantPolicy(toolName: string): AgentPolicyState { - return DESTRUCTIVE_TOOL_RE.test(toolName) ? "do_not_use" : "approved"; + return isLikelyMutatingToolName(toolName) ? "do_not_use" : "approved"; } diff --git a/packages/core/src/mcp-servers/toolDerivation.test.ts b/packages/core/src/mcp-servers/toolDerivation.test.ts index fdc0349cfb..c595a726c4 100644 --- a/packages/core/src/mcp-servers/toolDerivation.test.ts +++ b/packages/core/src/mcp-servers/toolDerivation.test.ts @@ -5,6 +5,8 @@ import { countRemovedTools, countToolsByApproval, filterToolsByName, + groupToolsByAccess, + isLikelyMutatingToolName, sortToolsForDisplay, } from "./toolDerivation"; @@ -51,6 +53,30 @@ describe("sortToolsForDisplay", () => { }); }); +describe("groupToolsByAccess", () => { + it("separates likely mutating tools from read tools", () => { + const tools = [ + tool("get_project"), + tool("create_project"), + tool("delete_project"), + tool("search_events"), + ]; + + expect(groupToolsByAccess(tools)).toEqual({ + readTools: [tools[0], tools[3]], + writeTools: [tools[1], tools[2]], + }); + }); + + it.each([ + ["list_projects", false], + ["create_project", true], + ["send_message", true], + ] as const)("recognizes %s as mutating: %s", (name, expected) => { + expect(isLikelyMutatingToolName(name)).toBe(expected); + }); +}); + describe("filterToolsByName", () => { it("substring-matches case-insensitively, empty returns all", () => { const tools = [tool("readFile"), tool("writeFile"), tool("listDir")]; diff --git a/packages/core/src/mcp-servers/toolDerivation.ts b/packages/core/src/mcp-servers/toolDerivation.ts index b98f61e2e6..1bde17ccc9 100644 --- a/packages/core/src/mcp-servers/toolDerivation.ts +++ b/packages/core/src/mcp-servers/toolDerivation.ts @@ -27,6 +27,36 @@ export function sortToolsForDisplay( }); } +const MUTATING_TOOL_NAME_RE = + /delete|update|post|write|create|run-migration|close|drop|send/; + +/** + * MCP tool catalogs do not expose a machine-readable access level. Keep this + * deliberately conservative and use the same naming signal as the default + * policy for agent grants. + */ +export function isLikelyMutatingToolName(toolName: string): boolean { + return MUTATING_TOOL_NAME_RE.test(toolName); +} + +export function groupToolsByAccess(tools: McpInstallationTool[]): { + readTools: McpInstallationTool[]; + writeTools: McpInstallationTool[]; +} { + const readTools: McpInstallationTool[] = []; + const writeTools: McpInstallationTool[] = []; + + for (const tool of tools) { + if (isLikelyMutatingToolName(tool.tool_name)) { + writeTools.push(tool); + } else { + readTools.push(tool); + } + } + + return { readTools, writeTools }; +} + export function filterToolsByName( tools: McpInstallationTool[], term: string, diff --git a/packages/ui/src/features/mcp-servers/components/parts/ToolPermissionList.tsx b/packages/ui/src/features/mcp-servers/components/parts/ToolPermissionList.tsx index c1546b9a9e..b7aed86e71 100644 --- a/packages/ui/src/features/mcp-servers/components/parts/ToolPermissionList.tsx +++ b/packages/ui/src/features/mcp-servers/components/parts/ToolPermissionList.tsx @@ -14,6 +14,7 @@ import { countActiveTools, countToolsByApproval, filterToolsByName, + groupToolsByAccess, sortToolsForDisplay, } from "@posthog/core/mcp-servers/toolDerivation"; import { ToolPolicyToggle } from "@posthog/ui/features/mcp-servers/components/parts/ToolPolicyToggle"; @@ -120,6 +121,10 @@ export function ToolPermissionList({ () => filterToolsByName(visibleTools, toolSearch), [visibleTools, toolSearch], ); + const { readTools, writeTools } = useMemo( + () => groupToolsByAccess(filteredTools), + [filteredTools], + ); const bulkDisabled = disabled || bulk?.pending || filteredTools.length === 0; const bulkTargets = toolSearch ? filteredTools : undefined; @@ -294,15 +299,22 @@ export function ToolPermissionList({ ) : ( - filteredTools.map((tool) => ( - - onSetTool(tool.tool_name, approval_state) - } - /> - )) + + {readTools.length > 0 && ( + + )} + {writeTools.length > 0 && ( + + )} + )} )} @@ -321,3 +333,35 @@ export function ToolPermissionList({ ); } + +function ToolAccessGroup({ + heading, + tools, + onSetTool, +}: { + heading: string; + tools: McpInstallationTool[]; + onSetTool: ToolPermissionListProps["onSetTool"]; +}) { + return ( + + + {heading} + + + {tools.map((tool) => ( + + onSetTool(tool.tool_name, approval_state) + } + /> + ))} + + + ); +}