diff --git a/apps/web/src/components/settings/CustomMcpServers.client.test.tsx b/apps/web/src/components/settings/CustomMcpServers.client.test.tsx
index 95ef96f03..acc51a0a1 100644
--- a/apps/web/src/components/settings/CustomMcpServers.client.test.tsx
+++ b/apps/web/src/components/settings/CustomMcpServers.client.test.tsx
@@ -376,7 +376,7 @@ describe('useCustomMcpServers', () => {
expect(
screen.getByRole('button', { name: 'Show less' }),
).toBeInTheDocument();
- expect(screen.getByRole('checkbox', { name: 'resolve' })).toBeChecked();
+ expect(screen.getByRole('checkbox', { name: 'Resolve' })).toBeChecked();
state.tools = [];
});
@@ -425,7 +425,7 @@ describe('useCustomMcpServers', () => {
it('never loads or shows approval policies for non-admins or with the experiment off', async () => {
state.isAdmin = false;
await openToolsDialog();
- await screen.findByText('search');
+ await screen.findByText('Search');
expect(
screen.queryByRole('radiogroup', { name: 'Approval mode for search' }),
).not.toBeInTheDocument();
@@ -436,7 +436,7 @@ describe('useCustomMcpServers', () => {
approvals.experimentEnabled = false;
approvals.listEnabled.length = 0;
await openToolsDialog();
- await screen.findByText('search');
+ await screen.findByText('Search');
expect(
screen.queryByRole('radiogroup', { name: 'Approval mode for search' }),
).not.toBeInTheDocument();
diff --git a/apps/web/src/components/settings/CustomMcpServers.tsx b/apps/web/src/components/settings/CustomMcpServers.tsx
index 414e35bd8..56245c2b0 100644
--- a/apps/web/src/components/settings/CustomMcpServers.tsx
+++ b/apps/web/src/components/settings/CustomMcpServers.tsx
@@ -8,7 +8,6 @@ import { toast } from 'sonner';
import {
Button,
- Checkbox,
Dialog,
DialogContent,
DialogDescription,
@@ -745,32 +744,6 @@ function ServerFormDialog({
);
}
-/**
- * Some servers ship prompt-length tool descriptions. Two lines are enough to
- * recognize a tool; the rest is one click away instead of burying the list.
- * The toggle lives outside the row's label so it never flips the checkbox.
- */
-function ToolDescription({ text }: { text: string }) {
- const [expanded, setExpanded] = useState(false);
- const isLong = text.length > 140;
-
- return (
-
-
{text}
- {isLong ? (
-
setExpanded((current) => !current)}
- className="mt-0.5 cursor-pointer font-medium text-foreground/80 hover:text-foreground"
- >
- {expanded ? 'Show less' : 'Show more'}
-
- ) : null}
-
- );
-}
-
function CustomToolManagementDialog({
server,
scope,
@@ -863,47 +836,27 @@ function CustomToolManagementDialog({
- {(tool) => (
- <>
- {
- setDisabledNames((current) => {
- const next = new Set(current);
-
- if (checked === true) {
- next.delete(tool.name);
- } else {
- next.add(tool.name);
- }
+ isToolEnabled={(toolName) => !disabledNames.has(toolName)}
+ onToggleTool={(toolName, enabled) =>
+ setDisabledNames((current) => {
+ const next = new Set(current);
+
+ if (enabled) {
+ next.delete(toolName);
+ } else {
+ next.add(toolName);
+ }
- return next;
- });
- }}
- className="mt-0.5"
- />
-
-
- {tool.name}
-
- {tool.description ? (
-
- ) : null}
-
- >
- )}
-
+ return next;
+ })
+ }
+ />
)}
diff --git a/apps/web/src/components/settings/IntegrationToolApprovalControls.tsx b/apps/web/src/components/settings/IntegrationToolApprovalControls.tsx
index 852d6c977..e2b2b672c 100644
--- a/apps/web/src/components/settings/IntegrationToolApprovalControls.tsx
+++ b/apps/web/src/components/settings/IntegrationToolApprovalControls.tsx
@@ -14,6 +14,7 @@ import { cn } from '@/lib/utils';
import {
Badge,
Ban,
+ Checkbox,
ChevronDown,
ChevronRight,
CircleCheck,
@@ -227,18 +228,22 @@ function IntegrationToolApprovalGroup({
* adds the per-tool mode control, the group-level control, and the save hint,
* or nothing but the grouping when approvals are not active for the viewer.
*/
-export function IntegrationToolApprovalList({
+export function IntegrationToolApprovalList({
integrationId,
+ integrationName,
scope,
canManage,
open,
tools,
saveNote,
- rowClassName,
- children,
+ isToolEnabled,
+ onToggleTool,
+ toggleDisabled,
}: {
/** The id policies are keyed on: the mount name agents see. */
integrationId: string | null;
+ /** Dropped from tool names that repeat it ("resend_list_domains"). */
+ integrationName: string | null;
/** `personal` policies apply to the viewer's own sessions only. */
scope: 'deployment' | 'personal';
/** Deployment policies are admin-managed; never load them otherwise. */
@@ -247,8 +252,10 @@ export function IntegrationToolApprovalList({
tools: T[];
/** How the dialog's own enable/disable changes are saved. */
saveNote: string;
- rowClassName: string;
- children: (tool: T) => ReactNode;
+ isToolEnabled: (toolName: string) => boolean;
+ /** Staged by the dialog until its Save, hence a checkbox, not a switch. */
+ onToggleTool: (toolName: string, enabled: boolean) => void;
+ toggleDisabled?: boolean;
}) {
const experiment = useIntegrationToolApprovalsExperiment();
const active = experiment.enabled && canManage && integrationId != null;
@@ -290,23 +297,100 @@ export function IntegrationToolApprovalList({
}
: {})}
>
- {group.tools.map((tool) => (
-
- {children(tool)}
- {active ? (
-
- policies.setMode(integrationId, tool.name, mode)
+ {group.tools.map((tool) => {
+ const checkboxId = `integration-tool-${integrationId ?? 'unknown'}-${tool.name}`;
+ return (
+
+
+ onToggleTool(tool.name, checked === true)
}
+ className="mt-0.5"
/>
- ) : null}
-
- ))}
+
+
+ {prettifyToolName(tool.name, integrationName)}
+
+ {tool.description ? (
+
+ ) : null}
+
+ {active ? (
+
+ policies.setMode(integrationId, tool.name, mode)
+ }
+ />
+ ) : null}
+
+ );
+ })}
))}
>
);
}
+
+type ManageableTool = GroupableTool & { description?: string | null };
+
+function splitToolNameParts(name: string): string[] {
+ return name.split(/[-_\s]+/).filter((part) => part.length > 0);
+}
+
+/** "resend_list_api_keys" under Resend reads as "List Api Keys". */
+function prettifyToolName(
+ name: string,
+ integrationName: string | null,
+): string {
+ const nameParts = splitToolNameParts(name);
+ const integrationParts = integrationName
+ ? splitToolNameParts(integrationName)
+ : [];
+ const hasIntegrationPrefix =
+ integrationParts.length > 0 &&
+ integrationParts.every(
+ (part, index) => nameParts[index]?.toLowerCase() === part.toLowerCase(),
+ );
+ const displayParts = hasIntegrationPrefix
+ ? nameParts.slice(integrationParts.length)
+ : nameParts;
+
+ return (displayParts.length > 0 ? displayParts : nameParts)
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase())
+ .join(' ');
+}
+
+/**
+ * MCP tool descriptions are written for the model and can run to several
+ * paragraphs, so long ones clamp to two lines behind a toggle.
+ */
+function ToolDescription({ text }: { text: string }) {
+ const [expanded, setExpanded] = useState(false);
+ const isLong = text.length > 140;
+
+ return (
+
+
{text}
+ {isLong ? (
+
setExpanded((current) => !current)}
+ className="mt-0.5 cursor-pointer font-medium text-foreground/80 hover:text-foreground"
+ >
+ {expanded ? 'Show less' : 'Show more'}
+
+ ) : null}
+
+ );
+}
diff --git a/apps/web/src/components/settings/Integrations.test.tsx b/apps/web/src/components/settings/Integrations.test.tsx
index 49a1c0e6e..8bca31a79 100644
--- a/apps/web/src/components/settings/Integrations.test.tsx
+++ b/apps/web/src/components/settings/Integrations.test.tsx
@@ -233,14 +233,22 @@ vi.mock('@/hooks/linear', () => ({
vi.mock('./IntegrationToolApprovalControls', () => ({
IntegrationToolApprovalList: ({
tools,
- children,
+ isToolEnabled,
+ onToggleTool,
}: {
tools: T[];
- children: (tool: T) => ReactNode;
+ isToolEnabled: (toolName: string) => boolean;
+ onToggleTool: (toolName: string, enabled: boolean) => void;
}) => (
{tools.map((tool) => (
-
{children(tool)}
+
onToggleTool(tool.name, event.target.checked)}
+ />
))}
),
@@ -1556,7 +1564,7 @@ describe('Integrations settings', () => {
).not.toBeNull();
});
- it('opens the manage tools dialog with prettified tool labels', () => {
+ it('opens the manage tools dialog with a toggle per tool', () => {
state.deploymentEnablements = [{ mcpId: 'sentry', enabled: true }];
state.userConnections = [
{ id: 'conn-sentry', mcpId: 'sentry', authStatus: 'authenticated' },
@@ -1582,16 +1590,8 @@ describe('Integrations settings', () => {
screen.getByRole('heading', { name: 'Manage tools for Sentry' }),
).toBeInTheDocument();
expect(
- screen.getByRole('button', { name: 'Disable get_sentry_resource' }),
- ).toBeInTheDocument();
- expect(screen.getByText('Get Sentry Resource')).toHaveAttribute(
- 'for',
- expect.stringMatching(/^mcp-tool-sentry-/),
- );
- expect(screen.queryByText('get_sentry_resource')).not.toBeInTheDocument();
- expect(
- screen.queryByText('Inspect a Sentry resource'),
- ).not.toBeInTheDocument();
+ screen.getByRole('checkbox', { name: 'get_sentry_resource' }),
+ ).toBeChecked();
expect(
screen.getByRole('button', { name: 'Save changes' }),
).toBeInTheDocument();
@@ -2632,7 +2632,7 @@ describe('Integrations settings', () => {
screen.getByRole('button', { name: 'Manage Sentry tools' }),
);
fireEvent.click(
- screen.getByRole('button', { name: 'Disable get_sentry_resource' }),
+ screen.getByRole('checkbox', { name: 'get_sentry_resource' }),
);
rerender( );
@@ -2641,11 +2641,11 @@ describe('Integrations settings', () => {
screen.getByRole('heading', { name: 'Manage tools for Sentry' }),
).toBeInTheDocument();
expect(
- screen.getByRole('button', { name: 'Enable get_sentry_resource' }),
- ).toBeInTheDocument();
+ screen.getByRole('checkbox', { name: 'get_sentry_resource' }),
+ ).not.toBeChecked();
expect(
- screen.getByRole('button', { name: 'Enable search_events' }),
- ).toBeInTheDocument();
+ screen.getByRole('checkbox', { name: 'search_events' }),
+ ).not.toBeChecked();
});
it('lets an admin store a voice key from the Voice card', async () => {
diff --git a/apps/web/src/components/settings/McpToolManagementDialog.client.test.tsx b/apps/web/src/components/settings/McpToolManagementDialog.client.test.tsx
index bee33d445..0c930cf2e 100644
--- a/apps/web/src/components/settings/McpToolManagementDialog.client.test.tsx
+++ b/apps/web/src/components/settings/McpToolManagementDialog.client.test.tsx
@@ -15,6 +15,7 @@ const state = vi.hoisted(() => ({
mode: string;
}[],
annotated: false,
+ searchDescription: null as string | null,
policiesQueryEnabled: undefined as boolean | undefined,
}));
@@ -56,7 +57,7 @@ vi.mock('@/hooks/mcp-connections', () => ({
tools: [
{
name: 'web_search_exa',
- description: null,
+ description: state.searchDescription,
enabled: true,
readOnly: state.annotated ? true : null,
},
@@ -103,6 +104,25 @@ describe('McpToolManagementDialog tool approvals', () => {
Element.prototype.scrollIntoView = vi.fn();
});
+ it('shows each tool as a staged checkbox with a readable name and its description', () => {
+ state.searchDescription = 'Search the web with Exa.';
+ try {
+ renderDialog();
+ const checkbox = screen.getByRole('checkbox', {
+ name: 'Web Search Exa',
+ });
+ expect(checkbox).toBeChecked();
+ expect(screen.getByText('Web Search Exa')).toHaveAttribute(
+ 'title',
+ 'web_search_exa',
+ );
+ expect(screen.getByText('Search the web with Exa.')).toBeInTheDocument();
+ expect(screen.queryByRole('switch')).not.toBeInTheDocument();
+ } finally {
+ state.searchDescription = null;
+ }
+ });
+
it('hides per-tool approval modes while the experiment is off', () => {
renderDialog();
expect(
diff --git a/apps/web/src/components/settings/McpToolManagementDialog.tsx b/apps/web/src/components/settings/McpToolManagementDialog.tsx
index 006c72042..5c25d899b 100644
--- a/apps/web/src/components/settings/McpToolManagementDialog.tsx
+++ b/apps/web/src/components/settings/McpToolManagementDialog.tsx
@@ -16,9 +16,7 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
- Label,
Spinner,
- Switch,
ToggleLeft,
ToggleRight,
} from '@/components/system';
@@ -40,36 +38,6 @@ type McpToolManagementDialogProps = {
isAdmin?: boolean;
};
-function splitToolNameParts(name: string): string[] {
- return name.split(/[-_\s]+/).filter((part) => part.length > 0);
-}
-
-function titleCaseToolNamePart(part: string): string {
- return part.charAt(0).toUpperCase() + part.slice(1).toLowerCase();
-}
-
-function prettifyToolName(
- name: string,
- integrationName: string | null,
-): string {
- const nameParts = splitToolNameParts(name);
- const integrationParts = integrationName
- ? splitToolNameParts(integrationName)
- : [];
- const hasIntegrationPrefix =
- integrationParts.length > 0 &&
- integrationParts.every(
- (part, index) => nameParts[index]?.toLowerCase() === part.toLowerCase(),
- );
- const displayParts = hasIntegrationPrefix
- ? nameParts.slice(integrationParts.length)
- : nameParts;
-
- return (displayParts.length > 0 ? displayParts : nameParts)
- .map(titleCaseToolNamePart)
- .join(' ');
-}
-
function McpToolLoadErrorMessage({
integrationName,
message,
@@ -277,42 +245,18 @@ export function McpToolManagementDialog({
- {(tool) => {
- const enabled = !normalizedDisabledToolNames.includes(
- tool.name,
- );
- const switchId = `mcp-tool-${mcpId ?? 'unknown'}-${tool.name}`;
-
- return (
-
-
-
- handleToggle(tool.name, nextEnabled)
- }
- />
-
- {prettifyToolName(tool.name, integrationName)}
-
-
-
- );
- }}
-
+ isToolEnabled={(toolName) =>
+ !normalizedDisabledToolNames.includes(toolName)
+ }
+ onToggleTool={handleToggle}
+ toggleDisabled={setDisabledTools.isPending}
+ />
) : null}