Skip to content

Commit 6e5200a

Browse files
feat(health): add unified health status calculation for upstream servers
Implement unified health status that provides consistent server health information across all interfaces (CLI, REST API, MCP tools). Core changes: - Add internal/health package with CalculateHealth() function - Add HealthStatus struct with level, admin_state, summary, detail, action - Integrate health calculation into runtime.GetAllServers() - Add health field to MCP handleListUpstreams() response - Update CLI upstream list to show health status and action hints - Add HealthStatus schema to OpenAPI spec - Add oauth_expiry_warning_hours config option Health levels: healthy, degraded, unhealthy Admin states: enabled, disabled, quarantined Actions: login, restart, enable, approve, view_logs The health calculator uses a priority-based algorithm: 1. Admin state (disabled/quarantined) short-circuits 2. Connection state (error/disconnected/connecting) 3. OAuth state (expired/error/expiring soon) 4. Healthy connected state Includes 20+ unit tests covering all health scenarios including FR-016 verification (token with refresh returns healthy). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 320f812 commit 6e5200a

10 files changed

Lines changed: 931 additions & 109 deletions

File tree

cmd/mcpproxy/upstream_cmd.go

Lines changed: 70 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -177,13 +177,35 @@ func runUpstreamListFromConfig(globalConfig *config.Config) error {
177177
// Convert config servers to output format
178178
servers := make([]map[string]interface{}, len(globalConfig.Servers))
179179
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"
194+
}
195+
180196
servers[i] = map[string]interface{}{
181197
"name": srv.Name,
182198
"enabled": srv.Enabled,
183199
"protocol": srv.Protocol,
184200
"connected": false,
185201
"tool_count": 0,
186-
"status": "unknown (daemon not running)",
202+
"status": healthSummary,
203+
"health": map[string]interface{}{
204+
"level": healthLevel,
205+
"admin_state": healthAdminState,
206+
"summary": healthSummary,
207+
"action": healthAction,
208+
},
187209
}
188210
}
189211

@@ -206,73 +228,65 @@ func outputServers(servers []map[string]interface{}) error {
206228
}
207229
fmt.Println(string(output))
208230
case "table", "":
209-
// Table format (default) with OAuth token validity column
210-
fmt.Printf("%-25s %-10s %-10s %-12s %-10s %-20s %s\n",
211-
"NAME", "ENABLED", "PROTOCOL", "CONNECTED", "TOOLS", "OAUTH TOKEN", "STATUS")
212-
fmt.Printf("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n")
231+
// Table format (default) with unified health status
232+
fmt.Printf("%-4s %-25s %-10s %-10s %-30s %s\n",
233+
"", "NAME", "PROTOCOL", "TOOLS", "STATUS", "ACTION")
234+
fmt.Printf("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n")
213235

214236
for _, srv := range servers {
215237
name := getStringField(srv, "name")
216-
enabled := getBoolField(srv, "enabled")
217238
protocol := getStringField(srv, "protocol")
218-
connected := getBoolField(srv, "connected")
219239
toolCount := getIntField(srv, "tool_count")
220-
status := getStringField(srv, "status")
221240

222-
enabledStr := "no"
223-
if enabled {
224-
enabledStr = "yes"
241+
// Extract unified health status
242+
healthData, _ := srv["health"].(map[string]interface{})
243+
healthLevel := "unknown"
244+
healthAdminState := "enabled"
245+
healthSummary := getStringField(srv, "status") // fallback to old status
246+
healthAction := ""
247+
248+
if healthData != nil {
249+
healthLevel = getStringField(healthData, "level")
250+
healthAdminState = getStringField(healthData, "admin_state")
251+
healthSummary = getStringField(healthData, "summary")
252+
healthAction = getStringField(healthData, "action")
225253
}
226254

227-
connectedStr := "no"
228-
if connected {
229-
connectedStr = "yes"
255+
// Status emoji based on health level and admin state
256+
statusEmoji := "⚪" // unknown
257+
switch healthAdminState {
258+
case "disabled":
259+
statusEmoji = "⏸️ " // paused
260+
case "quarantined":
261+
statusEmoji = "🔒" // locked
262+
default:
263+
switch healthLevel {
264+
case "healthy":
265+
statusEmoji = "✅"
266+
case "degraded":
267+
statusEmoji = "⚠️ "
268+
case "unhealthy":
269+
statusEmoji = "❌"
270+
}
230271
}
231272

232-
// Extract OAuth token validity info
233-
oauthStatus := "-"
234-
oauth, _ := srv["oauth"].(map[string]interface{})
235-
authenticated, _ := srv["authenticated"].(bool)
236-
lastError, _ := srv["last_error"].(string)
237-
238-
// Check if this is an OAuth-related server
239-
isOAuthServer := (oauth != nil) ||
240-
containsIgnoreCase(lastError, "oauth") ||
241-
authenticated
242-
243-
if isOAuthServer {
244-
if oauth != nil {
245-
if tokenExpiresAt, ok := oauth["token_expires_at"].(string); ok && tokenExpiresAt != "" {
246-
if expiryTime, err := time.Parse(time.RFC3339, tokenExpiresAt); err == nil {
247-
timeUntilExpiry := time.Until(expiryTime)
248-
if timeUntilExpiry > 0 {
249-
oauthStatus = formatDurationShort(timeUntilExpiry)
250-
} else {
251-
oauthStatus = "⚠️ EXPIRED"
252-
}
253-
}
254-
} else if tokenValid, ok := oauth["token_valid"].(bool); ok {
255-
if tokenValid {
256-
oauthStatus = "✅ Valid"
257-
} else {
258-
oauthStatus = "⚠️ Invalid"
259-
}
260-
} else if authenticated {
261-
oauthStatus = "✅ Active"
262-
} else {
263-
oauthStatus = "⏳ Pending"
264-
}
265-
} else if authenticated {
266-
// OAuth server without config (DCR) but authenticated
267-
oauthStatus = "✅ Active"
268-
} else {
269-
// OAuth required but not authenticated yet
270-
oauthStatus = "⏳ Pending"
271-
}
273+
// Format action as CLI command hint
274+
actionHint := "-"
275+
switch healthAction {
276+
case "login":
277+
actionHint = fmt.Sprintf("auth login --server=%s", name)
278+
case "restart":
279+
actionHint = fmt.Sprintf("upstream restart %s", name)
280+
case "enable":
281+
actionHint = fmt.Sprintf("upstream enable %s", name)
282+
case "approve":
283+
actionHint = "Approve in Web UI"
284+
case "view_logs":
285+
actionHint = fmt.Sprintf("upstream logs %s", name)
272286
}
273287

274-
fmt.Printf("%-25s %-10s %-10s %-12s %-10d %-20s %s\n",
275-
name, enabledStr, protocol, connectedStr, toolCount, oauthStatus, status)
288+
fmt.Printf("%-4s %-25s %-10s %-10d %-30s %s\n",
289+
statusEmoji, name, protocol, toolCount, healthSummary, actionHint)
276290
}
277291
default:
278292
return fmt.Errorf("unknown output format: %s", upstreamOutputFormat)

internal/config/config.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,9 @@ type Config struct {
102102
CodeExecutionTimeoutMs int `json:"code_execution_timeout_ms,omitempty" mapstructure:"code-execution-timeout-ms"` // Timeout in milliseconds (default: 120000, max: 600000)
103103
CodeExecutionMaxToolCalls int `json:"code_execution_max_tool_calls,omitempty" mapstructure:"code-execution-max-tool-calls"` // Max tool calls per execution (0 = unlimited, default: 0)
104104
CodeExecutionPoolSize int `json:"code_execution_pool_size,omitempty" mapstructure:"code-execution-pool-size"` // JavaScript runtime pool size (default: 10)
105+
106+
// Health status settings
107+
OAuthExpiryWarningHours float64 `json:"oauth_expiry_warning_hours,omitempty" mapstructure:"oauth-expiry-warning-hours"` // Hours before token expiry to show degraded status (default: 1.0)
105108
}
106109

107110
// TLSConfig represents TLS configuration

internal/contracts/types.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ type Server struct {
4545
RetryCount int `json:"retry_count,omitempty"`
4646
LastRetryTime *time.Time `json:"last_retry_time,omitempty"`
4747
UserLoggedOut bool `json:"user_logged_out,omitempty"` // True if user explicitly logged out (prevents auto-reconnection)
48+
Health *HealthStatus `json:"health,omitempty"` // Unified health status calculated by the backend
4849
}
4950

5051
// OAuthConfig represents OAuth configuration for a server
@@ -561,3 +562,22 @@ type ErrorResponse struct {
561562
Success bool `json:"success"`
562563
Error string `json:"error"`
563564
}
565+
566+
// HealthStatus represents the unified health status of an upstream MCP server.
567+
// Calculated once in the backend and rendered identically by all interfaces.
568+
type HealthStatus struct {
569+
// Level indicates the health level: "healthy", "degraded", or "unhealthy"
570+
Level string `json:"level"`
571+
572+
// AdminState indicates the admin state: "enabled", "disabled", or "quarantined"
573+
AdminState string `json:"admin_state"`
574+
575+
// Summary is a human-readable status message (e.g., "Connected (5 tools)")
576+
Summary string `json:"summary"`
577+
578+
// Detail is an optional longer explanation of the status
579+
Detail string `json:"detail,omitempty"`
580+
581+
// Action is the suggested fix action: "login", "restart", "enable", "approve", "view_logs", or "" (none)
582+
Action string `json:"action,omitempty"`
583+
}

0 commit comments

Comments
 (0)