Skip to content

Commit 5647518

Browse files
committed
feat: add quarantine tool visibility to Dashboard and CLI doctor
Add prominent quarantine tool notifications so users can quickly see which servers have tools pending approval, instead of discovering them only through server detail pages. Dashboard changes: - Add warning banner showing "X tools pending approval across Y servers" - Lists each server with pending count, links to server detail page - Fetches tool approvals for all enabled servers in parallel on load - Auto-refreshes every 30 seconds alongside other dashboard data CLI doctor changes: - Add "Tools Pending Quarantine Approval" section to doctor output - Queries each enabled server's tool approvals via the existing API - Shows per-server breakdown with new vs changed tool counts - Includes remediation hints for Web UI and CLI approval commands - Quarantine data included in JSON output format Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent f1d4d52 commit 5647518

4 files changed

Lines changed: 351 additions & 16 deletions

File tree

cmd/mcpproxy/doctor_cmd.go

Lines changed: 118 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,13 @@ func shouldUseDoctorDaemon(dataDir string) bool {
8888
return socket.IsSocketAvailable(socketPath)
8989
}
9090

91+
// quarantineServerStats holds quarantine stats for a single server.
92+
type quarantineServerStats struct {
93+
ServerName string
94+
PendingCount int
95+
ChangedCount int
96+
}
97+
9198
func runDoctorClientMode(ctx context.Context, dataDir string, logger *zap.Logger) error {
9299
socketPath := socket.DetectSocketPath(dataDir)
93100
client := cliclient.NewClient(socketPath, logger.Sugar())
@@ -105,10 +112,63 @@ func runDoctorClientMode(ctx context.Context, dataDir string, logger *zap.Logger
105112
return fmt.Errorf("failed to get diagnostics from daemon: %w", err)
106113
}
107114

108-
return outputDiagnostics(diag, info)
115+
// Collect quarantine stats from servers
116+
quarantineStats := collectQuarantineStats(ctx, client, logger)
117+
118+
return outputDiagnostics(diag, info, quarantineStats)
119+
}
120+
121+
// collectQuarantineStats queries each server's tool approvals to find pending tools.
122+
func collectQuarantineStats(ctx context.Context, client *cliclient.Client, logger *zap.Logger) []quarantineServerStats {
123+
servers, err := client.GetServers(ctx)
124+
if err != nil {
125+
logger.Debug("Failed to get servers for quarantine check", zap.Error(err))
126+
return nil
127+
}
128+
129+
var stats []quarantineServerStats
130+
for _, srv := range servers {
131+
name := getStringField(srv, "name")
132+
enabled := getBoolField(srv, "enabled")
133+
if name == "" || !enabled {
134+
continue
135+
}
136+
137+
approvals, err := client.GetToolApprovals(ctx, name)
138+
if err != nil {
139+
logger.Debug("Failed to get tool approvals", zap.String("server", name), zap.Error(err))
140+
continue
141+
}
142+
143+
pending := 0
144+
changed := 0
145+
for _, a := range approvals {
146+
switch a.Status {
147+
case "pending":
148+
pending++
149+
case "changed":
150+
changed++
151+
}
152+
}
153+
154+
if pending > 0 || changed > 0 {
155+
stats = append(stats, quarantineServerStats{
156+
ServerName: name,
157+
PendingCount: pending,
158+
ChangedCount: changed,
159+
})
160+
}
161+
}
162+
163+
// Sort by server name for consistent output
164+
sort.Slice(stats, func(i, j int) bool {
165+
return stats[i].ServerName < stats[j].ServerName
166+
})
167+
168+
return stats
109169
}
110170

111-
func outputDiagnostics(diag map[string]interface{}, info map[string]interface{}) error {
171+
func outputDiagnostics(diag map[string]interface{}, info map[string]interface{}, quarantineStats []quarantineServerStats) error {
112172
switch doctorOutput {
113173
case "json":
114174
// Combine diagnostics with info for JSON output
@@ -118,6 +178,9 @@ func outputDiagnostics(diag map[string]interface{}, info map[string]interface{})
118178
if info != nil {
119179
combined["info"] = info
120180
}
181+
if len(quarantineStats) > 0 {
182+
combined["quarantine"] = quarantineStats
183+
}
121184
output, err := json.MarshalIndent(combined, "", " ")
122185
if err != nil {
123186
return fmt.Errorf("failed to format output: %w", err)
@@ -160,6 +223,9 @@ func outputDiagnostics(diag map[string]interface{}, info map[string]interface{})
160223
fmt.Println("✅ All systems operational! No issues detected.")
161224
fmt.Println()
162225

226+
// Show quarantine stats even when no other issues
227+
displayQuarantineStats(quarantineStats)
228+
163229
// Show deprecated config warnings even when no issues
164230
displayDeprecatedConfigs(diag)
165231

@@ -320,6 +386,9 @@ func outputDiagnostics(diag map[string]interface{}, info map[string]interface{})
320386
fmt.Println()
321387
}
322388

389+
// 5. Tools Pending Quarantine Approval
390+
displayQuarantineStats(quarantineStats)
391+
323392
// Deprecated Configuration warnings
324393
displayDeprecatedConfigs(diag)
325394

@@ -486,3 +555,50 @@ func joinStrings(items []string, sep string) string {
486555
}
487556
return result
488557
}
558+
559+
// displayQuarantineStats shows tools pending quarantine approval in the doctor output.
560+
func displayQuarantineStats(stats []quarantineServerStats) {
561+
if len(stats) == 0 {
562+
return
563+
}
564+
565+
totalPending := 0
566+
totalChanged := 0
567+
for _, s := range stats {
568+
totalPending += s.PendingCount
569+
totalChanged += s.ChangedCount
570+
}
571+
572+
fmt.Println("⚠️ Tools Pending Quarantine Approval")
573+
fmt.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
574+
for _, s := range stats {
575+
total := s.PendingCount + s.ChangedCount
576+
detail := ""
577+
if s.PendingCount > 0 && s.ChangedCount > 0 {
578+
detail = fmt.Sprintf(" (%d new, %d changed)", s.PendingCount, s.ChangedCount)
579+
} else if s.ChangedCount > 0 {
580+
detail = " (changed)"
581+
}
582+
fmt.Printf(" %s: %d tool%s pending%s\n", s.ServerName, total, pluralSuffix(total), detail)
583+
}
584+
fmt.Println()
585+
586+
totalTools := totalPending + totalChanged
587+
fmt.Printf(" Total: %d tool%s across %d server%s\n",
588+
totalTools, pluralSuffix(totalTools),
589+
len(stats), pluralSuffix(len(stats)))
590+
fmt.Println()
591+
fmt.Println("💡 Remediation:")
592+
fmt.Println(" • Review and approve tools in Web UI: Server Detail → Tools tab")
593+
fmt.Println(" • Approve via CLI: mcpproxy upstream approve <server-name>")
594+
fmt.Println(" • Inspect tools: mcpproxy upstream inspect <server-name>")
595+
fmt.Println()
596+
}
597+
598+
// pluralSuffix returns "s" if count != 1, "" otherwise.
599+
func pluralSuffix(count int) string {
600+
if count == 1 {
601+
return ""
602+
}
603+
return "s"
604+
}

cmd/mcpproxy/doctor_cmd_test.go

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ func TestOutputDiagnostics_JSONFormat(t *testing.T) {
3030
defer func() { os.Stdout = oldStdout }()
3131

3232
doctorOutput = "json"
33-
err := outputDiagnostics(diag, nil)
33+
err := outputDiagnostics(diag, nil, nil)
3434

3535
w.Close()
3636
var buf bytes.Buffer
@@ -69,7 +69,7 @@ func TestOutputDiagnostics_PrettyFormat_NoIssues(t *testing.T) {
6969
defer func() { os.Stdout = oldStdout }()
7070

7171
doctorOutput = "pretty"
72-
err := outputDiagnostics(diag, nil)
72+
err := outputDiagnostics(diag, nil, nil)
7373

7474
w.Close()
7575
var buf bytes.Buffer
@@ -111,7 +111,7 @@ func TestOutputDiagnostics_PrettyFormat_WithUpstreamErrors(t *testing.T) {
111111
defer func() { os.Stdout = oldStdout }()
112112

113113
doctorOutput = "pretty"
114-
err := outputDiagnostics(diag, nil)
114+
err := outputDiagnostics(diag, nil, nil)
115115

116116
w.Close()
117117
var buf bytes.Buffer
@@ -167,7 +167,7 @@ func TestOutputDiagnostics_PrettyFormat_WithOAuthRequired(t *testing.T) {
167167
defer func() { os.Stdout = oldStdout }()
168168

169169
doctorOutput = "pretty"
170-
err := outputDiagnostics(diag, nil)
170+
err := outputDiagnostics(diag, nil, nil)
171171

172172
w.Close()
173173
var buf bytes.Buffer
@@ -213,7 +213,7 @@ func TestOutputDiagnostics_PrettyFormat_WithMissingSecrets(t *testing.T) {
213213
defer func() { os.Stdout = oldStdout }()
214214

215215
doctorOutput = "pretty"
216-
err := outputDiagnostics(diag, nil)
216+
err := outputDiagnostics(diag, nil, nil)
217217

218218
w.Close()
219219
var buf bytes.Buffer
@@ -255,7 +255,7 @@ func TestOutputDiagnostics_PrettyFormat_WithRuntimeWarnings(t *testing.T) {
255255
defer func() { os.Stdout = oldStdout }()
256256

257257
doctorOutput = "pretty"
258-
err := outputDiagnostics(diag, nil)
258+
err := outputDiagnostics(diag, nil, nil)
259259

260260
w.Close()
261261
var buf bytes.Buffer
@@ -309,7 +309,7 @@ func TestOutputDiagnostics_PrettyFormat_MultipleIssueTypes(t *testing.T) {
309309
defer func() { os.Stdout = oldStdout }()
310310

311311
doctorOutput = "pretty"
312-
err := outputDiagnostics(diag, nil)
312+
err := outputDiagnostics(diag, nil, nil)
313313

314314
w.Close()
315315
var buf bytes.Buffer
@@ -356,7 +356,7 @@ func TestOutputDiagnostics_PrettyFormat_SingleIssue(t *testing.T) {
356356
defer func() { os.Stdout = oldStdout }()
357357

358358
doctorOutput = "pretty"
359-
err := outputDiagnostics(diag, nil)
359+
err := outputDiagnostics(diag, nil, nil)
360360

361361
w.Close()
362362
var buf bytes.Buffer
@@ -386,7 +386,7 @@ func TestOutputDiagnostics_EmptyFormat(t *testing.T) {
386386

387387
// Empty string should default to pretty format
388388
doctorOutput = ""
389-
err := outputDiagnostics(diag, nil)
389+
err := outputDiagnostics(diag, nil, nil)
390390

391391
w.Close()
392392
var buf bytes.Buffer
@@ -551,7 +551,7 @@ func TestOutputDiagnostics_WarningWithoutTitle(t *testing.T) {
551551
defer func() { os.Stdout = oldStdout }()
552552

553553
doctorOutput = "pretty"
554-
err := outputDiagnostics(diag, nil)
554+
err := outputDiagnostics(diag, nil, nil)
555555

556556
w.Close()
557557
var buf bytes.Buffer
@@ -586,7 +586,7 @@ func TestOutputDiagnostics_HighSeverityWarning(t *testing.T) {
586586
defer func() { os.Stdout = oldStdout }()
587587

588588
doctorOutput = "pretty"
589-
err := outputDiagnostics(diag, nil)
589+
err := outputDiagnostics(diag, nil, nil)
590590

591591
w.Close()
592592
var buf bytes.Buffer
@@ -624,7 +624,7 @@ func TestOutputDiagnostics_SecretWithoutOptionalFields(t *testing.T) {
624624
defer func() { os.Stdout = oldStdout }()
625625

626626
doctorOutput = "pretty"
627-
err := outputDiagnostics(diag, nil)
627+
err := outputDiagnostics(diag, nil, nil)
628628

629629
w.Close()
630630
var buf bytes.Buffer
@@ -665,7 +665,7 @@ func TestOutputDiagnostics_MissingSecretsRealJSON(t *testing.T) {
665665
defer func() { os.Stdout = oldStdout }()
666666

667667
doctorOutput = "pretty"
668-
err := outputDiagnostics(diag, nil)
668+
err := outputDiagnostics(diag, nil, nil)
669669

670670
w.Close()
671671
var buf bytes.Buffer

0 commit comments

Comments
 (0)