From 69f2fbfaa1d4b35430d065d26c593a82280b57a4 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Fri, 19 Jun 2026 10:21:21 +0300 Subject: [PATCH] feat(frontend): batch approve/reject on global Tools page (MCP-2918) Add batch Approve and Reject (block) actions to the global Tools page, alongside the existing batch Enable/Disable. The page spans multiple servers, so the actions group the current multi-select by server_name and fan out one approveTools/blockTools call per server (Promise.all), aggregate counts, and show a single success/error toast. - Buttons (data-test batch-approve / batch-reject) enable only when the selection contains at least one pending/changed tool. - Already-approved tools in the selection are silently skipped. - Partial failures surface a per-server error toast. Related #MCP-2918 --- frontend/src/views/Tools.vue | 93 ++++++++++- .../tests/unit/tools-batch-approve.spec.ts | 156 ++++++++++++++++++ 2 files changed, 248 insertions(+), 1 deletion(-) create mode 100644 frontend/tests/unit/tools-batch-approve.spec.ts diff --git a/frontend/src/views/Tools.vue b/frontend/src/views/Tools.vue index 28634bbc..ac6b7d9e 100644 --- a/frontend/src/views/Tools.vue +++ b/frontend/src/views/Tools.vue @@ -185,6 +185,24 @@ Disable selected + + @@ -450,6 +468,9 @@ import CollapsibleHintsPanel from '@/components/CollapsibleHintsPanel.vue' import type { Hint } from '@/components/CollapsibleHintsPanel.vue' import type { GlobalTool, GlobalToolsStats } from '@/types/api' import api from '@/services/api' +import { useSystemStore } from '@/stores/system' + +const systemStore = useSystemStore() // ---- State ---- const allTools = ref([]) @@ -568,6 +589,76 @@ async function batchEnable(enabled: boolean) { await loadTools() } +// ---- Batch approve / reject (multi-server fan-out) ---- +// A tool is "approvable" when it is awaiting a decision: pending (brand-new) or +// changed (rug-pull). Already-approved tools are silently skipped so the action +// never errors when a mixed selection includes them. +function isApprovable(tool: GlobalTool): boolean { + return tool.approval_status === 'pending' || tool.approval_status === 'changed' +} + +const hasApprovableSelection = computed(() => + allTools.value.some(t => selectedKeys.value.has(toolKey(t)) && isApprovable(t)) +) + +async function batchApproval(action: 'approve' | 'reject') { + if (batchLoading.value || !hasApprovableSelection.value) return + batchLoading.value = true + batchResult.value = null + + // Group the approvable tools in the selection by server, then fan out one + // call per server — the page spans multiple servers but each approve/block + // endpoint is per-server. + const byServer = new Map() + for (const tool of allTools.value) { + if (!selectedKeys.value.has(toolKey(tool)) || !isApprovable(tool)) continue + const names = byServer.get(tool.server_name) || [] + names.push(tool.name) + byServer.set(tool.server_name, names) + } + + let toolCount = 0 + byServer.forEach(names => { toolCount += names.length }) + + const failedServers: string[] = [] + await Promise.all( + Array.from(byServer.entries()).map(async ([server, names]) => { + try { + const resp = action === 'approve' + ? await api.approveTools(server, names) + : await api.blockTools(server, names) + if (!resp.success) { + failedServers.push(`${server} (${resp.error || 'failed'})`) + } + } catch (err) { + failedServers.push(`${server} (${err instanceof Error ? err.message : 'failed'})`) + } + }) + ) + + const verb = action === 'approve' ? 'Approved' : 'Rejected' + const okServers = byServer.size - failedServers.length + if (failedServers.length === 0) { + systemStore.addToast({ + type: 'success', + title: `${verb} tools`, + message: `${verb} ${toolCount} tool${toolCount === 1 ? '' : 's'} across ${byServer.size} server${byServer.size === 1 ? '' : 's'}`, + }) + } else { + systemStore.addToast({ + type: 'error', + title: `${verb} with errors`, + message: `${okServers} of ${byServer.size} server${byServer.size === 1 ? '' : 's'} succeeded. Failed: ${failedServers.join(', ')}`, + }) + } + + selectedKeys.value.clear() + batchLoading.value = false + + // Refresh to get authoritative approval state + stats. + await loadTools() +} + // ---- Computed: available filter options ---- const availableServers = computed(() => { const s = new Set() @@ -797,7 +888,7 @@ const toolsHints = computed(() => [ 'Search by tool name, description, or server', 'Filter by status, risk level, or approval state', 'Sort any column to find stale or unused tools', - 'Select multiple tools for batch enable/disable', + 'Select multiple tools for batch enable/disable, or batch approve/reject across servers', ], }, { diff --git a/frontend/tests/unit/tools-batch-approve.spec.ts b/frontend/tests/unit/tools-batch-approve.spec.ts new file mode 100644 index 00000000..b6bf5edf --- /dev/null +++ b/frontend/tests/unit/tools-batch-approve.spec.ts @@ -0,0 +1,156 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import Tools from '@/views/Tools.vue' +import { useSystemStore } from '@/stores/system' +import api from '@/services/api' + +// MCP-2918: batch Approve / Reject on the global Tools page. The page spans +// multiple servers, so the actions group the multi-select by server_name and +// fan out one approveTools / blockTools call per server. These tests lock the +// group-by-server fan-out, the skip-already-approved rule, the enable/disable +// guard on the buttons, and partial-failure surfacing. + +vi.mock('@/services/api', () => ({ + default: { + getGlobalTools: vi.fn(), + setToolEnabled: vi.fn(), + approveTools: vi.fn(), + blockTools: vi.fn(), + }, +})) + +const globalStubs = { CollapsibleHintsPanel: { template: '
' } } + +// One approved tool + pending/changed tools spread across two servers. +const TOOLS = [ + { name: 'new_a', server_name: 'alpha', approval_status: 'pending', enabled: true, description: '' }, + { name: 'changed_a', server_name: 'alpha', approval_status: 'changed', enabled: true, description: '' }, + { name: 'new_b', server_name: 'beta', approval_status: 'pending', enabled: true, description: '' }, + { name: 'ok_b', server_name: 'beta', approval_status: 'approved', enabled: true, description: '' }, +] + +function mountView() { + return mount(Tools, { global: { plugins: [createPinia()], stubs: globalStubs } }) +} + +async function selectAll(wrapper: any) { + // Select every row currently rendered on the page. + await wrapper.find('[data-test="tools-select-all"]').setValue(true) + await flushPromises() +} + +describe('Tools — batch approve / reject (multi-server fan-out)', () => { + beforeEach(() => { + setActivePinia(createPinia()) + ;(api.getGlobalTools as any).mockResolvedValue({ + success: true, + data: { + stats: { total: 4, enabled: 4, disabled: 0, pending_approval: 3 }, + tools: TOOLS, + }, + }) + ;(api.approveTools as any).mockResolvedValue({ success: true, data: { approved: 2 } }) + ;(api.blockTools as any).mockResolvedValue({ success: true, data: { blocked: 2 } }) + }) + + it('disables Approve/Reject when no pending or changed tool is selected', async () => { + const wrapper = mountView() + await flushPromises() + + // Nothing selected → batch bar (and its buttons) not even rendered. + expect(wrapper.find('[data-test="batch-approve"]').exists()).toBe(false) + + // Select only the already-approved tool → buttons rendered but disabled. + const approvedTool = TOOLS.find(t => t.approval_status === 'approved')! + const wrapperVm = wrapper.vm as any + wrapperVm.selectedKeys.add(`${approvedTool.server_name}\x00${approvedTool.name}`) + await flushPromises() + + const approveBtn = wrapper.find('[data-test="batch-approve"]') + const rejectBtn = wrapper.find('[data-test="batch-reject"]') + expect(approveBtn.exists()).toBe(true) + expect(rejectBtn.exists()).toBe(true) + expect((approveBtn.element as HTMLButtonElement).disabled).toBe(true) + expect((rejectBtn.element as HTMLButtonElement).disabled).toBe(true) + }) + + it('enables Approve/Reject when at least one pending or changed tool is selected', async () => { + const wrapper = mountView() + await flushPromises() + await selectAll(wrapper) + + const approveBtn = wrapper.find('[data-test="batch-approve"]') + const rejectBtn = wrapper.find('[data-test="batch-reject"]') + expect((approveBtn.element as HTMLButtonElement).disabled).toBe(false) + expect((rejectBtn.element as HTMLButtonElement).disabled).toBe(false) + }) + + it('groups the selection by server and calls approveTools once per server with only pending/changed names', async () => { + const wrapper = mountView() + await flushPromises() + await selectAll(wrapper) + + await wrapper.find('[data-test="batch-approve"]').trigger('click') + await flushPromises() + + // One call per server that has approvable tools; the approved-only server + // ('beta' has new_b pending + ok_b approved) still gets a call, but only + // for its pending/changed names. + expect((api.approveTools as any)).toHaveBeenCalledTimes(2) + const calls = (api.approveTools as any).mock.calls + const byServer = Object.fromEntries(calls.map((c: any[]) => [c[0], c[1]])) + expect(Object.keys(byServer).sort()).toEqual(['alpha', 'beta']) + expect([...byServer.alpha].sort()).toEqual(['changed_a', 'new_a']) + expect(byServer.beta).toEqual(['new_b']) // ok_b (approved) skipped + }) + + it('calls blockTools per server on Reject', async () => { + const wrapper = mountView() + await flushPromises() + await selectAll(wrapper) + + await wrapper.find('[data-test="batch-reject"]').trigger('click') + await flushPromises() + + expect((api.blockTools as any)).toHaveBeenCalledTimes(2) + const calls = (api.blockTools as any).mock.calls + const byServer = Object.fromEntries(calls.map((c: any[]) => [c[0], c[1]])) + expect([...byServer.alpha].sort()).toEqual(['changed_a', 'new_a']) + expect(byServer.beta).toEqual(['new_b']) + }) + + it('shows a single success toast summarising tools and servers', async () => { + const wrapper = mountView() + await flushPromises() + const store = useSystemStore() + await selectAll(wrapper) + + await wrapper.find('[data-test="batch-approve"]').trigger('click') + await flushPromises() + + const success = store.toasts.filter(t => t.type === 'success') + expect(success.length).toBe(1) + expect(success[0].message).toMatch(/3 tools/) + expect(success[0].message).toMatch(/2 servers/) + }) + + it('surfaces partial failures with an error toast', async () => { + ;(api.approveTools as any).mockImplementation((server: string) => { + if (server === 'beta') return Promise.resolve({ success: false, error: 'boom' }) + return Promise.resolve({ success: true, data: { approved: 2 } }) + }) + + const wrapper = mountView() + await flushPromises() + const store = useSystemStore() + await selectAll(wrapper) + + await wrapper.find('[data-test="batch-approve"]').trigger('click') + await flushPromises() + + const errors = store.toasts.filter(t => t.type === 'error') + expect(errors.length).toBe(1) + expect(errors[0].message).toMatch(/beta/) + }) +})