Skip to content

Commit 2acc7ae

Browse files
fix(health): improve unified health status consistency across interfaces
- Refactor CLI auth status to use unified health status from backend (FR-006, FR-007) - CLI upstream list now uses health.CalculateHealth() for DRY principle (I-003) - Add tooltip in ServerCard.vue showing health.detail for context (M-004) - Add defensive null check in Dashboard.vue for backward compatibility (I-004) - Include exact token expiration time in Detail field (M-002) - Add debug logging for health status calculation (M-005) - Add E2E tests verifying health field structure in MCP responses (FR-017, FR-018) - Add unit tests ensuring Summary is never empty (FR-004, I-002) - Extract health status in management service ListServers 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 69495c2 commit 2acc7ae

9 files changed

Lines changed: 158 additions & 65 deletions

File tree

cmd/mcpproxy/auth_cmd.go

Lines changed: 45 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -228,10 +228,7 @@ func runAuthStatusClientMode(ctx context.Context, dataDir, serverName string, al
228228
name, _ := srv["name"].(string)
229229
oauth, _ := srv["oauth"].(map[string]interface{})
230230
authenticated, _ := srv["authenticated"].(bool)
231-
connected, _ := srv["connected"].(bool)
232231
lastError, _ := srv["last_error"].(string)
233-
enabled, _ := srv["enabled"].(bool)
234-
userLoggedOut, _ := srv["user_logged_out"].(bool)
235232

236233
// Check if this is an OAuth server by:
237234
// 1. Has oauth config OR
@@ -247,50 +244,56 @@ func runAuthStatusClientMode(ctx context.Context, dataDir, serverName string, al
247244

248245
hasOAuthServers = true
249246

250-
// Determine status emoji and text using oauth_status for accurate state
251-
oauthStatus, _ := srv["oauth_status"].(string)
252-
var status string
253-
254-
// Check priority states first
255-
if !enabled {
256-
// Server is disabled - no reconnection attempts
257-
status = "⏸️ Disabled"
258-
} else if userLoggedOut {
259-
// User explicitly logged out - no auto-reconnection
260-
status = "🚪 Logged Out (Login Required)"
261-
} else if connected {
262-
// Connected states
263-
if authenticated {
264-
status = "✅ Authenticated & Connected"
265-
} else {
266-
status = "⚠️ Connected (No OAuth Token)"
267-
}
268-
} else {
269-
// Disconnected states - use oauth_status for clarity
270-
switch oauthStatus {
271-
case "authenticated":
272-
// Token valid but not connected - likely reconnecting
273-
status = "⏳ Reconnecting (Token Valid)"
274-
case "expired":
275-
// Token expired - needs re-authentication
276-
status = "⚠️ Token Expired (Login Required)"
277-
case "error":
278-
status = "❌ Authentication Error"
247+
// Use unified health status from backend (FR-006, FR-007)
248+
var healthLevel, adminState, healthSummary, healthAction string
249+
if health, ok := srv["health"].(map[string]interface{}); ok && health != nil {
250+
healthLevel, _ = health["level"].(string)
251+
adminState, _ = health["admin_state"].(string)
252+
healthSummary, _ = health["summary"].(string)
253+
healthAction, _ = health["action"].(string)
254+
}
255+
256+
// Determine status emoji based on admin_state first, then health level
257+
var statusEmoji string
258+
switch adminState {
259+
case "disabled":
260+
statusEmoji = "⏸️"
261+
case "quarantined":
262+
statusEmoji = "🔒"
263+
default:
264+
// Use health level for enabled servers
265+
switch healthLevel {
266+
case "healthy":
267+
statusEmoji = "✅"
268+
case "degraded":
269+
statusEmoji = "⚠️"
270+
case "unhealthy":
271+
statusEmoji = "❌"
279272
default:
280-
// No token or oauth_status not set
281-
if lastError != "" {
282-
status = "❌ Authentication Failed"
283-
} else if authenticated {
284-
// Fallback: has token but no oauth_status
285-
status = "⏳ Reconnecting"
286-
} else {
287-
status = "⏳ Pending Authentication"
288-
}
273+
statusEmoji = "❓"
289274
}
290275
}
291276

292277
fmt.Printf("Server: %s\n", name)
293-
fmt.Printf(" Status: %s\n", status)
278+
fmt.Printf(" Health: %s %s\n", statusEmoji, healthSummary)
279+
if adminState != "" && adminState != "enabled" {
280+
fmt.Printf(" Admin State: %s\n", adminState)
281+
}
282+
// Show action as command hint (FR-007)
283+
if healthAction != "" {
284+
switch healthAction {
285+
case "login":
286+
fmt.Printf(" Action: mcpproxy auth login --server=%s\n", name)
287+
case "restart":
288+
fmt.Printf(" Action: mcpproxy upstream restart %s\n", name)
289+
case "enable":
290+
fmt.Printf(" Action: mcpproxy upstream enable %s\n", name)
291+
case "approve":
292+
fmt.Printf(" Action: Approve via Web UI or tray menu\n")
293+
case "view_logs":
294+
fmt.Printf(" Action: mcpproxy upstream logs %s\n", name)
295+
}
296+
}
294297

295298
// Display OAuth configuration details (if available)
296299
// Check if this is an autodiscovery server (no explicit OAuth config, but has token)

cmd/mcpproxy/upstream_cmd.go

Lines changed: 22 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818

1919
"mcpproxy-go/internal/cliclient"
2020
"mcpproxy-go/internal/config"
21+
"mcpproxy-go/internal/health"
2122
"mcpproxy-go/internal/logs"
2223
"mcpproxy-go/internal/reqcontext"
2324
"mcpproxy-go/internal/socket"
@@ -177,20 +178,21 @@ func runUpstreamListFromConfig(globalConfig *config.Config) error {
177178
// Convert config servers to output format
178179
servers := make([]map[string]interface{}, len(globalConfig.Servers))
179180
for i, srv := range globalConfig.Servers {
180-
// Create health status for config-only mode
181-
healthLevel := "unknown"
182-
healthAdminState := "enabled"
183-
healthSummary := "Daemon not running"
184-
healthAction := ""
185-
186-
if !srv.Enabled {
187-
healthAdminState = "disabled"
188-
healthSummary = "Disabled"
189-
healthAction = "enable"
190-
} else if srv.Quarantined {
191-
healthAdminState = "quarantined"
192-
healthSummary = "Quarantined for review"
193-
healthAction = "approve"
181+
// I-003: Use health.CalculateHealth() instead of inline logic for DRY principle
182+
healthInput := health.HealthCalculatorInput{
183+
Name: srv.Name,
184+
Enabled: srv.Enabled,
185+
Quarantined: srv.Quarantined,
186+
State: "disconnected", // Daemon not running
187+
Connected: false,
188+
ToolCount: 0,
189+
}
190+
healthStatus := health.CalculateHealth(healthInput, health.DefaultHealthConfig())
191+
192+
// Override summary for config-only mode to indicate daemon status
193+
summary := healthStatus.Summary
194+
if healthStatus.AdminState == health.StateEnabled {
195+
summary = "Daemon not running"
194196
}
195197

196198
servers[i] = map[string]interface{}{
@@ -199,12 +201,13 @@ func runUpstreamListFromConfig(globalConfig *config.Config) error {
199201
"protocol": srv.Protocol,
200202
"connected": false,
201203
"tool_count": 0,
202-
"status": healthSummary,
204+
"status": summary,
203205
"health": map[string]interface{}{
204-
"level": healthLevel,
205-
"admin_state": healthAdminState,
206-
"summary": healthSummary,
207-
"action": healthAction,
206+
"level": healthStatus.Level,
207+
"admin_state": healthStatus.AdminState,
208+
"summary": summary,
209+
"detail": healthStatus.Detail,
210+
"action": healthStatus.Action,
208211
},
209212
}
210213
}

frontend/src/components/ServerCard.vue

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,14 @@
1111
</div>
1212

1313
<!-- Status indicator using unified health status -->
14+
<!-- M-004: Add tooltip showing health.detail if present -->
1415
<div
1516
:class="[
1617
'badge badge-sm flex-shrink-0',
17-
statusBadgeClass
18+
statusBadgeClass,
19+
statusTooltip ? 'tooltip tooltip-left' : ''
1820
]"
21+
:data-tip="statusTooltip"
1922
>
2023
{{ statusText }}
2124
</div>
@@ -236,6 +239,15 @@ const statusText = computed(() => {
236239
return 'Disconnected'
237240
})
238241
242+
// M-004: Tooltip showing health.detail if present (for additional context)
243+
const statusTooltip = computed(() => {
244+
const health = props.server.health
245+
if (health?.detail) {
246+
return health.detail
247+
}
248+
return ''
249+
})
250+
239251
// Suggested action from health status
240252
const healthAction = computed(() => {
241253
return props.server.health?.action || ''

frontend/src/views/Dashboard.vue

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -462,12 +462,17 @@ const diagnosticsBadgeClass = computed(() => {
462462
// Servers needing attention (unhealthy or degraded health level, excluding admin states)
463463
const serversNeedingAttention = computed(() => {
464464
return serversStore.servers.filter(server => {
465+
// I-004: Defensive null check for backward compatibility
466+
if (!server.health) {
467+
console.warn(`Server ${server.name} missing health field`)
468+
return false
469+
}
465470
// Skip servers with admin states (disabled, quarantined)
466-
if (server.health?.admin_state === 'disabled' || server.health?.admin_state === 'quarantined') {
471+
if (server.health.admin_state === 'disabled' || server.health.admin_state === 'quarantined') {
467472
return false
468473
}
469474
// Include servers with unhealthy or degraded health level
470-
return server.health?.level === 'unhealthy' || server.health?.level === 'degraded'
475+
return server.health.level === 'unhealthy' || server.health.level === 'degraded'
471476
})
472477
})
473478

internal/health/calculator.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,10 +154,12 @@ func CalculateHealth(input HealthCalculatorInput, cfg *HealthCalculatorConfig) *
154154
}
155155
}
156156
// No refresh token - user needs to re-authenticate soon
157+
// M-002: Include exact expiration time in Detail field
157158
return &contracts.HealthStatus{
158159
Level: LevelDegraded,
159160
AdminState: StateEnabled,
160161
Summary: formatExpiringTokenSummary(timeUntilExpiry),
162+
Detail: fmt.Sprintf("Token expires at %s", input.TokenExpiresAt.Format(time.RFC3339)),
161163
Action: ActionLogin,
162164
}
163165
}

internal/health/calculator_test.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -374,3 +374,38 @@ func TestDefaultHealthConfig(t *testing.T) {
374374
assert.NotNil(t, cfg)
375375
assert.Equal(t, time.Hour, cfg.ExpiryWarningDuration)
376376
}
377+
378+
// I-002: Test FR-004 - All health status responses must include non-empty summary
379+
func TestCalculateHealth_AlwaysIncludesSummary(t *testing.T) {
380+
expiresAt := time.Now().Add(30 * time.Minute)
381+
382+
testCases := []struct {
383+
name string
384+
input HealthCalculatorInput
385+
}{
386+
{"disabled server", HealthCalculatorInput{Name: "test", Enabled: false}},
387+
{"quarantined server", HealthCalculatorInput{Name: "test", Enabled: true, Quarantined: true}},
388+
{"error state", HealthCalculatorInput{Name: "test", Enabled: true, State: "error", LastError: "connection refused"}},
389+
{"error state no message", HealthCalculatorInput{Name: "test", Enabled: true, State: "error", LastError: ""}},
390+
{"disconnected state", HealthCalculatorInput{Name: "test", Enabled: true, State: "disconnected"}},
391+
{"connecting state", HealthCalculatorInput{Name: "test", Enabled: true, State: "connecting"}},
392+
{"idle state", HealthCalculatorInput{Name: "test", Enabled: true, State: "idle"}},
393+
{"connected healthy", HealthCalculatorInput{Name: "test", Enabled: true, State: "connected", Connected: true, ToolCount: 5}},
394+
{"connected no tools", HealthCalculatorInput{Name: "test", Enabled: true, State: "connected", Connected: true, ToolCount: 0}},
395+
{"oauth expired", HealthCalculatorInput{Name: "test", Enabled: true, State: "connected", Connected: true, OAuthRequired: true, OAuthStatus: "expired"}},
396+
{"oauth none", HealthCalculatorInput{Name: "test", Enabled: true, State: "connected", Connected: true, OAuthRequired: true, OAuthStatus: "none"}},
397+
{"oauth error", HealthCalculatorInput{Name: "test", Enabled: true, State: "connected", Connected: true, OAuthRequired: true, OAuthStatus: "error"}},
398+
{"user logged out", HealthCalculatorInput{Name: "test", Enabled: true, State: "connected", OAuthRequired: true, OAuthStatus: "authenticated", UserLoggedOut: true}},
399+
{"token expiring no refresh", HealthCalculatorInput{Name: "test", Enabled: true, State: "connected", Connected: true, OAuthRequired: true, OAuthStatus: "authenticated", TokenExpiresAt: &expiresAt, HasRefreshToken: false}},
400+
{"token expiring with refresh", HealthCalculatorInput{Name: "test", Enabled: true, State: "connected", Connected: true, OAuthRequired: true, OAuthStatus: "authenticated", TokenExpiresAt: &expiresAt, HasRefreshToken: true, ToolCount: 5}},
401+
{"unknown state", HealthCalculatorInput{Name: "test", Enabled: true, State: "unknown"}},
402+
{"empty state", HealthCalculatorInput{Name: "test", Enabled: true, State: ""}},
403+
}
404+
405+
for _, tc := range testCases {
406+
t.Run(tc.name, func(t *testing.T) {
407+
result := CalculateHealth(tc.input, nil)
408+
assert.NotEmpty(t, result.Summary, "FR-004: Summary should never be empty for %s", tc.name)
409+
})
410+
}
411+
}

internal/management/service.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,11 @@ func (s *service) ListServers(ctx context.Context) ([]*contracts.Server, *contra
289289
srv.Updated = updated
290290
}
291291

292+
// Extract unified health status
293+
if health, ok := srvRaw["health"].(*contracts.HealthStatus); ok {
294+
srv.Health = health
295+
}
296+
292297
servers = append(servers, srv)
293298

294299
// Update stats

internal/runtime/runtime.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1679,7 +1679,16 @@ func (r *Runtime) GetAllServers() ([]map[string]interface{}, error) {
16791679
healthInput.TokenExpiresAt = &tokenExpiresAt
16801680
}
16811681

1682-
serverMap["health"] = health.CalculateHealth(healthInput, healthConfig)
1682+
healthStatus := health.CalculateHealth(healthInput, healthConfig)
1683+
serverMap["health"] = healthStatus
1684+
1685+
// M-005: Log health status for debugging
1686+
r.logger.Debug("Server health calculated",
1687+
zap.String("server", serverStatus.Name),
1688+
zap.String("level", healthStatus.Level),
1689+
zap.String("admin_state", healthStatus.AdminState),
1690+
zap.String("summary", healthStatus.Summary),
1691+
)
16831692

16841693
result = append(result, serverMap)
16851694
}

internal/server/e2e_mcp_test.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,25 @@ func TestMCPProtocolWithBinary(t *testing.T) {
141141
assert.Equal(t, "memory", serverMap["name"])
142142
assert.Equal(t, "stdio", serverMap["protocol"])
143143
assert.Equal(t, true, serverMap["enabled"])
144+
145+
// I-001: Verify health field is present with expected structure (FR-017, FR-018)
146+
healthMap, ok := serverMap["health"].(map[string]interface{})
147+
require.True(t, ok, "Server should have health field")
148+
assert.NotEmpty(t, healthMap["level"], "Health level should be present")
149+
assert.NotEmpty(t, healthMap["admin_state"], "Admin state should be present")
150+
assert.NotEmpty(t, healthMap["summary"], "Summary should be present")
151+
152+
// Verify health level is one of the valid values
153+
level, ok := healthMap["level"].(string)
154+
require.True(t, ok, "Health level should be a string")
155+
validLevels := []string{"healthy", "degraded", "unhealthy"}
156+
assert.Contains(t, validLevels, level, "Health level should be valid")
157+
158+
// Verify admin state is one of the valid values
159+
adminState, ok := healthMap["admin_state"].(string)
160+
require.True(t, ok, "Admin state should be a string")
161+
validStates := []string{"enabled", "disabled", "quarantined"}
162+
assert.Contains(t, validStates, adminState, "Admin state should be valid")
144163
})
145164
}
146165

0 commit comments

Comments
 (0)