diff --git a/frontend/src/components/ServerCard.vue b/frontend/src/components/ServerCard.vue
index 7ebeed7d..69ddc2e8 100644
--- a/frontend/src/components/ServerCard.vue
+++ b/frontend/src/components/ServerCard.vue
@@ -35,13 +35,13 @@
{{ blockedToolCount }} disabled
-
+
{{ quarantineToolCount }} pending approval
-
+
{{ server.tool_list_token_size.toLocaleString() }} tokens
@@ -106,7 +106,9 @@
{{ server.last_error }}
-
+
+
+
+
+
{{ toolQuarantineSummary }}
+
+ Review
+
+
+
@@ -408,6 +429,34 @@ const blockedToolCount = computed(() => {
return q.blocked_count ?? 0
})
+// Human-readable summary for the tool-quarantine banner. Differentiates
+// fully-quarantined (every tool needs approval) from partially-quarantined,
+// and surfaces "changed" tools separately because they indicate a rug-pull
+// rather than a first-time review.
+const toolQuarantineSummary = computed(() => {
+ const q = props.server.quarantine
+ if (!q) return ''
+ const pending = q.pending_count ?? 0
+ const changed = q.changed_count ?? 0
+ const total = pending + changed
+ if (total === 0) return ''
+ const toolCount = props.server.tool_count ?? 0
+ const noun = (n: number) => (n === 1 ? 'tool' : 'tools')
+ if (changed > 0 && pending > 0) {
+ return `${pending} ${noun(pending)} pending, ${changed} changed — approval needed`
+ }
+ if (changed > 0) {
+ return `${changed} ${noun(changed)} changed since approval — re-review needed`
+ }
+ if (toolCount > 0 && pending === toolCount) {
+ return `All ${pending} ${noun(pending)} pending security approval`
+ }
+ if (toolCount > 0) {
+ return `${pending} of ${toolCount} ${noun(toolCount)} pending security approval`
+ }
+ return `${pending} ${noun(pending)} pending security approval`
+})
+
// Security scan badge (Spec 039)
const securityScanStatus = computed(() => {
return props.server.security_scan?.status || 'not_scanned'
diff --git a/frontend/src/components/__tests__/ServerCard.test.ts b/frontend/src/components/__tests__/ServerCard.test.ts
index 4092dc56..770ff950 100644
--- a/frontend/src/components/__tests__/ServerCard.test.ts
+++ b/frontend/src/components/__tests__/ServerCard.test.ts
@@ -61,4 +61,106 @@ describe('ServerCard', () => {
expect(wrapper.text()).toContain('disabled-server')
expect(wrapper.text()).toContain('Disabled')
})
+
+ it('shows tool-quarantine banner with Review link when tools are pending and server is not quarantined', () => {
+ const server = {
+ name: 'partial-server',
+ protocol: 'stdio' as const,
+ enabled: true,
+ connected: true,
+ quarantined: false,
+ tool_count: 10,
+ quarantine: { pending_count: 3, changed_count: 0, blocked_count: 0 }
+ }
+
+ const wrapper = mount(ServerCard, {
+ props: { server },
+ global: { plugins: [pinia, router] }
+ })
+
+ expect(wrapper.text()).toContain('3 of 10 tools pending security approval')
+ const review = wrapper.find('a.btn-warning')
+ expect(review.exists()).toBe(true)
+ expect(review.attributes('href')).toBe('/servers/partial-server?tab=tools')
+ // The server-level "Server is quarantined" banner must NOT render here
+ expect(wrapper.text()).not.toContain('Server is quarantined')
+ })
+
+ it('says "All N pending" when every tool is pending', () => {
+ const server = {
+ name: 'fully-pending',
+ protocol: 'stdio' as const,
+ enabled: true,
+ connected: true,
+ quarantined: false,
+ tool_count: 4,
+ quarantine: { pending_count: 4, changed_count: 0, blocked_count: 0 }
+ }
+
+ const wrapper = mount(ServerCard, {
+ props: { server },
+ global: { plugins: [pinia, router] }
+ })
+
+ expect(wrapper.text()).toContain('All 4 tools pending security approval')
+ })
+
+ it('flags rug-pull-style changed tools separately', () => {
+ const server = {
+ name: 'rugpull',
+ protocol: 'stdio' as const,
+ enabled: true,
+ connected: true,
+ quarantined: false,
+ tool_count: 5,
+ quarantine: { pending_count: 0, changed_count: 2, blocked_count: 0 }
+ }
+
+ const wrapper = mount(ServerCard, {
+ props: { server },
+ global: { plugins: [pinia, router] }
+ })
+
+ expect(wrapper.text()).toContain('2 tools changed since approval')
+ })
+
+ it('does not double up: server-level banner wins over tool-level banner', () => {
+ const server = {
+ name: 'srv-quarantined',
+ protocol: 'stdio' as const,
+ enabled: true,
+ connected: false,
+ quarantined: true,
+ tool_count: 4,
+ quarantine: { pending_count: 4, changed_count: 0, blocked_count: 0 }
+ }
+
+ const wrapper = mount(ServerCard, {
+ props: { server },
+ global: { plugins: [pinia, router] }
+ })
+
+ expect(wrapper.text()).toContain('Server is quarantined')
+ expect(wrapper.text()).not.toContain('pending security approval')
+ })
+
+ it('shows both disabled and pending counts when both apply', () => {
+ const server = {
+ name: 'mixed',
+ protocol: 'stdio' as const,
+ enabled: true,
+ connected: true,
+ quarantined: false,
+ tool_count: 10,
+ quarantine: { pending_count: 2, changed_count: 0, blocked_count: 3 }
+ }
+
+ const wrapper = mount(ServerCard, {
+ props: { server },
+ global: { plugins: [pinia, router] }
+ })
+
+ expect(wrapper.text()).toContain('3 disabled')
+ expect(wrapper.text()).toContain('2 pending approval')
+ })
})
\ No newline at end of file