@@ -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+
9198func 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+ }
0 commit comments