Skip to content
Merged
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

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

77 changes: 15 additions & 62 deletions apps/web/src/components/settings/CustomMcpServers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import { toast } from 'sonner';

import {
Button,
Checkbox,
Dialog,
DialogContent,
DialogDescription,
Expand Down Expand Up @@ -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 (
<div className="text-xs text-muted-foreground">
<p className={isLong && !expanded ? 'line-clamp-2' : undefined}>{text}</p>
{isLong ? (
<button
type="button"
aria-expanded={expanded}
onClick={() => setExpanded((current) => !current)}
className="mt-0.5 cursor-pointer font-medium text-foreground/80 hover:text-foreground"
>
{expanded ? 'Show less' : 'Show more'}
</button>
) : null}
</div>
);
}

function CustomToolManagementDialog({
server,
scope,
Expand Down Expand Up @@ -863,47 +836,27 @@ function CustomToolManagementDialog({
<div className="space-y-2 max-h-96 overflow-y-auto">
<IntegrationToolApprovalList
integrationId={server?.name ?? null}
integrationName={server?.name ?? null}
scope={scope === 'owner' ? 'personal' : 'deployment'}
canManage={scope === 'owner' || isAdmin}
open={open}
tools={toolsQuery.data?.tools ?? []}
saveNote="Tool enable/disable still needs Save."
rowClassName="flex items-start gap-3 py-2.5"
>
{(tool) => (
<>
<Checkbox
id={`custom-mcp-tool-${tool.name}`}
checked={!disabledNames.has(tool.name)}
onCheckedChange={(checked) => {
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"
/>
<div className="min-w-0 flex-1 text-sm">
<label
htmlFor={`custom-mcp-tool-${tool.name}`}
className="cursor-pointer font-mono"
>
{tool.name}
</label>
{tool.description ? (
<ToolDescription text={tool.description} />
) : null}
</div>
</>
)}
</IntegrationToolApprovalList>
return next;
})
}
/>
</div>
)}

Expand Down
120 changes: 102 additions & 18 deletions apps/web/src/components/settings/IntegrationToolApprovalControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { cn } from '@/lib/utils';
import {
Badge,
Ban,
Checkbox,
ChevronDown,
ChevronRight,
CircleCheck,
Expand Down Expand Up @@ -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<T extends GroupableTool>({
export function IntegrationToolApprovalList<T extends ManageableTool>({
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. */
Expand All @@ -247,8 +252,10 @@ export function IntegrationToolApprovalList<T extends GroupableTool>({
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;
Expand Down Expand Up @@ -290,23 +297,100 @@ export function IntegrationToolApprovalList<T extends GroupableTool>({
}
: {})}
>
{group.tools.map((tool) => (
<div key={tool.name} className={rowClassName}>
{children(tool)}
{active ? (
<IntegrationToolApprovalModeControl
toolName={tool.name}
value={modeFor(tool.name)}
disabled={policies.isUpdating}
onChange={(mode) =>
policies.setMode(integrationId, tool.name, mode)
{group.tools.map((tool) => {
const checkboxId = `integration-tool-${integrationId ?? 'unknown'}-${tool.name}`;
return (
<div key={tool.name} className="flex items-start gap-3 py-2.5">
<Checkbox
id={checkboxId}
checked={isToolEnabled(tool.name)}
disabled={toggleDisabled}
onCheckedChange={(checked) =>
onToggleTool(tool.name, checked === true)
}
className="mt-0.5"
/>
) : null}
</div>
))}
<div className="min-w-0 flex-1 text-sm">
<label
htmlFor={checkboxId}
title={tool.name}
className="cursor-pointer"
>
{prettifyToolName(tool.name, integrationName)}
</label>
{tool.description ? (
<ToolDescription text={tool.description} />
) : null}
</div>
{active ? (
<IntegrationToolApprovalModeControl
toolName={tool.name}
value={modeFor(tool.name)}
disabled={policies.isUpdating}
onChange={(mode) =>
policies.setMode(integrationId, tool.name, mode)
}
/>
) : null}
</div>
);
})}
</IntegrationToolApprovalGroup>
))}
</>
);
}

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 (
<div className="text-xs text-muted-foreground">
<p className={isLong && !expanded ? 'line-clamp-2' : undefined}>{text}</p>
{isLong ? (
<button
type="button"
aria-expanded={expanded}
onClick={() => setExpanded((current) => !current)}
className="mt-0.5 cursor-pointer font-medium text-foreground/80 hover:text-foreground"
>
{expanded ? 'Show less' : 'Show more'}
</button>
) : null}
</div>
);
}
38 changes: 19 additions & 19 deletions apps/web/src/components/settings/Integrations.test.tsx

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading