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