Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions cmd/mcpproxy-tray/internal/api/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,11 @@ import (
// This matches the contracts.HealthStatus struct from the core.
// Spec 013: Health is the single source of truth for server status.
type HealthStatus struct {
Level string `json:"level"` // "healthy", "degraded", "unhealthy"
AdminState string `json:"admin_state"` // "enabled", "disabled", "quarantined"
Summary string `json:"summary"` // e.g., "Connected (5 tools)"
Detail string `json:"detail,omitempty"` // Optional longer explanation
Action string `json:"action,omitempty"` // "login", "restart", "enable", "approve", "set_secret", "configure", "view_logs", ""
Level string `json:"level"` // "healthy", "degraded", "unhealthy"
AdminState string `json:"admin_state"` // "enabled", "disabled", "quarantined"
Summary string `json:"summary"` // e.g., "Connected (5 tools)"
Detail string `json:"detail,omitempty"` // Optional longer explanation
Action string `json:"action,omitempty"` // "login", "restart", "enable", "approve", "set_secret", "configure", "view_logs", ""
}

// Server represents a server from the API
Expand Down Expand Up @@ -550,7 +550,7 @@ func (c *Client) GetServers() ([]Server, error) {
"base_url", c.baseURL)
} else if stateChanged {
// Only log when server states actually change
c.logger.Infow("Server state changed",
c.logger.Debugw("Server state changed",
"count", len(result),
"connected", countConnected(result),
"with_health", withHealthCount,
Expand Down
120 changes: 118 additions & 2 deletions cmd/mcpproxy/doctor_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,13 @@ func shouldUseDoctorDaemon(dataDir string) bool {
return socket.IsSocketAvailable(socketPath)
}

// quarantineServerStats holds quarantine stats for a single server.
type quarantineServerStats struct {
ServerName string
PendingCount int
ChangedCount int
}

func runDoctorClientMode(ctx context.Context, dataDir string, logger *zap.Logger) error {
socketPath := socket.DetectSocketPath(dataDir)
client := cliclient.NewClient(socketPath, logger.Sugar())
Expand All @@ -105,10 +112,63 @@ func runDoctorClientMode(ctx context.Context, dataDir string, logger *zap.Logger
return fmt.Errorf("failed to get diagnostics from daemon: %w", err)
}

return outputDiagnostics(diag, info)
// Collect quarantine stats from servers
quarantineStats := collectQuarantineStats(ctx, client, logger)

return outputDiagnostics(diag, info, quarantineStats)
}

// collectQuarantineStats queries each server's tool approvals to find pending tools.
func collectQuarantineStats(ctx context.Context, client *cliclient.Client, logger *zap.Logger) []quarantineServerStats {
servers, err := client.GetServers(ctx)
if err != nil {
logger.Debug("Failed to get servers for quarantine check", zap.Error(err))
return nil
}

var stats []quarantineServerStats
for _, srv := range servers {
name := getStringField(srv, "name")
enabled := getBoolField(srv, "enabled")
if name == "" || !enabled {
continue
}

approvals, err := client.GetToolApprovals(ctx, name)
if err != nil {
logger.Debug("Failed to get tool approvals", zap.String("server", name), zap.Error(err))
continue
}

pending := 0
changed := 0
for _, a := range approvals {
switch a.Status {
case "pending":
pending++
case "changed":
changed++
}
}

if pending > 0 || changed > 0 {
stats = append(stats, quarantineServerStats{
ServerName: name,
PendingCount: pending,
ChangedCount: changed,
})
}
}

// Sort by server name for consistent output
sort.Slice(stats, func(i, j int) bool {
return stats[i].ServerName < stats[j].ServerName
})

return stats
}

func outputDiagnostics(diag map[string]interface{}, info map[string]interface{}) error {
func outputDiagnostics(diag map[string]interface{}, info map[string]interface{}, quarantineStats []quarantineServerStats) error {
switch doctorOutput {
case "json":
// Combine diagnostics with info for JSON output
Expand All @@ -118,6 +178,9 @@ func outputDiagnostics(diag map[string]interface{}, info map[string]interface{})
if info != nil {
combined["info"] = info
}
if len(quarantineStats) > 0 {
combined["quarantine"] = quarantineStats
}
output, err := json.MarshalIndent(combined, "", " ")
if err != nil {
return fmt.Errorf("failed to format output: %w", err)
Expand Down Expand Up @@ -160,6 +223,9 @@ func outputDiagnostics(diag map[string]interface{}, info map[string]interface{})
fmt.Println("✅ All systems operational! No issues detected.")
fmt.Println()

// Show quarantine stats even when no other issues
displayQuarantineStats(quarantineStats)

// Show deprecated config warnings even when no issues
displayDeprecatedConfigs(diag)

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

// 5. Tools Pending Quarantine Approval
displayQuarantineStats(quarantineStats)

// Deprecated Configuration warnings
displayDeprecatedConfigs(diag)

Expand Down Expand Up @@ -486,3 +555,50 @@ func joinStrings(items []string, sep string) string {
}
return result
}

// displayQuarantineStats shows tools pending quarantine approval in the doctor output.
func displayQuarantineStats(stats []quarantineServerStats) {
if len(stats) == 0 {
return
}

totalPending := 0
totalChanged := 0
for _, s := range stats {
totalPending += s.PendingCount
totalChanged += s.ChangedCount
}

fmt.Println("⚠️ Tools Pending Quarantine Approval")
fmt.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
for _, s := range stats {
total := s.PendingCount + s.ChangedCount
detail := ""
if s.PendingCount > 0 && s.ChangedCount > 0 {
detail = fmt.Sprintf(" (%d new, %d changed)", s.PendingCount, s.ChangedCount)
} else if s.ChangedCount > 0 {
detail = " (changed)"
}
fmt.Printf(" %s: %d tool%s pending%s\n", s.ServerName, total, pluralSuffix(total), detail)
}
fmt.Println()

totalTools := totalPending + totalChanged
fmt.Printf(" Total: %d tool%s across %d server%s\n",
totalTools, pluralSuffix(totalTools),
len(stats), pluralSuffix(len(stats)))
fmt.Println()
fmt.Println("💡 Remediation:")
fmt.Println(" • Review and approve tools in Web UI: Server Detail → Tools tab")
fmt.Println(" • Approve via CLI: mcpproxy upstream approve <server-name>")
fmt.Println(" • Inspect tools: mcpproxy upstream inspect <server-name>")
fmt.Println()
}

// pluralSuffix returns "s" if count != 1, "" otherwise.
func pluralSuffix(count int) string {
if count == 1 {
return ""
}
return "s"
}
26 changes: 13 additions & 13 deletions cmd/mcpproxy/doctor_cmd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ func TestOutputDiagnostics_JSONFormat(t *testing.T) {
defer func() { os.Stdout = oldStdout }()

doctorOutput = "json"
err := outputDiagnostics(diag, nil)
err := outputDiagnostics(diag, nil, nil)

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

doctorOutput = "pretty"
err := outputDiagnostics(diag, nil)
err := outputDiagnostics(diag, nil, nil)

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

doctorOutput = "pretty"
err := outputDiagnostics(diag, nil)
err := outputDiagnostics(diag, nil, nil)

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

doctorOutput = "pretty"
err := outputDiagnostics(diag, nil)
err := outputDiagnostics(diag, nil, nil)

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

doctorOutput = "pretty"
err := outputDiagnostics(diag, nil)
err := outputDiagnostics(diag, nil, nil)

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

doctorOutput = "pretty"
err := outputDiagnostics(diag, nil)
err := outputDiagnostics(diag, nil, nil)

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

doctorOutput = "pretty"
err := outputDiagnostics(diag, nil)
err := outputDiagnostics(diag, nil, nil)

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

doctorOutput = "pretty"
err := outputDiagnostics(diag, nil)
err := outputDiagnostics(diag, nil, nil)

w.Close()
var buf bytes.Buffer
Expand Down Expand Up @@ -386,7 +386,7 @@ func TestOutputDiagnostics_EmptyFormat(t *testing.T) {

// Empty string should default to pretty format
doctorOutput = ""
err := outputDiagnostics(diag, nil)
err := outputDiagnostics(diag, nil, nil)

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

doctorOutput = "pretty"
err := outputDiagnostics(diag, nil)
err := outputDiagnostics(diag, nil, nil)

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

doctorOutput = "pretty"
err := outputDiagnostics(diag, nil)
err := outputDiagnostics(diag, nil, nil)

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

doctorOutput = "pretty"
err := outputDiagnostics(diag, nil)
err := outputDiagnostics(diag, nil, nil)

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

doctorOutput = "pretty"
err := outputDiagnostics(diag, nil)
err := outputDiagnostics(diag, nil, nil)

w.Close()
var buf bytes.Buffer
Expand Down
Loading
Loading