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>
  );
}