diff --git a/sidecar/src/Aryx.AgentHost/Services/CopilotApprovalCoordinator.cs b/sidecar/src/Aryx.AgentHost/Services/CopilotApprovalCoordinator.cs
index 1cc7e41..decf91b 100644
--- a/sidecar/src/Aryx.AgentHost/Services/CopilotApprovalCoordinator.cs
+++ b/sidecar/src/Aryx.AgentHost/Services/CopilotApprovalCoordinator.cs
@@ -57,9 +57,10 @@ internal sealed class CopilotApprovalCoordinator
{
string? toolName = ResolveApprovalToolName(request, toolNamesByCallId);
string? autoApprovedToolName = ResolveAutoApprovedToolName(request);
+ string? mcpServerApprovalKey = ResolveMcpServerApprovalKey(request);
string? approvalCacheKey = ResolveApprovalCacheKey(toolName, autoApprovedToolName);
if (IsToolApprovedForRequest(command.RequestId, approvalCacheKey)
- || !RequiresToolCallApproval(command.Pattern.ApprovalPolicy, agent.Id, toolName, autoApprovedToolName))
+ || !RequiresToolCallApproval(command.Pattern.ApprovalPolicy, agent.Id, toolName, autoApprovedToolName, mcpServerApprovalKey))
{
return CreateApprovalResult(PermissionRequestResultKind.Approved);
}
@@ -227,7 +228,8 @@ internal sealed class CopilotApprovalCoordinator
ApprovalPolicyDto? approvalPolicy,
string agentId,
string? toolName,
- string? autoApprovedToolName = null)
+ string? autoApprovedToolName = null,
+ string? mcpServerApprovalKey = null)
{
if (approvalPolicy?.Rules is null || approvalPolicy.Rules.Count == 0)
{
@@ -245,7 +247,8 @@ internal sealed class CopilotApprovalCoordinator
return true;
}
- return !MatchesAutoApprovedTool(autoApprovedToolNames, toolName, autoApprovedToolName);
+ return !MatchesAutoApprovedTool(autoApprovedToolNames, toolName, autoApprovedToolName)
+ && !MatchesAutoApprovedToolName(autoApprovedToolNames, mcpServerApprovalKey);
}
internal static bool TryGetApprovalToolName(
@@ -327,6 +330,19 @@ internal sealed class CopilotApprovalCoordinator
return GetFallbackToolName(request);
}
+ private const string McpServerApprovalPrefix = "mcp_server:";
+
+ private static string? ResolveMcpServerApprovalKey(PermissionRequest request)
+ {
+ if (request is not PermissionRequestMcp mcp)
+ {
+ return null;
+ }
+
+ string? serverName = NormalizeOptionalString(mcp.ServerName);
+ return serverName is not null ? $"{McpServerApprovalPrefix}{serverName}" : null;
+ }
+
private static string? ResolveApprovalCacheKey(
string? toolName,
string? autoApprovedToolName)
diff --git a/sidecar/tests/Aryx.AgentHost.Tests/CopilotWorkflowRunnerTests.cs b/sidecar/tests/Aryx.AgentHost.Tests/CopilotWorkflowRunnerTests.cs
index ed236cf..89d23ed 100644
--- a/sidecar/tests/Aryx.AgentHost.Tests/CopilotWorkflowRunnerTests.cs
+++ b/sidecar/tests/Aryx.AgentHost.Tests/CopilotWorkflowRunnerTests.cs
@@ -843,6 +843,36 @@ public sealed class CopilotWorkflowRunnerTests
Assert.True(CopilotApprovalCoordinator.RequiresToolCallApproval(policy, "agent-1", "git.status"));
}
+ [Fact]
+ public void RequiresToolCallApproval_HonorsMcpServerLevelApprovalKey()
+ {
+ ApprovalPolicyDto policy = new()
+ {
+ Rules =
+ [
+ new ApprovalCheckpointRuleDto
+ {
+ Kind = "tool-call",
+ },
+ ],
+ AutoApprovedToolNames = ["mcp_server:Git MCP"],
+ };
+
+ // Server-level key approves any tool from that server
+ Assert.False(CopilotApprovalCoordinator.RequiresToolCallApproval(
+ policy, "agent-1", "git.status", null, "mcp_server:Git MCP"));
+ Assert.False(CopilotApprovalCoordinator.RequiresToolCallApproval(
+ policy, "agent-1", "git.diff", null, "mcp_server:Git MCP"));
+
+ // Different server still requires approval
+ Assert.True(CopilotApprovalCoordinator.RequiresToolCallApproval(
+ policy, "agent-1", "fs.read", null, "mcp_server:Filesystem"));
+
+ // Non-MCP tools unaffected
+ Assert.True(CopilotApprovalCoordinator.RequiresToolCallApproval(
+ policy, "agent-1", "unknown_tool"));
+ }
+
[Fact]
public void TryGetApprovalToolName_ResolvesDirectNamesAndRuntimeFallbacks()
{
diff --git a/src/renderer/components/ChatPane.tsx b/src/renderer/components/ChatPane.tsx
index 478592a..0295d9c 100644
--- a/src/renderer/components/ChatPane.tsx
+++ b/src/renderer/components/ChatPane.tsx
@@ -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({
diff --git a/src/renderer/components/chat/InlinePills.tsx b/src/renderer/components/chat/InlinePills.tsx
index c74f0be..8553740 100644
--- a/src/renderer/components/chat/InlinePills.tsx
+++ b/src/renderer/components/chat/InlinePills.tsx
@@ -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"
>