mcp-permissions-section.tsx6.4 KBView on GitHub
'use client';

import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { AlertTriangle, ChevronDown, RefreshCw, Shield } from 'lucide-react';
import { useTRPC } from '@/providers/query-provider';
import { Button } from '@/components/ui/button';
import { useEffect, useState } from 'react';
import { toast } from 'sonner';

import { McpReviewPermissionsBanner, McpToolChecklist } from './mcp-tool-checklist';
import {
  buildPolicyFromRows,
  countAllowed,
  patchToolRow,
  type McpToolPolicyDigest,
  type McpToolRow,
} from './mcp-tool-policy';

interface McpPermissionsSectionProps {
  connectionId: string;
  userId?: string;
  policy?: McpToolPolicyDigest | null;
}

/**
 * Per-connection Permissions. Lives on the connection because the permission belongs
 * to the connection: an agent's grant can only ever narrow this ceiling, never widen it.
 *
 * The tool list is fetched only once the section is opened — `listMcpConnectionTools`
 * does a live `tools/list` round trip per connection, which is not something to do for
 * every row on page load.
 */
export function McpPermissionsSection({ connectionId, userId, policy }: McpPermissionsSectionProps) {
  const trpc = useTRPC();
  const queryClient = useQueryClient();
  const [open, setOpen] = useState(false);
  const [rows, setRows] = useState<McpToolRow[] | null>(null);

  const toolsInput = { connectionId, ...(userId ? { userId } : {}) };
  const toolsQueryOptions = trpc.integrations.listMcpConnectionTools.queryOptions(toolsInput);

  const { data, isLoading, isFetching, refetch } = useQuery({
    ...toolsQueryOptions,
    enabled: open,
  });

  const { mutateAsync: setToolPolicy, isPending: isSaving } = useMutation(
    trpc.integrations.setMcpToolPolicy.mutationOptions(),
  );

  // Re-seed the draft whenever a fresh read lands. A policy edit is a short-lived
  // form, not a long-lived document, so last-read-wins is the honest behaviour:
  // silently keeping stale ticks over a newer tool list is how consent gets
  // inherited by a tool nobody reviewed.
  useEffect(() => {
    if (data?.tools) setRows(data.tools as McpToolRow[]);
  }, [data]);

  const handleSave = async () => {
    if (!rows) return;
    try {
      await setToolPolicy({
        connectionId,
        ...(userId ? { userId } : {}),
        policy: buildPolicyFromRows(rows, {
          discoveredAt: data?.policy?.discoveredAt,
        }),
      });
      await queryClient.invalidateQueries({ queryKey=[redacted] });
      void queryClient.invalidateQueries({
        queryKey=[redacted] ? { userId } : undefined)
          .queryKey,
      });
      toast.success('Permissions saved');
    } catch (err) {
      toast.error(err instanceof Error ? err.message : 'Failed to save permissions');
    }
  };

  const allowedCount = rows ? countAllowed(rows) : (policy?.allowedCount ?? 0);
  const total = rows ? rows.length : (policy?.ruleCount ?? 0);
  const unreachable = data?.success === false;

  return (
    <div className="space-y-2">
      <McpReviewPermissionsBanner policy={policy} onReview={() => setOpen(true)} />

      <Collapsible open={open} onOpenChange={setOpen}>
        <CollapsibleTrigger asChild>
          <Button
            type="button"
            variant="ghost"
            size="sm"
            className="text-muted-foreground h-7 gap-1.5 px-2 text-xs"
          >
            <Shield className="h-3.5 w-3.5" />
            Permissions
            <span className="text-muted-foreground">
              ({allowedCount} of {total} tools enabled)
            </span>
            <ChevronDown className={`h-3.5 w-3.5 transition-transform ${open ? 'rotate-180' : ''}`} />
          </Button>
        </CollapsibleTrigger>
        <CollapsibleContent>
          <div className="mt-2 space-y-3 rounded-md border p-3">
            {isLoading ? (
              <p className="text-muted-foreground text-xs">Loading tools…</p>
            ) : (
              <>
                {unreachable && (
                  <div
                    role="alert"
                    className="border-amber-500/40 bg-amber-500/10 space-y-1 rounded-md border px-3 py-2"
                  >
                    <p className="flex items-center gap-1.5 text-xs font-medium">
                      <AlertTriangle className="h-3.5 w-3.5 flex-shrink-0 text-amber-600" />
                      Could not reach this server
                    </p>
                    <p className="text-muted-foreground text-xs">
                      Showing the last saved permissions. Edits still apply.
                    </p>
                  </div>
                )}
                <McpToolChecklist
                  tools={rows ?? []}
                  disabled={isSaving}
                  idPrefix={`mcp-perm-${connectionId}`}
                  onToggle={(toolName, allowed) =>
                    setRows((current) =>
                      current ? patchToolRow(current, toolName, { allowed, isNew: false }) : current,
                    )
                  }
                  onInstructionChange={(toolName, instruction) =>
                    setRows((current) =>
                      current ? patchToolRow(current, toolName, { instruction }) : current,
                    )
                  }
                  onRequireApprovalChange={(toolName, requireApproval) =>
                    setRows((current) =>
                      current ? patchToolRow(current, toolName, { requireApproval }) : current,
                    )
                  }
                  emptyMessage="No tools discovered on this server yet."
                />
                <div className="flex items-center gap-2">
                  <Button size="sm" onClick={handleSave} disabled={isSaving || !rows}>
                    {isSaving ? 'Saving…' : 'Save permissions'}
                  </Button>
                  <Button
                    variant="ghost"
                    size="sm"
                    className="gap-1.5"
                    onClick={() => void refetch()}
                    disabled={isFetching}
                  >
                    <RefreshCw className={`h-3.5 w-3.5 ${isFetching ? 'animate-spin' : ''}`} />
                    Re-check tools
                  </Button>
                </div>
              </>
            )}
          </div>
        </CollapsibleContent>
      </Collapsible>
    </div>
  );
}