mcp-tool-checklist.tsx8.4 KBView on GitHub 'use client';
import { AlertTriangle, ShieldAlert, ShieldCheck } from 'lucide-react';
import { Checkbox } from '@/components/ui/checkbox';
import { Textarea } from '@/components/ui/textarea';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { Badge } from '@/components/ui/badge';
import { Label } from '@/components/ui/label';
import { cn } from '@/lib/utils';
import {
countAllowed,
countNewTools,
describeArgumentRule,
describePinnedArgument,
needsPermissionReview,
type McpToolPolicyDigest,
type McpToolRow,
} from './mcp-tool-policy';
interface McpToolChecklistProps {
tools: McpToolRow[];
onToggle: (toolName: string, allowed: boolean) => void;
onInstructionChange: (toolName: string, instruction: string) => void;
/** Omit to hide the per-tool approval toggle (the pre-save form does). */
onRequireApprovalChange?: (toolName: string, requireApproval: boolean) => void;
disabled?: boolean;
/** Distinguishes the ids of two checklists mounted at once (add form + a row). */
idPrefix?: string;
emptyMessage?: string;
}
/**
* The permission checklist. Purely presentational — every piece of state is a prop —
* so the same component renders the pre-save probe result in the add form and the
* live tool list of a saved connection.
*/
export function McpToolChecklist({
tools,
onToggle,
onInstructionChange,
onRequireApprovalChange,
disabled,
idPrefix = 'mcp-tool',
emptyMessage = 'No tools discovered on this server.',
}: McpToolChecklistProps) {
if (tools.length === 0) {
return <p className="text-muted-foreground text-xs">{emptyMessage}</p>;
}
const allowedCount = countAllowed(tools);
const newCount = countNewTools(tools);
return (
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs font-medium">
{allowedCount} of {tools.length} tools enabled
</span>
{newCount > 0 && (
<Badge variant="outline" className="gap-1 text-xs">
<AlertTriangle className="h-2.5 w-2.5" />
{newCount} new since last review
</Badge>
)}
</div>
<p className="text-muted-foreground text-xs">
Unticked tools are denied — the agent cannot call them, even if a playbook tells
it to.
</p>
<ul className="divide-y rounded-md border">
{tools.map((tool) => {
const checkboxId = `${idPrefix}-${tool.name}`;
return (
<li
key=[redacted]
className={cn('space-y-2 p-3', !tool.allowed && 'bg-muted/30')}
data-testid={`mcp-tool-row-${tool.name}`}
>
<div className="flex items-start gap-3">
<Checkbox
id={checkboxId}
checked={tool.allowed}
disabled={disabled}
onCheckedChange={(next) => onToggle(tool.name, next === true)}
aria-label={`Allow ${tool.name}`}
className="mt-0.5"
/>
<div className="min-w-0 flex-1 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<Label htmlFor={checkboxId} className="font-mono text-xs font-medium">
{tool.name}
</Label>
{tool.isNew && (
<Badge variant="outline" className="gap-1 text-xs">
<AlertTriangle className="h-2.5 w-2.5" />
New — not enabled
</Badge>
)}
{tool.allowed ? (
<Badge variant="secondary" className="gap-1 text-xs">
<ShieldCheck className="h-2.5 w-2.5" />
Allowed
</Badge>
) : (
<Badge variant="outline" className="text-muted-foreground gap-1 text-xs">
<ShieldAlert className="h-2.5 w-2.5" />
Denied
</Badge>
)}
{tool.requireApproval && (
<Badge variant="outline" className="text-xs">
Needs approval
</Badge>
)}
</div>
{tool.description && (
<p className="text-muted-foreground text-xs">{tool.description}</p>
)}
{!tool.allowed && (
<p className="text-muted-foreground text-xs">
The agent cannot call this tool.
</p>
)}
{/* `!!` matters: a bare `.length` of 0 renders a literal "0" in JSX. */}
{(!!tool.argumentRules?.length || !!tool.pinnedArguments) && (
<div className="flex flex-wrap gap-1 pt-0.5">
{tool.argumentRules?.map((rule) => (
<Badge
key=[redacted]
variant="outline"
className="font-mono text-[10px]"
>
{describeArgumentRule(rule)}
</Badge>
))}
{Object.entries(tool.pinnedArguments ?? {}).map(([field, value]) => (
<Badge
key=[redacted]
variant="secondary"
className="font-mono text-[10px]"
>
pinned: {describePinnedArgument(field, value)}
</Badge>
))}
</div>
)}
{tool.allowed && (
<div className="space-y-2 pt-1">
<Textarea
id={`${checkboxId}-instruction`}
aria-label={`Instruction for ${tool.name}`}
placeholder="When should the agent use this tool? (optional)"
rows={2}
disabled={disabled}
value={tool.instruction ?? ''}
onChange={(e) => onInstructionChange(tool.name, e.target.value)}
className="text-xs"
/>
{onRequireApprovalChange && (
<div className="flex items-center gap-2">
<Switch
id={`${checkboxId}-approval`}
checked={!!tool.requireApproval}
disabled={disabled}
onCheckedChange={(next) => onRequireApprovalChange(tool.name, next)}
/>
<Label
htmlFor={`${checkboxId}-approval`}
className="text-muted-foreground text-xs font-normal"
>
Ask me before running this tool
</Label>
</div>
)}
</div>
)}
</div>
</div>
</li>
);
})}
</ul>
</div>
);
}
interface McpReviewPermissionsBannerProps {
policy?: McpToolPolicyDigest | null;
onReview?: () => void;
className?: string;
}
/**
* Shown on any connection still on `allow_all` — the mode every pre-policy connection
* was backfilled to. Renders nothing for an allowlist connection, so the card can drop
* it in unconditionally.
*/
export function McpReviewPermissionsBanner({
policy,
onReview,
className,
}: McpReviewPermissionsBannerProps) {
if (!needsPermissionReview(policy)) return null;
return (
<div
role="status"
className={cn(
'border-amber-500/40 bg-amber-500/10 flex flex-wrap items-center gap-2 rounded-md border px-3 py-2',
className,
)}
>
<AlertTriangle className="h-3.5 w-3.5 flex-shrink-0 text-amber-600" />
<span className="text-xs">
Review permissions — the agent can call every tool on this server.
</span>
{onReview && (
<Button variant="outline" size="sm" className="h-6 px-2 text-xs" onClick={onReview}>
Review
</Button>
)}
</div>
);
}