Skip to content

Commit 99c6390

Browse files
authored
feat(security): US4 collapse scan-notification storm into one settled event (Spec 077, MCP-2207) (#794)
* feat(security): debounce scan notifications into one settled event per server Related #786 Spec 077 US4 (MCP-2207): the security-scan notification storm came from per-scanner scan_started/progress/completed/failed SSE events multiplied by reconnect storms (prior partial fixes: #659, MCP-2223). Replace those per-scanner lifecycle emissions with a single debounced security.scan_settled event per server per scan. ## Changes - Add scanNotifyDebouncer (internal/runtime/scan_notify.go): terminal-triggered per-server debounce with a generation counter guarding the AfterFunc race; only completed/failed arm the timer, started/progress are dropped. - Add EventTypeSecurityScanSettled; route runtime EmitSecurityScan* through the debouncer (started/progress become no-ops, completed/failed record terminal state) and publish one settled event carrying the terminal findings summary. - Wire scanNotify (750ms) into newRuntime alongside the existing coalescer. - Collapse the activity log to one handleSecurityScanSettled record per scan, removing the former started/completed/failed handlers. ## Testing - scan_notify_test.go: a reconnect storm across N servers yields <= N settled events (exactly one per server) and zero per-scanner lifecycle events; settled event carries the terminal summary. Related: Spec 077 (specs/077-scanner-simplification) * feat(web-ui): consume debounced scan.settled event; drop per-scanner lifecycle Related #786 Spec 077 US4 (MCP-2207): forward the new security.scan_settled SSE event from the system store as a mcpproxy:scan-settled window event, and have useSecurityScannerStatus refresh its cached scan totals off that single settled signal instead of tracking per-scanner lifecycle events. ## Changes - stores/system.ts: add a security.scan_settled SSE listener that dispatches mcpproxy:scan-settled. - composables/useSecurityScannerStatus.ts: register a module-scope mcpproxy:scan-settled listener that triggers a status refresh. ## Testing - frontend vue-tsc --noEmit clean; vite build succeeds. Related: Spec 077 (specs/077-scanner-simplification)
1 parent 5b925e4 commit 99c6390

8 files changed

Lines changed: 318 additions & 91 deletions

File tree

frontend/src/composables/useSecurityScannerStatus.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,18 @@ const totalFindings = ref<number>(0)
1717
const totalScans = ref<number>(0)
1818
let inflight: Promise<void> | null = null
1919

20+
// Spec 077 US4 (MCP-2207): the backend now collapses the per-scanner
21+
// scan_started/progress/completed/failed storm into a single debounced
22+
// `security.scan_settled` event per server per scan (forwarded by the system
23+
// store as `mcpproxy:scan-settled`). We consume that one settled signal to
24+
// refresh the cached scan totals, instead of tracking per-scanner lifecycle
25+
// events. Registered once at module scope so all consumers share it.
26+
if (typeof window !== 'undefined') {
27+
window.addEventListener('mcpproxy:scan-settled', () => {
28+
void refreshSecurityScannerStatus()
29+
})
30+
}
31+
2032
export async function refreshSecurityScannerStatus(): Promise<void> {
2133
if (inflight) {
2234
return inflight

frontend/src/stores/system.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,19 @@ export const useSystemStore = defineStore('system', () => {
185185
}
186186
})
187187

188+
// Spec 077 US4 (MCP-2207): a single debounced settled event per server per
189+
// scan replaces the per-scanner scan_started/progress/completed/failed
190+
// storm. Forward it so scan-status consumers can refresh once per scan.
191+
es.addEventListener('security.scan_settled', (event) => {
192+
try {
193+
const data = JSON.parse(event.data)
194+
const payload = data.payload || data
195+
window.dispatchEvent(new CustomEvent('mcpproxy:scan-settled', { detail: payload }))
196+
} catch (error) {
197+
console.error('Failed to parse SSE security.scan_settled event:', error)
198+
}
199+
})
200+
188201
// Listen for activity events (tool calls, policy decisions, etc.)
189202
es.addEventListener('activity.tool_call.started', (event) => {
190203
try {

internal/runtime/activity_service.go

Lines changed: 18 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -246,13 +246,10 @@ func (s *ActivityService) handleEvent(evt Event) {
246246
// Spec 032: Tool-level quarantine events
247247
case EventTypeActivityToolQuarantineChange:
248248
s.handleToolQuarantineChange(evt)
249-
// Spec 039: Security scan events
250-
case EventTypeSecurityScanStarted:
251-
s.handleSecurityScanStarted(evt)
252-
case EventTypeSecurityScanCompleted:
253-
s.handleSecurityScanCompleted(evt)
254-
case EventTypeSecurityScanFailed:
255-
s.handleSecurityScanFailed(evt)
249+
// Spec 077 US4: one settled activity record per server per scan replaces the
250+
// former per-scanner started/completed/failed storm (Spec 039).
251+
case EventTypeSecurityScanSettled:
252+
s.handleSecurityScanSettled(evt)
256253
default:
257254
// Ignore other event types
258255
}
@@ -855,82 +852,39 @@ func (s *ActivityService) handleToolQuarantineChange(evt Event) {
855852
}
856853

857854
// handleSecurityScanStarted records a security scan start event (Spec 039).
858-
func (s *ActivityService) handleSecurityScanStarted(evt Event) {
859-
serverName := getStringPayload(evt.Payload, "server_name")
860-
jobID := getStringPayload(evt.Payload, "job_id")
861-
862-
metadata := map[string]interface{}{
863-
"job_id": jobID,
864-
}
865-
if scanners := evt.Payload["scanners"]; scanners != nil {
866-
metadata["scanners"] = scanners
867-
}
868-
869-
record := &storage.ActivityRecord{
870-
Type: storage.ActivityTypeSecurityScan,
871-
Source: storage.ActivitySourceInternal,
872-
ServerName: serverName,
873-
ToolName: "security_scan",
874-
Status: "started",
875-
Timestamp: evt.Timestamp,
876-
Metadata: metadata,
877-
}
878-
879-
if err := s.storage.SaveActivity(record); err != nil {
880-
s.logger.Error("Failed to save security scan started activity",
881-
zap.String("server", serverName),
882-
zap.Error(err))
883-
}
884-
}
885-
886-
// handleSecurityScanCompleted records a security scan completion event (Spec 039).
887-
func (s *ActivityService) handleSecurityScanCompleted(evt Event) {
855+
// handleSecurityScanSettled records the single settled scan result per server
856+
// per scan (Spec 077 US4, MCP-2207). It replaces the former started/completed/
857+
// failed handlers so the activity log carries one entry per scan instead of a
858+
// per-scanner storm.
859+
func (s *ActivityService) handleSecurityScanSettled(evt Event) {
888860
serverName := getStringPayload(evt.Payload, "server_name")
861+
scanStatus := getStringPayload(evt.Payload, "status")
862+
errMsg := getStringPayload(evt.Payload, "error")
889863

890864
metadata := map[string]interface{}{}
891865
if findingsSummary := getMapPayload(evt.Payload, "findings_summary"); findingsSummary != nil {
892866
metadata["findings_summary"] = findingsSummary
893867
}
894-
if jobID := getStringPayload(evt.Payload, "job_id"); jobID != "" {
895-
metadata["job_id"] = jobID
896-
}
897-
898-
record := &storage.ActivityRecord{
899-
Type: storage.ActivityTypeSecurityScan,
900-
Source: storage.ActivitySourceInternal,
901-
ServerName: serverName,
902-
ToolName: "security_scan",
903-
Status: "success",
904-
Timestamp: evt.Timestamp,
905-
Metadata: metadata,
906-
}
907868

908-
if err := s.storage.SaveActivity(record); err != nil {
909-
s.logger.Error("Failed to save security scan completed activity",
910-
zap.String("server", serverName),
911-
zap.Error(err))
869+
// Map the scan's terminal state onto the activity record status.
870+
status := "success"
871+
if scanStatus == "failed" {
872+
status = "error"
912873
}
913-
}
914-
915-
// handleSecurityScanFailed records a security scan failure event (Spec 039).
916-
func (s *ActivityService) handleSecurityScanFailed(evt Event) {
917-
serverName := getStringPayload(evt.Payload, "server_name")
918-
scannerID := getStringPayload(evt.Payload, "scanner_id")
919-
errMsg := getStringPayload(evt.Payload, "error")
920874

921875
record := &storage.ActivityRecord{
922876
Type: storage.ActivityTypeSecurityScan,
923877
Source: storage.ActivitySourceInternal,
924878
ServerName: serverName,
925879
ToolName: "security_scan",
926-
Status: "error",
880+
Status: status,
927881
ErrorMessage: errMsg,
928882
Timestamp: evt.Timestamp,
929-
Metadata: map[string]interface{}{"scanner_id": scannerID},
883+
Metadata: metadata,
930884
}
931885

932886
if err := s.storage.SaveActivity(record); err != nil {
933-
s.logger.Error("Failed to save security scan failed activity",
887+
s.logger.Error("Failed to save settled security scan activity",
934888
zap.String("server", serverName),
935889
zap.Error(err))
936890
}

internal/runtime/event_bus.go

Lines changed: 37 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -585,44 +585,54 @@ func (r *Runtime) EmitSensitiveDataDetected(activityID string, detectionCount in
585585
r.publishEvent(newEvent(EventTypeSensitiveDataDetected, payload))
586586
}
587587

588-
// EmitSecurityScanStarted emits an event when a security scan begins (Spec 039).
589-
func (r *Runtime) EmitSecurityScanStarted(serverName string, scanners []string, jobID string) {
590-
payload := map[string]any{
591-
"server_name": serverName,
592-
"scanners": scanners,
593-
"job_id": jobID,
588+
// EmitSecurityScanStarted feeds the scan-notification debouncer (Spec 077 US4,
589+
// MCP-2207). The started signal is intentionally dropped: it is part of the
590+
// per-scanner storm the debouncer collapses. The service still invalidates its
591+
// summary cache independently so the UI shows "scanning" while the scan runs.
592+
func (r *Runtime) EmitSecurityScanStarted(serverName string, _ []string, _ string) {
593+
// No-op for notifications: a single settled event replaces the whole
594+
// per-scanner lifecycle. Kept to satisfy the scanner EventEmitter contract.
595+
_ = serverName
596+
}
597+
598+
// EmitSecurityScanProgress is dropped: intermediate per-scanner progress is the
599+
// noise the settled-event model eliminates (Spec 077 US4).
600+
func (r *Runtime) EmitSecurityScanProgress(_, _, _ string, _ int) {}
601+
602+
// EmitSecurityScanCompleted records a terminal scan result for the debouncer,
603+
// which publishes exactly one settled event per server per scan (Spec 077 US4).
604+
func (r *Runtime) EmitSecurityScanCompleted(serverName string, findingsSummary map[string]int) {
605+
if r.scanNotify != nil {
606+
r.scanNotify.noteTerminal(serverName, "completed", findingsSummary, "")
607+
return
594608
}
595-
r.publishEvent(newEvent(EventTypeSecurityScanStarted, payload))
609+
// Fallback (no debouncer wired, e.g. some tests): publish directly so the
610+
// terminal result is not silently lost.
611+
r.publishScanSettled(serverName, "completed", findingsSummary, "")
596612
}
597613

598-
// EmitSecurityScanProgress emits an event for scanner progress updates (Spec 039).
599-
func (r *Runtime) EmitSecurityScanProgress(serverName, scannerID, status string, progress int) {
600-
payload := map[string]any{
601-
"server_name": serverName,
602-
"scanner_id": scannerID,
603-
"status": status,
604-
"progress": progress,
614+
// EmitSecurityScanFailed records a terminal failure for the debouncer. Under
615+
// Spec 077 the baseline verdict is derived elsewhere; here we only ensure the
616+
// storm still collapses to a single settled event (Spec 077 US4).
617+
func (r *Runtime) EmitSecurityScanFailed(serverName, _, errMsg string) {
618+
if r.scanNotify != nil {
619+
r.scanNotify.noteTerminal(serverName, "failed", nil, errMsg)
620+
return
605621
}
606-
r.publishEvent(newEvent(EventTypeSecurityScanProgress, payload))
622+
r.publishScanSettled(serverName, "failed", nil, errMsg)
607623
}
608624

609-
// EmitSecurityScanCompleted emits an event when a security scan completes (Spec 039).
610-
func (r *Runtime) EmitSecurityScanCompleted(serverName string, findingsSummary map[string]int) {
625+
// publishScanSettled emits the single debounced terminal scan event.
626+
func (r *Runtime) publishScanSettled(serverName, status string, findingsSummary map[string]int, errMsg string) {
611627
payload := map[string]any{
612628
"server_name": serverName,
629+
"status": status,
613630
"findings_summary": findingsSummary,
614631
}
615-
r.publishEvent(newEvent(EventTypeSecurityScanCompleted, payload))
616-
}
617-
618-
// EmitSecurityScanFailed emits an event when a scanner fails (Spec 039).
619-
func (r *Runtime) EmitSecurityScanFailed(serverName, scannerID, errMsg string) {
620-
payload := map[string]any{
621-
"server_name": serverName,
622-
"scanner_id": scannerID,
623-
"error": errMsg,
632+
if errMsg != "" {
633+
payload["error"] = errMsg
624634
}
625-
r.publishEvent(newEvent(EventTypeSecurityScanFailed, payload))
635+
r.publishEvent(newEvent(EventTypeSecurityScanSettled, payload))
626636
}
627637

628638
// EmitSecurityIntegrityAlert emits an event for integrity violations (Spec 039).

internal/runtime/events.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,11 @@ const (
6060
EventTypeSecurityScanCompleted EventType = "security.scan_completed"
6161
// EventTypeSecurityScanFailed is emitted when a scanner fails.
6262
EventTypeSecurityScanFailed EventType = "security.scan_failed"
63+
// EventTypeSecurityScanSettled is the single, debounced terminal event that
64+
// Spec 077 US4 (MCP-2207) emits per server per scan. It collapses the
65+
// per-scanner scan_started/progress/completed/failed storm — including
66+
// repeats from reconnect storms — into one settled result.
67+
EventTypeSecurityScanSettled EventType = "security.scan_settled"
6368
// EventTypeSecurityIntegrityAlert is emitted for integrity violations.
6469
EventTypeSecurityIntegrityAlert EventType = "security.integrity_alert"
6570
// EventTypeSecurityScannerChanged is emitted when a scanner plugin's state

internal/runtime/runtime.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,10 @@ type Runtime struct {
8989
// GET /api/v1/servers.
9090
coalescer *serversChangedCoalescer
9191

92+
// Spec 077 US4 (MCP-2207): debounces the per-scanner security-scan
93+
// lifecycle storm into one settled event per server per scan.
94+
scanNotify *scanNotifyDebouncer
95+
9296
// Phase 6: Supervisor for state reconciliation (lock-free reads via StateView)
9397
supervisor *supervisor.Supervisor
9498

@@ -282,6 +286,11 @@ func New(cfg *config.Config, cfgPath string, logger *zap.Logger) (*Runtime, erro
282286
rt.coalescer = newServersChangedCoalescer(rt, 50*time.Millisecond)
283287
rt.coalescer.start(appCtx)
284288

289+
// Spec 077 US4 (MCP-2207): collapse the per-scanner scan-notification storm
290+
// into one settled event per server. 750ms bridges the rapid lifecycle
291+
// signals of a reconnect storm without noticeably delaying the result.
292+
rt.scanNotify = newScanNotifyDebouncer(rt, 750*time.Millisecond)
293+
285294
return rt, nil
286295
}
287296

internal/runtime/scan_notify.go

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
package runtime
2+
3+
import (
4+
"sync"
5+
"time"
6+
)
7+
8+
// scanNotifyDebouncer collapses the per-scanner security-scan lifecycle storm
9+
// into a single debounced "settled" event per server per scan (Spec 077 US4,
10+
// MCP-2207). Prior partial fixes (#659, MCP-2223) trimmed individual event
11+
// classes but the reconnect-storm × per-scanner multiplication remained: every
12+
// reconnect re-ran the full scan_started/progress/completed lifecycle for every
13+
// enabled scanner, flooding SSE subscribers.
14+
//
15+
// The model here is deliberately terminal-triggered: only terminal signals
16+
// (scan completed / scan failed) arm the per-server debounce timer; the noisy
17+
// started/progress signals are dropped entirely. Each terminal signal for a
18+
// server (re)arms a short timer; when the timer finally fires quietly, exactly
19+
// one EventTypeSecurityScanSettled is published carrying the last-known terminal
20+
// state. A reconnect storm across N servers therefore yields at most N settled
21+
// events (one per server), satisfying FR-015/SC-006.
22+
//
23+
// Concurrency: a per-server generation counter guards against the classic
24+
// AfterFunc race where a timer that has already fired blocks on the mutex while
25+
// a fresh signal re-arms — only the flush whose generation still matches the
26+
// latest signal is allowed to publish, so a superseded timer is a no-op.
27+
type scanNotifyDebouncer struct {
28+
rt *Runtime
29+
interval time.Duration
30+
31+
mu sync.Mutex
32+
pending map[string]*pendingScan
33+
}
34+
35+
// pendingScan holds the debounced terminal state for one server between the
36+
// last terminal signal and the settled publish.
37+
type pendingScan struct {
38+
gen uint64
39+
timer *time.Timer
40+
status string // "completed" | "failed"
41+
summary map[string]int // findings-by-severity from the last completed scan
42+
errMsg string // last error, when status == "failed"
43+
}
44+
45+
func newScanNotifyDebouncer(rt *Runtime, interval time.Duration) *scanNotifyDebouncer {
46+
return &scanNotifyDebouncer{
47+
rt: rt,
48+
interval: interval,
49+
pending: make(map[string]*pendingScan),
50+
}
51+
}
52+
53+
// noteTerminal records a terminal scan signal for a server and (re)arms the
54+
// debounce timer. Non-nil summaries and non-empty statuses/errors overwrite the
55+
// prior state last-write-wins, so the eventual settled event reflects the most
56+
// recent scan in a storm.
57+
func (d *scanNotifyDebouncer) noteTerminal(server, status string, summary map[string]int, errMsg string) {
58+
d.mu.Lock()
59+
defer d.mu.Unlock()
60+
61+
p := d.pending[server]
62+
if p == nil {
63+
p = &pendingScan{}
64+
d.pending[server] = p
65+
}
66+
if status != "" {
67+
p.status = status
68+
}
69+
if summary != nil {
70+
p.summary = summary
71+
}
72+
if errMsg != "" {
73+
p.errMsg = errMsg
74+
}
75+
76+
p.gen++
77+
gen := p.gen
78+
if p.timer != nil {
79+
p.timer.Stop()
80+
}
81+
p.timer = time.AfterFunc(d.interval, func() { d.flush(server, gen) })
82+
}
83+
84+
// flush publishes the single settled event for a server, but only if no newer
85+
// terminal signal has superseded the timer that scheduled this flush.
86+
func (d *scanNotifyDebouncer) flush(server string, gen uint64) {
87+
d.mu.Lock()
88+
p := d.pending[server]
89+
if p == nil || p.gen != gen {
90+
// Superseded by a newer signal (or already flushed) — do nothing.
91+
d.mu.Unlock()
92+
return
93+
}
94+
delete(d.pending, server)
95+
status := p.status
96+
summary := p.summary
97+
errMsg := p.errMsg
98+
d.mu.Unlock()
99+
100+
d.rt.publishScanSettled(server, status, summary, errMsg)
101+
}

0 commit comments

Comments
 (0)