Skip to content
Closed
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
8 changes: 2 additions & 6 deletions packages/core/src/mcp-gateway/gatewayServers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -281,12 +282,7 @@ export const AUDIT_DECISION_LABELS: Record<McpAuditDecision, string> = {
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";
}
26 changes: 26 additions & 0 deletions packages/core/src/mcp-servers/toolDerivation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import {
countRemovedTools,
countToolsByApproval,
filterToolsByName,
groupToolsByAccess,
isLikelyMutatingToolName,
sortToolsForDisplay,
} from "./toolDerivation";

Expand Down Expand Up @@ -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")];
Expand Down
30 changes: 30 additions & 0 deletions packages/core/src/mcp-servers/toolDerivation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -294,15 +299,22 @@ export function ToolPermissionList({
</Text>
</Flex>
) : (
filteredTools.map((tool) => (
<ToolRow
key={tool.tool_name}
tool={tool}
onChange={(approval_state) =>
onSetTool(tool.tool_name, approval_state)
}
/>
))
<Flex direction="column" gap="4">
{readTools.length > 0 && (
<ToolAccessGroup
heading="Read tools"
tools={readTools}
onSetTool={onSetTool}
/>
)}
{writeTools.length > 0 && (
<ToolAccessGroup
heading="Write and delete tools"
tools={writeTools}
onSetTool={onSetTool}
/>
)}
</Flex>
)}
</Flex>
)}
Expand All @@ -321,3 +333,35 @@ export function ToolPermissionList({
</Flex>
);
}

function ToolAccessGroup({
heading,
tools,
onSetTool,
}: {
heading: string;
tools: McpInstallationTool[];
onSetTool: ToolPermissionListProps["onSetTool"];
}) {
return (
<Flex direction="column" gap="2">
<Text
color="gray"
className="font-medium text-xs uppercase tracking-wide"
>
{heading}
</Text>
<Flex direction="column" gap="2">
{tools.map((tool) => (
<ToolRow
key={tool.tool_name}
tool={tool}
onChange={(approval_state) =>
onSetTool(tool.tool_name, approval_state)
}
/>
))}
</Flex>
</Flex>
);
}