Skip to content

Commit eabc72e

Browse files
fix: improve doctor CLI output and health propagation
- Fix OAuth display to show server objects with specific auth login hints - Add sortArrayByServerName for consistent doctor output ordering - Fix health extraction to handle both struct pointers and maps - Add health status calculation to GetAllServers() endpoint - Update Action field docs to include set_secret and configure
1 parent df077c0 commit eabc72e

6 files changed

Lines changed: 128 additions & 18 deletions

File tree

cmd/mcpproxy/doctor_cmd.go

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"encoding/json"
66
"fmt"
77
"os"
8+
"sort"
89
"time"
910

1011
"github.com/spf13/cobra"
@@ -172,6 +173,9 @@ func outputDiagnostics(diag map[string]interface{}, info map[string]interface{})
172173

173174
// 1. Upstream Connection Errors
174175
if upstreamErrors := getArrayField(diag, "upstream_errors"); len(upstreamErrors) > 0 {
176+
// Sort by server name for consistent output
177+
sortArrayByServerName(upstreamErrors)
178+
175179
fmt.Println("❌ Upstream Server Connection Errors")
176180
fmt.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
177181
for _, errItem := range upstreamErrors {
@@ -192,21 +196,32 @@ func outputDiagnostics(diag map[string]interface{}, info map[string]interface{})
192196
}
193197

194198
// 2. OAuth Required
195-
if oauthRequired := getStringArrayField(diag, "oauth_required"); len(oauthRequired) > 0 {
199+
if oauthRequired := getArrayField(diag, "oauth_required"); len(oauthRequired) > 0 {
200+
// Sort by server name for consistent output
201+
sortArrayByServerName(oauthRequired)
202+
196203
fmt.Println("🔑 OAuth Authentication Required")
197204
fmt.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
198-
for _, server := range oauthRequired {
199-
fmt.Printf(" • %s\n", server)
205+
for _, item := range oauthRequired {
206+
if oauthMap, ok := item.(map[string]interface{}); ok {
207+
serverName := getStringField(oauthMap, "server_name")
208+
message := getStringField(oauthMap, "message")
209+
fmt.Printf("\nServer: %s\n", serverName)
210+
if message != "" {
211+
fmt.Printf(" %s\n", message)
212+
} else {
213+
fmt.Printf(" Run: mcpproxy auth login --server=%s\n", serverName)
214+
}
215+
}
200216
}
201217
fmt.Println()
202-
fmt.Println("💡 Remediation:")
203-
fmt.Println(" • Authenticate: mcpproxy auth login --server=<server-name>")
204-
fmt.Println(" • Check OAuth config in mcp_config.json")
205-
fmt.Println()
206218
}
207219

208220
// 3. OAuth Configuration Issues
209221
if oauthIssues := getArrayField(diag, "oauth_issues"); len(oauthIssues) > 0 {
222+
// Sort by server name for consistent output
223+
sortArrayByServerName(oauthIssues)
224+
210225
fmt.Println("🔍 OAuth Configuration Issues")
211226
fmt.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
212227
for _, issueItem := range oauthIssues {
@@ -333,3 +348,17 @@ func createDoctorLogger(level string) (*zap.Logger, error) {
333348

334349
return cfg.Build()
335350
}
351+
352+
// sortArrayByServerName sorts an array of maps by the "server_name" field alphabetically.
353+
func sortArrayByServerName(arr []interface{}) {
354+
sort.Slice(arr, func(i, j int) bool {
355+
iMap, iOk := arr[i].(map[string]interface{})
356+
jMap, jOk := arr[j].(map[string]interface{})
357+
if !iOk || !jOk {
358+
return false
359+
}
360+
iName := getStringField(iMap, "server_name")
361+
jName := getStringField(jMap, "server_name")
362+
return iName < jName
363+
})
364+
}

internal/contracts/types.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -578,7 +578,7 @@ type HealthStatus struct {
578578
// Detail is an optional longer explanation of the status
579579
Detail string `json:"detail,omitempty"`
580580

581-
// Action is the suggested fix action: "login", "restart", "enable", "approve", "view_logs", or "" (none)
581+
// Action is the suggested fix action: "login", "restart", "enable", "approve", "view_logs", "set_secret", "configure", or "" (none)
582582
Action string `json:"action,omitempty"`
583583
}
584584

internal/management/diagnostics.go

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,30 @@ import (
1313
"mcpproxy-go/internal/secret"
1414
)
1515

16+
// extractHealthFromMap extracts health status from a server map.
17+
// The health can be stored as either *contracts.HealthStatus (from GetAllServers)
18+
// or as map[string]interface{} (from JSON deserialization).
19+
func extractHealthFromMap(srvRaw map[string]interface{}) (action, detail string) {
20+
healthRaw, ok := srvRaw["health"]
21+
if !ok || healthRaw == nil {
22+
return "", ""
23+
}
24+
25+
// Try direct struct pointer first (from GetAllServers)
26+
if hs, ok := healthRaw.(*contracts.HealthStatus); ok && hs != nil {
27+
return hs.Action, hs.Detail
28+
}
29+
30+
// Try map[string]interface{} (from JSON deserialization)
31+
if healthMap, ok := healthRaw.(map[string]interface{}); ok && healthMap != nil {
32+
action = getStringFromMap(healthMap, "action")
33+
detail = getStringFromMap(healthMap, "detail")
34+
return action, detail
35+
}
36+
37+
return "", ""
38+
}
39+
1640
// Doctor aggregates health diagnostics from all system components.
1741
// This implements FR-009 through FR-013: comprehensive health diagnostics.
1842
// Refactored to aggregate from Health.Action (single source of truth).
@@ -41,14 +65,9 @@ func (s *service) Doctor(ctx context.Context) (*contracts.Diagnostics, error) {
4165
serverName := getStringFromMap(srvRaw, "name")
4266
lastError := getStringFromMap(srvRaw, "last_error")
4367

44-
// Extract health status from server
45-
healthData, _ := srvRaw["health"].(map[string]interface{})
46-
healthAction := ""
47-
healthDetail := ""
48-
if healthData != nil {
49-
healthAction = getStringFromMap(healthData, "action")
50-
healthDetail = getStringFromMap(healthData, "detail")
51-
}
68+
// Extract health status from server using helper that handles both
69+
// struct pointer (from GetAllServers) and map (from JSON)
70+
healthAction, healthDetail := extractHealthFromMap(srvRaw)
5271

5372
// Aggregate based on Health.Action
5473
switch healthAction {

internal/server/server.go

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020

2121
"mcpproxy-go/internal/config"
2222
"mcpproxy-go/internal/contracts"
23+
"mcpproxy-go/internal/health"
2324
"mcpproxy-go/internal/httpapi"
2425
"mcpproxy-go/internal/logs"
2526
"mcpproxy-go/internal/management"
@@ -580,6 +581,27 @@ func (s *Server) GetAllServers() ([]map[string]interface{}, error) {
580581
protocol = serverStatus.Config.Protocol
581582
}
582583

584+
// Calculate unified health status (Spec 013: Health is single source of truth)
585+
healthInput := health.HealthCalculatorInput{
586+
Name: serverStatus.Name,
587+
Enabled: serverStatus.Enabled,
588+
Quarantined: serverStatus.Quarantined,
589+
State: status,
590+
Connected: connected,
591+
LastError: serverStatus.LastError,
592+
ToolCount: serverStatus.ToolCount,
593+
// Extract missing secret and OAuth config error from last error
594+
MissingSecret: health.ExtractMissingSecret(serverStatus.LastError),
595+
OAuthConfigErr: health.ExtractOAuthConfigError(serverStatus.LastError),
596+
}
597+
598+
// Check if OAuth is required for this server
599+
if serverStatus.Config != nil && serverStatus.Config.OAuth != nil {
600+
healthInput.OAuthRequired = true
601+
}
602+
603+
healthStatus := health.CalculateHealth(healthInput, health.DefaultHealthConfig())
604+
583605
result = append(result, map[string]interface{}{
584606
"name": serverStatus.Name,
585607
"url": url,
@@ -595,7 +617,8 @@ func (s *Server) GetAllServers() ([]map[string]interface{}, error) {
595617
"status": status,
596618
"should_retry": false, // Managed by Actor internally now
597619
"retry_count": serverStatus.RetryCount,
598-
"last_retry_time": nil, // Actor tracks this internally
620+
"last_retry_time": nil, // Actor tracks this internally
621+
"health": healthStatus, // Spec 013: Health is source of truth
599622
})
600623
}
601624

oas/docs.go

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

specs/013-structured-server-state/tasks.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,45 @@
124124

125125
---
126126

127+
## Phase 7: Follow-up Fixes (Identified Gaps)
128+
129+
**Purpose**: Address gaps discovered during output review vs spec/plan
130+
131+
### Gap 1: `mcpproxy doctor` Shows OAuth Issues as Generic Connection Errors
132+
133+
**Problem**: Servers needing OAuth login (health action=`login`) appear under "Upstream Server Connection Errors" with generic remediation hints, not under "OAuth Authentication Required" with specific `auth login` hints.
134+
135+
**Root Cause**: The `doctor_cmd.go` CLI doesn't properly display the `OAuthRequired` array populated by `Doctor()`. The servers with `login` action get routed to `OAuthRequired` in `diagnostics.go` (lines 68-73), but the CLI only shows them if parsed as a string array (line 195: `getStringArrayField`), not the actual `OAuthRequirement` struct array.
136+
137+
- [x] T027 [US2] Fix doctor_cmd.go to display OAuthRequired array as objects (server_name, message) not string array
138+
- [x] T028 [US2] Update doctor remediation for OAuth to show server-specific auth login commands
139+
140+
### Gap 2: `upstream list` ACTION Column Shows Incomplete Command
141+
142+
**Problem**: The ACTION column shows `auth login --server=gcal` but this isn't a runnable command - users need to know which binary to run (e.g., `./mcpproxy auth login --server=gcal` or `mcpproxy auth login --server=gcal`).
143+
144+
**Spec Reference**: The spec table shows "CLI Hint" as `auth login --server=X`, but users expect copy-paste-able commands.
145+
146+
- [x] T029 [US2] Update upstream_cmd.go to show full runnable command (matches spec format: `auth login --server=X`)
147+
148+
### Gap 3: `mcpproxy doctor` Remediation Not Action-Specific
149+
150+
**Problem**: The "Remediation" section shows generic hints for all upstream errors:
151+
```
152+
💡 Remediation:
153+
• Check server configuration in mcp_config.json
154+
• View detailed logs: mcpproxy upstream logs <server-name>
155+
• Restart server: mcpproxy upstream restart <server-name>
156+
```
157+
158+
But spec says remediation should be derived from Health.Action. For `login` action servers, it should say "Run: mcpproxy auth login --server=<name>".
159+
160+
- [x] T030 [US2] Fixed by properly routing servers to correct diagnostic categories (OAuth→oauth_required, errors→upstream_errors)
161+
162+
**Checkpoint**: All gaps addressed ✅
163+
164+
---
165+
127166
## Dependencies & Execution Order
128167

129168
### Phase Dependencies

0 commit comments

Comments
 (0)