- {OPTIONS.map((option, index) => {
+ {visibleOptions.map((option, index) => {
const active = value === option.id;
+ const disabledReason = disabledStates?.[option.id];
const activeClass =
option.id === "approved"
? "bg-green-9 text-white"
@@ -54,13 +63,17 @@ export function ToolPolicyToggle({
? "bg-amber-9 text-white"
: "bg-red-9 text-white";
return (
-
+
{/* biome-ignore lint/a11y/useSemanticElements: segmented radio group needs custom button styling */}
onChange(option.id)}
className={`flex items-center justify-center px-2.5 py-1.5 text-xs transition-colors disabled:opacity-50 ${
index > 0 ? "border-gray-5 border-l" : ""
diff --git a/packages/ui/src/features/mcp-servers/components/parts/ToolRow.tsx b/packages/ui/src/features/mcp-servers/components/parts/ToolRow.tsx
index 7ef5cefbd1..8eaac9de64 100644
--- a/packages/ui/src/features/mcp-servers/components/parts/ToolRow.tsx
+++ b/packages/ui/src/features/mcp-servers/components/parts/ToolRow.tsx
@@ -1,21 +1,31 @@
-import { CaretDown, CaretRight } from "@phosphor-icons/react";
+import { CaretDown, CaretRight, Lock } from "@phosphor-icons/react";
import type {
McpApprovalState,
McpInstallationTool,
} from "@posthog/api-client/posthog-client";
+import { isPolicyStateAllowedByCeiling } from "@posthog/core/mcp-gateway/gatewayServers";
import { Badge, Flex, Text } from "@radix-ui/themes";
import { useState } from "react";
import { ToolPolicyToggle } from "./ToolPolicyToggle";
interface ToolRowProps {
tool: McpInstallationTool;
+ teamScope?: boolean;
onChange: (approval_state: McpApprovalState) => void;
}
-export function ToolRow({ tool, onChange }: ToolRowProps) {
+export function ToolRow({ tool, teamScope = false, onChange }: ToolRowProps) {
const [open, setOpen] = useState(false);
const hasDescription = !!tool.description?.trim();
const removed = !!tool.removed_at;
+ const setByTeamAdmin =
+ !teamScope && (tool.decided_by === "team" || tool.decided_by === "preset");
+ const disabledStates: Partial> = {};
+ for (const state of ["approved", "needs_approval", "do_not_use"] as const) {
+ if (!teamScope && !isPolicyStateAllowedByCeiling(state, tool.team_state)) {
+ disabledStates[state] = "Unavailable because of the team admin ceiling";
+ }
+ }
return (
@@ -65,13 +75,19 @@ export function ToolRow({ tool, onChange }: ToolRowProps) {
-
+
+ {setByTeamAdmin && (
+
+ Set by team admin
+
+ )}
-
+
{open && (
diff --git a/packages/ui/src/features/mcp-servers/hooks/useMcpInstallationTools.test.tsx b/packages/ui/src/features/mcp-servers/hooks/useMcpInstallationTools.test.tsx
new file mode 100644
index 0000000000..dcdcf07eb4
--- /dev/null
+++ b/packages/ui/src/features/mcp-servers/hooks/useMcpInstallationTools.test.tsx
@@ -0,0 +1,146 @@
+import type { McpInstallationTool } from "@posthog/api-client/posthog-client";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { act, renderHook, waitFor } from "@testing-library/react";
+import type { ReactNode } from "react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const mockClient = vi.hoisted(() => ({
+ getMcpInstallationTools: vi.fn(),
+ updateMcpToolApproval: vi.fn(),
+ refreshMcpInstallationTools: vi.fn(),
+}));
+
+const mockTrpc = vi.hoisted(() => ({
+ mcpCallback: {
+ onOAuthComplete: {
+ subscriptionOptions: vi.fn(() => ({})),
+ },
+ },
+}));
+
+vi.mock("@posthog/ui/features/auth/authClient", () => ({
+ useOptionalAuthenticatedClient: () => mockClient,
+}));
+
+vi.mock("@posthog/host-router/react", () => ({
+ useHostTRPC: () => mockTrpc,
+}));
+
+vi.mock("@trpc/tanstack-react-query", () => ({
+ useSubscription: vi.fn(),
+}));
+
+vi.mock("@posthog/ui/primitives/toast", () => ({
+ toast: {
+ error: vi.fn(),
+ success: vi.fn(),
+ },
+}));
+
+import { useMcpInstallationTools } from "./useMcpInstallationTools";
+
+function wrapper({ children }: { children: ReactNode }) {
+ const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: { retry: false },
+ mutations: { retry: false },
+ },
+ });
+ return (
+
{children}
+ );
+}
+
+function makeTool(
+ overrides: Partial
& { tool_name: string },
+): McpInstallationTool {
+ return {
+ id: overrides.tool_name,
+ display_name: overrides.tool_name,
+ description: "",
+ input_schema: {},
+ approval_state: "approved",
+ last_seen_at: "2026-01-01T00:00:00Z",
+ removed_at: null,
+ created_at: "2026-01-01T00:00:00Z",
+ updated_at: null,
+ ...overrides,
+ };
+}
+
+describe("useMcpInstallationTools setBulkApproval", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockClient.updateMcpToolApproval.mockResolvedValue({});
+ });
+
+ it("excludes rule-locked tools from bulk writes even in team scope", async () => {
+ mockClient.getMcpInstallationTools.mockResolvedValue([
+ makeTool({ tool_name: "unlocked" }),
+ makeTool({ tool_name: "rule-locked", locked: true }),
+ ]);
+
+ const { result } = renderHook(
+ () => useMcpInstallationTools("inst-1", { teamScope: true }),
+ { wrapper },
+ );
+ await waitFor(() => expect(result.current.tools).toHaveLength(2));
+
+ act(() => result.current.setBulkApproval("do_not_use"));
+
+ await waitFor(() =>
+ expect(mockClient.updateMcpToolApproval).toHaveBeenCalledTimes(1),
+ );
+ expect(mockClient.updateMcpToolApproval).toHaveBeenCalledWith(
+ "inst-1",
+ "unlocked",
+ "do_not_use",
+ );
+ });
+
+ it("skips the team ceiling in team scope, since team scope sets it", async () => {
+ mockClient.getMcpInstallationTools.mockResolvedValue([
+ makeTool({ tool_name: "restricted", team_state: "do_not_use" }),
+ ]);
+
+ const { result } = renderHook(
+ () => useMcpInstallationTools("inst-1", { teamScope: true }),
+ { wrapper },
+ );
+ await waitFor(() => expect(result.current.tools).toHaveLength(1));
+
+ act(() => result.current.setBulkApproval("approved"));
+
+ await waitFor(() =>
+ expect(mockClient.updateMcpToolApproval).toHaveBeenCalledWith(
+ "inst-1",
+ "restricted",
+ "approved",
+ ),
+ );
+ });
+
+ it("excludes locked tools and tools above the team ceiling in member scope", async () => {
+ mockClient.getMcpInstallationTools.mockResolvedValue([
+ makeTool({ tool_name: "unlocked" }),
+ makeTool({ tool_name: "rule-locked", locked: true }),
+ makeTool({ tool_name: "restricted", team_state: "do_not_use" }),
+ ]);
+
+ const { result } = renderHook(() => useMcpInstallationTools("inst-1"), {
+ wrapper,
+ });
+ await waitFor(() => expect(result.current.tools).toHaveLength(3));
+
+ act(() => result.current.setBulkApproval("approved"));
+
+ await waitFor(() =>
+ expect(mockClient.updateMcpToolApproval).toHaveBeenCalledTimes(1),
+ );
+ expect(mockClient.updateMcpToolApproval).toHaveBeenCalledWith(
+ "inst-1",
+ "unlocked",
+ "approved",
+ );
+ });
+});
diff --git a/packages/ui/src/features/mcp-servers/hooks/useMcpInstallationTools.ts b/packages/ui/src/features/mcp-servers/hooks/useMcpInstallationTools.ts
index 5039e17d48..6f6824950e 100644
--- a/packages/ui/src/features/mcp-servers/hooks/useMcpInstallationTools.ts
+++ b/packages/ui/src/features/mcp-servers/hooks/useMcpInstallationTools.ts
@@ -2,6 +2,7 @@ import type {
McpApprovalState,
McpInstallationTool,
} from "@posthog/api-client/posthog-client";
+import { isPolicyStateAllowedByCeiling } from "@posthog/core/mcp-gateway/gatewayServers";
import { dispatchBulkApproval } from "@posthog/core/mcp-servers/toolBulk";
import { shouldAutoRefreshTools } from "@posthog/core/mcp-servers/toolRefresh";
import { useHostTRPC } from "@posthog/host-router/react";
@@ -16,6 +17,7 @@ import { mcpKeys } from "./useMcpServers";
interface UseMcpInstallationToolsOptions {
includeRemoved?: boolean;
autoRefreshIfEmpty?: boolean;
+ teamScope?: boolean;
}
// Module-scoped on purpose: state must survive remounts of this hook so a
@@ -52,9 +54,11 @@ export function useMcpInstallationTools(
const invalidate = useCallback(() => {
if (!installationId) return;
queryClient.invalidateQueries({
- queryKey: mcpKeys.tools(installationId),
+ queryKey: options.teamScope
+ ? mcpKeys.installations
+ : mcpKeys.tools(installationId),
});
- }, [installationId, queryClient]);
+ }, [installationId, options.teamScope, queryClient]);
const setToolApprovalMutation = useAuthenticatedMutation(
(client, vars: { toolName: string; approval_state: McpApprovalState }) => {
@@ -88,10 +92,19 @@ export function useMcpInstallationTools(
if (!installationId) {
return Promise.reject(new Error("No installation selected"));
}
+ const eligibleTools = (vars.targetTools ?? tools ?? []).filter(
+ (tool) =>
+ !tool.locked &&
+ (options.teamScope ||
+ isPolicyStateAllowedByCeiling(
+ vars.approval_state,
+ tool.team_state,
+ )),
+ );
return dispatchBulkApproval(
client,
installationId,
- vars.targetTools ?? tools ?? [],
+ eligibleTools,
vars.approval_state,
);
},
@@ -125,7 +138,13 @@ export function useMcpInstallationTools(
onError: (error: Error) => {
const silent = silentRefreshRef.current;
silentRefreshRef.current = false;
- if (!silent) toast.error(error.message || "Failed to refresh tools");
+ if (!silent) {
+ toast.error(error.message || "Failed to refresh tools");
+ return;
+ }
+ // A silent refresh reports nothing, so leaving the id marked would
+ // strand the page empty for the session. Let the next mount retry.
+ if (installationId) autoRefreshedInstallations.delete(installationId);
},
},
);
diff --git a/packages/ui/src/features/pr-review/PrCommentsSection.tsx b/packages/ui/src/features/pr-review/PrCommentsSection.tsx
index cf2d574367..a6202311d4 100644
--- a/packages/ui/src/features/pr-review/PrCommentsSection.tsx
+++ b/packages/ui/src/features/pr-review/PrCommentsSection.tsx
@@ -1,11 +1,11 @@
import { ArrowSquareOutIcon, ChatCircleIcon } from "@phosphor-icons/react";
import { Spinner } from "@posthog/quill";
import { MarkdownRenderer } from "@posthog/ui/features/editor/components/MarkdownRenderer";
+import { DetailSection } from "@posthog/ui/features/inbox/components/DetailSection";
import { NestedButton } from "@posthog/ui/primitives/NestedButton";
import { RelativeTimestamp } from "@posthog/ui/primitives/RelativeTimestamp";
-import { useMemo, useState } from "react";
+import { useMemo } from "react";
import { openExternalUrl } from "../../shell/openExternal";
-import { PrSectionHeader } from "./PrSectionHeader";
import { usePrComments } from "./usePrComments";
import { usePrReviewThreads } from "./usePrReviewThreads";
@@ -32,7 +32,6 @@ interface PrCommentsSectionProps {
export function PrCommentsSection({ prUrl }: PrCommentsSectionProps) {
const commentsQuery = usePrComments(prUrl);
const threadsQuery = usePrReviewThreads(prUrl);
- const [collapsed, setCollapsed] = useState(true);
const items = useMemo((): CommentItem[] => {
// Conversation items mix issue comments and review summaries, whose ids
@@ -70,18 +69,21 @@ export function PrCommentsSection({ prUrl }: PrCommentsSectionProps) {
if (commentsQuery.isLoading || threadsQuery.isLoading) {
return (
- {}}
- summary={
+ collapsible
+ defaultCollapsed
+ rightSlot={
Loading…
}
- />
+ >
+ {/* Header-only while loading – the section lands collapsed anyway. */}
+ {null}
+
);
}
@@ -110,26 +112,23 @@ export function PrCommentsSection({ prUrl }: PrCommentsSectionProps) {
}
return (
-
-
setCollapsed(!collapsed)}
- summary={
-
- {items.length} comment{items.length === 1 ? "" : "s"}
-
- }
- />
- {!collapsed && (
-
- {items.map((item) => (
-
- ))}
-
- )}
-
+
+ {items.length} comment{items.length === 1 ? "" : "s"}
+
+ }
+ >
+
+ {items.map((item) => (
+
+ ))}
+
+
);
}
diff --git a/packages/ui/src/features/pr-review/PrSectionHeader.tsx b/packages/ui/src/features/pr-review/PrSectionHeader.tsx
deleted file mode 100644
index 6a3227a421..0000000000
--- a/packages/ui/src/features/pr-review/PrSectionHeader.tsx
+++ /dev/null
@@ -1,50 +0,0 @@
-import type { IconProps } from "@phosphor-icons/react";
-import { CaretDownIcon } from "@phosphor-icons/react";
-import { Text } from "@radix-ui/themes";
-import type { ComponentType, ReactNode } from "react";
-
-/**
- * Clickable section header matching the DetailSection chrome, for the
- * collapsible PR sections (checks, comments). The right slot stays visible
- * while collapsed so it can carry a summary.
- */
-export function PrSectionHeader({
- Icon,
- title,
- collapsed,
- onToggle,
- summary,
-}: {
- Icon: ComponentType;
- title: string;
- collapsed: boolean;
- onToggle: () => void;
- summary?: ReactNode;
-}) {
- return (
-
-
-
-
- {title}
-
-
-
-
- {summary}
-
-
-
- );
-}
diff --git a/packages/ui/src/features/scouts/components/ScoutConfigControls.tsx b/packages/ui/src/features/scouts/components/ScoutConfigControls.tsx
index 0aad11cc7f..5947a7da1a 100644
--- a/packages/ui/src/features/scouts/components/ScoutConfigControls.tsx
+++ b/packages/ui/src/features/scouts/components/ScoutConfigControls.tsx
@@ -44,12 +44,19 @@ export function ScoutEnabledSwitch({
}: ScoutConfigControlsProps) {
return (
- onUpdate(config.id, { enabled: checked })}
- aria-label={`${config.skill_name} enabled`}
- />
+ {/* Tooltip stamps its own data-state on its child, which would overwrite
+ the Switch's checked/unchecked state and leave the track stuck on the
+ accent color. Give it a span to stamp. */}
+
+
+ onUpdate(config.id, { enabled: checked })
+ }
+ aria-label={`${config.skill_name} enabled`}
+ />
+
);
}