+ ... and {{ serversNeedingAttention.length - 3 }} more
+
+
+
+
+ View All Servers
+
+
+
@@ -412,6 +459,23 @@ const diagnosticsBadgeClass = computed(() => {
return 'badge-info'
})
+// Servers needing attention (unhealthy or degraded health level, excluding admin states)
+const serversNeedingAttention = computed(() => {
+ return serversStore.servers.filter(server => {
+ // I-004: Defensive null check for backward compatibility
+ if (!server.health) {
+ console.warn(`Server ${server.name} missing health field`)
+ return false
+ }
+ // Skip servers with admin states (disabled, quarantined)
+ if (server.health.admin_state === 'disabled' || server.health.admin_state === 'quarantined') {
+ return false
+ }
+ // Include servers with unhealthy or degraded health level
+ return server.health.level === 'unhealthy' || server.health.level === 'degraded'
+ })
+})
+
const lastUpdateTime = computed(() => {
if (!systemStore.status?.timestamp) return 'Never'
@@ -479,6 +543,51 @@ const triggerOAuthLogin = async (server: string) => {
}
}
+// Trigger server action based on health.action
+const triggerServerAction = async (serverName: string, action: string) => {
+ try {
+ switch (action) {
+ case 'oauth_login':
+ await serversStore.triggerOAuthLogin(serverName)
+ systemStore.addToast({
+ type: 'success',
+ title: 'OAuth Login',
+ message: `OAuth login initiated for ${serverName}`
+ })
+ break
+ case 'restart':
+ await serversStore.restartServer(serverName)
+ systemStore.addToast({
+ type: 'success',
+ title: 'Server Restarted',
+ message: `${serverName} is restarting`
+ })
+ break
+ case 'enable':
+ await serversStore.enableServer(serverName)
+ systemStore.addToast({
+ type: 'success',
+ title: 'Server Enabled',
+ message: `${serverName} has been enabled`
+ })
+ break
+ default:
+ console.warn(`Unknown action: ${action}`)
+ }
+ // Refresh after action
+ setTimeout(() => {
+ loadDiagnostics()
+ serversStore.fetchServers()
+ }, 1000)
+ } catch (error) {
+ systemStore.addToast({
+ type: 'error',
+ title: 'Action Failed',
+ message: error instanceof Error ? error.message : 'Unknown error'
+ })
+ }
+}
+
// Token Savings Data
const tokenSavingsData = ref(null)
const tokenSavingsLoading = ref(false)
diff --git a/internal/config/config.go b/internal/config/config.go
index 532711a1..2d8bf0af 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -102,6 +102,9 @@ type Config struct {
CodeExecutionTimeoutMs int `json:"code_execution_timeout_ms,omitempty" mapstructure:"code-execution-timeout-ms"` // Timeout in milliseconds (default: 120000, max: 600000)
CodeExecutionMaxToolCalls int `json:"code_execution_max_tool_calls,omitempty" mapstructure:"code-execution-max-tool-calls"` // Max tool calls per execution (0 = unlimited, default: 0)
CodeExecutionPoolSize int `json:"code_execution_pool_size,omitempty" mapstructure:"code-execution-pool-size"` // JavaScript runtime pool size (default: 10)
+
+ // Health status settings
+ OAuthExpiryWarningHours float64 `json:"oauth_expiry_warning_hours,omitempty" mapstructure:"oauth-expiry-warning-hours"` // Hours before token expiry to show degraded status (default: 1.0)
}
// TLSConfig represents TLS configuration
diff --git a/internal/contracts/types.go b/internal/contracts/types.go
index ea8e6dbf..ec3f13d5 100644
--- a/internal/contracts/types.go
+++ b/internal/contracts/types.go
@@ -45,6 +45,7 @@ type Server struct {
RetryCount int `json:"retry_count,omitempty"`
LastRetryTime *time.Time `json:"last_retry_time,omitempty"`
UserLoggedOut bool `json:"user_logged_out,omitempty"` // True if user explicitly logged out (prevents auto-reconnection)
+ Health *HealthStatus `json:"health,omitempty"` // Unified health status calculated by the backend
}
// OAuthConfig represents OAuth configuration for a server
@@ -562,6 +563,25 @@ type ErrorResponse struct {
Error string `json:"error"`
}
+// HealthStatus represents the unified health status of an upstream MCP server.
+// Calculated once in the backend and rendered identically by all interfaces.
+type HealthStatus struct {
+ // Level indicates the health level: "healthy", "degraded", or "unhealthy"
+ Level string `json:"level"`
+
+ // AdminState indicates the admin state: "enabled", "disabled", or "quarantined"
+ AdminState string `json:"admin_state"`
+
+ // Summary is a human-readable status message (e.g., "Connected (5 tools)")
+ Summary string `json:"summary"`
+
+ // Detail is an optional longer explanation of the status
+ Detail string `json:"detail,omitempty"`
+
+ // Action is the suggested fix action: "login", "restart", "enable", "approve", "view_logs", or "" (none)
+ Action string `json:"action,omitempty"`
+}
+
// UpdateInfo represents version update check information
type UpdateInfo struct {
Available bool `json:"available"` // Whether an update is available
diff --git a/internal/health/calculator.go b/internal/health/calculator.go
new file mode 100644
index 00000000..325e85b8
--- /dev/null
+++ b/internal/health/calculator.go
@@ -0,0 +1,291 @@
+// Package health provides unified health status calculation for upstream MCP servers.
+package health
+
+import (
+ "fmt"
+ "time"
+
+ "mcpproxy-go/internal/contracts"
+)
+
+// HealthCalculatorInput contains all fields needed to calculate health status.
+// This struct normalizes data from different sources (StateView, storage, config).
+type HealthCalculatorInput struct {
+ // Server identification
+ Name string
+
+ // Admin state
+ Enabled bool
+ Quarantined bool
+
+ // Connection state
+ State string // "connected", "connecting", "error", "idle", "disconnected"
+ Connected bool
+ LastError string
+
+ // OAuth state (only for OAuth-enabled servers)
+ OAuthRequired bool
+ OAuthStatus string // "authenticated", "expired", "error", "none"
+ TokenExpiresAt *time.Time // When token expires
+ HasRefreshToken bool // True if refresh token exists
+ UserLoggedOut bool // True if user explicitly logged out
+
+ // Tool info
+ ToolCount int
+}
+
+// HealthCalculatorConfig contains configurable thresholds for health calculation.
+type HealthCalculatorConfig struct {
+ // ExpiryWarningDuration is the duration before token expiry to show degraded status.
+ // Default: 1 hour
+ ExpiryWarningDuration time.Duration
+}
+
+// DefaultHealthConfig returns the default health calculator configuration.
+func DefaultHealthConfig() *HealthCalculatorConfig {
+ return &HealthCalculatorConfig{
+ ExpiryWarningDuration: time.Hour,
+ }
+}
+
+// CalculateHealth calculates the unified health status for a server.
+// The algorithm uses a priority-based approach where admin state is checked first,
+// followed by connection state, then OAuth state.
+func CalculateHealth(input HealthCalculatorInput, cfg *HealthCalculatorConfig) *contracts.HealthStatus {
+ if cfg == nil {
+ cfg = DefaultHealthConfig()
+ }
+
+ // 1. Admin state checks - these short-circuit health calculation
+ if !input.Enabled {
+ return &contracts.HealthStatus{
+ Level: LevelHealthy, // Disabled is intentional, not broken
+ AdminState: StateDisabled,
+ Summary: "Disabled",
+ Action: ActionEnable,
+ }
+ }
+
+ if input.Quarantined {
+ return &contracts.HealthStatus{
+ Level: LevelHealthy, // Quarantined is intentional, not broken
+ AdminState: StateQuarantined,
+ Summary: "Quarantined for review",
+ Action: ActionApprove,
+ }
+ }
+
+ // 2. Connection state checks
+ switch input.State {
+ case "error":
+ return &contracts.HealthStatus{
+ Level: LevelUnhealthy,
+ AdminState: StateEnabled,
+ Summary: formatErrorSummary(input.LastError),
+ Detail: input.LastError,
+ Action: ActionRestart,
+ }
+ case "disconnected":
+ summary := "Disconnected"
+ if input.LastError != "" {
+ summary = formatErrorSummary(input.LastError)
+ }
+ return &contracts.HealthStatus{
+ Level: LevelUnhealthy,
+ AdminState: StateEnabled,
+ Summary: summary,
+ Detail: input.LastError,
+ Action: ActionRestart,
+ }
+ case "connecting", "idle":
+ return &contracts.HealthStatus{
+ Level: LevelDegraded,
+ AdminState: StateEnabled,
+ Summary: "Connecting...",
+ Action: ActionNone, // Will resolve on its own
+ }
+ }
+
+ // 3. OAuth state checks (only for servers that require OAuth)
+ if input.OAuthRequired {
+ // User explicitly logged out - needs re-authentication
+ if input.UserLoggedOut {
+ return &contracts.HealthStatus{
+ Level: LevelUnhealthy,
+ AdminState: StateEnabled,
+ Summary: "Logged out",
+ Action: ActionLogin,
+ }
+ }
+
+ // Token expired
+ if input.OAuthStatus == "expired" {
+ return &contracts.HealthStatus{
+ Level: LevelUnhealthy,
+ AdminState: StateEnabled,
+ Summary: "Token expired",
+ Action: ActionLogin,
+ }
+ }
+
+ // OAuth error (but not expired)
+ if input.OAuthStatus == "error" {
+ return &contracts.HealthStatus{
+ Level: LevelUnhealthy,
+ AdminState: StateEnabled,
+ Summary: "Authentication error",
+ Detail: input.LastError,
+ Action: ActionLogin,
+ }
+ }
+
+ // Token expiring soon (only degraded if no refresh token for auto-refresh)
+ if input.TokenExpiresAt != nil && !input.TokenExpiresAt.IsZero() {
+ timeUntilExpiry := time.Until(*input.TokenExpiresAt)
+ if timeUntilExpiry > 0 && timeUntilExpiry <= cfg.ExpiryWarningDuration {
+ // If we have a refresh token, the system can auto-refresh - stay healthy
+ if input.HasRefreshToken {
+ // Token will be auto-refreshed, show healthy with tool count
+ return &contracts.HealthStatus{
+ Level: LevelHealthy,
+ AdminState: StateEnabled,
+ Summary: formatConnectedSummary(input.ToolCount),
+ Action: ActionNone,
+ }
+ }
+ // No refresh token - user needs to re-authenticate soon
+ // M-002: Include exact expiration time in Detail field
+ return &contracts.HealthStatus{
+ Level: LevelDegraded,
+ AdminState: StateEnabled,
+ Summary: formatExpiringTokenSummary(timeUntilExpiry),
+ Detail: fmt.Sprintf("Token expires at %s", input.TokenExpiresAt.Format(time.RFC3339)),
+ Action: ActionLogin,
+ }
+ }
+ }
+
+ // Token is not authenticated yet (none status)
+ if input.OAuthStatus == "none" || input.OAuthStatus == "" {
+ // Server requires OAuth but no token - needs login
+ return &contracts.HealthStatus{
+ Level: LevelUnhealthy,
+ AdminState: StateEnabled,
+ Summary: "Authentication required",
+ Action: ActionLogin,
+ }
+ }
+ }
+
+ // 4. Healthy state - connected with valid authentication (if required)
+ return &contracts.HealthStatus{
+ Level: LevelHealthy,
+ AdminState: StateEnabled,
+ Summary: formatConnectedSummary(input.ToolCount),
+ Action: ActionNone,
+ }
+}
+
+// formatConnectedSummary formats the summary for a healthy connected server.
+func formatConnectedSummary(toolCount int) string {
+ if toolCount == 0 {
+ return "Connected"
+ }
+ if toolCount == 1 {
+ return "Connected (1 tool)"
+ }
+ return fmt.Sprintf("Connected (%d tools)", toolCount)
+}
+
+// formatErrorSummary formats an error message for the summary field.
+// It truncates long errors and makes them more user-friendly.
+func formatErrorSummary(lastError string) string {
+ if lastError == "" {
+ return "Connection error"
+ }
+
+ // Common error patterns to friendly messages.
+ // Order matters: more specific patterns must come before generic ones.
+ // For example, "no such host" must be checked before "dial tcp" since
+ // DNS errors often appear as "dial tcp: no such host".
+ errorMappings := []struct {
+ pattern string
+ friendly string
+ }{
+ // Specific patterns first
+ {"no such host", "Host not found"},
+ {"connection refused", "Connection refused"},
+ {"connection reset", "Connection reset"},
+ {"timeout", "Connection timeout"},
+ {"EOF", "Connection closed"},
+ {"authentication failed", "Authentication failed"},
+ {"unauthorized", "Unauthorized"},
+ {"forbidden", "Access forbidden"},
+ {"oauth", "OAuth error"},
+ {"certificate", "Certificate error"},
+ // Generic patterns last
+ {"dial tcp", "Cannot connect"},
+ }
+
+ // Check for known patterns (in order)
+ for _, mapping := range errorMappings {
+ if containsIgnoreCase(lastError, mapping.pattern) {
+ return mapping.friendly
+ }
+ }
+
+ // Truncate if too long (max 50 chars for summary)
+ if len(lastError) > 50 {
+ return lastError[:47] + "..."
+ }
+ return lastError
+}
+
+// formatExpiringTokenSummary formats the summary for an expiring token.
+func formatExpiringTokenSummary(timeUntilExpiry time.Duration) string {
+ if timeUntilExpiry < time.Minute {
+ return "Token expiring now"
+ }
+ if timeUntilExpiry < time.Hour {
+ minutes := int(timeUntilExpiry.Minutes())
+ if minutes == 1 {
+ return "Token expiring in 1m"
+ }
+ return fmt.Sprintf("Token expiring in %dm", minutes)
+ }
+ hours := int(timeUntilExpiry.Hours())
+ if hours == 1 {
+ return "Token expiring in 1h"
+ }
+ return fmt.Sprintf("Token expiring in %dh", hours)
+}
+
+// containsIgnoreCase checks if s contains substr, ignoring case.
+func containsIgnoreCase(s, substr string) bool {
+ return len(s) >= len(substr) &&
+ (s == substr ||
+ containsLower(toLower(s), toLower(substr)))
+}
+
+// toLower is a simple ASCII lowercase conversion.
+func toLower(s string) string {
+ b := make([]byte, len(s))
+ for i := 0; i < len(s); i++ {
+ c := s[i]
+ if c >= 'A' && c <= 'Z' {
+ c += 'a' - 'A'
+ }
+ b[i] = c
+ }
+ return string(b)
+}
+
+// containsLower checks if s contains substr (both should be lowercase).
+func containsLower(s, substr string) bool {
+ for i := 0; i <= len(s)-len(substr); i++ {
+ if s[i:i+len(substr)] == substr {
+ return true
+ }
+ }
+ return false
+}
diff --git a/internal/health/calculator_test.go b/internal/health/calculator_test.go
new file mode 100644
index 00000000..f468b7ec
--- /dev/null
+++ b/internal/health/calculator_test.go
@@ -0,0 +1,411 @@
+package health
+
+import (
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestCalculateHealth_DisabledServer(t *testing.T) {
+ input := HealthCalculatorInput{
+ Name: "test-server",
+ Enabled: false,
+ }
+
+ result := CalculateHealth(input, nil)
+
+ assert.Equal(t, LevelHealthy, result.Level)
+ assert.Equal(t, StateDisabled, result.AdminState)
+ assert.Equal(t, "Disabled", result.Summary)
+ assert.Equal(t, ActionEnable, result.Action)
+}
+
+func TestCalculateHealth_QuarantinedServer(t *testing.T) {
+ input := HealthCalculatorInput{
+ Name: "test-server",
+ Enabled: true,
+ Quarantined: true,
+ }
+
+ result := CalculateHealth(input, nil)
+
+ assert.Equal(t, LevelHealthy, result.Level)
+ assert.Equal(t, StateQuarantined, result.AdminState)
+ assert.Equal(t, "Quarantined for review", result.Summary)
+ assert.Equal(t, ActionApprove, result.Action)
+}
+
+func TestCalculateHealth_ErrorState(t *testing.T) {
+ input := HealthCalculatorInput{
+ Name: "test-server",
+ Enabled: true,
+ State: "error",
+ LastError: "connection refused",
+ }
+
+ result := CalculateHealth(input, nil)
+
+ assert.Equal(t, LevelUnhealthy, result.Level)
+ assert.Equal(t, StateEnabled, result.AdminState)
+ assert.Equal(t, "Connection refused", result.Summary)
+ assert.Equal(t, ActionRestart, result.Action)
+}
+
+func TestCalculateHealth_DisconnectedState(t *testing.T) {
+ input := HealthCalculatorInput{
+ Name: "test-server",
+ Enabled: true,
+ State: "disconnected",
+ LastError: "no such host",
+ }
+
+ result := CalculateHealth(input, nil)
+
+ assert.Equal(t, LevelUnhealthy, result.Level)
+ assert.Equal(t, StateEnabled, result.AdminState)
+ assert.Equal(t, "Host not found", result.Summary)
+ assert.Equal(t, ActionRestart, result.Action)
+}
+
+func TestCalculateHealth_ConnectingState(t *testing.T) {
+ input := HealthCalculatorInput{
+ Name: "test-server",
+ Enabled: true,
+ State: "connecting",
+ }
+
+ result := CalculateHealth(input, nil)
+
+ assert.Equal(t, LevelDegraded, result.Level)
+ assert.Equal(t, StateEnabled, result.AdminState)
+ assert.Equal(t, "Connecting...", result.Summary)
+ assert.Equal(t, ActionNone, result.Action)
+}
+
+func TestCalculateHealth_IdleState(t *testing.T) {
+ input := HealthCalculatorInput{
+ Name: "test-server",
+ Enabled: true,
+ State: "idle",
+ }
+
+ result := CalculateHealth(input, nil)
+
+ assert.Equal(t, LevelDegraded, result.Level)
+ assert.Equal(t, StateEnabled, result.AdminState)
+ assert.Equal(t, "Connecting...", result.Summary)
+ assert.Equal(t, ActionNone, result.Action)
+}
+
+func TestCalculateHealth_HealthyConnected(t *testing.T) {
+ input := HealthCalculatorInput{
+ Name: "test-server",
+ Enabled: true,
+ State: "connected",
+ Connected: true,
+ ToolCount: 5,
+ }
+
+ result := CalculateHealth(input, nil)
+
+ assert.Equal(t, LevelHealthy, result.Level)
+ assert.Equal(t, StateEnabled, result.AdminState)
+ assert.Equal(t, "Connected (5 tools)", result.Summary)
+ assert.Equal(t, ActionNone, result.Action)
+}
+
+func TestCalculateHealth_HealthyConnectedSingleTool(t *testing.T) {
+ input := HealthCalculatorInput{
+ Name: "test-server",
+ Enabled: true,
+ State: "connected",
+ Connected: true,
+ ToolCount: 1,
+ }
+
+ result := CalculateHealth(input, nil)
+
+ assert.Equal(t, "Connected (1 tool)", result.Summary)
+}
+
+func TestCalculateHealth_HealthyConnectedNoTools(t *testing.T) {
+ input := HealthCalculatorInput{
+ Name: "test-server",
+ Enabled: true,
+ State: "connected",
+ Connected: true,
+ ToolCount: 0,
+ }
+
+ result := CalculateHealth(input, nil)
+
+ assert.Equal(t, "Connected", result.Summary)
+}
+
+func TestCalculateHealth_OAuthExpired(t *testing.T) {
+ input := HealthCalculatorInput{
+ Name: "test-server",
+ Enabled: true,
+ State: "connected",
+ Connected: true,
+ OAuthRequired: true,
+ OAuthStatus: "expired",
+ }
+
+ result := CalculateHealth(input, nil)
+
+ assert.Equal(t, LevelUnhealthy, result.Level)
+ assert.Equal(t, StateEnabled, result.AdminState)
+ assert.Equal(t, "Token expired", result.Summary)
+ assert.Equal(t, ActionLogin, result.Action)
+}
+
+func TestCalculateHealth_OAuthError(t *testing.T) {
+ input := HealthCalculatorInput{
+ Name: "test-server",
+ Enabled: true,
+ State: "connected",
+ OAuthRequired: true,
+ OAuthStatus: "error",
+ LastError: "invalid_grant",
+ }
+
+ result := CalculateHealth(input, nil)
+
+ assert.Equal(t, LevelUnhealthy, result.Level)
+ assert.Equal(t, "Authentication error", result.Summary)
+ assert.Equal(t, ActionLogin, result.Action)
+}
+
+func TestCalculateHealth_OAuthNone(t *testing.T) {
+ input := HealthCalculatorInput{
+ Name: "test-server",
+ Enabled: true,
+ State: "connected",
+ OAuthRequired: true,
+ OAuthStatus: "none",
+ }
+
+ result := CalculateHealth(input, nil)
+
+ assert.Equal(t, LevelUnhealthy, result.Level)
+ assert.Equal(t, "Authentication required", result.Summary)
+ assert.Equal(t, ActionLogin, result.Action)
+}
+
+func TestCalculateHealth_UserLoggedOut(t *testing.T) {
+ input := HealthCalculatorInput{
+ Name: "test-server",
+ Enabled: true,
+ State: "connected",
+ OAuthRequired: true,
+ OAuthStatus: "authenticated",
+ UserLoggedOut: true,
+ }
+
+ result := CalculateHealth(input, nil)
+
+ assert.Equal(t, LevelUnhealthy, result.Level)
+ assert.Equal(t, "Logged out", result.Summary)
+ assert.Equal(t, ActionLogin, result.Action)
+}
+
+func TestCalculateHealth_TokenExpiringSoonNoRefresh(t *testing.T) {
+ expiresAt := time.Now().Add(30 * time.Minute)
+ input := HealthCalculatorInput{
+ Name: "test-server",
+ Enabled: true,
+ State: "connected",
+ Connected: true,
+ OAuthRequired: true,
+ OAuthStatus: "authenticated",
+ TokenExpiresAt: &expiresAt,
+ HasRefreshToken: false,
+ ToolCount: 5,
+ }
+
+ result := CalculateHealth(input, nil)
+
+ assert.Equal(t, LevelDegraded, result.Level)
+ assert.Equal(t, StateEnabled, result.AdminState)
+ assert.Contains(t, result.Summary, "Token expiring")
+ assert.Equal(t, ActionLogin, result.Action)
+}
+
+// T039a: Test that token with working auto-refresh returns healthy (FR-016)
+func TestCalculateHealth_TokenExpiringSoonWithRefresh(t *testing.T) {
+ expiresAt := time.Now().Add(30 * time.Minute)
+ input := HealthCalculatorInput{
+ Name: "test-server",
+ Enabled: true,
+ State: "connected",
+ Connected: true,
+ OAuthRequired: true,
+ OAuthStatus: "authenticated",
+ TokenExpiresAt: &expiresAt,
+ HasRefreshToken: true, // Has refresh token - will auto-refresh
+ ToolCount: 5,
+ }
+
+ result := CalculateHealth(input, nil)
+
+ // FR-016: Token with working auto-refresh should return healthy
+ assert.Equal(t, LevelHealthy, result.Level, "Server with refresh token should be healthy")
+ assert.Equal(t, StateEnabled, result.AdminState)
+ assert.Equal(t, "Connected (5 tools)", result.Summary)
+ assert.Equal(t, ActionNone, result.Action, "No action needed when auto-refresh is available")
+}
+
+func TestCalculateHealth_TokenNotExpiringSoon(t *testing.T) {
+ expiresAt := time.Now().Add(2 * time.Hour) // More than 1 hour
+ input := HealthCalculatorInput{
+ Name: "test-server",
+ Enabled: true,
+ State: "connected",
+ Connected: true,
+ OAuthRequired: true,
+ OAuthStatus: "authenticated",
+ TokenExpiresAt: &expiresAt,
+ HasRefreshToken: false,
+ ToolCount: 5,
+ }
+
+ result := CalculateHealth(input, nil)
+
+ assert.Equal(t, LevelHealthy, result.Level)
+ assert.Equal(t, "Connected (5 tools)", result.Summary)
+ assert.Equal(t, ActionNone, result.Action)
+}
+
+func TestCalculateHealth_CustomExpiryWarningDuration(t *testing.T) {
+ expiresAt := time.Now().Add(45 * time.Minute)
+ cfg := &HealthCalculatorConfig{
+ ExpiryWarningDuration: 30 * time.Minute, // Shorter than default 1 hour
+ }
+ input := HealthCalculatorInput{
+ Name: "test-server",
+ Enabled: true,
+ State: "connected",
+ Connected: true,
+ OAuthRequired: true,
+ OAuthStatus: "authenticated",
+ TokenExpiresAt: &expiresAt,
+ HasRefreshToken: false,
+ ToolCount: 5,
+ }
+
+ result := CalculateHealth(input, cfg)
+
+ // 45 minutes is beyond the 30-minute warning threshold
+ assert.Equal(t, LevelHealthy, result.Level)
+}
+
+func TestCalculateHealth_ErrorSummaryTruncation(t *testing.T) {
+ longError := "This is a very long error message that exceeds the maximum length allowed for the summary field and should be truncated"
+ input := HealthCalculatorInput{
+ Name: "test-server",
+ Enabled: true,
+ State: "error",
+ LastError: longError,
+ }
+
+ result := CalculateHealth(input, nil)
+
+ assert.LessOrEqual(t, len(result.Summary), 50)
+ assert.True(t, len(result.Detail) > len(result.Summary))
+}
+
+func TestFormatExpiringTokenSummary(t *testing.T) {
+ tests := []struct {
+ duration time.Duration
+ expected string
+ }{
+ {30 * time.Second, "Token expiring now"},
+ {5 * time.Minute, "Token expiring in 5m"},
+ {1 * time.Minute, "Token expiring in 1m"},
+ {45 * time.Minute, "Token expiring in 45m"},
+ {1 * time.Hour, "Token expiring in 1h"},
+ {2 * time.Hour, "Token expiring in 2h"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.expected, func(t *testing.T) {
+ result := formatExpiringTokenSummary(tt.duration)
+ assert.Equal(t, tt.expected, result)
+ })
+ }
+}
+
+func TestFormatConnectedSummary(t *testing.T) {
+ assert.Equal(t, "Connected", formatConnectedSummary(0))
+ assert.Equal(t, "Connected (1 tool)", formatConnectedSummary(1))
+ assert.Equal(t, "Connected (5 tools)", formatConnectedSummary(5))
+ assert.Equal(t, "Connected (100 tools)", formatConnectedSummary(100))
+}
+
+func TestFormatErrorSummary(t *testing.T) {
+ tests := []struct {
+ error string
+ expected string
+ }{
+ {"", "Connection error"},
+ {"connection refused", "Connection refused"},
+ {"dial tcp: no such host", "Host not found"},
+ {"connection reset by peer", "Connection reset"},
+ {"context deadline exceeded (timeout)", "Connection timeout"},
+ {"unexpected EOF", "Connection closed"},
+ {"oauth: invalid_grant", "OAuth error"},
+ {"x509: certificate signed by unknown authority", "Certificate error"},
+ {"dial tcp 127.0.0.1:8080", "Cannot connect"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.error, func(t *testing.T) {
+ result := formatErrorSummary(tt.error)
+ assert.Equal(t, tt.expected, result)
+ })
+ }
+}
+
+func TestDefaultHealthConfig(t *testing.T) {
+ cfg := DefaultHealthConfig()
+
+ assert.NotNil(t, cfg)
+ assert.Equal(t, time.Hour, cfg.ExpiryWarningDuration)
+}
+
+// I-002: Test FR-004 - All health status responses must include non-empty summary
+func TestCalculateHealth_AlwaysIncludesSummary(t *testing.T) {
+ expiresAt := time.Now().Add(30 * time.Minute)
+
+ testCases := []struct {
+ name string
+ input HealthCalculatorInput
+ }{
+ {"disabled server", HealthCalculatorInput{Name: "test", Enabled: false}},
+ {"quarantined server", HealthCalculatorInput{Name: "test", Enabled: true, Quarantined: true}},
+ {"error state", HealthCalculatorInput{Name: "test", Enabled: true, State: "error", LastError: "connection refused"}},
+ {"error state no message", HealthCalculatorInput{Name: "test", Enabled: true, State: "error", LastError: ""}},
+ {"disconnected state", HealthCalculatorInput{Name: "test", Enabled: true, State: "disconnected"}},
+ {"connecting state", HealthCalculatorInput{Name: "test", Enabled: true, State: "connecting"}},
+ {"idle state", HealthCalculatorInput{Name: "test", Enabled: true, State: "idle"}},
+ {"connected healthy", HealthCalculatorInput{Name: "test", Enabled: true, State: "connected", Connected: true, ToolCount: 5}},
+ {"connected no tools", HealthCalculatorInput{Name: "test", Enabled: true, State: "connected", Connected: true, ToolCount: 0}},
+ {"oauth expired", HealthCalculatorInput{Name: "test", Enabled: true, State: "connected", Connected: true, OAuthRequired: true, OAuthStatus: "expired"}},
+ {"oauth none", HealthCalculatorInput{Name: "test", Enabled: true, State: "connected", Connected: true, OAuthRequired: true, OAuthStatus: "none"}},
+ {"oauth error", HealthCalculatorInput{Name: "test", Enabled: true, State: "connected", Connected: true, OAuthRequired: true, OAuthStatus: "error"}},
+ {"user logged out", HealthCalculatorInput{Name: "test", Enabled: true, State: "connected", OAuthRequired: true, OAuthStatus: "authenticated", UserLoggedOut: true}},
+ {"token expiring no refresh", HealthCalculatorInput{Name: "test", Enabled: true, State: "connected", Connected: true, OAuthRequired: true, OAuthStatus: "authenticated", TokenExpiresAt: &expiresAt, HasRefreshToken: false}},
+ {"token expiring with refresh", HealthCalculatorInput{Name: "test", Enabled: true, State: "connected", Connected: true, OAuthRequired: true, OAuthStatus: "authenticated", TokenExpiresAt: &expiresAt, HasRefreshToken: true, ToolCount: 5}},
+ {"unknown state", HealthCalculatorInput{Name: "test", Enabled: true, State: "unknown"}},
+ {"empty state", HealthCalculatorInput{Name: "test", Enabled: true, State: ""}},
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ result := CalculateHealth(tc.input, nil)
+ assert.NotEmpty(t, result.Summary, "FR-004: Summary should never be empty for %s", tc.name)
+ })
+ }
+}
diff --git a/internal/health/constants.go b/internal/health/constants.go
new file mode 100644
index 00000000..1c2b2bf8
--- /dev/null
+++ b/internal/health/constants.go
@@ -0,0 +1,26 @@
+// Package health provides unified health status calculation for upstream MCP servers.
+package health
+
+// Health levels
+const (
+ LevelHealthy = "healthy"
+ LevelDegraded = "degraded"
+ LevelUnhealthy = "unhealthy"
+)
+
+// Admin states
+const (
+ StateEnabled = "enabled"
+ StateDisabled = "disabled"
+ StateQuarantined = "quarantined"
+)
+
+// Actions - suggested remediation for health issues
+const (
+ ActionNone = ""
+ ActionLogin = "login"
+ ActionRestart = "restart"
+ ActionEnable = "enable"
+ ActionApprove = "approve"
+ ActionViewLogs = "view_logs"
+)
diff --git a/internal/management/service.go b/internal/management/service.go
index c5881748..0a099d63 100644
--- a/internal/management/service.go
+++ b/internal/management/service.go
@@ -289,6 +289,11 @@ func (s *service) ListServers(ctx context.Context) ([]*contracts.Server, *contra
srv.Updated = updated
}
+ // Extract unified health status
+ if health, ok := srvRaw["health"].(*contracts.HealthStatus); ok {
+ srv.Health = health
+ }
+
servers = append(servers, srv)
// Update stats
diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go
index 4e943ee9..06a2b3b4 100644
--- a/internal/runtime/runtime.go
+++ b/internal/runtime/runtime.go
@@ -19,6 +19,7 @@ import (
"mcpproxy-go/internal/config"
"mcpproxy-go/internal/contracts"
"mcpproxy-go/internal/experiments"
+ "mcpproxy-go/internal/health"
"mcpproxy-go/internal/index"
"mcpproxy-go/internal/oauth"
"mcpproxy-go/internal/registries"
@@ -1523,6 +1524,7 @@ func (r *Runtime) GetAllServers() ([]map[string]interface{}, error) {
var authenticated bool
var oauthStatus string // OAuth status: "authenticated", "expired", "error", "none"
var tokenExpiresAt time.Time
+ var hasRefreshToken bool
if serverStatus.Config != nil {
created = serverStatus.Config.Created
url = serverStatus.Config.URL
@@ -1570,44 +1572,46 @@ func (r *Runtime) GetAllServers() ([]map[string]interface{}, error) {
zap.Error(err))
if err == nil && token != nil {
- authenticated = true
- tokenExpiresAt = token.ExpiresAt
- r.logger.Info("OAuth token found for server",
- zap.String("server", serverStatus.Name),
- zap.String("server_key", serverKey),
- zap.Time("expires_at", token.ExpiresAt))
-
- // For autodiscovery servers (no explicit OAuth config), create minimal oauthConfig
- if oauthConfig == nil {
- oauthConfig = map[string]interface{}{
- "autodiscovery": true,
- }
+ authenticated = true
+ tokenExpiresAt = token.ExpiresAt
+ hasRefreshToken = token.RefreshToken != ""
+ r.logger.Info("OAuth token found for server",
+ zap.String("server", serverStatus.Name),
+ zap.String("server_key", serverKey),
+ zap.Time("expires_at", token.ExpiresAt),
+ zap.Bool("has_refresh_token", hasRefreshToken))
+
+ // For autodiscovery servers (no explicit OAuth config), create minimal oauthConfig
+ if oauthConfig == nil {
+ oauthConfig = map[string]interface{}{
+ "autodiscovery": true,
}
+ }
- // Add token expiration info to oauth config
- if !token.ExpiresAt.IsZero() {
- oauthConfig["token_expires_at"] = token.ExpiresAt.Format(time.RFC3339)
- // Check if token is expired
- isValid := time.Now().Before(token.ExpiresAt)
- oauthConfig["token_valid"] = isValid
- if isValid {
- oauthStatus = string(oauth.OAuthStatusAuthenticated)
- } else {
- oauthStatus = string(oauth.OAuthStatusExpired)
- }
- } else {
- // No expiration means token is valid indefinitely
- oauthConfig["token_valid"] = true
+ // Add token expiration info to oauth config
+ if !token.ExpiresAt.IsZero() {
+ oauthConfig["token_expires_at"] = token.ExpiresAt.Format(time.RFC3339)
+ // Check if token is expired
+ isValid := time.Now().Before(token.ExpiresAt)
+ oauthConfig["token_valid"] = isValid
+ if isValid {
oauthStatus = string(oauth.OAuthStatusAuthenticated)
+ } else {
+ oauthStatus = string(oauth.OAuthStatusExpired)
}
} else {
- // No token found - check if OAuth config exists to determine status
- if oauthConfig != nil {
- oauthStatus = string(oauth.OAuthStatusNone)
- }
+ // No expiration means token is valid indefinitely
+ oauthConfig["token_valid"] = true
+ oauthStatus = string(oauth.OAuthStatusAuthenticated)
+ }
+ } else {
+ // No token found - check if OAuth config exists to determine status
+ if oauthConfig != nil {
+ oauthStatus = string(oauth.OAuthStatusNone)
}
}
}
+ }
// Check for OAuth error in last_error
if oauthStatus != string(oauth.OAuthStatusExpired) && serverStatus.LastError != "" {
@@ -1654,6 +1658,40 @@ func (r *Runtime) GetAllServers() ([]map[string]interface{}, error) {
}
serverMap["user_logged_out"] = userLoggedOut
+ // Calculate unified health status
+ healthConfig := health.DefaultHealthConfig()
+ if r.cfg != nil && r.cfg.OAuthExpiryWarningHours > 0 {
+ healthConfig.ExpiryWarningDuration = time.Duration(r.cfg.OAuthExpiryWarningHours * float64(time.Hour))
+ }
+
+ healthInput := health.HealthCalculatorInput{
+ Name: serverStatus.Name,
+ Enabled: serverStatus.Enabled,
+ Quarantined: serverStatus.Quarantined,
+ State: serverStatus.State,
+ Connected: connected,
+ LastError: serverStatus.LastError,
+ OAuthRequired: oauthConfig != nil,
+ OAuthStatus: oauthStatus,
+ HasRefreshToken: hasRefreshToken,
+ UserLoggedOut: userLoggedOut,
+ ToolCount: serverStatus.ToolCount,
+ }
+ if !tokenExpiresAt.IsZero() {
+ healthInput.TokenExpiresAt = &tokenExpiresAt
+ }
+
+ healthStatus := health.CalculateHealth(healthInput, healthConfig)
+ serverMap["health"] = healthStatus
+
+ // M-005: Log health status for debugging
+ r.logger.Debug("Server health calculated",
+ zap.String("server", serverStatus.Name),
+ zap.String("level", healthStatus.Level),
+ zap.String("admin_state", healthStatus.AdminState),
+ zap.String("summary", healthStatus.Summary),
+ )
+
result = append(result, serverMap)
}
diff --git a/internal/server/e2e_mcp_test.go b/internal/server/e2e_mcp_test.go
index 2dceeac3..19e6bf19 100644
--- a/internal/server/e2e_mcp_test.go
+++ b/internal/server/e2e_mcp_test.go
@@ -141,6 +141,25 @@ func TestMCPProtocolWithBinary(t *testing.T) {
assert.Equal(t, "memory", serverMap["name"])
assert.Equal(t, "stdio", serverMap["protocol"])
assert.Equal(t, true, serverMap["enabled"])
+
+ // I-001: Verify health field is present with expected structure (FR-017, FR-018)
+ healthMap, ok := serverMap["health"].(map[string]interface{})
+ require.True(t, ok, "Server should have health field")
+ assert.NotEmpty(t, healthMap["level"], "Health level should be present")
+ assert.NotEmpty(t, healthMap["admin_state"], "Admin state should be present")
+ assert.NotEmpty(t, healthMap["summary"], "Summary should be present")
+
+ // Verify health level is one of the valid values
+ level, ok := healthMap["level"].(string)
+ require.True(t, ok, "Health level should be a string")
+ validLevels := []string{"healthy", "degraded", "unhealthy"}
+ assert.Contains(t, validLevels, level, "Health level should be valid")
+
+ // Verify admin state is one of the valid values
+ adminState, ok := healthMap["admin_state"].(string)
+ require.True(t, ok, "Admin state should be a string")
+ validStates := []string{"enabled", "disabled", "quarantined"}
+ assert.Contains(t, validStates, adminState, "Admin state should be valid")
})
}
diff --git a/internal/server/info_shutdown_e2e_test.go b/internal/server/info_shutdown_e2e_test.go
index d391ac94..33ed753b 100644
--- a/internal/server/info_shutdown_e2e_test.go
+++ b/internal/server/info_shutdown_e2e_test.go
@@ -36,6 +36,15 @@ func TestInfoEndpoint(t *testing.T) {
err := os.Chmod(tempDir, 0700)
require.NoError(t, err, "Failed to set secure permissions on temp directory")
+ // Create a minimal config file to avoid loading user's real config
+ configPath := filepath.Join(tempDir, "mcp_config.json")
+ minimalConfig := `{
+ "listen": "127.0.0.1:0",
+ "mcpServers": [],
+ "docker_isolation": {"enabled": false}
+ }`
+ require.NoError(t, os.WriteFile(configPath, []byte(minimalConfig), 0600))
+
// Find available port
ln, err := net.Listen("tcp", ":0")
require.NoError(t, err)
@@ -49,6 +58,7 @@ func TestInfoEndpoint(t *testing.T) {
defer cancel()
cmd := exec.CommandContext(ctx, binaryPath, "serve",
+ "--config", configPath,
"--data-dir", tempDir,
"--listen", listenAddr)
@@ -150,6 +160,15 @@ func TestGracefulShutdownNoPanic(t *testing.T) {
err := os.Chmod(tempDir, 0700)
require.NoError(t, err, "Failed to set secure permissions on temp directory")
+ // Create a minimal config file to avoid loading user's real config
+ configPath := filepath.Join(tempDir, "mcp_config.json")
+ minimalConfig := `{
+ "listen": "127.0.0.1:0",
+ "mcpServers": [],
+ "docker_isolation": {"enabled": false}
+ }`
+ require.NoError(t, os.WriteFile(configPath, []byte(minimalConfig), 0600))
+
// Find available port
ln, err := net.Listen("tcp", ":0")
require.NoError(t, err)
@@ -163,6 +182,7 @@ func TestGracefulShutdownNoPanic(t *testing.T) {
defer cancel()
cmd := exec.CommandContext(ctx, binaryPath, "serve",
+ "--config", configPath,
"--data-dir", tempDir,
"--listen", listenAddr,
"--log-level", "debug")
@@ -244,6 +264,15 @@ func TestSocketInfoEndpoint(t *testing.T) {
err := os.Chmod(tempDir, 0700)
require.NoError(t, err, "Failed to set secure permissions on temp directory")
+ // Create a minimal config file to avoid loading user's real config
+ configPath := filepath.Join(tempDir, "mcp_config.json")
+ minimalConfig := `{
+ "listen": "127.0.0.1:0",
+ "mcpServers": [],
+ "docker_isolation": {"enabled": false}
+ }`
+ require.NoError(t, os.WriteFile(configPath, []byte(minimalConfig), 0600))
+
socketPath := filepath.Join(tempDir, "mcpproxy.sock")
ctx, cancel := context.WithCancel(context.Background())
@@ -251,6 +280,7 @@ func TestSocketInfoEndpoint(t *testing.T) {
// Start server with socket enabled
cmd := exec.CommandContext(ctx, binaryPath, "serve",
+ "--config", configPath,
"--data-dir", tempDir,
"--listen", "127.0.0.1:0", // Random port for HTTP
"--enable-socket", "true")
diff --git a/internal/server/mcp.go b/internal/server/mcp.go
index 45ae1f35..da37dcd5 100644
--- a/internal/server/mcp.go
+++ b/internal/server/mcp.go
@@ -14,6 +14,7 @@ import (
"mcpproxy-go/internal/config"
"mcpproxy-go/internal/contracts"
"mcpproxy-go/internal/experiments"
+ "mcpproxy-go/internal/health"
"mcpproxy-go/internal/index"
"mcpproxy-go/internal/jsruntime"
"mcpproxy-go/internal/logs"
@@ -1223,20 +1224,38 @@ func (p *MCPProxyServer) handleListUpstreams(_ context.Context) (*mcp.CallToolRe
"updated": server.Updated,
}
- // Add connection status information
+ // Add connection status information and calculate health
+ var connState string
+ var lastError string
+ var isConnected bool
+ var toolCount int
+ var userLoggedOut bool
+
if client, exists := p.upstreamManager.GetClient(server.Name); exists {
connInfo := client.GetConnectionInfo()
containerInfo := p.getDockerContainerInfo(client)
+ connState = connInfo.State.String()
+ if connInfo.LastError != nil {
+ lastError = connInfo.LastError.Error()
+ }
+ isConnected = connInfo.State.String() == "connected"
+ userLoggedOut = client.IsUserLoggedOut()
+ // Get tool count from client
+ if tools, err := client.ListTools(context.Background()); err == nil {
+ toolCount = len(tools)
+ }
+
serverMap["connection_status"] = map[string]interface{}{
- "state": connInfo.State.String(),
- "last_error": connInfo.LastError,
+ "state": connState,
+ "last_error": lastError,
"retry_count": connInfo.RetryCount,
"last_retry_time": connInfo.LastRetryTime.Format(time.RFC3339),
"container_id": containerInfo["container_id"],
"container_status": containerInfo["status"],
}
} else {
+ connState = "disconnected"
serverMap["connection_status"] = map[string]interface{}{
"state": "Not Started",
"last_error": nil,
@@ -1244,6 +1263,20 @@ func (p *MCPProxyServer) handleListUpstreams(_ context.Context) (*mcp.CallToolRe
}
}
+ // Calculate unified health status
+ healthInput := health.HealthCalculatorInput{
+ Name: server.Name,
+ Enabled: server.Enabled,
+ Quarantined: server.Quarantined,
+ State: strings.ToLower(connState),
+ Connected: isConnected,
+ LastError: lastError,
+ OAuthRequired: server.OAuth != nil,
+ UserLoggedOut: userLoggedOut,
+ ToolCount: toolCount,
+ }
+ serverMap["health"] = health.CalculateHealth(healthInput, health.DefaultHealthConfig())
+
// Add Docker isolation information
dockerInfo := map[string]interface{}{
"global_enabled": dockerIsolationGlobalEnabled,
diff --git a/internal/tray/managers.go b/internal/tray/managers.go
index a000309d..ea993820 100644
--- a/internal/tray/managers.go
+++ b/internal/tray/managers.go
@@ -295,6 +295,7 @@ type MenuManager struct {
serverActionItems map[string]*systray.MenuItem // server name -> enable/disable action menu item
serverQuarantineItems map[string]*systray.MenuItem // server name -> quarantine action menu item
serverOAuthItems map[string]*systray.MenuItem // server name -> OAuth login menu item
+ serverRestartItems map[string]*systray.MenuItem // server name -> restart action menu item
quarantineInfoEmpty *systray.MenuItem // "No servers" info item
quarantineInfoHelp *systray.MenuItem // "Click to unquarantine" help item
@@ -317,6 +318,7 @@ func NewMenuManager(upstreamMenu, quarantineMenu *systray.MenuItem, logger *zap.
serverActionItems: make(map[string]*systray.MenuItem),
serverQuarantineItems: make(map[string]*systray.MenuItem),
serverOAuthItems: make(map[string]*systray.MenuItem),
+ serverRestartItems: make(map[string]*systray.MenuItem),
}
}
@@ -396,6 +398,7 @@ func (m *MenuManager) UpdateUpstreamServersMenu(servers []map[string]interface{}
m.serverActionItems = make(map[string]*systray.MenuItem)
m.serverQuarantineItems = make(map[string]*systray.MenuItem)
m.serverOAuthItems = make(map[string]*systray.MenuItem)
+ m.serverRestartItems = make(map[string]*systray.MenuItem)
// Create all servers in sorted order
for _, serverName := range currentServerNames {
@@ -625,14 +628,10 @@ func (m *MenuManager) ForceRefresh() {
}
// getServerStatusDisplay returns display text, tooltip, and icon data for a server
+// Uses the unified health status from the backend when available
func (m *MenuManager) getServerStatusDisplay(server map[string]interface{}) (displayText, tooltip string, iconData []byte) {
serverName, _ := server["name"].(string)
- enabled, _ := server["enabled"].(bool)
- connected, _ := server["connected"].(bool)
- quarantined, _ := server["quarantined"].(bool)
- toolCount, _ := server["tool_count"].(int)
lastError, _ := server["last_error"].(string)
- statusValue, _ := server["status"].(string)
shouldRetry, _ := server["should_retry"].(bool)
var retryCount int
@@ -648,49 +647,98 @@ func (m *MenuManager) getServerStatusDisplay(server map[string]interface{}) (dis
var statusText string
var iconPath string
- if quarantined {
- statusIcon = "π"
- statusText = "quarantined"
- iconPath = iconLocked
- } else if !enabled {
- statusIcon = "βΈοΈ"
- statusText = "disabled"
- iconPath = iconPaused
- } else if st := strings.ToLower(statusValue); st != "" {
- switch st {
- case "ready", "connected":
- statusIcon = "π’"
- statusText = fmt.Sprintf("connected (%d tools)", toolCount)
- iconPath = iconConnected
- case "connecting":
- statusIcon = "π "
- statusText = "connecting"
- iconPath = iconDisconnected
- case "pending auth":
- statusIcon = "β³"
- statusText = "pending auth"
- iconPath = iconDisconnected // Use disconnected icon for now since we don't have a specific auth icon
- case "error", "disconnected":
- statusIcon = "π΄"
- statusText = "connection error"
- iconPath = iconDisconnected
+ // Extract unified health status from server data
+ healthData, hasHealth := server["health"].(map[string]interface{})
+ if hasHealth {
+ // Use unified health status from backend
+ healthLevel, _ := healthData["level"].(string)
+ healthAdminState, _ := healthData["admin_state"].(string)
+ healthSummary, _ := healthData["summary"].(string)
+
+ // Determine status icon based on admin_state first, then health level
+ switch healthAdminState {
case "disabled":
statusIcon = "βΈοΈ"
- statusText = "disabled"
iconPath = iconPaused
+ case "quarantined":
+ statusIcon = "π"
+ iconPath = iconLocked
default:
+ // Use health level for enabled servers
+ switch healthLevel {
+ case "healthy":
+ statusIcon = "π’"
+ iconPath = iconConnected
+ case "degraded":
+ statusIcon = "π "
+ iconPath = iconDisconnected
+ case "unhealthy":
+ statusIcon = "π΄"
+ iconPath = iconDisconnected
+ default:
+ statusIcon = "βͺ"
+ iconPath = iconDisconnected
+ }
+ }
+
+ // Use health.summary for status text
+ if healthSummary != "" {
+ statusText = healthSummary
+ } else {
+ statusText = healthLevel
+ }
+ } else {
+ // Fallback to legacy logic if health field not present
+ enabled, _ := server["enabled"].(bool)
+ connected, _ := server["connected"].(bool)
+ quarantined, _ := server["quarantined"].(bool)
+ toolCount, _ := server["tool_count"].(int)
+ statusValue, _ := server["status"].(string)
+
+ if quarantined {
+ statusIcon = "π"
+ statusText = "quarantined"
+ iconPath = iconLocked
+ } else if !enabled {
+ statusIcon = "βΈοΈ"
+ statusText = "disabled"
+ iconPath = iconPaused
+ } else if st := strings.ToLower(statusValue); st != "" {
+ switch st {
+ case "ready", "connected":
+ statusIcon = "π’"
+ statusText = fmt.Sprintf("connected (%d tools)", toolCount)
+ iconPath = iconConnected
+ case "connecting":
+ statusIcon = "π "
+ statusText = "connecting"
+ iconPath = iconDisconnected
+ case "pending auth":
+ statusIcon = "β³"
+ statusText = "pending auth"
+ iconPath = iconDisconnected
+ case "error", "disconnected":
+ statusIcon = "π΄"
+ statusText = "connection error"
+ iconPath = iconDisconnected
+ case "disabled":
+ statusIcon = "βΈοΈ"
+ statusText = "disabled"
+ iconPath = iconPaused
+ default:
+ statusIcon = "π΄"
+ statusText = st
+ iconPath = iconDisconnected
+ }
+ } else if connected {
+ statusIcon = "π’"
+ statusText = fmt.Sprintf("connected (%d tools)", toolCount)
+ iconPath = iconConnected
+ } else {
statusIcon = "π΄"
- statusText = st
+ statusText = "disconnected"
iconPath = iconDisconnected
}
- } else if connected {
- statusIcon = "π’"
- statusText = fmt.Sprintf("connected (%d tools)", toolCount)
- iconPath = iconConnected
- } else {
- statusIcon = "π΄"
- statusText = "disconnected"
- iconPath = iconDisconnected
}
// On Windows, use icons instead of emoji for better visual appearance
@@ -705,8 +753,11 @@ func (m *MenuManager) getServerStatusDisplay(server map[string]interface{}) (dis
var tooltipLines []string
tooltipLines = append(tooltipLines, fmt.Sprintf("%s - %s", serverName, statusText))
- if statusValue != "" && !strings.EqualFold(statusValue, statusText) {
- tooltipLines = append(tooltipLines, fmt.Sprintf("Status: %s", statusValue))
+ // Add health detail if available
+ if hasHealth {
+ if detail, ok := healthData["detail"].(string); ok && detail != "" {
+ tooltipLines = append(tooltipLines, fmt.Sprintf("Detail: %s", detail))
+ }
}
if lastError != "" {
@@ -773,7 +824,8 @@ func (m *MenuManager) serverSupportsOAuth(server map[string]interface{}) bool {
return true
}
-// createServerActionSubmenus creates action submenus for a server (enable/disable, quarantine, OAuth login)
+// createServerActionSubmenus creates action submenus for a server (enable/disable, quarantine, OAuth login, restart)
+// Uses health.action to determine which actions are most relevant
func (m *MenuManager) createServerActionSubmenus(serverMenuItem *systray.MenuItem, server map[string]interface{}) {
serverName, _ := server["name"].(string)
if serverName == "" {
@@ -783,6 +835,12 @@ func (m *MenuManager) createServerActionSubmenus(serverMenuItem *systray.MenuIte
enabled, _ := server["enabled"].(bool)
quarantined, _ := server["quarantined"].(bool)
+ // Get health.action if available
+ healthAction := ""
+ if healthData, ok := server["health"].(map[string]interface{}); ok {
+ healthAction, _ = healthData["action"].(string)
+ }
+
// Enable/Disable action
var enableText string
if enabled {
@@ -793,9 +851,29 @@ func (m *MenuManager) createServerActionSubmenus(serverMenuItem *systray.MenuIte
enableItem := serverMenuItem.AddSubMenuItem(enableText, fmt.Sprintf("%s server %s", enableText, serverName))
m.serverActionItems[serverName] = enableItem
+ // Restart action (for stdio servers when health.action is "restart" or server has errors)
+ if enabled && !quarantined {
+ restartItem := serverMenuItem.AddSubMenuItem("π Restart", fmt.Sprintf("Restart server %s", serverName))
+ m.serverRestartItems[serverName] = restartItem
+
+ // Set up restart click handler
+ go func(name string, item *systray.MenuItem) {
+ for range item.ClickedCh {
+ if m.onServerAction != nil {
+ go m.onServerAction(name, "restart")
+ }
+ }
+ }(serverName, restartItem)
+ }
+
// OAuth Login action (only for servers that support OAuth)
if m.serverSupportsOAuth(server) && !quarantined {
- oauthItem := serverMenuItem.AddSubMenuItem("π OAuth Login", fmt.Sprintf("Authenticate with %s using OAuth", serverName))
+ // Highlight login if health.action suggests it
+ loginLabel := "π OAuth Login"
+ if healthAction == "login" {
+ loginLabel = "β οΈ Login Required"
+ }
+ oauthItem := serverMenuItem.AddSubMenuItem(loginLabel, fmt.Sprintf("Authenticate with %s using OAuth", serverName))
m.serverOAuthItems[serverName] = oauthItem
// Set up OAuth login click handler
diff --git a/internal/upstream/core/instance.go b/internal/upstream/core/instance.go
index c20b92fd..db6ec93e 100644
--- a/internal/upstream/core/instance.go
+++ b/internal/upstream/core/instance.go
@@ -32,6 +32,11 @@ func getInstanceID() string {
return instanceID
}
+// GetInstanceID returns the unique identifier for this mcpproxy instance (exported for use by manager)
+func GetInstanceID() string {
+ return getInstanceID()
+}
+
// loadInstanceID attempts to load the instance ID from disk
func loadInstanceID() (string, error) {
instanceFile := filepath.Join(os.TempDir(), "mcpproxy-instance-id")
diff --git a/internal/upstream/manager.go b/internal/upstream/manager.go
index 22d562af..83f5fbb9 100644
--- a/internal/upstream/manager.go
+++ b/internal/upstream/manager.go
@@ -616,16 +616,19 @@ func (m *Manager) cleanupAllManagedContainers(ctx context.Context) {
// ForceCleanupAllContainers is a public wrapper for emergency container cleanup
// This is called when graceful shutdown fails and containers must be force-removed
+// Only removes containers owned by THIS instance (matching instance ID)
func (m *Manager) ForceCleanupAllContainers() {
- m.logger.Warn("Force cleanup requested - removing all managed containers immediately")
+ m.logger.Warn("Force cleanup requested - removing all managed containers for this instance")
// Create a short-lived context for force cleanup (30 seconds max)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
- // Find all containers with our management label
+ // Find all containers with our management label AND our instance ID
+ instanceID := core.GetInstanceID()
listCmd := exec.CommandContext(ctx, "docker", "ps", "-a",
"--filter", "label=com.mcpproxy.managed=true",
+ "--filter", fmt.Sprintf("label=com.mcpproxy.instance=%s", instanceID),
"--format", "{{.ID}}\t{{.Names}}")
output, err := listCmd.Output()
@@ -1141,14 +1144,16 @@ func (m *Manager) DisconnectAll() error {
return nil
}
-// HasDockerContainers checks if any Docker containers are actually running
+// HasDockerContainers checks if any Docker containers owned by THIS instance are actually running
func (m *Manager) HasDockerContainers() bool {
- // Check if any containers with our labels are actually running
+ // Check if any containers with our labels AND our instance ID are running
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
+ instanceID := core.GetInstanceID()
listCmd := exec.CommandContext(ctx, "docker", "ps", "-q",
- "--filter", "label=com.mcpproxy.managed=true")
+ "--filter", "label=com.mcpproxy.managed=true",
+ "--filter", fmt.Sprintf("label=com.mcpproxy.instance=%s", instanceID))
output, err := listCmd.Output()
if err != nil {
diff --git a/oas/docs.go b/oas/docs.go
index 62f24da5..ce575797 100644
--- a/oas/docs.go
+++ b/oas/docs.go
@@ -6,7 +6,7 @@ import "github.com/swaggo/swag/v2"
const docTemplate = `{
"schemes": {{ marshal .Schemes }},
- "components": {"schemas":{"config.Config":{"type":"object"},"config.IsolationConfig":{"description":"Per-server isolation settings","properties":{"enabled":{"description":"Enable Docker isolation for this server","type":"boolean"},"extra_args":{"description":"Additional docker run arguments for this server","items":{"type":"string"},"type":"array","uniqueItems":false},"image":{"description":"Custom Docker image (overrides default)","type":"string"},"log_driver":{"description":"Docker log driver override for this server","type":"string"},"log_max_files":{"description":"Maximum number of log files override","type":"string"},"log_max_size":{"description":"Maximum size of log files override","type":"string"},"network_mode":{"description":"Custom network mode for this server","type":"string"},"working_dir":{"description":"Custom working directory in container","type":"string"}},"type":"object"},"config.OAuthConfig":{"description":"OAuth configuration (keep even when empty to signal OAuth requirement)","properties":{"client_id":{"type":"string"},"client_secret":{"type":"string"},"extra_params":{"additionalProperties":{"type":"string"},"description":"Additional OAuth parameters (e.g., RFC 8707 resource)","type":"object"},"pkce_enabled":{"type":"boolean"},"redirect_uri":{"type":"string"},"scopes":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"config.ServerConfig":{"properties":{"args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"command":{"type":"string"},"created":{"type":"string"},"enabled":{"type":"boolean"},"env":{"additionalProperties":{"type":"string"},"type":"object"},"headers":{"additionalProperties":{"type":"string"},"description":"For HTTP servers","type":"object"},"isolation":{"$ref":"#/components/schemas/config.IsolationConfig"},"name":{"type":"string"},"oauth":{"$ref":"#/components/schemas/config.OAuthConfig"},"protocol":{"description":"stdio, http, sse, streamable-http, auto","type":"string"},"quarantined":{"description":"Security quarantine status","type":"boolean"},"updated":{"type":"string"},"url":{"type":"string"},"working_dir":{"description":"Working directory for stdio servers","type":"string"}},"type":"object"},"contracts.APIResponse":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{},"error":{"type":"string"},"success":{"type":"boolean"}},"type":"object"},"contracts.ConfigApplyResult":{"properties":{"applied_immediately":{"type":"boolean"},"changed_fields":{"items":{"type":"string"},"type":"array","uniqueItems":false},"requires_restart":{"type":"boolean"},"restart_reason":{"type":"string"},"success":{"type":"boolean"},"validation_errors":{"items":{"$ref":"#/components/schemas/contracts.ValidationError"},"type":"array","uniqueItems":false}},"type":"object"},"contracts.Diagnostics":{"properties":{"docker_status":{"$ref":"#/components/schemas/contracts.DockerStatus"},"missing_secrets":{"description":"Renamed to avoid conflict","items":{"$ref":"#/components/schemas/contracts.MissingSecretInfo"},"type":"array","uniqueItems":false},"oauth_issues":{"description":"OAuth parameter mismatches","items":{"$ref":"#/components/schemas/contracts.OAuthIssue"},"type":"array","uniqueItems":false},"oauth_required":{"items":{"$ref":"#/components/schemas/contracts.OAuthRequirement"},"type":"array","uniqueItems":false},"runtime_warnings":{"items":{"type":"string"},"type":"array","uniqueItems":false},"timestamp":{"type":"string"},"total_issues":{"type":"integer"},"upstream_errors":{"items":{"$ref":"#/components/schemas/contracts.UpstreamError"},"type":"array","uniqueItems":false}},"type":"object"},"contracts.DockerStatus":{"properties":{"available":{"type":"boolean"},"error":{"type":"string"},"version":{"type":"string"}},"type":"object"},"contracts.ErrorResponse":{"properties":{"error":{"type":"string"},"success":{"type":"boolean"}},"type":"object"},"contracts.GetConfigResponse":{"properties":{"config":{"description":"The configuration object"},"config_path":{"description":"Path to config file","type":"string"}},"type":"object"},"contracts.GetRegistriesResponse":{"properties":{"registries":{"items":{"$ref":"#/components/schemas/contracts.Registry"},"type":"array","uniqueItems":false},"total":{"type":"integer"}},"type":"object"},"contracts.GetServerLogsResponse":{"properties":{"count":{"type":"integer"},"logs":{"items":{"$ref":"#/components/schemas/contracts.LogEntry"},"type":"array","uniqueItems":false},"server_name":{"type":"string"}},"type":"object"},"contracts.GetServerToolCallsResponse":{"properties":{"server_name":{"type":"string"},"tool_calls":{"items":{"$ref":"#/components/schemas/contracts.ToolCallRecord"},"type":"array","uniqueItems":false},"total":{"type":"integer"}},"type":"object"},"contracts.GetServerToolsResponse":{"properties":{"count":{"type":"integer"},"server_name":{"type":"string"},"tools":{"items":{"$ref":"#/components/schemas/contracts.Tool"},"type":"array","uniqueItems":false}},"type":"object"},"contracts.GetServersResponse":{"properties":{"servers":{"items":{"$ref":"#/components/schemas/contracts.Server"},"type":"array","uniqueItems":false},"stats":{"$ref":"#/components/schemas/contracts.ServerStats"}},"type":"object"},"contracts.GetSessionDetailResponse":{"properties":{"session":{"$ref":"#/components/schemas/contracts.MCPSession"}},"type":"object"},"contracts.GetSessionsResponse":{"properties":{"limit":{"type":"integer"},"offset":{"type":"integer"},"sessions":{"items":{"$ref":"#/components/schemas/contracts.MCPSession"},"type":"array","uniqueItems":false},"total":{"type":"integer"}},"type":"object"},"contracts.GetToolCallDetailResponse":{"properties":{"tool_call":{"$ref":"#/components/schemas/contracts.ToolCallRecord"}},"type":"object"},"contracts.GetToolCallsResponse":{"properties":{"limit":{"type":"integer"},"offset":{"type":"integer"},"tool_calls":{"items":{"$ref":"#/components/schemas/contracts.ToolCallRecord"},"type":"array","uniqueItems":false},"total":{"type":"integer"}},"type":"object"},"contracts.InfoEndpoints":{"description":"Available API endpoints","properties":{"http":{"description":"HTTP endpoint address (e.g., \"127.0.0.1:8080\")","type":"string"},"socket":{"description":"Unix socket path (empty if disabled)","type":"string"}},"type":"object"},"contracts.InfoResponse":{"properties":{"endpoints":{"$ref":"#/components/schemas/contracts.InfoEndpoints"},"listen_addr":{"description":"Listen address (e.g., \"127.0.0.1:8080\")","type":"string"},"update":{"$ref":"#/components/schemas/contracts.UpdateInfo"},"version":{"description":"Current MCPProxy version","type":"string"},"web_ui_url":{"description":"URL to access the web control panel","type":"string"}},"type":"object"},"contracts.IsolationConfig":{"properties":{"cpu_limit":{"type":"string"},"enabled":{"type":"boolean"},"image":{"type":"string"},"memory_limit":{"type":"string"},"timeout":{"type":"string"},"working_dir":{"type":"string"}},"type":"object"},"contracts.LogEntry":{"properties":{"fields":{"additionalProperties":{},"type":"object"},"level":{"type":"string"},"message":{"type":"string"},"server":{"type":"string"},"timestamp":{"type":"string"}},"type":"object"},"contracts.MCPSession":{"properties":{"client_name":{"type":"string"},"client_version":{"type":"string"},"end_time":{"type":"string"},"experimental":{"items":{"type":"string"},"type":"array","uniqueItems":false},"has_roots":{"description":"MCP Client Capabilities","type":"boolean"},"has_sampling":{"type":"boolean"},"id":{"type":"string"},"last_activity":{"type":"string"},"start_time":{"type":"string"},"status":{"type":"string"},"tool_call_count":{"type":"integer"},"total_tokens":{"type":"integer"}},"type":"object"},"contracts.MissingSecretInfo":{"properties":{"secret_name":{"type":"string"},"used_by":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"contracts.NPMPackageInfo":{"properties":{"exists":{"type":"boolean"},"install_cmd":{"type":"string"}},"type":"object"},"contracts.OAuthConfig":{"properties":{"auth_url":{"type":"string"},"client_id":{"type":"string"},"extra_params":{"additionalProperties":{"type":"string"},"type":"object"},"pkce_enabled":{"type":"boolean"},"redirect_port":{"type":"integer"},"scopes":{"items":{"type":"string"},"type":"array","uniqueItems":false},"token_expires_at":{"description":"When the OAuth token expires","type":"string"},"token_url":{"type":"string"},"token_valid":{"description":"Whether token is currently valid","type":"boolean"}},"type":"object"},"contracts.OAuthIssue":{"properties":{"documentation_url":{"type":"string"},"error":{"type":"string"},"issue":{"type":"string"},"missing_params":{"items":{"type":"string"},"type":"array","uniqueItems":false},"resolution":{"type":"string"},"server_name":{"type":"string"}},"type":"object"},"contracts.OAuthRequirement":{"properties":{"expires_at":{"type":"string"},"message":{"type":"string"},"server_name":{"type":"string"},"state":{"type":"string"}},"type":"object"},"contracts.Registry":{"properties":{"count":{"description":"number or string"},"description":{"type":"string"},"id":{"type":"string"},"name":{"type":"string"},"protocol":{"type":"string"},"servers_url":{"type":"string"},"tags":{"items":{"type":"string"},"type":"array","uniqueItems":false},"url":{"type":"string"}},"type":"object"},"contracts.ReplayToolCallRequest":{"properties":{"arguments":{"additionalProperties":{},"description":"Modified arguments for replay","type":"object"}},"type":"object"},"contracts.ReplayToolCallResponse":{"properties":{"error":{"description":"Error if replay failed","type":"string"},"new_call_id":{"description":"ID of the newly created call","type":"string"},"new_tool_call":{"$ref":"#/components/schemas/contracts.ToolCallRecord"},"replayed_from":{"description":"Original call ID","type":"string"},"success":{"type":"boolean"}},"type":"object"},"contracts.RepositoryInfo":{"description":"Detected package info","properties":{"npm":{"$ref":"#/components/schemas/contracts.NPMPackageInfo"}},"type":"object"},"contracts.RepositoryServer":{"properties":{"connect_url":{"description":"Alternative connection URL","type":"string"},"created_at":{"type":"string"},"description":{"type":"string"},"id":{"type":"string"},"install_cmd":{"description":"Installation command","type":"string"},"name":{"type":"string"},"registry":{"description":"Which registry this came from","type":"string"},"repository_info":{"$ref":"#/components/schemas/contracts.RepositoryInfo"},"source_code_url":{"description":"Source repository URL","type":"string"},"updated_at":{"type":"string"},"url":{"description":"MCP endpoint for remote servers only","type":"string"}},"type":"object"},"contracts.SearchRegistryServersResponse":{"properties":{"query":{"type":"string"},"registry_id":{"type":"string"},"servers":{"items":{"$ref":"#/components/schemas/contracts.RepositoryServer"},"type":"array","uniqueItems":false},"tag":{"type":"string"},"total":{"type":"integer"}},"type":"object"},"contracts.SearchResult":{"properties":{"matches":{"type":"integer"},"score":{"type":"number"},"snippet":{"type":"string"},"tool":{"$ref":"#/components/schemas/contracts.Tool"}},"type":"object"},"contracts.SearchToolsResponse":{"properties":{"query":{"type":"string"},"results":{"items":{"$ref":"#/components/schemas/contracts.SearchResult"},"type":"array","uniqueItems":false},"took":{"type":"string"},"total":{"type":"integer"}},"type":"object"},"contracts.Server":{"properties":{"args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"authenticated":{"description":"OAuth authentication status","type":"boolean"},"command":{"type":"string"},"connected":{"type":"boolean"},"connected_at":{"type":"string"},"connecting":{"type":"boolean"},"created":{"type":"string"},"enabled":{"type":"boolean"},"env":{"additionalProperties":{"type":"string"},"type":"object"},"headers":{"additionalProperties":{"type":"string"},"type":"object"},"id":{"type":"string"},"isolation":{"$ref":"#/components/schemas/contracts.IsolationConfig"},"last_error":{"type":"string"},"last_reconnect_at":{"type":"string"},"last_retry_time":{"type":"string"},"name":{"type":"string"},"oauth":{"$ref":"#/components/schemas/contracts.OAuthConfig"},"oauth_status":{"description":"OAuth status: \"authenticated\", \"expired\", \"error\", \"none\"","type":"string"},"protocol":{"type":"string"},"quarantined":{"type":"boolean"},"reconnect_count":{"type":"integer"},"retry_count":{"type":"integer"},"should_retry":{"type":"boolean"},"status":{"type":"string"},"token_expires_at":{"description":"When the OAuth token expires (ISO 8601)","type":"string"},"tool_count":{"type":"integer"},"tool_list_token_size":{"description":"Token size for this server's tools","type":"integer"},"updated":{"type":"string"},"url":{"type":"string"},"user_logged_out":{"description":"True if user explicitly logged out (prevents auto-reconnection)","type":"boolean"},"working_dir":{"type":"string"}},"type":"object"},"contracts.ServerActionResponse":{"properties":{"action":{"type":"string"},"async":{"type":"boolean"},"server":{"type":"string"},"success":{"type":"boolean"}},"type":"object"},"contracts.ServerStats":{"properties":{"connected_servers":{"type":"integer"},"docker_containers":{"type":"integer"},"quarantined_servers":{"type":"integer"},"token_metrics":{"$ref":"#/components/schemas/contracts.ServerTokenMetrics"},"total_servers":{"type":"integer"},"total_tools":{"type":"integer"}},"type":"object"},"contracts.ServerTokenMetrics":{"properties":{"average_query_result_size":{"description":"Typical retrieve_tools output (tokens)","type":"integer"},"per_server_tool_list_sizes":{"additionalProperties":{"type":"integer"},"description":"Token size per server","type":"object"},"saved_tokens":{"description":"Difference","type":"integer"},"saved_tokens_percentage":{"description":"Percentage saved","type":"number"},"total_server_tool_list_size":{"description":"All upstream tools combined (tokens)","type":"integer"}},"type":"object"},"contracts.SuccessResponse":{"properties":{"data":{},"success":{"type":"boolean"}},"type":"object"},"contracts.TokenMetrics":{"description":"Token usage metrics (nil for older records)","properties":{"encoding":{"description":"Encoding used (e.g., cl100k_base)","type":"string"},"estimated_cost":{"description":"Optional cost estimate","type":"number"},"input_tokens":{"description":"Tokens in the request","type":"integer"},"model":{"description":"Model used for tokenization","type":"string"},"output_tokens":{"description":"Tokens in the response","type":"integer"},"total_tokens":{"description":"Total tokens (input + output)","type":"integer"},"truncated_tokens":{"description":"Tokens removed by truncation","type":"integer"},"was_truncated":{"description":"Whether response was truncated","type":"boolean"}},"type":"object"},"contracts.Tool":{"properties":{"annotations":{"$ref":"#/components/schemas/contracts.ToolAnnotation"},"description":{"type":"string"},"last_used":{"type":"string"},"name":{"type":"string"},"schema":{"additionalProperties":{},"type":"object"},"server_name":{"type":"string"},"usage":{"type":"integer"}},"type":"object"},"contracts.ToolAnnotation":{"description":"Tool behavior hints snapshot","properties":{"destructiveHint":{"type":"boolean"},"idempotentHint":{"type":"boolean"},"openWorldHint":{"type":"boolean"},"readOnlyHint":{"type":"boolean"},"title":{"type":"string"}},"type":"object"},"contracts.ToolCallRecord":{"description":"The new tool call record","properties":{"annotations":{"$ref":"#/components/schemas/contracts.ToolAnnotation"},"arguments":{"additionalProperties":{},"description":"Tool arguments","type":"object"},"config_path":{"description":"Active config file path","type":"string"},"duration":{"description":"Duration in nanoseconds","type":"integer"},"error":{"description":"Error message (failure only)","type":"string"},"execution_type":{"description":"\"direct\" or \"code_execution\"","type":"string"},"id":{"description":"Unique identifier","type":"string"},"mcp_client_name":{"description":"MCP client name from InitializeRequest","type":"string"},"mcp_client_version":{"description":"MCP client version","type":"string"},"mcp_session_id":{"description":"MCP session identifier","type":"string"},"metrics":{"$ref":"#/components/schemas/contracts.TokenMetrics"},"parent_call_id":{"description":"Links nested calls to parent code_execution","type":"string"},"request_id":{"description":"Request correlation ID","type":"string"},"response":{"description":"Tool response (success only)"},"server_id":{"description":"Server identity hash","type":"string"},"server_name":{"description":"Human-readable server name","type":"string"},"timestamp":{"description":"When the call was made","type":"string"},"tool_name":{"description":"Tool name (without server prefix)","type":"string"}},"type":"object"},"contracts.UpdateInfo":{"description":"Update information (if available)","properties":{"available":{"description":"Whether an update is available","type":"boolean"},"check_error":{"description":"Error message if update check failed","type":"string"},"checked_at":{"description":"When the update check was performed","type":"string"},"is_prerelease":{"description":"Whether the latest version is a prerelease","type":"boolean"},"latest_version":{"description":"Latest version available (e.g., \"v1.2.3\")","type":"string"},"release_url":{"description":"URL to the release page","type":"string"}},"type":"object"},"contracts.UpstreamError":{"properties":{"error_message":{"type":"string"},"server_name":{"type":"string"},"timestamp":{"type":"string"}},"type":"object"},"contracts.ValidateConfigResponse":{"properties":{"errors":{"items":{"$ref":"#/components/schemas/contracts.ValidationError"},"type":"array","uniqueItems":false},"valid":{"type":"boolean"}},"type":"object"},"contracts.ValidationError":{"properties":{"field":{"type":"string"},"message":{"type":"string"}},"type":"object"},"data":{"properties":{"data":{"$ref":"#/components/schemas/contracts.InfoResponse"}},"type":"object"},"management.BulkOperationResult":{"properties":{"errors":{"additionalProperties":{"type":"string"},"description":"Map of server name to error message","type":"object"},"failed":{"description":"Number of failed operations","type":"integer"},"successful":{"description":"Number of successful operations","type":"integer"},"total":{"description":"Total servers processed","type":"integer"}},"type":"object"},"observability.HealthResponse":{"properties":{"components":{"items":{"$ref":"#/components/schemas/observability.HealthStatus"},"type":"array","uniqueItems":false},"status":{"description":"\"healthy\" or \"unhealthy\"","type":"string"},"timestamp":{"type":"string"}},"type":"object"},"observability.HealthStatus":{"properties":{"error":{"type":"string"},"latency":{"type":"string"},"name":{"type":"string"},"status":{"description":"\"healthy\" or \"unhealthy\"","type":"string"}},"type":"object"},"observability.ReadinessResponse":{"properties":{"components":{"items":{"$ref":"#/components/schemas/observability.HealthStatus"},"type":"array","uniqueItems":false},"status":{"description":"\"ready\" or \"not_ready\"","type":"string"},"timestamp":{"type":"string"}},"type":"object"}},"securitySchemes":{"ApiKeyAuth":{"description":"API key authentication via query parameter. Use ?apikey=your-key","in":"query","name":"apikey","type":"apiKey"}}},
+ "components": {"schemas":{"config.Config":{"type":"object"},"config.IsolationConfig":{"description":"Per-server isolation settings","properties":{"enabled":{"description":"Enable Docker isolation for this server","type":"boolean"},"extra_args":{"description":"Additional docker run arguments for this server","items":{"type":"string"},"type":"array","uniqueItems":false},"image":{"description":"Custom Docker image (overrides default)","type":"string"},"log_driver":{"description":"Docker log driver override for this server","type":"string"},"log_max_files":{"description":"Maximum number of log files override","type":"string"},"log_max_size":{"description":"Maximum size of log files override","type":"string"},"network_mode":{"description":"Custom network mode for this server","type":"string"},"working_dir":{"description":"Custom working directory in container","type":"string"}},"type":"object"},"config.OAuthConfig":{"description":"OAuth configuration (keep even when empty to signal OAuth requirement)","properties":{"client_id":{"type":"string"},"client_secret":{"type":"string"},"extra_params":{"additionalProperties":{"type":"string"},"description":"Additional OAuth parameters (e.g., RFC 8707 resource)","type":"object"},"pkce_enabled":{"type":"boolean"},"redirect_uri":{"type":"string"},"scopes":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"config.ServerConfig":{"properties":{"args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"command":{"type":"string"},"created":{"type":"string"},"enabled":{"type":"boolean"},"env":{"additionalProperties":{"type":"string"},"type":"object"},"headers":{"additionalProperties":{"type":"string"},"description":"For HTTP servers","type":"object"},"isolation":{"$ref":"#/components/schemas/config.IsolationConfig"},"name":{"type":"string"},"oauth":{"$ref":"#/components/schemas/config.OAuthConfig"},"protocol":{"description":"stdio, http, sse, streamable-http, auto","type":"string"},"quarantined":{"description":"Security quarantine status","type":"boolean"},"updated":{"type":"string"},"url":{"type":"string"},"working_dir":{"description":"Working directory for stdio servers","type":"string"}},"type":"object"},"contracts.APIResponse":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{},"error":{"type":"string"},"success":{"type":"boolean"}},"type":"object"},"contracts.ConfigApplyResult":{"properties":{"applied_immediately":{"type":"boolean"},"changed_fields":{"items":{"type":"string"},"type":"array","uniqueItems":false},"requires_restart":{"type":"boolean"},"restart_reason":{"type":"string"},"success":{"type":"boolean"},"validation_errors":{"items":{"$ref":"#/components/schemas/contracts.ValidationError"},"type":"array","uniqueItems":false}},"type":"object"},"contracts.Diagnostics":{"properties":{"docker_status":{"$ref":"#/components/schemas/contracts.DockerStatus"},"missing_secrets":{"description":"Renamed to avoid conflict","items":{"$ref":"#/components/schemas/contracts.MissingSecretInfo"},"type":"array","uniqueItems":false},"oauth_issues":{"description":"OAuth parameter mismatches","items":{"$ref":"#/components/schemas/contracts.OAuthIssue"},"type":"array","uniqueItems":false},"oauth_required":{"items":{"$ref":"#/components/schemas/contracts.OAuthRequirement"},"type":"array","uniqueItems":false},"runtime_warnings":{"items":{"type":"string"},"type":"array","uniqueItems":false},"timestamp":{"type":"string"},"total_issues":{"type":"integer"},"upstream_errors":{"items":{"$ref":"#/components/schemas/contracts.UpstreamError"},"type":"array","uniqueItems":false}},"type":"object"},"contracts.DockerStatus":{"properties":{"available":{"type":"boolean"},"error":{"type":"string"},"version":{"type":"string"}},"type":"object"},"contracts.ErrorResponse":{"properties":{"error":{"type":"string"},"success":{"type":"boolean"}},"type":"object"},"contracts.GetConfigResponse":{"properties":{"config":{"description":"The configuration object"},"config_path":{"description":"Path to config file","type":"string"}},"type":"object"},"contracts.GetRegistriesResponse":{"properties":{"registries":{"items":{"$ref":"#/components/schemas/contracts.Registry"},"type":"array","uniqueItems":false},"total":{"type":"integer"}},"type":"object"},"contracts.GetServerLogsResponse":{"properties":{"count":{"type":"integer"},"logs":{"items":{"$ref":"#/components/schemas/contracts.LogEntry"},"type":"array","uniqueItems":false},"server_name":{"type":"string"}},"type":"object"},"contracts.GetServerToolCallsResponse":{"properties":{"server_name":{"type":"string"},"tool_calls":{"items":{"$ref":"#/components/schemas/contracts.ToolCallRecord"},"type":"array","uniqueItems":false},"total":{"type":"integer"}},"type":"object"},"contracts.GetServerToolsResponse":{"properties":{"count":{"type":"integer"},"server_name":{"type":"string"},"tools":{"items":{"$ref":"#/components/schemas/contracts.Tool"},"type":"array","uniqueItems":false}},"type":"object"},"contracts.GetServersResponse":{"properties":{"servers":{"items":{"$ref":"#/components/schemas/contracts.Server"},"type":"array","uniqueItems":false},"stats":{"$ref":"#/components/schemas/contracts.ServerStats"}},"type":"object"},"contracts.GetSessionDetailResponse":{"properties":{"session":{"$ref":"#/components/schemas/contracts.MCPSession"}},"type":"object"},"contracts.GetSessionsResponse":{"properties":{"limit":{"type":"integer"},"offset":{"type":"integer"},"sessions":{"items":{"$ref":"#/components/schemas/contracts.MCPSession"},"type":"array","uniqueItems":false},"total":{"type":"integer"}},"type":"object"},"contracts.GetToolCallDetailResponse":{"properties":{"tool_call":{"$ref":"#/components/schemas/contracts.ToolCallRecord"}},"type":"object"},"contracts.GetToolCallsResponse":{"properties":{"limit":{"type":"integer"},"offset":{"type":"integer"},"tool_calls":{"items":{"$ref":"#/components/schemas/contracts.ToolCallRecord"},"type":"array","uniqueItems":false},"total":{"type":"integer"}},"type":"object"},"contracts.HealthStatus":{"description":"Unified health status calculated by the backend","properties":{"action":{"description":"Action is the suggested fix action: \"login\", \"restart\", \"enable\", \"approve\", \"view_logs\", or \"\" (none)","type":"string"},"admin_state":{"description":"AdminState indicates the admin state: \"enabled\", \"disabled\", or \"quarantined\"","type":"string"},"detail":{"description":"Detail is an optional longer explanation of the status","type":"string"},"level":{"description":"Level indicates the health level: \"healthy\", \"degraded\", or \"unhealthy\"","type":"string"},"summary":{"description":"Summary is a human-readable status message (e.g., \"Connected (5 tools)\")","type":"string"}},"type":"object"},"contracts.InfoEndpoints":{"description":"Available API endpoints","properties":{"http":{"description":"HTTP endpoint address (e.g., \"127.0.0.1:8080\")","type":"string"},"socket":{"description":"Unix socket path (empty if disabled)","type":"string"}},"type":"object"},"contracts.InfoResponse":{"properties":{"endpoints":{"$ref":"#/components/schemas/contracts.InfoEndpoints"},"listen_addr":{"description":"Listen address (e.g., \"127.0.0.1:8080\")","type":"string"},"update":{"$ref":"#/components/schemas/contracts.UpdateInfo"},"version":{"description":"Current MCPProxy version","type":"string"},"web_ui_url":{"description":"URL to access the web control panel","type":"string"}},"type":"object"},"contracts.IsolationConfig":{"properties":{"cpu_limit":{"type":"string"},"enabled":{"type":"boolean"},"image":{"type":"string"},"memory_limit":{"type":"string"},"timeout":{"type":"string"},"working_dir":{"type":"string"}},"type":"object"},"contracts.LogEntry":{"properties":{"fields":{"additionalProperties":{},"type":"object"},"level":{"type":"string"},"message":{"type":"string"},"server":{"type":"string"},"timestamp":{"type":"string"}},"type":"object"},"contracts.MCPSession":{"properties":{"client_name":{"type":"string"},"client_version":{"type":"string"},"end_time":{"type":"string"},"experimental":{"items":{"type":"string"},"type":"array","uniqueItems":false},"has_roots":{"description":"MCP Client Capabilities","type":"boolean"},"has_sampling":{"type":"boolean"},"id":{"type":"string"},"last_activity":{"type":"string"},"start_time":{"type":"string"},"status":{"type":"string"},"tool_call_count":{"type":"integer"},"total_tokens":{"type":"integer"}},"type":"object"},"contracts.MissingSecretInfo":{"properties":{"secret_name":{"type":"string"},"used_by":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"contracts.NPMPackageInfo":{"properties":{"exists":{"type":"boolean"},"install_cmd":{"type":"string"}},"type":"object"},"contracts.OAuthConfig":{"properties":{"auth_url":{"type":"string"},"client_id":{"type":"string"},"extra_params":{"additionalProperties":{"type":"string"},"type":"object"},"pkce_enabled":{"type":"boolean"},"redirect_port":{"type":"integer"},"scopes":{"items":{"type":"string"},"type":"array","uniqueItems":false},"token_expires_at":{"description":"When the OAuth token expires","type":"string"},"token_url":{"type":"string"},"token_valid":{"description":"Whether token is currently valid","type":"boolean"}},"type":"object"},"contracts.OAuthIssue":{"properties":{"documentation_url":{"type":"string"},"error":{"type":"string"},"issue":{"type":"string"},"missing_params":{"items":{"type":"string"},"type":"array","uniqueItems":false},"resolution":{"type":"string"},"server_name":{"type":"string"}},"type":"object"},"contracts.OAuthRequirement":{"properties":{"expires_at":{"type":"string"},"message":{"type":"string"},"server_name":{"type":"string"},"state":{"type":"string"}},"type":"object"},"contracts.Registry":{"properties":{"count":{"description":"number or string"},"description":{"type":"string"},"id":{"type":"string"},"name":{"type":"string"},"protocol":{"type":"string"},"servers_url":{"type":"string"},"tags":{"items":{"type":"string"},"type":"array","uniqueItems":false},"url":{"type":"string"}},"type":"object"},"contracts.ReplayToolCallRequest":{"properties":{"arguments":{"additionalProperties":{},"description":"Modified arguments for replay","type":"object"}},"type":"object"},"contracts.ReplayToolCallResponse":{"properties":{"error":{"description":"Error if replay failed","type":"string"},"new_call_id":{"description":"ID of the newly created call","type":"string"},"new_tool_call":{"$ref":"#/components/schemas/contracts.ToolCallRecord"},"replayed_from":{"description":"Original call ID","type":"string"},"success":{"type":"boolean"}},"type":"object"},"contracts.RepositoryInfo":{"description":"Detected package info","properties":{"npm":{"$ref":"#/components/schemas/contracts.NPMPackageInfo"}},"type":"object"},"contracts.RepositoryServer":{"properties":{"connect_url":{"description":"Alternative connection URL","type":"string"},"created_at":{"type":"string"},"description":{"type":"string"},"id":{"type":"string"},"install_cmd":{"description":"Installation command","type":"string"},"name":{"type":"string"},"registry":{"description":"Which registry this came from","type":"string"},"repository_info":{"$ref":"#/components/schemas/contracts.RepositoryInfo"},"source_code_url":{"description":"Source repository URL","type":"string"},"updated_at":{"type":"string"},"url":{"description":"MCP endpoint for remote servers only","type":"string"}},"type":"object"},"contracts.SearchRegistryServersResponse":{"properties":{"query":{"type":"string"},"registry_id":{"type":"string"},"servers":{"items":{"$ref":"#/components/schemas/contracts.RepositoryServer"},"type":"array","uniqueItems":false},"tag":{"type":"string"},"total":{"type":"integer"}},"type":"object"},"contracts.SearchResult":{"properties":{"matches":{"type":"integer"},"score":{"type":"number"},"snippet":{"type":"string"},"tool":{"$ref":"#/components/schemas/contracts.Tool"}},"type":"object"},"contracts.SearchToolsResponse":{"properties":{"query":{"type":"string"},"results":{"items":{"$ref":"#/components/schemas/contracts.SearchResult"},"type":"array","uniqueItems":false},"took":{"type":"string"},"total":{"type":"integer"}},"type":"object"},"contracts.Server":{"properties":{"args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"authenticated":{"description":"OAuth authentication status","type":"boolean"},"command":{"type":"string"},"connected":{"type":"boolean"},"connected_at":{"type":"string"},"connecting":{"type":"boolean"},"created":{"type":"string"},"enabled":{"type":"boolean"},"env":{"additionalProperties":{"type":"string"},"type":"object"},"headers":{"additionalProperties":{"type":"string"},"type":"object"},"health":{"$ref":"#/components/schemas/contracts.HealthStatus"},"id":{"type":"string"},"isolation":{"$ref":"#/components/schemas/contracts.IsolationConfig"},"last_error":{"type":"string"},"last_reconnect_at":{"type":"string"},"last_retry_time":{"type":"string"},"name":{"type":"string"},"oauth":{"$ref":"#/components/schemas/contracts.OAuthConfig"},"oauth_status":{"description":"OAuth status: \"authenticated\", \"expired\", \"error\", \"none\"","type":"string"},"protocol":{"type":"string"},"quarantined":{"type":"boolean"},"reconnect_count":{"type":"integer"},"retry_count":{"type":"integer"},"should_retry":{"type":"boolean"},"status":{"type":"string"},"token_expires_at":{"description":"When the OAuth token expires (ISO 8601)","type":"string"},"tool_count":{"type":"integer"},"tool_list_token_size":{"description":"Token size for this server's tools","type":"integer"},"updated":{"type":"string"},"url":{"type":"string"},"user_logged_out":{"description":"True if user explicitly logged out (prevents auto-reconnection)","type":"boolean"},"working_dir":{"type":"string"}},"type":"object"},"contracts.ServerActionResponse":{"properties":{"action":{"type":"string"},"async":{"type":"boolean"},"server":{"type":"string"},"success":{"type":"boolean"}},"type":"object"},"contracts.ServerStats":{"properties":{"connected_servers":{"type":"integer"},"docker_containers":{"type":"integer"},"quarantined_servers":{"type":"integer"},"token_metrics":{"$ref":"#/components/schemas/contracts.ServerTokenMetrics"},"total_servers":{"type":"integer"},"total_tools":{"type":"integer"}},"type":"object"},"contracts.ServerTokenMetrics":{"properties":{"average_query_result_size":{"description":"Typical retrieve_tools output (tokens)","type":"integer"},"per_server_tool_list_sizes":{"additionalProperties":{"type":"integer"},"description":"Token size per server","type":"object"},"saved_tokens":{"description":"Difference","type":"integer"},"saved_tokens_percentage":{"description":"Percentage saved","type":"number"},"total_server_tool_list_size":{"description":"All upstream tools combined (tokens)","type":"integer"}},"type":"object"},"contracts.SuccessResponse":{"properties":{"data":{},"success":{"type":"boolean"}},"type":"object"},"contracts.TokenMetrics":{"description":"Token usage metrics (nil for older records)","properties":{"encoding":{"description":"Encoding used (e.g., cl100k_base)","type":"string"},"estimated_cost":{"description":"Optional cost estimate","type":"number"},"input_tokens":{"description":"Tokens in the request","type":"integer"},"model":{"description":"Model used for tokenization","type":"string"},"output_tokens":{"description":"Tokens in the response","type":"integer"},"total_tokens":{"description":"Total tokens (input + output)","type":"integer"},"truncated_tokens":{"description":"Tokens removed by truncation","type":"integer"},"was_truncated":{"description":"Whether response was truncated","type":"boolean"}},"type":"object"},"contracts.Tool":{"properties":{"annotations":{"$ref":"#/components/schemas/contracts.ToolAnnotation"},"description":{"type":"string"},"last_used":{"type":"string"},"name":{"type":"string"},"schema":{"additionalProperties":{},"type":"object"},"server_name":{"type":"string"},"usage":{"type":"integer"}},"type":"object"},"contracts.ToolAnnotation":{"description":"Tool behavior hints snapshot","properties":{"destructiveHint":{"type":"boolean"},"idempotentHint":{"type":"boolean"},"openWorldHint":{"type":"boolean"},"readOnlyHint":{"type":"boolean"},"title":{"type":"string"}},"type":"object"},"contracts.ToolCallRecord":{"description":"The new tool call record","properties":{"annotations":{"$ref":"#/components/schemas/contracts.ToolAnnotation"},"arguments":{"additionalProperties":{},"description":"Tool arguments","type":"object"},"config_path":{"description":"Active config file path","type":"string"},"duration":{"description":"Duration in nanoseconds","type":"integer"},"error":{"description":"Error message (failure only)","type":"string"},"execution_type":{"description":"\"direct\" or \"code_execution\"","type":"string"},"id":{"description":"Unique identifier","type":"string"},"mcp_client_name":{"description":"MCP client name from InitializeRequest","type":"string"},"mcp_client_version":{"description":"MCP client version","type":"string"},"mcp_session_id":{"description":"MCP session identifier","type":"string"},"metrics":{"$ref":"#/components/schemas/contracts.TokenMetrics"},"parent_call_id":{"description":"Links nested calls to parent code_execution","type":"string"},"request_id":{"description":"Request correlation ID","type":"string"},"response":{"description":"Tool response (success only)"},"server_id":{"description":"Server identity hash","type":"string"},"server_name":{"description":"Human-readable server name","type":"string"},"timestamp":{"description":"When the call was made","type":"string"},"tool_name":{"description":"Tool name (without server prefix)","type":"string"}},"type":"object"},"contracts.UpdateInfo":{"description":"Update information (if available)","properties":{"available":{"description":"Whether an update is available","type":"boolean"},"check_error":{"description":"Error message if update check failed","type":"string"},"checked_at":{"description":"When the update check was performed","type":"string"},"is_prerelease":{"description":"Whether the latest version is a prerelease","type":"boolean"},"latest_version":{"description":"Latest version available (e.g., \"v1.2.3\")","type":"string"},"release_url":{"description":"URL to the release page","type":"string"}},"type":"object"},"contracts.UpstreamError":{"properties":{"error_message":{"type":"string"},"server_name":{"type":"string"},"timestamp":{"type":"string"}},"type":"object"},"contracts.ValidateConfigResponse":{"properties":{"errors":{"items":{"$ref":"#/components/schemas/contracts.ValidationError"},"type":"array","uniqueItems":false},"valid":{"type":"boolean"}},"type":"object"},"contracts.ValidationError":{"properties":{"field":{"type":"string"},"message":{"type":"string"}},"type":"object"},"data":{"properties":{"data":{"$ref":"#/components/schemas/contracts.InfoResponse"}},"type":"object"},"management.BulkOperationResult":{"properties":{"errors":{"additionalProperties":{"type":"string"},"description":"Map of server name to error message","type":"object"},"failed":{"description":"Number of failed operations","type":"integer"},"successful":{"description":"Number of successful operations","type":"integer"},"total":{"description":"Total servers processed","type":"integer"}},"type":"object"},"observability.HealthResponse":{"properties":{"components":{"items":{"$ref":"#/components/schemas/observability.HealthStatus"},"type":"array","uniqueItems":false},"status":{"description":"\"healthy\" or \"unhealthy\"","type":"string"},"timestamp":{"type":"string"}},"type":"object"},"observability.HealthStatus":{"properties":{"error":{"type":"string"},"latency":{"type":"string"},"name":{"type":"string"},"status":{"description":"\"healthy\" or \"unhealthy\"","type":"string"}},"type":"object"},"observability.ReadinessResponse":{"properties":{"components":{"items":{"$ref":"#/components/schemas/observability.HealthStatus"},"type":"array","uniqueItems":false},"status":{"description":"\"ready\" or \"not_ready\"","type":"string"},"timestamp":{"type":"string"}},"type":"object"}},"securitySchemes":{"ApiKeyAuth":{"description":"API key authentication via query parameter. Use ?apikey=your-key","in":"query","name":"apikey","type":"apiKey"}}},
"info": {"contact":{"name":"MCPProxy Support","url":"https://github.com/smart-mcp-proxy/mcpproxy-go"},"description":"{{escape .Description}}","license":{"name":"MIT","url":"https://opensource.org/licenses/MIT"},"title":"{{.Title}}","version":"{{.Version}}"},
"externalDocs": {"description":"","url":""},
"paths": {"/api/v1/config":{"get":{"description":"Retrieves the current MCPProxy configuration including all server definitions, global settings, and runtime parameters","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetConfigResponse"}}},"description":"Configuration retrieved successfully"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to get configuration"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get current configuration","tags":["config"]}},"/api/v1/config/apply":{"post":{"description":"Applies a new MCPProxy configuration. Validates and persists the configuration to disk. Some changes apply immediately, while others may require a restart. Returns detailed information about applied changes and restart requirements.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/config.Config"}}},"description":"Configuration to apply","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ConfigApplyResult"}}},"description":"Configuration applied successfully with change details"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid JSON payload"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to apply configuration"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Apply configuration","tags":["config"]}},"/api/v1/config/validate":{"post":{"description":"Validates a provided MCPProxy configuration without applying it. Checks for syntax errors, invalid server definitions, conflicting settings, and other configuration issues.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/config.Config"}}},"description":"Configuration to validate","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ValidateConfigResponse"}}},"description":"Configuration validation result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid JSON payload"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Validation failed"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Validate configuration","tags":["config"]}},"/api/v1/diagnostics":{"get":{"description":"Get comprehensive health diagnostics including upstream errors, OAuth requirements, missing secrets, and Docker status","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.Diagnostics"}}},"description":"Health diagnostics"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get health diagnostics","tags":["diagnostics"]}},"/api/v1/docker/status":{"get":{"description":"Retrieve current Docker availability and recovery status","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Docker status information"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get Docker status","tags":["docker"]}},"/api/v1/doctor":{"get":{"description":"Get comprehensive health diagnostics including upstream errors, OAuth requirements, missing secrets, and Docker status","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.Diagnostics"}}},"description":"Health diagnostics"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get health diagnostics","tags":["diagnostics"]}},"/api/v1/index/search":{"get":{"description":"Search across all upstream MCP server tools using BM25 keyword search","parameters":[{"description":"Search query","in":"query","name":"q","required":true,"schema":{"type":"string"}},{"description":"Maximum number of results","in":"query","name":"limit","schema":{"default":10,"maximum":100,"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SearchToolsResponse"}}},"description":"Search results"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing query parameter)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Search for tools","tags":["tools"]}},"/api/v1/info":{"get":{"description":"Get essential server metadata including version, web UI URL, endpoint addresses, and update availability\nThis endpoint is designed for tray-core communication and version checking","responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{},"error":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"Server information with optional update info"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get server information","tags":["status"]}},"/api/v1/registries":{"get":{"description":"Retrieves list of all MCP server registries that can be browsed for discovering and installing new upstream servers. Includes registry metadata, server counts, and API endpoints.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetRegistriesResponse"}}},"description":"Registries retrieved successfully"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to list registries"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"List available MCP server registries","tags":["registries"]}},"/api/v1/registries/{id}/servers":{"get":{"description":"Searches for MCP servers within a specific registry by keyword or tag. Returns server metadata including installation commands, source code URLs, and npm package information for easy discovery and installation.","parameters":[{"description":"Registry ID","in":"path","name":"id","required":true,"schema":{"type":"string"}},{"description":"Search query keyword","in":"query","name":"q","schema":{"type":"string"}},{"description":"Filter by tag","in":"query","name":"tag","schema":{"type":"string"}},{"description":"Maximum number of results (default 10)","in":"query","name":"limit","schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SearchRegistryServersResponse"}}},"description":"Servers retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Registry ID required"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to search servers"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Search MCP servers in a registry","tags":["registries"]}},"/api/v1/secrets":{"post":{"description":"Stores a secret value in the operating system's secure keyring. The secret can then be referenced in configuration using ${keyring:secret-name} syntax. Automatically notifies runtime to restart affected servers.","requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":{},"type":"object"}}},"description":"Secret stored successfully with reference syntax"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid JSON payload, missing name/value, or unsupported type"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Secret resolver not available or failed to store secret"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Store a secret in OS keyring","tags":["secrets"]}},"/api/v1/secrets/{name}":{"delete":{"description":"Deletes a secret from the operating system's secure keyring. Automatically notifies runtime to restart affected servers. Only keyring type is supported for security.","parameters":[{"description":"Name of the secret to delete","in":"path","name":"name","required":true,"schema":{"type":"string"}},{"description":"Secret type (only 'keyring' supported, defaults to 'keyring')","in":"query","name":"type","schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":{},"type":"object"}}},"description":"Secret deleted successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Missing secret name or unsupported type"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Secret resolver not available or failed to delete secret"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Delete a secret from OS keyring","tags":["secrets"]}},"/api/v1/servers":{"get":{"description":"Get a list of all configured upstream MCP servers with their connection status and statistics","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetServersResponse"}}},"description":"Server list with statistics"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"List all upstream MCP servers","tags":["servers"]}},"/api/v1/servers/disable_all":{"post":{"description":"Disable all configured upstream MCP servers with partial failure handling","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/management.BulkOperationResult"}}},"description":"Bulk disable results with success/failure counts"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (management disabled)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Disable all servers","tags":["servers"]}},"/api/v1/servers/enable_all":{"post":{"description":"Enable all configured upstream MCP servers with partial failure handling","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/management.BulkOperationResult"}}},"description":"Bulk enable results with success/failure counts"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (management disabled)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Enable all servers","tags":["servers"]}},"/api/v1/servers/reconnect":{"post":{"description":"Force reconnection to all upstream MCP servers","parameters":[{"description":"Reason for reconnection","in":"query","name":"reason","schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"All servers reconnected successfully"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Reconnect all servers","tags":["servers"]}},"/api/v1/servers/restart_all":{"post":{"description":"Restart all configured upstream MCP servers sequentially with partial failure handling","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/management.BulkOperationResult"}}},"description":"Bulk restart results with success/failure counts"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (management disabled)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Restart all servers","tags":["servers"]}},"/api/v1/servers/{id}/disable":{"post":{"description":"Disable a specific upstream MCP server","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server disabled successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Disable an upstream server","tags":["servers"]}},"/api/v1/servers/{id}/enable":{"post":{"description":"Enable a specific upstream MCP server","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server enabled successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Enable an upstream server","tags":["servers"]}},"/api/v1/servers/{id}/login":{"post":{"description":"Initiate OAuth authentication flow for a specific upstream MCP server","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"OAuth login initiated successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Trigger OAuth login for server","tags":["servers"]}},"/api/v1/servers/{id}/logout":{"post":{"description":"Clear OAuth authentication token and disconnect a specific upstream MCP server. The server will need to re-authenticate before tools can be used again.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"OAuth logout completed successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (management disabled or read-only mode)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Clear OAuth token and disconnect server","tags":["servers"]}},"/api/v1/servers/{id}/logs":{"get":{"description":"Retrieve log entries for a specific upstream MCP server","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}},{"description":"Number of log lines to retrieve","in":"query","name":"tail","schema":{"default":100,"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetServerLogsResponse"}}},"description":"Server logs retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get server logs","tags":["servers"]}},"/api/v1/servers/{id}/quarantine":{"post":{"description":"Place a specific upstream MCP server in quarantine to prevent tool execution","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server quarantined successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Quarantine a server","tags":["servers"]}},"/api/v1/servers/{id}/restart":{"post":{"description":"Restart the connection to a specific upstream MCP server","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server restarted successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Restart an upstream server","tags":["servers"]}},"/api/v1/servers/{id}/tool-calls":{"get":{"description":"Retrieves tool call history filtered by upstream server ID. Returns recent tool executions for the specified server including timestamps, arguments, results, and errors. Useful for server-specific debugging and monitoring.","parameters":[{"description":"Upstream server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}},{"description":"Maximum number of records to return (1-100, default 50)","in":"query","name":"limit","schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetServerToolCallsResponse"}}},"description":"Server tool calls retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server ID required"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to get server tool calls"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get tool call history for specific server","tags":["tool-calls"]}},"/api/v1/servers/{id}/tools":{"get":{"description":"Retrieve all available tools for a specific upstream MCP server","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetServerToolsResponse"}}},"description":"Server tools retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get tools for a server","tags":["servers"]}},"/api/v1/servers/{id}/unquarantine":{"post":{"description":"Remove a specific upstream MCP server from quarantine to allow tool execution","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server unquarantined successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Unquarantine a server","tags":["servers"]}},"/api/v1/sessions":{"get":{"description":"Retrieves paginated list of active and recent MCP client sessions. Each session represents a connection from an MCP client to MCPProxy, tracking initialization time, tool calls, and connection status.","parameters":[{"description":"Maximum number of sessions to return (1-100, default 10)","in":"query","name":"limit","schema":{"type":"integer"}},{"description":"Number of sessions to skip for pagination (default 0)","in":"query","name":"offset","schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetSessionsResponse"}}},"description":"Sessions retrieved successfully"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to get sessions"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get active MCP sessions","tags":["sessions"]}},"/api/v1/sessions/{id}":{"get":{"description":"Retrieves detailed information about a specific MCP client session including initialization parameters, connection status, tool call count, and activity timestamps.","parameters":[{"description":"Session ID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetSessionDetailResponse"}}},"description":"Session details retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Session ID required"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Session not found"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get MCP session details by ID","tags":["sessions"]}},"/api/v1/stats/tokens":{"get":{"description":"Retrieve token savings statistics across all servers and sessions","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Token statistics"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get token savings statistics","tags":["stats"]}},"/api/v1/status":{"get":{"description":"Get comprehensive server status including running state, listen address, upstream statistics, and timestamp","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Server status information"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get server status","tags":["status"]}},"/api/v1/tool-calls":{"get":{"description":"Retrieves paginated tool call history across all upstream servers or filtered by session ID. Includes execution timestamps, arguments, results, and error information for debugging and auditing.","parameters":[{"description":"Maximum number of records to return (1-100, default 50)","in":"query","name":"limit","schema":{"type":"integer"}},{"description":"Number of records to skip for pagination (default 0)","in":"query","name":"offset","schema":{"type":"integer"}},{"description":"Filter tool calls by MCP session ID","in":"query","name":"session_id","schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetToolCallsResponse"}}},"description":"Tool calls retrieved successfully"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to get tool calls"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get tool call history","tags":["tool-calls"]}},"/api/v1/tool-calls/{id}":{"get":{"description":"Retrieves detailed information about a specific tool call execution including full request arguments, response data, execution time, and any errors encountered.","parameters":[{"description":"Tool call ID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetToolCallDetailResponse"}}},"description":"Tool call details retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Tool call ID required"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Tool call not found"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get tool call details by ID","tags":["tool-calls"]}},"/api/v1/tool-calls/{id}/replay":{"post":{"description":"Re-executes a previous tool call with optional modified arguments. Useful for debugging and testing tool behavior with different inputs. Creates a new tool call record linked to the original.","parameters":[{"description":"Original tool call ID to replay","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ReplayToolCallRequest"}}},"description":"Optional modified arguments for replay"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ReplayToolCallResponse"}}},"description":"Tool call replayed successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Tool call ID required or invalid JSON payload"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to replay tool call"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Replay a tool call","tags":["tool-calls"]}},"/api/v1/tools/call":{"post":{"description":"Execute a tool on an upstream MCP server (wrapper around MCP tool calls)","requestBody":{"content":{"application/json":{"schema":{"properties":{"arguments":{"type":"object"},"tool_name":{"type":"string"}},"type":"object"}}},"description":"Tool call request with tool name and arguments","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Tool call result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (invalid payload or missing tool name)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error or tool execution failure"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Call a tool","tags":["tools"]}},"/healthz":{"get":{"description":"Get comprehensive health status including all component health (Kubernetes-compatible liveness probe)","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/observability.HealthResponse"}}},"description":"Service is healthy"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/observability.HealthResponse"}}},"description":"Service is unhealthy"}},"summary":"Get health status","tags":["health"]}},"/readyz":{"get":{"description":"Get readiness status including all component readiness checks (Kubernetes-compatible readiness probe)","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/observability.ReadinessResponse"}}},"description":"Service is ready"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/observability.ReadinessResponse"}}},"description":"Service is not ready"}},"summary":"Get readiness status","tags":["health"]}}},
diff --git a/oas/swagger.yaml b/oas/swagger.yaml
index 655855f8..4eb61777 100644
--- a/oas/swagger.yaml
+++ b/oas/swagger.yaml
@@ -282,6 +282,29 @@ components:
total:
type: integer
type: object
+ contracts.HealthStatus:
+ description: Unified health status calculated by the backend
+ properties:
+ action:
+ description: 'Action is the suggested fix action: "login", "restart", "enable",
+ "approve", "view_logs", or "" (none)'
+ type: string
+ admin_state:
+ description: 'AdminState indicates the admin state: "enabled", "disabled",
+ or "quarantined"'
+ type: string
+ detail:
+ description: Detail is an optional longer explanation of the status
+ type: string
+ level:
+ description: 'Level indicates the health level: "healthy", "degraded", or
+ "unhealthy"'
+ type: string
+ summary:
+ description: Summary is a human-readable status message (e.g., "Connected
+ (5 tools)")
+ type: string
+ type: object
contracts.InfoEndpoints:
description: Available API endpoints
properties:
@@ -594,6 +617,8 @@ components:
additionalProperties:
type: string
type: object
+ health:
+ $ref: '#/components/schemas/contracts.HealthStatus'
id:
type: string
isolation:
diff --git a/scripts/verify-oas-coverage.sh b/scripts/verify-oas-coverage.sh
index 70345385..f8dfbe7f 100755
--- a/scripts/verify-oas-coverage.sh
+++ b/scripts/verify-oas-coverage.sh
@@ -40,11 +40,15 @@ echo "π Extracting implemented routes from Go handlers..."
# Extract routes from server.go
# Matches patterns like: r.Get("/config", s.handleGetConfig)
# Captures HTTP method and path
+# Extract routes, excluding:
+# - /ui (web UI routes)
+# - /swagger (Swagger UI routes)
+# - /mcp (MCP protocol endpoints, unprotected by design)
ROUTES=$(grep -E '\br\.(Get|Post|Put|Delete|Patch|Head)\(' "$SERVER_GO" "$CODE_EXEC_GO" 2>/dev/null | \
sed -E 's/.*r\.(Get|Post|Put|Delete|Patch|Head)\("([^"]+)".*/\U\1 \2/' | \
- grep -v '/ui' | \ # Exclude web UI routes
- grep -v '/swagger' | \ # Exclude Swagger UI routes
- grep -v '/mcp' | \ # Exclude MCP protocol endpoints (unprotected by design)
+ grep -v '/ui' | \
+ grep -v '/swagger' | \
+ grep -v '/mcp' | \
sort -u)
# Extract documented paths from OAS
diff --git a/specs/012-unified-health-status/checklists/requirements.md b/specs/012-unified-health-status/checklists/requirements.md
new file mode 100644
index 00000000..c077cca1
--- /dev/null
+++ b/specs/012-unified-health-status/checklists/requirements.md
@@ -0,0 +1,38 @@
+# Specification Quality Checklist: Unified Health Status
+
+**Purpose**: Validate specification completeness and quality before proceeding to planning
+**Created**: 2025-12-11
+**Feature**: [spec.md](../spec.md)
+
+## Content Quality
+
+- [x] No implementation details (languages, frameworks, APIs)
+- [x] Focused on user value and business needs
+- [x] Written for non-technical stakeholders
+- [x] All mandatory sections completed
+
+## Requirement Completeness
+
+- [x] No [NEEDS CLARIFICATION] markers remain
+- [x] Requirements are testable and unambiguous
+- [x] Success criteria are measurable
+- [x] Success criteria are technology-agnostic (no implementation details)
+- [x] All acceptance scenarios are defined
+- [x] Edge cases are identified
+- [x] Scope is clearly bounded
+- [x] Dependencies and assumptions identified
+
+## Feature Readiness
+
+- [x] All functional requirements have clear acceptance criteria
+- [x] User scenarios cover primary flows
+- [x] Feature meets measurable outcomes defined in Success Criteria
+- [x] No implementation details leak into specification
+
+## Notes
+
+- Spec derived from existing design document (docs/designs/2025-12-10-unified-health-status.md)
+- Design decisions were already made through brainstorming session
+- All edge cases have documented resolutions
+- No clarifications needed - design is complete
+- **2025-12-11 Update**: Added MCP tools coverage (User Story 6, FR-017/FR-018, SC-006) to address gap where `upstream_servers list` returns raw fields instead of unified health status
diff --git a/specs/012-unified-health-status/contracts/api.yaml b/specs/012-unified-health-status/contracts/api.yaml
new file mode 100644
index 00000000..b7391608
--- /dev/null
+++ b/specs/012-unified-health-status/contracts/api.yaml
@@ -0,0 +1,178 @@
+# OpenAPI 3.1 Additions for Unified Health Status
+# This file documents the new HealthStatus schema and its integration
+# into existing endpoints. Merge into oas/swagger.yaml during implementation.
+
+openapi: 3.1.0
+info:
+ title: MCPProxy Unified Health Status API Additions
+ version: 1.0.0
+ description: |
+ Schema definitions and endpoint changes for the unified health status feature.
+ This provides consistent health information across CLI, tray, web UI, and MCP tools.
+
+components:
+ schemas:
+ # New schema: HealthStatus
+ HealthStatus:
+ type: object
+ required:
+ - level
+ - admin_state
+ - summary
+ properties:
+ level:
+ type: string
+ enum:
+ - healthy
+ - degraded
+ - unhealthy
+ description: |
+ Health level indicating server readiness:
+ - healthy: Server is ready and functioning normally
+ - degraded: Server works but needs attention soon (e.g., token expiring)
+ - unhealthy: Server is broken and cannot be used until fixed
+ example: "healthy"
+
+ admin_state:
+ type: string
+ enum:
+ - enabled
+ - disabled
+ - quarantined
+ description: |
+ Administrative state of the server:
+ - enabled: Server is active and participating in tool discovery
+ - disabled: Server is intentionally turned off by the user
+ - quarantined: Server is pending security review before activation
+ example: "enabled"
+
+ summary:
+ type: string
+ maxLength: 100
+ description: |
+ Human-readable status message suitable for display in all interfaces.
+ Examples: "Connected (5 tools)", "Token expiring in 45m", "Connection refused"
+ example: "Connected (5 tools)"
+
+ detail:
+ type: string
+ description: |
+ Optional longer explanation providing additional context for debugging.
+ May include technical details not shown in the summary.
+ example: "Last error: connection refused at 10.0.0.1:3000"
+
+ action:
+ type: string
+ enum:
+ - ""
+ - login
+ - restart
+ - enable
+ - approve
+ - view_logs
+ description: |
+ Suggested action to resolve the issue:
+ - "" (empty): No action needed (healthy state)
+ - login: OAuth authentication required
+ - restart: Server needs to be restarted
+ - enable: Server is disabled and should be enabled
+ - approve: Server is quarantined and needs security approval
+ - view_logs: Check server logs for more details
+ example: ""
+
+ # Modified schema: Server (showing health field addition)
+ Server:
+ type: object
+ description: |
+ Upstream MCP server configuration and status.
+ Now includes a unified health field.
+ properties:
+ # ... existing properties (id, name, url, protocol, etc.) ...
+
+ health:
+ $ref: '#/components/schemas/HealthStatus'
+ description: |
+ Unified health status calculated by the backend.
+ Always populated for API responses; all interfaces should use this
+ for consistent status display instead of calculating from raw fields.
+
+# Endpoint changes documentation (for reference)
+paths:
+ /api/v1/servers:
+ get:
+ summary: List all upstream servers
+ description: |
+ Returns all configured upstream MCP servers with their current status.
+
+ **Change in this feature**: Each server in the response now includes a
+ `health` field containing the unified health status. Clients should use
+ this field for status display instead of interpreting raw connection fields.
+ responses:
+ '200':
+ description: List of servers with health status
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ servers:
+ type: array
+ items:
+ $ref: '#/components/schemas/Server'
+ stats:
+ type: object
+ description: Aggregated server statistics
+ example:
+ servers:
+ - id: "abc123"
+ name: "github"
+ protocol: "http"
+ enabled: true
+ connected: true
+ tool_count: 5
+ health:
+ level: "healthy"
+ admin_state: "enabled"
+ summary: "Connected (5 tools)"
+ action: ""
+ - id: "def456"
+ name: "slack"
+ protocol: "http"
+ enabled: true
+ connected: true
+ oauth_status: "expiring"
+ tool_count: 10
+ health:
+ level: "degraded"
+ admin_state: "enabled"
+ summary: "Token expiring in 45m"
+ action: "login"
+ - id: "ghi789"
+ name: "filesystem"
+ protocol: "stdio"
+ enabled: true
+ connected: false
+ last_error: "connection refused"
+ health:
+ level: "unhealthy"
+ admin_state: "enabled"
+ summary: "Connection refused"
+ action: "restart"
+ - id: "jkl012"
+ name: "new-server"
+ protocol: "http"
+ enabled: true
+ quarantined: true
+ health:
+ level: "healthy"
+ admin_state: "quarantined"
+ summary: "Quarantined for review"
+ action: "approve"
+ stats:
+ total_servers: 4
+ connected_servers: 2
+ quarantined_servers: 1
+
+# MCP Protocol Changes (documentation only - not OpenAPI)
+# The MCP upstream_servers tool with operation: list will include the same
+# health field structure in its response for LLM consumption.
diff --git a/specs/012-unified-health-status/data-model.md b/specs/012-unified-health-status/data-model.md
new file mode 100644
index 00000000..3b700eb9
--- /dev/null
+++ b/specs/012-unified-health-status/data-model.md
@@ -0,0 +1,330 @@
+# Data Model: Unified Health Status
+
+**Feature**: 012-unified-health-status
+**Date**: 2025-12-11
+
+## Entities
+
+### HealthStatus (NEW)
+
+Represents the unified health status of an upstream MCP server.
+
+**Location**: `internal/contracts/types.go`
+
+```go
+// HealthStatus represents the unified health status of a server.
+// Calculated once in the backend and rendered identically by all interfaces.
+type HealthStatus struct {
+ // Level indicates the health level: "healthy", "degraded", or "unhealthy"
+ Level string `json:"level"`
+
+ // AdminState indicates the admin state: "enabled", "disabled", or "quarantined"
+ AdminState string `json:"admin_state"`
+
+ // Summary is a human-readable status message (e.g., "Connected (5 tools)")
+ Summary string `json:"summary"`
+
+ // Detail is an optional longer explanation of the status
+ Detail string `json:"detail,omitempty"`
+
+ // Action is the suggested fix action: "login", "restart", "enable", "approve", "view_logs", or "" (none)
+ Action string `json:"action,omitempty"`
+}
+```
+
+**Field Definitions**:
+
+| Field | Type | Required | Description |
+|-------|------|----------|-------------|
+| `level` | string | Yes | Health level: `healthy`, `degraded`, `unhealthy` |
+| `admin_state` | string | Yes | Admin state: `enabled`, `disabled`, `quarantined` |
+| `summary` | string | Yes | Human-readable status (max 100 chars) |
+| `detail` | string | No | Extended explanation for debugging |
+| `action` | string | No | Suggested remediation action |
+
+**Validation Rules**:
+- `level` must be one of: `healthy`, `degraded`, `unhealthy`
+- `admin_state` must be one of: `enabled`, `disabled`, `quarantined`
+- `summary` must be non-empty, max 100 characters
+- `action` must be empty or one of: `login`, `restart`, `enable`, `approve`, `view_logs`
+
+**Constants** (in `internal/health/constants.go`):
+
+```go
+package health
+
+// Health levels
+const (
+ LevelHealthy = "healthy"
+ LevelDegraded = "degraded"
+ LevelUnhealthy = "unhealthy"
+)
+
+// Admin states
+const (
+ StateEnabled = "enabled"
+ StateDisabled = "disabled"
+ StateQuarantined = "quarantined"
+)
+
+// Actions
+const (
+ ActionNone = ""
+ ActionLogin = "login"
+ ActionRestart = "restart"
+ ActionEnable = "enable"
+ ActionApprove = "approve"
+ ActionViewLogs = "view_logs"
+)
+```
+
+---
+
+### Server (MODIFIED)
+
+Extended to include the new `Health` field.
+
+**Location**: `internal/contracts/types.go`
+
+```go
+type Server struct {
+ // ... existing fields (ID, Name, URL, Protocol, etc.) ...
+
+ // Health is the unified health status calculated by the backend.
+ // Always populated for enabled servers; may be minimal for disabled/quarantined.
+ Health *HealthStatus `json:"health,omitempty"`
+}
+```
+
+**Field Definition**:
+
+| Field | Type | Required | Description |
+|-------|------|----------|-------------|
+| `health` | *HealthStatus | No | Unified health status; nil only during migration |
+
+**Migration Notes**:
+- Field is additive; existing clients that don't read `health` are unaffected
+- Backend always populates `health` for new responses
+- Old cached responses may lack `health` field (graceful degradation)
+
+---
+
+### HealthCalculatorInput (INTERNAL)
+
+Input struct for the health calculator function.
+
+**Location**: `internal/health/calculator.go`
+
+```go
+// HealthCalculatorInput contains all fields needed to calculate health status.
+// This struct normalizes data from different sources (StateView, storage, config).
+type HealthCalculatorInput struct {
+ // Server identification
+ Name string
+
+ // Admin state
+ Enabled bool
+ Quarantined bool
+
+ // Connection state
+ State string // "connected", "connecting", "error", "idle", "disconnected"
+ Connected bool
+ LastError string
+
+ // OAuth state (only for OAuth-enabled servers)
+ OAuthRequired bool
+ OAuthStatus string // "authenticated", "expired", "error", "none"
+ TokenExpiresAt *time.Time // When token expires
+ HasRefreshToken bool // True if refresh token exists
+ UserLoggedOut bool // True if user explicitly logged out
+
+ // Tool info
+ ToolCount int
+}
+```
+
+---
+
+### HealthCalculatorConfig (INTERNAL)
+
+Configuration for health calculation thresholds.
+
+**Location**: `internal/health/calculator.go`
+
+```go
+// HealthCalculatorConfig contains configurable thresholds for health calculation.
+type HealthCalculatorConfig struct {
+ // ExpiryWarningDuration is the duration before token expiry to show degraded status.
+ // Default: 1 hour
+ ExpiryWarningDuration time.Duration
+}
+
+// DefaultHealthConfig returns the default health calculator configuration.
+func DefaultHealthConfig() *HealthCalculatorConfig {
+ return &HealthCalculatorConfig{
+ ExpiryWarningDuration: time.Hour,
+ }
+}
+```
+
+---
+
+## State Transitions
+
+### Health Level Transitions
+
+Health level is stateless; it's recalculated on every request based on current state.
+
+```
+βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+β Health Calculation Flow β
+βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+ βββββββββββββββββββ
+ β Start Check β
+ ββββββββββ¬βββββββββ
+ β
+ ββββββββββββββββββΌβββββββββββββββββ
+ β β β
+ βΌ βΌ βΌ
+ ββββββββββββ ββββββββββββ ββββββββββββ
+ β Disabled β βQuarantineβ β Enabled β
+ ββββββ¬ββββββ ββββββ¬ββββββ ββββββ¬ββββββ
+ β β β
+ βΌ βΌ βΌ
+ AdminState: AdminState: Check Connection
+ "disabled" "quarantined" & OAuth State
+ Action:"enable" Action:"approve" β
+ β
+ βββββββββββββββββββΌββββββββββββββββββ
+ β β β
+ βΌ βΌ βΌ
+ ββββββββββββ ββββββββββββ ββββββββββββ
+ β Error β βConnectingβ βConnected β
+ ββββββ¬ββββββ ββββββ¬ββββββ ββββββ¬ββββββ
+ β β β
+ βΌ βΌ βΌ
+ "unhealthy" "degraded" Check OAuth
+ Action:* β β
+ β ββββββββββββΌβββββββββββ
+ β β β β
+ β βΌ βΌ βΌ
+ β Expired Expiring Valid/
+ β β Soon Refreshing
+ β β β β
+ β βΌ βΌ βΌ
+ β"unhealthy" "degraded" "healthy"
+ β "login" "login" ""
+ β
+ βββββββββββββββββββββββββββββ
+```
+
+### Admin State Priority
+
+Admin state is checked first and short-circuits health calculation:
+
+1. If `!enabled` β return immediately with `AdminState: "disabled"`
+2. If `quarantined` β return immediately with `AdminState: "quarantined"`
+3. Otherwise β proceed to connection/OAuth health checks
+
+---
+
+## Relationships
+
+```
+βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+β Entity Relationships β
+βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+β contracts.Server β
+β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+β β ID, Name, URL, Protocol, Command, Args, Env, Headers ββ
+β β Enabled, Quarantined, Connected, Connecting, Status ββ
+β β OAuthStatus, TokenExpiresAt, ToolCount, ... ββ
+β β ββ
+β β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ ββ
+β β β Health *HealthStatus (NEW) β ββ
+β β β - Level: healthy/degraded/unhealthy β ββ
+β β β - AdminState: enabled/disabled/quarantined β ββ
+β β β - Summary: "Connected (5 tools)" β ββ
+β β β - Action: login/restart/enable/approve/view_logs β ββ
+β β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ ββ
+β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ β
+ β Calculated by
+ βΌ
+βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+β health.Calculator β
+β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+β β CalculateHealth(input HealthCalculatorInput) HealthStatus ββ
+β β ββ
+β β Uses: ββ
+β β - HealthCalculatorConfig (thresholds) ββ
+β β - Input fields from Server/StateView ββ
+β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+```
+
+---
+
+## API Impact
+
+### REST API
+
+**Endpoint**: `GET /api/v1/servers`
+
+**Response Change**: Each server in the response now includes a `health` field.
+
+Before:
+```json
+{
+ "servers": [
+ {
+ "id": "abc123",
+ "name": "github",
+ "enabled": true,
+ "connected": true,
+ "oauth_status": "authenticated",
+ "tool_count": 5
+ }
+ ]
+}
+```
+
+After:
+```json
+{
+ "servers": [
+ {
+ "id": "abc123",
+ "name": "github",
+ "enabled": true,
+ "connected": true,
+ "oauth_status": "authenticated",
+ "tool_count": 5,
+ "health": {
+ "level": "healthy",
+ "admin_state": "enabled",
+ "summary": "Connected (5 tools)",
+ "action": ""
+ }
+ }
+ ]
+}
+```
+
+### MCP Protocol
+
+**Tool**: `upstream_servers` with `operation: list`
+
+**Response Change**: Each server includes a `health` field (same structure as REST API).
+
+---
+
+## Storage Impact
+
+**No database changes required.**
+
+The `HealthStatus` is calculated at runtime from existing stored fields. No new tables, buckets, or indices are needed.
diff --git a/specs/012-unified-health-status/plan.md b/specs/012-unified-health-status/plan.md
new file mode 100644
index 00000000..8402d9d3
--- /dev/null
+++ b/specs/012-unified-health-status/plan.md
@@ -0,0 +1,98 @@
+# Implementation Plan: Unified Health Status
+
+**Branch**: `012-unified-health-status` | **Date**: 2025-12-11 | **Spec**: [spec.md](./spec.md)
+**Input**: Feature specification from `/specs/012-unified-health-status/spec.md`
+
+**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/templates/commands/plan.md` for the execution workflow.
+
+## Summary
+
+Implement a unified health status calculation in the backend that provides consistent health information (level, admin state, summary, action) across all four interfaces: CLI, tray, web UI, and MCP tools. The backend calculates health once using a deterministic priority-based algorithm, and all interfaces render the same `HealthStatus` struct. This eliminates the current inconsistency where different interfaces calculate status independently from raw fields.
+
+## Technical Context
+
+**Language/Version**: Go 1.24.0
+**Primary Dependencies**: mcp-go (MCP protocol), zap (logging), chi (HTTP router), Vue 3/TypeScript (frontend)
+**Storage**: BBolt embedded database (`~/.mcpproxy/config.db`) - existing, no schema changes
+**Testing**: `go test`, `./scripts/test-api-e2e.sh`, `./scripts/run-all-tests.sh`
+**Target Platform**: macOS, Linux, Windows (cross-platform)
+**Project Type**: Backend (Go) + Frontend (Vue 3 SPA)
+**Performance Goals**: Health calculation <1ms per server (already fast lock-free StateView reads)
+**Constraints**: Must not break existing API responses; health field is additive
+**Scale/Scope**: 10-50 upstream servers typical; tested up to 1000 tools
+
+## Constitution Check
+
+*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
+
+| Principle | Status | Notes |
+|-----------|--------|-------|
+| **I. Performance at Scale** | β PASS | Health calculation is O(1) per server using existing lock-free StateView; no additional queries required |
+| **II. Actor-Based Concurrency** | β PASS | No new locks or mutexes; health calculated from existing StateView snapshot (immutable) |
+| **III. Configuration-Driven Architecture** | β PASS | Expiry warning threshold will be configurable via `mcp_config.json` |
+| **IV. Security by Default** | β PASS | No security changes; health status only exposes what's already accessible |
+| **V. Test-Driven Development** | β PASS | Will add unit tests for `CalculateHealth()`, integration tests for API response, E2E tests for CLI |
+| **VI. Documentation Hygiene** | β PASS | Will update CLAUDE.md, OpenAPI spec, and inline code comments |
+
+**Architecture Constraints:**
+
+| Constraint | Status | Notes |
+|------------|--------|-------|
+| Core + Tray Split | β PASS | Core calculates health; tray/web UI render via SSE/REST API |
+| Event-Driven Updates | β PASS | Existing `servers.changed` event will include health status |
+| DDD Layering | β PASS | Health calculator is domain logic; placed in new `internal/health/` package |
+| Upstream Client Modularity | N/A | No changes to upstream client layers |
+
+## Project Structure
+
+### Documentation (this feature)
+
+```text
+specs/012-unified-health-status/
+βββ plan.md # This file (/speckit.plan command output)
+βββ research.md # Phase 0 output (/speckit.plan command)
+βββ data-model.md # Phase 1 output (/speckit.plan command)
+βββ quickstart.md # Phase 1 output (/speckit.plan command)
+βββ contracts/ # Phase 1 output (/speckit.plan command)
+β βββ api.yaml # OpenAPI additions for health field
+βββ tasks.md # Phase 2 output (/speckit.tasks command - NOT created by /speckit.plan)
+```
+
+### Source Code (repository root)
+
+```text
+internal/
+βββ contracts/
+β βββ types.go # Add HealthStatus struct
+βββ health/ # NEW: Health calculation domain logic
+β βββ calculator.go # CalculateHealth() function
+β βββ calculator_test.go # Unit tests
+βββ runtime/
+β βββ runtime.go # Integrate health calculation in GetAllServers()
+βββ httpapi/
+β βββ server.go # Health field already included via contracts.Server
+βββ server/
+ βββ mcp.go # Add health to handleListUpstreams() response
+
+cmd/mcpproxy/
+βββ upstream_cmd.go # Update `upstream list` display
+βββ auth_cmd.go # Update `auth status` display
+
+frontend/
+βββ src/
+ βββ components/
+ β βββ ServerCard.vue # Use health.level for badge color, show action
+ βββ views/
+ βββ Dashboard.vue # Show "X servers need attention" banner
+```
+
+**Structure Decision**: This feature touches existing backend (Go) and frontend (Vue) code. No new top-level directories; health calculation is a new package under `internal/health/`. All other changes modify existing files.
+
+## Complexity Tracking
+
+No constitution violations. All changes align with existing architecture:
+
+- No new abstractions beyond simple `CalculateHealth()` function
+- No new dependencies
+- No new storage requirements
+- No new concurrency patterns
diff --git a/specs/012-unified-health-status/quickstart.md b/specs/012-unified-health-status/quickstart.md
new file mode 100644
index 00000000..e2d236c4
--- /dev/null
+++ b/specs/012-unified-health-status/quickstart.md
@@ -0,0 +1,179 @@
+# Quickstart: Unified Health Status Implementation
+
+**Feature**: 012-unified-health-status
+**Estimated Implementation**: Backend (1-2 days), Frontend (1 day), Testing (1 day)
+
+## Overview
+
+This feature adds a unified health status system that calculates server health once in the backend and displays it consistently across CLI, tray, web UI, and MCP tools.
+
+## Implementation Order
+
+### Phase 1: Backend Core (Day 1)
+
+1. **Add HealthStatus type** (`internal/contracts/types.go`)
+ ```go
+ type HealthStatus struct {
+ Level string `json:"level"`
+ AdminState string `json:"admin_state"`
+ Summary string `json:"summary"`
+ Detail string `json:"detail,omitempty"`
+ Action string `json:"action,omitempty"`
+ }
+ ```
+
+2. **Create health calculator** (`internal/health/calculator.go`)
+ ```go
+ func CalculateHealth(input HealthCalculatorInput, cfg *HealthCalculatorConfig) *contracts.HealthStatus
+ ```
+
+3. **Integrate into GetAllServers()** (`internal/runtime/runtime.go`)
+ - After building server response, call `CalculateHealth()` and assign to `Health` field
+
+4. **Add to MCP list response** (`internal/server/mcp.go`)
+ - In `handleListUpstreams()`, include `health` field in each server object
+
+### Phase 2: CLI & Tray (Day 2)
+
+5. **Update CLI display** (`cmd/mcpproxy/upstream_cmd.go`)
+ - Change `upstream list` to show emoji + summary from `Health` field
+ - Add action hints column
+
+6. **Update auth status** (`cmd/mcpproxy/auth_cmd.go`)
+ - Use `Health` field for consistent display
+
+7. **Update tray menu** (`cmd/mcpproxy-tray/`)
+ - Use `Health.Level` for status emoji
+ - Click action based on `Health.Action`
+
+### Phase 3: Web UI (Day 3)
+
+8. **Update ServerCard** (`frontend/src/components/ServerCard.vue`)
+ - Badge color from `health.level`
+ - Action button from `health.action`
+
+9. **Update Dashboard** (`frontend/src/views/Dashboard.vue`)
+ - "X servers need attention" banner
+ - Filter by `health.level !== 'healthy'`
+
+### Phase 4: Testing & Polish (Day 4)
+
+10. **Unit tests** (`internal/health/calculator_test.go`)
+11. **Integration tests** (API response validation)
+12. **E2E tests** (CLI output verification)
+13. **Documentation** (CLAUDE.md, OpenAPI spec)
+
+## Key Files to Modify
+
+| File | Change |
+|------|--------|
+| `internal/contracts/types.go` | Add `HealthStatus` struct, add `Health` field to `Server` |
+| `internal/health/calculator.go` | NEW: Health calculation logic |
+| `internal/health/calculator_test.go` | NEW: Unit tests |
+| `internal/runtime/runtime.go` | Call `CalculateHealth()` in `GetAllServers()` |
+| `internal/server/mcp.go` | Add `health` to `handleListUpstreams()` response |
+| `cmd/mcpproxy/upstream_cmd.go` | Update display format |
+| `cmd/mcpproxy/auth_cmd.go` | Update display format |
+| `frontend/src/components/ServerCard.vue` | Use `health` for display |
+| `frontend/src/views/Dashboard.vue` | Add "needs attention" banner |
+| `oas/swagger.yaml` | Add `HealthStatus` schema |
+| `CLAUDE.md` | Document new health fields |
+
+## Testing Commands
+
+```bash
+# Run unit tests for health calculator
+go test ./internal/health/... -v
+
+# Run full test suite
+./scripts/run-all-tests.sh
+
+# Run API E2E tests
+./scripts/test-api-e2e.sh
+
+# Manual CLI verification
+./mcpproxy upstream list
+./mcpproxy auth status
+```
+
+## Verification Checklist
+
+- [ ] `GET /api/v1/servers` includes `health` field for each server
+- [ ] `mcpproxy upstream list` shows emoji and action hints
+- [ ] Tray menu shows consistent status with CLI
+- [ ] Web UI ServerCard shows colored badge
+- [ ] Dashboard shows "X servers need attention" when applicable
+- [ ] MCP `upstream_servers list` includes `health` field
+- [ ] All interfaces show identical status for same server
+
+## Health Calculation Reference
+
+```go
+// Priority order (first match wins):
+
+// 1. Admin state (short-circuit)
+if !enabled {
+ return HealthStatus{Level: "healthy", AdminState: "disabled", Action: "enable"}
+}
+if quarantined {
+ return HealthStatus{Level: "healthy", AdminState: "quarantined", Action: "approve"}
+}
+
+// 2. Connection errors β unhealthy
+if state == "error" || state == "disconnected" {
+ return HealthStatus{Level: "unhealthy", AdminState: "enabled", Action: "restart"}
+}
+
+// 3. Connecting β degraded
+if state == "connecting" || state == "idle" {
+ return HealthStatus{Level: "degraded", AdminState: "enabled", Action: ""}
+}
+
+// 4. OAuth checks (only if connected)
+if oauthRequired {
+ if userLoggedOut || oauthStatus == "expired" {
+ return HealthStatus{Level: "unhealthy", AdminState: "enabled", Action: "login"}
+ }
+ if tokenExpiringSoon && !hasRefreshToken {
+ return HealthStatus{Level: "degraded", AdminState: "enabled", Action: "login"}
+ }
+}
+
+// 5. Healthy
+return HealthStatus{Level: "healthy", AdminState: "enabled", Action: ""}
+```
+
+## Frontend Color Mapping
+
+```typescript
+const levelToColor = {
+ healthy: 'green',
+ degraded: 'yellow',
+ unhealthy: 'red'
+}
+
+const adminStateToColor = {
+ enabled: null, // Use level color
+ disabled: 'gray',
+ quarantined: 'purple'
+}
+```
+
+## MCP Response Example
+
+```json
+{
+ "servers": [
+ {
+ "name": "github",
+ "enabled": true,
+ "connected": true,
+ "health": {
+ "level": "healthy",
+ "admin_state": "enabled",
+ "summary": "Connected (5 tools)"
+ }
+ }
+ ]
+}
+```
diff --git a/specs/012-unified-health-status/research.md b/specs/012-unified-health-status/research.md
new file mode 100644
index 00000000..875de024
--- /dev/null
+++ b/specs/012-unified-health-status/research.md
@@ -0,0 +1,238 @@
+# Research: Unified Health Status
+
+**Feature**: 012-unified-health-status
+**Date**: 2025-12-11
+**Status**: Complete
+
+## Research Tasks
+
+### 1. OAuth Status Integration
+
+**Question**: How does MCPProxy currently track OAuth token status, and how should it be integrated into health calculation?
+
+**Findings**:
+
+The OAuth system has multiple status indicators stored across different locations:
+
+1. **`contracts.Server.OAuthStatus`** - String field with values: `authenticated`, `expired`, `error`, `none`
+2. **`contracts.Server.TokenExpiresAt`** - `*time.Time` indicating when the token expires
+3. **`contracts.Server.Authenticated`** - Boolean for simple auth check
+4. **`contracts.Server.UserLoggedOut`** - Boolean indicating explicit user logout
+
+OAuth status calculation in `internal/oauth/status.go`:
+- `GetOAuthStatus()` returns the current status string
+- `IsTokenExpired()` checks expiration against current time
+- Token refresh is handled by `internal/oauth/refresh.go` with automatic retry
+
+**Decision**: Use existing OAuth fields directly in health calculation:
+- `OAuthStatus == "expired"` β unhealthy
+- `TokenExpiresAt` within 1 hour && no refresh token β degraded
+- Token valid OR auto-refresh working β healthy
+
+**Rationale**: No new OAuth tracking needed; existing fields provide sufficient information.
+
+**Alternatives Considered**:
+- Creating a new consolidated OAuth state struct β Rejected: adds complexity, duplicates data
+- Querying OAuth manager directly β Rejected: breaks StateView's lock-free read pattern
+
+---
+
+### 2. Connection State Mapping
+
+**Question**: How do StateView connection states map to health levels?
+
+**Findings**:
+
+`internal/runtime/stateview/stateview.go` defines `ServerStatus.State` with values:
+- `idle` - Not started
+- `connecting` - Connection in progress
+- `connected` - Successfully connected
+- `error` - Connection failed
+- `disconnected` - Was connected, now disconnected
+
+Additional fields:
+- `Connected` (bool) - True when successfully connected
+- `LastError` (string) - Error message if in error state
+- `RetryCount` (int) - Number of reconnection attempts
+
+**Decision**: Map states to health levels:
+| State | Connected | Level | Action |
+|-------|-----------|-------|--------|
+| `connected` | true | healthy | - |
+| `connecting` | false | degraded | - |
+| `idle` | false | degraded | - |
+| `error` | false | unhealthy | restart |
+| `disconnected` | false | unhealthy | restart |
+
+**Rationale**: `connecting` and `idle` are transitional states that will resolve; `error` and `disconnected` require intervention.
+
+**Alternatives Considered**:
+- Treating `idle` as unhealthy β Rejected: may occur during normal startup
+- Adding more granular states β Rejected: current states are sufficient; YAGNI
+
+---
+
+### 3. Admin State Precedence
+
+**Question**: How should admin state (disabled/quarantined) interact with health status?
+
+**Findings**:
+
+Design document specifies: "Admin state takes precedence - show 'Disabled'" when server is both disabled AND has other issues.
+
+Current codebase:
+- `ServerStatus.Enabled` - Boolean, false = disabled
+- `ServerStatus.Quarantined` - Boolean, true = quarantined
+
+**Decision**: Check admin state FIRST before health calculation:
+```go
+// Pseudocode
+if !enabled {
+ return AdminState: "disabled", Level: "healthy", Action: "enable"
+}
+if quarantined {
+ return AdminState: "quarantined", Level: "healthy", Action: "approve"
+}
+// Then calculate health from connection/OAuth state
+```
+
+**Rationale**: Disabled/quarantined servers shouldn't show as "unhealthy" because their state is intentional. The action tells users how to enable them.
+
+**Alternatives Considered**:
+- Showing both admin state AND health β Rejected: confusing ("disabled but also unhealthy")
+- Setting Level to "unhealthy" for admin states β Rejected: implies something is broken
+
+---
+
+### 4. Action Types and Hints
+
+**Question**: What action types should be supported and how should they map to interface-specific hints?
+
+**Findings**:
+
+Design document defines actions:
+- `""` (empty) - No action needed
+- `login` - OAuth authentication required
+- `restart` - Server needs restart
+- `enable` - Server is disabled
+- `approve` - Server is quarantined
+- `view_logs` - Check logs for details
+
+**Decision**: Use these exact action types. Map to hints:
+
+| Action | CLI Hint | Tray Action | Web UI Button |
+|--------|----------|-------------|---------------|
+| `login` | `auth login --server=%s` | Open login page | "Login" button |
+| `restart` | `upstream restart %s` | API call | "Restart" button |
+| `enable` | `upstream enable %s` | API call | Toggle switch |
+| `approve` | "Approve in Web UI" | Open approve page | "Approve" button |
+| `view_logs` | `upstream logs %s` | Open logs page | "View Logs" link |
+
+**Rationale**: Matches design document exactly; each interface adapts the action to its UX idiom.
+
+**Alternatives Considered**:
+- Generic "fix" action β Rejected: not actionable
+- Including full command in action field β Rejected: mixes concerns; CLI builds its own hints
+
+---
+
+### 5. Token Expiry Warning Threshold
+
+**Question**: What threshold should trigger "expiring soon" degraded status?
+
+**Findings**:
+
+Spec assumption: "Token expiration threshold for 'expiring soon' warning is configurable (default: 1 hour)"
+
+Current config structure in `internal/config/config.go` has no expiry threshold setting.
+
+**Decision**: Add `oauth_expiry_warning_hours` config option with default 1 hour.
+
+Default: 1 hour (3600 seconds)
+Range: 0.25 hours (15 minutes) to 24 hours
+
+**Rationale**: 1 hour gives users time to re-authenticate without being annoying. Configurable for different use cases.
+
+**Alternatives Considered**:
+- Fixed threshold β Rejected: user feedback may require adjustment
+- Per-server threshold β Rejected: over-engineering for initial implementation
+
+---
+
+### 6. Frontend Integration Pattern
+
+**Question**: How should the Vue frontend consume and display health status?
+
+**Findings**:
+
+Current pattern in `frontend/src/`:
+- `ServerCard.vue` displays server status using individual fields
+- `Dashboard.vue` lists servers but doesn't filter by health
+- API responses are fetched via composables/services
+
+**Decision**:
+1. Use `health.level` for badge color: healthy=green, degraded=yellow, unhealthy=red
+2. Use `health.admin_state` for special styling: disabled=gray, quarantined=purple
+3. Show `health.summary` as status text
+4. Render action button based on `health.action`
+
+Badge component mapping:
+```vue
+
+ {{ server.health.summary }}
+
+```
+
+**Rationale**: Frontend becomes a pure renderer; all intelligence is in backend.
+
+**Alternatives Considered**:
+- Client-side health calculation β Rejected: defeats the purpose of unified backend calculation
+- Multiple API calls to get health β Rejected: health should be embedded in existing endpoints
+
+---
+
+### 7. MCP Tools Response Structure
+
+**Question**: How should health status be structured in MCP `upstream_servers list` responses?
+
+**Findings**:
+
+Current MCP response in `internal/server/mcp.go` `handleListUpstreams()`:
+- Returns `[]map[string]interface{}` with server fields
+- Consumed by LLMs (Claude Code, Cursor, etc.)
+
+**Decision**: Add `health` field to each server object:
+```json
+{
+ "name": "github",
+ "enabled": true,
+ "health": {
+ "level": "unhealthy",
+ "admin_state": "enabled",
+ "summary": "Token expired",
+ "action": "login"
+ }
+}
+```
+
+**Rationale**: LLMs can use `health.action` directly for next steps without interpreting raw fields.
+
+**Alternatives Considered**:
+- Separate `get_server_health` tool β Rejected: requires extra call; less convenient
+- Flattening health fields β Rejected: loses semantic grouping
+
+---
+
+## Summary of Decisions
+
+| Area | Decision |
+|------|----------|
+| OAuth Integration | Use existing `OAuthStatus`, `TokenExpiresAt` fields |
+| Connection Mapping | `connected`=healthy, `connecting`=degraded, `error`=unhealthy |
+| Admin Precedence | Check disabled/quarantined FIRST, before health |
+| Action Types | 6 types: empty, login, restart, enable, approve, view_logs |
+| Expiry Threshold | Configurable, default 1 hour |
+| Frontend Pattern | Backend calculates; frontend renders with color/action mapping |
+| MCP Response | Nested `health` object in server list response |
+
+All research questions resolved. No NEEDS CLARIFICATION items remain.
diff --git a/specs/012-unified-health-status/spec.md b/specs/012-unified-health-status/spec.md
new file mode 100644
index 00000000..c1a0c0bd
--- /dev/null
+++ b/specs/012-unified-health-status/spec.md
@@ -0,0 +1,192 @@
+# Feature Specification: Unified Health Status
+
+**Feature Branch**: `012-unified-health-status`
+**Created**: 2025-12-11
+**Status**: Draft
+**Input**: User description: "from docs/designs/2025-12-10-unified-health-status.md"
+**Design Document**: [docs/designs/2025-12-10-unified-health-status.md](../../docs/designs/2025-12-10-unified-health-status.md)
+
+## Problem Statement
+
+MCPProxy currently displays inconsistent server health status across its four interfaces:
+
+1. **CLI** reads `oauth_status` and shows "Token Expired"
+2. **Tray** only checks HTTP connectivity and shows "Healthy"
+3. **Web UI** may show different status based on its own interpretation
+4. **MCP Tools** (`upstream_servers list`) return raw connection state fields without unified health interpretation
+
+This leads to user confusion when the same server shows different states in different interfaces. LLMs interacting via MCP tools must interpret raw fields (`connection_status.state`, `enabled`, `quarantined`, `oauth_status`) and calculate health themselves, leading to inconsistent conclusions. Additionally, when servers have issues, users often don't know what action to take to resolve them.
+
+## User Scenarios & Testing *(mandatory)*
+
+### User Story 1 - Consistent Status Across Interfaces (Priority: P1)
+
+As a user, I want to see the same health status for a server regardless of whether I'm using the CLI, tray, web UI, or MCP tools, so I can trust the information and not be confused by conflicting reports.
+
+**Why this priority**: This is the core problem - inconsistent status erodes trust and causes confusion. Without this, all other improvements are undermined.
+
+**Independent Test**: Can be tested by checking any server's status in all four interfaces and verifying they show identical health level and summary.
+
+**Acceptance Scenarios**:
+
+1. **Given** a server with an expired OAuth token, **When** I check status in CLI, tray, web UI, and MCP tools, **Then** all four show "unhealthy" status with the same summary message.
+2. **Given** a healthy connected server, **When** I check status in CLI, tray, web UI, and MCP tools, **Then** all four show "healthy" status with matching tool counts.
+3. **Given** a disabled server, **When** I check status in all interfaces, **Then** all four show "disabled" admin state consistently.
+
+---
+
+### User Story 2 - Actionable Guidance for Issues (Priority: P1)
+
+As a user, when a server has an issue, I want to see what action I should take to fix it, so I don't have to guess or search documentation.
+
+**Why this priority**: Equally critical to consistency - users need to know HOW to fix problems, not just that problems exist.
+
+**Independent Test**: Can be tested by creating various error conditions and verifying each displays an appropriate action.
+
+**Acceptance Scenarios**:
+
+1. **Given** a server with expired OAuth token, **When** I view its status, **Then** I see an action suggesting to login (CLI shows command, tray/web show button).
+2. **Given** a server with connection refused error, **When** I view its status, **Then** I see an action suggesting to restart.
+3. **Given** a healthy server, **When** I view its status, **Then** no action is shown (none needed).
+
+---
+
+### User Story 3 - OAuth Token Visibility in Tray/Web (Priority: P2)
+
+As a user, I want to see OAuth token issues (expired, expiring soon) in the tray and web UI, not just the CLI, so I'm aware of authentication problems across all interfaces.
+
+**Why this priority**: Addresses a specific gap where OAuth status was only visible in CLI, which many users don't use regularly.
+
+**Independent Test**: Can be tested by letting an OAuth token expire and verifying tray and web UI both indicate the issue.
+
+**Acceptance Scenarios**:
+
+1. **Given** a server with OAuth token expiring in 30 minutes (and no refresh token), **When** I view the tray menu, **Then** I see a yellow/degraded status indicator with "Token expiring" message.
+2. **Given** a server with expired OAuth token, **When** I view the web dashboard, **Then** I see the server listed as needing attention with a Login action.
+
+---
+
+### User Story 4 - Admin State Separate from Health (Priority: P2)
+
+As a user, I want disabled and quarantined servers to show their admin state clearly distinct from health status, so I understand they're intentionally inactive rather than broken.
+
+**Why this priority**: Prevents confusion between "server is off" and "server is broken".
+
+**Independent Test**: Can be tested by disabling a server and verifying it shows disabled state, not an error.
+
+**Acceptance Scenarios**:
+
+1. **Given** a disabled server, **When** I view its status, **Then** I see "Disabled" admin state (not "unhealthy" or "error").
+2. **Given** a quarantined server, **When** I view its status, **Then** I see "Quarantined" admin state with an "approve" action.
+
+---
+
+### User Story 5 - Dashboard Shows Servers Needing Attention (Priority: P3)
+
+As a user, I want the web dashboard to highlight servers that need attention (degraded or unhealthy), so I can quickly identify and fix issues.
+
+**Why this priority**: Quality-of-life improvement that builds on the core health status feature.
+
+**Independent Test**: Can be tested by having a mix of healthy and unhealthy servers and verifying dashboard shows the right count/list.
+
+**Acceptance Scenarios**:
+
+1. **Given** 3 healthy servers and 2 unhealthy servers, **When** I view the dashboard, **Then** I see "2 servers need attention" with quick-fix buttons.
+2. **Given** all servers healthy, **When** I view the dashboard, **Then** I see no "needs attention" banner.
+
+---
+
+### User Story 6 - MCP Tools Return Unified Health Status (Priority: P2)
+
+As an LLM (Claude Code, Cursor, etc.) interacting with MCPProxy via MCP tools, I want the `upstream_servers list` operation to return a unified health status for each server, so I can understand server health without interpreting raw connection fields.
+
+**Why this priority**: LLMs are a primary consumer of MCPProxy. Without unified health in MCP tools, LLMs must interpret raw fields and may draw incorrect conclusions about server health.
+
+**Independent Test**: Can be tested by calling `upstream_servers` with `operation=list` via MCP protocol and verifying each server includes a `health` field with the unified status structure.
+
+**Acceptance Scenarios**:
+
+1. **Given** a server with an expired OAuth token, **When** an LLM calls `upstream_servers list` via MCP, **Then** the response includes `health.level: "unhealthy"` and `health.action: "login"`.
+2. **Given** a healthy connected server, **When** an LLM calls `upstream_servers list` via MCP, **Then** the response includes `health.level: "healthy"` with appropriate summary.
+3. **Given** a quarantined server, **When** an LLM calls `upstream_servers list` via MCP, **Then** the response includes `health.admin_state: "quarantined"` and `health.action: "approve"`.
+
+---
+
+### Edge Cases
+
+- What happens when a server is both disabled AND has an expired token? Admin state takes precedence - show "Disabled".
+- How does system handle servers that are connecting but not yet ready? Show "degraded" with no action required.
+- What if OAuth auto-refresh is working but token is about to expire? Show "healthy" - auto-refresh handles it automatically.
+- What if token has no expiration time set? Assume valid if no explicit expiration.
+
+## Requirements *(mandatory)*
+
+### Functional Requirements
+
+- **FR-001**: System MUST calculate a single unified health status in the backend for each server
+- **FR-002**: System MUST include health level (healthy/degraded/unhealthy) in the status
+- **FR-003**: System MUST include admin state (enabled/disabled/quarantined) separate from health
+- **FR-004**: System MUST include a human-readable summary message in the status
+- **FR-005**: System MUST include an action type (login/restart/enable/approve/view_logs) when applicable
+- **FR-006**: CLI MUST display health status with emoji indicators: β healthy, β οΈ degraded, β unhealthy, βΈοΈ disabled, π quarantined
+- **FR-007**: CLI MUST display action as a command hint (e.g., "auth login --server=X")
+- **FR-008**: Tray MUST display health status with emoji indicators matching CLI: β healthy, β οΈ degraded, β unhealthy, βΈοΈ disabled, π quarantined
+- **FR-009**: Tray MUST provide clickable actions that resolve the issue (open web UI or trigger API)
+- **FR-010**: Web UI MUST display health status with colored badges: green=healthy, yellow=degraded, red=unhealthy, gray=disabled, purple=quarantined
+- **FR-011**: Web UI MUST display action buttons appropriate to each issue type
+- **FR-012**: Dashboard MUST show count of servers needing attention
+- **FR-013**: Admin state MUST take precedence over health when server is not enabled
+- **FR-014**: OAuth token expiration MUST be considered unhealthy (not degraded)
+- **FR-015**: OAuth token expiring soon with no refresh token MUST be considered degraded
+- **FR-016**: OAuth token with working auto-refresh MUST be considered healthy regardless of expiration time
+- **FR-017**: MCP `upstream_servers` tool with `operation: list` MUST include a `health` field for each server using the same HealthStatus structure as other interfaces
+- **FR-018**: MCP tools MUST return the same health level, admin state, summary, and action as CLI, tray, and web UI for any given server
+
+### Key Entities
+
+- **HealthStatus**: Represents the unified health of a server
+ - Level: healthy, degraded, or unhealthy
+ - AdminState: enabled, disabled, or quarantined
+ - Summary: Human-readable status message
+ - Detail: Optional longer explanation
+ - Action: Suggested fix action type
+
+- **Server**: Existing entity extended with Health field
+ - All existing fields preserved
+ - New Health field containing HealthStatus
+
+## Success Criteria *(mandatory)*
+
+### Measurable Outcomes
+
+- **SC-001**: All four interfaces (CLI, tray, web, MCP tools) display identical health level for any given server
+- **SC-002**: 100% of unhealthy/degraded states include an appropriate action suggestion
+- **SC-003**: Users can identify and fix server issues without consulting documentation
+- **SC-004**: OAuth token expiration is visible in tray, web UI, and MCP tools (not just CLI)
+- **SC-005**: Admin state (disabled/quarantined) is visually distinct from health issues in all interfaces
+- **SC-006**: LLMs can determine server health and required actions from a single MCP tool call without interpreting raw fields
+
+## Assumptions
+
+- All clients (CLI, tray, web) and MCP tools are deployed together, so no backward compatibility is needed
+- The existing `/api/v1/servers` endpoint will be extended to include the health field
+- The existing `upstream_servers` MCP tool will be extended to include the health field in `operation: list` responses
+- Token expiration threshold for "expiring soon" warning is configurable (default: 1 hour)
+- Auto-refresh working means the system will handle token renewal automatically
+- MCP tool responses use the same HealthStatus structure as the REST API to ensure consistency
+
+## Commit Message Conventions *(mandatory)*
+
+When committing changes for this feature, follow these guidelines:
+
+### Issue References
+- Use: `Related #[issue-number]` - Links the commit to the issue without auto-closing
+- Do NOT use: `Fixes #[issue-number]`, `Closes #[issue-number]`, `Resolves #[issue-number]` - These auto-close issues on merge
+
+**Rationale**: Issues should only be closed manually after verification and testing in production, not automatically on merge.
+
+### Co-Authorship
+- Do NOT include: `Co-Authored-By: Claude `
+- Do NOT include: "Generated with Claude Code"
+
+**Rationale**: Commit authorship should reflect the human contributors, not the AI tools used.
diff --git a/specs/012-unified-health-status/tasks.md b/specs/012-unified-health-status/tasks.md
new file mode 100644
index 00000000..f11a440c
--- /dev/null
+++ b/specs/012-unified-health-status/tasks.md
@@ -0,0 +1,262 @@
+# Tasks: Unified Health Status
+
+**Input**: Design documents from `/specs/012-unified-health-status/`
+**Prerequisites**: plan.md, spec.md, research.md, data-model.md, quickstart.md
+
+**Tests**: Not explicitly requested in feature specification; test tasks included only in Polish phase for regression testing.
+
+**Organization**: Tasks grouped by user story to enable independent implementation and testing.
+
+## Format: `[ID] [P?] [Story] Description`
+
+- **[P]**: Can run in parallel (different files, no dependencies)
+- **[Story]**: Which user story this task belongs to (e.g., US1, US2)
+- Include exact file paths in descriptions
+
+## Path Conventions
+
+- **Backend**: `internal/`, `cmd/mcpproxy/`
+- **Frontend**: `frontend/src/`
+- **Tray**: `cmd/mcpproxy-tray/`
+
+---
+
+## Phase 1: Setup (Shared Infrastructure)
+
+**Purpose**: Create the health package and core types
+
+- [X] T001 Create internal/health/ directory structure
+- [X] T002 Add HealthStatus struct to internal/contracts/types.go
+- [X] T003 [P] Create health level, admin state, and action constants in internal/health/constants.go
+
+---
+
+## Phase 2: Foundational (Blocking Prerequisites)
+
+**Purpose**: Core health calculator that ALL interfaces depend on
+
+**CRITICAL**: No user story work can begin until this phase is complete
+
+- [X] T004 Create HealthCalculatorInput struct in internal/health/calculator.go
+- [X] T005 Create HealthCalculatorConfig struct with ExpiryWarningDuration in internal/health/calculator.go
+- [X] T006 Implement CalculateHealth() function in internal/health/calculator.go
+- [X] T007 Add Health field to contracts.Server struct in internal/contracts/types.go
+- [X] T008 Integrate CalculateHealth() into runtime.GetAllServers() in internal/runtime/runtime.go
+- [X] T009 Add oauth_expiry_warning_hours config option to internal/config/config.go
+
+**Checkpoint**: Backend health calculation complete - all interfaces can now use server.Health field
+
+---
+
+## Phase 3: User Story 1 - Consistent Status Across Interfaces (Priority: P1)
+
+**Goal**: All four interfaces (CLI, tray, web UI, MCP tools) display identical health status for any server
+
+**Independent Test**: Check any server's status in all four interfaces and verify they show identical health level and summary
+
+### Implementation for User Story 1
+
+- [X] T010 [US1] Update CLI upstream list display to use health.level for status emoji in cmd/mcpproxy/upstream_cmd.go
+- [X] T011 [US1] Update CLI upstream list to show health.summary instead of calculating status in cmd/mcpproxy/upstream_cmd.go
+- [X] T012 [P] [US1] Update tray server menu to use health.level for status indicator in cmd/mcpproxy-tray/
+- [X] T013 [P] [US1] Update web UI ServerCard.vue to use health.level for badge color in frontend/src/components/ServerCard.vue
+- [X] T014 [US1] Update web UI ServerCard.vue to display health.summary as status text in frontend/src/components/ServerCard.vue
+
+**Checkpoint**: All four interfaces now display the same health level and summary for any given server
+
+---
+
+## Phase 4: User Story 2 - Actionable Guidance for Issues (Priority: P1)
+
+**Goal**: When a server has an issue, users see what action to take to fix it
+
+**Independent Test**: Create various error conditions and verify each displays an appropriate action
+
+### Implementation for User Story 2
+
+- [X] T015 [US2] Add action hints column to CLI upstream list in cmd/mcpproxy/upstream_cmd.go
+- [X] T016 [US2] Display CLI-appropriate action commands (e.g., "auth login --server=X") based on health.action in cmd/mcpproxy/upstream_cmd.go
+- [X] T017 [P] [US2] Add clickable action buttons to tray menu based on health.action in cmd/mcpproxy-tray/
+- [X] T018 [P] [US2] Add action button component to ServerCard.vue based on health.action in frontend/src/components/ServerCard.vue
+- [X] T019 [US2] Implement action button handlers (login, restart, enable, approve) in frontend/src/components/ServerCard.vue
+
+**Checkpoint**: All interfaces show appropriate actionable guidance when servers have issues
+
+---
+
+## Phase 5: User Story 3 - OAuth Token Visibility in Tray/Web (Priority: P2)
+
+**Goal**: OAuth token issues (expired, expiring soon) visible in tray and web UI, not just CLI
+
+**Independent Test**: Let an OAuth token expire and verify tray and web UI both indicate the issue
+
+### Implementation for User Story 3
+
+- [X] T020 [US3] Ensure tray displays degraded status (yellow indicator) for token expiring soon in cmd/mcpproxy-tray/
+- [X] T021 [US3] Ensure tray displays unhealthy status (red indicator) for expired token in cmd/mcpproxy-tray/
+- [X] T022 [P] [US3] Ensure web UI ServerCard shows degraded badge for expiring token in frontend/src/components/ServerCard.vue
+- [X] T023 [P] [US3] Ensure web UI ServerCard shows unhealthy badge for expired token in frontend/src/components/ServerCard.vue
+- [X] T024 [US3] Add "Token expiring" / "Token expired" message display in web UI in frontend/src/components/ServerCard.vue
+
+**Checkpoint**: OAuth token status now visible across all interfaces, not just CLI
+
+---
+
+## Phase 6: User Story 4 - Admin State Separate from Health (Priority: P2)
+
+**Goal**: Disabled and quarantined servers show admin state clearly distinct from health issues
+
+**Independent Test**: Disable a server and verify it shows "Disabled" state, not an error
+
+### Implementation for User Story 4
+
+- [X] T025 [US4] Add gray styling for disabled servers in frontend/src/components/ServerCard.vue
+- [X] T026 [US4] Add purple styling for quarantined servers in frontend/src/components/ServerCard.vue
+- [X] T027 [P] [US4] Display admin_state badge instead of level badge when server is disabled/quarantined in frontend/src/components/ServerCard.vue
+- [X] T028 [P] [US4] Update CLI upstream list to show distinct indicators for disabled/quarantined in cmd/mcpproxy/upstream_cmd.go
+- [X] T029 [US4] Update tray to show distinct indicators for disabled/quarantined servers in cmd/mcpproxy-tray/
+
+**Checkpoint**: Admin states are visually distinct from health issues in all interfaces
+
+---
+
+## Phase 7: User Story 5 - Dashboard Shows Servers Needing Attention (Priority: P3)
+
+**Goal**: Web dashboard highlights servers that need attention (degraded or unhealthy)
+
+**Independent Test**: Have a mix of healthy and unhealthy servers and verify dashboard shows correct count/list
+
+### Implementation for User Story 5
+
+- [X] T030 [US5] Add computed property to filter servers needing attention in frontend/src/views/Dashboard.vue
+- [X] T031 [US5] Create "X servers need attention" banner component in frontend/src/views/Dashboard.vue
+- [X] T032 [US5] Show quick-fix buttons for each server needing attention in frontend/src/views/Dashboard.vue
+- [X] T033 [US5] Hide banner when all servers are healthy in frontend/src/views/Dashboard.vue
+
+**Checkpoint**: Dashboard now shows servers needing attention with quick-fix actions
+
+---
+
+## Phase 8: User Story 6 - MCP Tools Return Unified Health Status (Priority: P2)
+
+**Goal**: LLMs can understand server health from MCP tools without interpreting raw fields
+
+**Independent Test**: Call upstream_servers with operation=list via MCP and verify each server includes health field
+
+### Implementation for User Story 6
+
+- [X] T034 [US6] Add health field to handleListUpstreams() response in internal/server/mcp.go
+- [X] T035 [US6] Ensure health field uses same HealthStatus structure as REST API in internal/server/mcp.go
+- [X] T036 [US6] Update MCP tool schema to document health field in response in internal/server/mcp.go
+
+**Checkpoint**: LLMs can now get unified health status from MCP tools
+
+---
+
+## Phase 9: Polish & Cross-Cutting Concerns
+
+**Purpose**: Documentation, testing, and cleanup
+
+- [X] T037 [P] Add HealthStatus schema to oas/swagger.yaml
+- [X] T038 [P] Update CLAUDE.md with new health fields documentation
+- [X] T039 [P] Create unit tests for CalculateHealth() in internal/health/calculator_test.go
+- [X] T039a [P] Add test case verifying FR-016: token with working auto-refresh returns healthy in internal/health/calculator_test.go
+- [X] T040 Run quickstart.md validation scenarios
+- [X] T041 Run full test suite (./scripts/run-all-tests.sh) - Pre-existing Docker timeout failures unrelated to health status
+- [X] T042 Run API E2E tests (./scripts/test-api-e2e.sh)
+- [X] T043 [P] Verify OpenAPI endpoint coverage (./scripts/verify-oas-coverage.sh)
+
+---
+
+## Dependencies & Execution Order
+
+### Phase Dependencies
+
+- **Setup (Phase 1)**: No dependencies - can start immediately
+- **Foundational (Phase 2)**: Depends on Setup completion - BLOCKS all user stories
+- **User Stories (Phase 3-8)**: All depend on Foundational phase completion
+ - US1 and US2 are both P1 priority - do them first
+ - US3, US4 are P2 priority - must run SEQUENTIALLY (both modify ServerCard.vue)
+ - US6 is P2 priority - can run in parallel with US3/US4 (MCP is independent of UI)
+ - US5 is P3 priority - do after P2 stories
+- **Polish (Phase 9)**: Depends on all user stories being complete
+
+### User Story Dependencies
+
+- **User Story 1 (P1)**: Depends only on Foundational - core consistency
+- **User Story 2 (P1)**: Depends only on Foundational - can run in parallel with US1
+- **User Story 3 (P2)**: Depends on US1 (needs health display infrastructure) - modifies ServerCard.vue
+- **User Story 4 (P2)**: Depends on US3 (sequential - both modify ServerCard.vue)
+- **User Story 5 (P3)**: Depends on US1 (needs filtering by health.level)
+- **User Story 6 (P2)**: Depends only on Foundational (MCP is independent of UI)
+
+### Within Each User Story
+
+- Implementation before integration tasks
+- Core changes before UI polish
+- Backend changes propagate through all interfaces via server.Health field
+
+### Parallel Opportunities
+
+- T002 and T003 can run in parallel (different files)
+- T012, T013 can run in parallel (tray and web UI)
+- T017, T018 can run in parallel (tray and web UI)
+- T020/T021 and T022/T023 can run in parallel (tray and web UI)
+- T025, T026, T027, T028 partially parallel (T27 || T28)
+- T037, T038, T039, T043 all parallel (documentation and tests)
+
+---
+
+## Parallel Example: Foundational Phase
+
+```bash
+# After T004-T005, these can run in parallel:
+Task: "Create health constants in internal/health/constants.go"
+Task: "Add Health field to contracts.Server in internal/contracts/types.go"
+```
+
+## Parallel Example: User Story 1
+
+```bash
+# T012 and T013 can run in parallel:
+Task: "Update tray server menu in cmd/mcpproxy-tray/"
+Task: "Update ServerCard.vue badge color in frontend/src/components/ServerCard.vue"
+```
+
+---
+
+## Implementation Strategy
+
+### MVP First (User Stories 1 + 2 Only)
+
+1. Complete Phase 1: Setup
+2. Complete Phase 2: Foundational (CRITICAL - blocks all stories)
+3. Complete Phase 3: User Story 1 (consistent status)
+4. Complete Phase 4: User Story 2 (actionable guidance)
+5. **STOP and VALIDATE**: All interfaces show identical status with actions
+6. Deploy/demo if ready - this is the core value
+
+### Incremental Delivery
+
+1. Complete Setup + Foundational β Backend health calculation ready
+2. Add US1 + US2 β Core consistency and actions (MVP!)
+3. Add US3 β OAuth visibility in tray/web
+4. Add US4 β Admin state clarity
+5. Add US6 β MCP tools (LLM support)
+6. Add US5 β Dashboard attention banner
+7. Polish β Documentation and testing
+
+### Single Developer Strategy
+
+Priority order: P1 stories first (US1 β US2), then P2 (US3 β US4 β US6), then P3 (US5)
+
+---
+
+## Notes
+
+- [P] tasks = different files, no dependencies
+- [Story] label maps task to specific user story for traceability
+- Each user story should be independently testable after completion
+- Backend changes (Phase 2) automatically propagate to all interfaces
+- No database schema changes required - health is calculated at runtime
+- Commit after each phase completion for easy rollback