feat: add server-level MCP auto-approval for wildcard tool servers

MCP servers configured with empty tools arrays (wildcard) now appear in the
auto-approval pill with a server-level toggle. When toggled, a server-level
approval key (mcp_server:<name>) is added to autoApprovedToolNames. The
sidecar matches this key against PermissionRequestMcp.ServerName to auto-
approve all tools from that server without needing individual tool names.

- Add buildMcpServerApprovalKey/isMcpServerApprovalKey helpers
- Create approval groups for all MCP servers including empty-tools ones
- Add serverApprovalKey to ApprovalToolGroup for server-level toggles
- Update sidecar RequiresToolCallApproval to check server-level keys
- Include server-level keys in listApprovalToolNames for pruning safety
- Add tests for both shared domain and sidecar approval matching

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-03-28 18:34:19 +01:00
co-authored by Copilot
parent 53a08e0ed4
commit 3937904548
6 changed files with 210 additions and 25 deletions
+15 -4
View File
@@ -26,6 +26,8 @@ import { type PatternDefinition, type ReasoningEffort } from '@shared/domain/pat
import { isScratchpadProject, type ProjectRecord } from '@shared/domain/project';
import { resolveSessionToolingSelection, type SessionRecord } from '@shared/domain/session';
import {
groupApprovalToolsByProvider,
isMcpServerApprovalKey,
listApprovalToolDefinitions,
type RuntimeToolDefinition,
type SessionToolingSelection,
@@ -128,10 +130,19 @@ export function ChatPane({
),
[isApprovalOverridden, session.approvalSettings, pattern.approvalPolicy],
);
const effectiveAutoApprovedCount = useMemo(
() => approvalTools.filter((t) => effectiveAutoApproved.has(t.id)).length,
[approvalTools, effectiveAutoApproved],
);
const effectiveAutoApprovedCount = useMemo(() => {
const groups = groupApprovalToolsByProvider(approvalTools, toolingSettings);
let count = 0;
for (const group of groups) {
if (group.serverApprovalKey && effectiveAutoApproved.has(group.serverApprovalKey)) {
// Server-level approval: count as 1 approved group even with 0 declared tools
count += Math.max(group.tools.length, 1);
} else {
count += group.tools.filter((t) => effectiveAutoApproved.has(t.id)).length;
}
}
return count;
}, [approvalTools, effectiveAutoApproved, toolingSettings]);
useEffect(() => {
transcriptRef.current?.scrollTo({
+51 -13
View File
@@ -367,7 +367,11 @@ export function InlineApprovalPill({
[approvalTools, toolingSettings],
);
const showSearch = approvalTools.length > SEARCH_THRESHOLD;
const totalItemCount = groups.reduce(
(sum, g) => sum + Math.max(g.tools.length, g.serverApprovalKey ? 1 : 0),
0,
);
const showSearch = totalItemCount > SEARCH_THRESHOLD;
const searchLower = search.toLowerCase().trim();
const filteredGroups = useMemo(() => {
@@ -382,7 +386,7 @@ export function InlineApprovalPill({
|| group.label.toLowerCase().includes(searchLower),
),
}))
.filter((g) => g.tools.length > 0);
.filter((g) => g.tools.length > 0 || g.label.toLowerCase().includes(searchLower));
}, [groups, searchLower]);
function toggleTool(toolId: string) {
@@ -396,18 +400,45 @@ export function InlineApprovalPill({
}
function toggleGroup(group: ApprovalToolGroup) {
const allApproved = group.tools.every((t) => effectiveAutoApproved.has(t.id));
const next = new Set(effectiveAutoApproved);
for (const tool of group.tools) {
if (allApproved) {
next.delete(tool.id);
if (group.serverApprovalKey) {
// MCP servers use server-level approval key
if (next.has(group.serverApprovalKey)) {
next.delete(group.serverApprovalKey);
} else {
next.add(tool.id);
next.add(group.serverApprovalKey);
}
// Also remove individual tool entries when toggling server-level
for (const tool of group.tools) {
next.delete(tool.id);
}
} else {
// Non-MCP groups: toggle individual tools
const allApproved = group.tools.every((t) => next.has(t.id));
for (const tool of group.tools) {
if (allApproved) {
next.delete(tool.id);
} else {
next.add(tool.id);
}
}
}
onUpdate({ autoApprovedToolNames: [...next] });
}
function isGroupApproved(group: ApprovalToolGroup): 'all' | 'some' | 'none' {
if (group.serverApprovalKey && effectiveAutoApproved.has(group.serverApprovalKey)) {
return 'all';
}
if (group.tools.length === 0) return 'none';
const approvedCount = group.tools.filter((t) => effectiveAutoApproved.has(t.id)).length;
if (approvedCount === group.tools.length) return 'all';
if (approvedCount > 0) return 'some';
return 'none';
}
function toggleExpanded(groupId: string) {
setExpandedGroups((prev) => {
const next = new Set(prev);
@@ -442,7 +473,7 @@ export function InlineApprovalPill({
type="button"
>
<ShieldCheck className="size-2.5" />
<span>{effectiveAutoApprovedCount}/{approvalTools.length} auto-approved</span>
<span>{effectiveAutoApprovedCount}/{totalItemCount} auto-approved</span>
<ChevronDown className={`size-2.5 transition ${open ? 'rotate-180' : ''}`} />
</button>
@@ -494,9 +525,12 @@ export function InlineApprovalPill({
const isBuiltin = group.kind === 'builtin';
const isCollapsible = !isBuiltin;
const expanded = isBuiltin || isGroupExpanded(group.id);
const approvedCount = group.tools.filter((t) => effectiveAutoApproved.has(t.id)).length;
const allApproved = approvedCount === group.tools.length;
const someApproved = approvedCount > 0 && !allApproved;
const groupState = isGroupApproved(group);
const allApproved = groupState === 'all';
const someApproved = groupState === 'some';
const approvedLabel = group.serverApprovalKey && allApproved
? 'all'
: `${group.tools.filter((t) => effectiveAutoApproved.has(t.id)).length}/${group.tools.length}`;
return (
<div key={group.id}>
@@ -511,10 +545,14 @@ export function InlineApprovalPill({
onClick={() => toggleExpanded(group.id)}
type="button"
>
<ChevronRight className={`size-3 shrink-0 text-zinc-600 transition ${expanded ? 'rotate-90' : ''}`} />
{group.tools.length > 0 ? (
<ChevronRight className={`size-3 shrink-0 text-zinc-600 transition ${expanded ? 'rotate-90' : ''}`} />
) : (
<Server className="size-3 shrink-0 text-zinc-600" />
)}
<span className="min-w-0 flex-1 truncate text-[12px] font-medium text-zinc-300">{group.label}</span>
<span className="shrink-0 rounded-full bg-zinc-800/80 px-1.5 py-px text-[9px] font-medium tabular-nums text-zinc-500">
{approvedCount}/{group.tools.length}
{approvedLabel}
</span>
<GroupToggle
allApproved={allApproved}