diff --git a/CLAUDE.md b/CLAUDE.md index 8079a1f2..4c692c8e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -159,8 +159,98 @@ See [docs/configuration.md](docs/configuration.md) for complete reference. **Authentication**: Use `X-API-Key` header or `?apikey=` query parameter. +**Real-time Updates**: +- `GET /events` - Server-Sent Events (SSE) stream for live updates +- Streams both status changes and runtime events (`servers.changed`, `config.reloaded`) +- Used by web UI and tray for real-time synchronization + +**API Authentication Examples**: +```bash +# Using X-API-Key header (recommended for curl) +curl -H "X-API-Key: your-api-key" http://127.0.0.1:8080/api/v1/servers + +# Using query parameter (for browser/SSE) +curl "http://127.0.0.1:8080/api/v1/servers?apikey=your-api-key" + +# SSE with API key +curl "http://127.0.0.1:8080/events?apikey=your-api-key" + +# Open Web UI with API key (tray app does this automatically) +open "http://127.0.0.1:8080/ui/?apikey=your-api-key" +``` + +**Security Notes**: +- **MCP endpoints (`/mcp`, `/mcp/`)** remain **unprotected** for client compatibility +- **REST API** requires authentication - API key is always enforced (auto-generated if not provided) +- **Secure by default**: Empty or missing API keys trigger automatic generation and persistence to config + See [docs/api/rest-api.md](docs/api/rest-api.md) and `oas/swagger.yaml` for API reference. +### Unified Health Status + +All server responses include a `health` field that provides consistent status information across all interfaces (CLI, web UI, tray, MCP tools): + +```json +{ + "health": { + "level": "healthy|degraded|unhealthy", + "admin_state": "enabled|disabled|quarantined", + "summary": "Human-readable status summary", + "detail": "Additional context about the status", + "action": "login|restart|enable|approve|view_logs|" + } +} +``` + +**Health Levels**: +- `healthy`: Server is connected and functioning normally +- `degraded`: Server has warnings (e.g., OAuth token expiring soon) +- `unhealthy`: Server has errors or is not functioning + +**Admin States**: +- `enabled`: Normal operation +- `disabled`: User disabled the server +- `quarantined`: Server pending security approval + +**Actions**: Suggested remediation action for the current state. Empty when no action is needed. + +**Configuration**: Token expiry warning threshold can be configured: +```json +{ + "oauth_expiry_warning_hours": 24 +} +``` + +## JavaScript Code Execution + +The `code_execution` tool enables orchestrating multiple upstream MCP tools in a single request using sandboxed JavaScript (ES5.1+). + +### Configuration + +```json +{ + "enable_code_execution": true, + "code_execution_timeout_ms": 120000, + "code_execution_max_tool_calls": 0, + "code_execution_pool_size": 10 +} +``` + +### CLI Usage + +```bash +mcpproxy code exec --code="({ result: input.value * 2 })" --input='{"value": 21}' +mcpproxy code exec --code="call_tool('github', 'get_user', {username: input.user})" --input='{"user":"octocat"}' +``` + +### Documentation + +See `docs/code_execution/` for complete guides: +- `overview.md` - Architecture and best practices +- `examples.md` - 13 working code samples +- `api-reference.md` - Complete schema documentation +- `troubleshooting.md` - Common issues and solutions + ## Security Model - **Localhost-only by default**: Core server binds to `127.0.0.1:8080` diff --git a/cmd/mcpproxy/auth_cmd.go b/cmd/mcpproxy/auth_cmd.go index 7a446084..79e0cae9 100644 --- a/cmd/mcpproxy/auth_cmd.go +++ b/cmd/mcpproxy/auth_cmd.go @@ -228,10 +228,7 @@ func runAuthStatusClientMode(ctx context.Context, dataDir, serverName string, al name, _ := srv["name"].(string) oauth, _ := srv["oauth"].(map[string]interface{}) authenticated, _ := srv["authenticated"].(bool) - connected, _ := srv["connected"].(bool) lastError, _ := srv["last_error"].(string) - enabled, _ := srv["enabled"].(bool) - userLoggedOut, _ := srv["user_logged_out"].(bool) // Check if this is an OAuth server by: // 1. Has oauth config OR @@ -247,50 +244,56 @@ func runAuthStatusClientMode(ctx context.Context, dataDir, serverName string, al hasOAuthServers = true - // Determine status emoji and text using oauth_status for accurate state - oauthStatus, _ := srv["oauth_status"].(string) - var status string - - // Check priority states first - if !enabled { - // Server is disabled - no reconnection attempts - status = "⏸️ Disabled" - } else if userLoggedOut { - // User explicitly logged out - no auto-reconnection - status = "πŸšͺ Logged Out (Login Required)" - } else if connected { - // Connected states - if authenticated { - status = "βœ… Authenticated & Connected" - } else { - status = "⚠️ Connected (No OAuth Token)" - } - } else { - // Disconnected states - use oauth_status for clarity - switch oauthStatus { - case "authenticated": - // Token valid but not connected - likely reconnecting - status = "⏳ Reconnecting (Token Valid)" - case "expired": - // Token expired - needs re-authentication - status = "⚠️ Token Expired (Login Required)" - case "error": - status = "❌ Authentication Error" + // Use unified health status from backend (FR-006, FR-007) + var healthLevel, adminState, healthSummary, healthAction string + if health, ok := srv["health"].(map[string]interface{}); ok && health != nil { + healthLevel, _ = health["level"].(string) + adminState, _ = health["admin_state"].(string) + healthSummary, _ = health["summary"].(string) + healthAction, _ = health["action"].(string) + } + + // Determine status emoji based on admin_state first, then health level + var statusEmoji string + switch adminState { + case "disabled": + statusEmoji = "⏸️" + case "quarantined": + statusEmoji = "πŸ”’" + default: + // Use health level for enabled servers + switch healthLevel { + case "healthy": + statusEmoji = "βœ…" + case "degraded": + statusEmoji = "⚠️" + case "unhealthy": + statusEmoji = "❌" default: - // No token or oauth_status not set - if lastError != "" { - status = "❌ Authentication Failed" - } else if authenticated { - // Fallback: has token but no oauth_status - status = "⏳ Reconnecting" - } else { - status = "⏳ Pending Authentication" - } + statusEmoji = "❓" } } fmt.Printf("Server: %s\n", name) - fmt.Printf(" Status: %s\n", status) + fmt.Printf(" Health: %s %s\n", statusEmoji, healthSummary) + if adminState != "" && adminState != "enabled" { + fmt.Printf(" Admin State: %s\n", adminState) + } + // Show action as command hint (FR-007) + if healthAction != "" { + switch healthAction { + case "login": + fmt.Printf(" Action: mcpproxy auth login --server=%s\n", name) + case "restart": + fmt.Printf(" Action: mcpproxy upstream restart %s\n", name) + case "enable": + fmt.Printf(" Action: mcpproxy upstream enable %s\n", name) + case "approve": + fmt.Printf(" Action: Approve via Web UI or tray menu\n") + case "view_logs": + fmt.Printf(" Action: mcpproxy upstream logs %s\n", name) + } + } // Display OAuth configuration details (if available) // Check if this is an autodiscovery server (no explicit OAuth config, but has token) diff --git a/cmd/mcpproxy/upstream_cmd.go b/cmd/mcpproxy/upstream_cmd.go index f5b7a7a8..f2ff3e44 100644 --- a/cmd/mcpproxy/upstream_cmd.go +++ b/cmd/mcpproxy/upstream_cmd.go @@ -18,6 +18,7 @@ import ( "mcpproxy-go/internal/cliclient" "mcpproxy-go/internal/config" + "mcpproxy-go/internal/health" "mcpproxy-go/internal/logs" "mcpproxy-go/internal/reqcontext" "mcpproxy-go/internal/socket" @@ -177,13 +178,37 @@ func runUpstreamListFromConfig(globalConfig *config.Config) error { // Convert config servers to output format servers := make([]map[string]interface{}, len(globalConfig.Servers)) for i, srv := range globalConfig.Servers { + // I-003: Use health.CalculateHealth() instead of inline logic for DRY principle + healthInput := health.HealthCalculatorInput{ + Name: srv.Name, + Enabled: srv.Enabled, + Quarantined: srv.Quarantined, + State: "disconnected", // Daemon not running + Connected: false, + ToolCount: 0, + } + healthStatus := health.CalculateHealth(healthInput, health.DefaultHealthConfig()) + + // Override summary for config-only mode to indicate daemon status + summary := healthStatus.Summary + if healthStatus.AdminState == health.StateEnabled { + summary = "Daemon not running" + } + servers[i] = map[string]interface{}{ "name": srv.Name, "enabled": srv.Enabled, "protocol": srv.Protocol, "connected": false, "tool_count": 0, - "status": "unknown (daemon not running)", + "status": summary, + "health": map[string]interface{}{ + "level": healthStatus.Level, + "admin_state": healthStatus.AdminState, + "summary": summary, + "detail": healthStatus.Detail, + "action": healthStatus.Action, + }, } } @@ -206,73 +231,65 @@ func outputServers(servers []map[string]interface{}) error { } fmt.Println(string(output)) case "table", "": - // Table format (default) with OAuth token validity column - fmt.Printf("%-25s %-10s %-10s %-12s %-10s %-20s %s\n", - "NAME", "ENABLED", "PROTOCOL", "CONNECTED", "TOOLS", "OAUTH TOKEN", "STATUS") - fmt.Printf("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n") + // Table format (default) with unified health status + fmt.Printf("%-4s %-25s %-10s %-10s %-30s %s\n", + "", "NAME", "PROTOCOL", "TOOLS", "STATUS", "ACTION") + fmt.Printf("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n") for _, srv := range servers { name := getStringField(srv, "name") - enabled := getBoolField(srv, "enabled") protocol := getStringField(srv, "protocol") - connected := getBoolField(srv, "connected") toolCount := getIntField(srv, "tool_count") - status := getStringField(srv, "status") - enabledStr := "no" - if enabled { - enabledStr = "yes" + // Extract unified health status + healthData, _ := srv["health"].(map[string]interface{}) + healthLevel := "unknown" + healthAdminState := "enabled" + healthSummary := getStringField(srv, "status") // fallback to old status + healthAction := "" + + if healthData != nil { + healthLevel = getStringField(healthData, "level") + healthAdminState = getStringField(healthData, "admin_state") + healthSummary = getStringField(healthData, "summary") + healthAction = getStringField(healthData, "action") } - connectedStr := "no" - if connected { - connectedStr = "yes" + // Status emoji based on health level and admin state + statusEmoji := "βšͺ" // unknown + switch healthAdminState { + case "disabled": + statusEmoji = "⏸️ " // paused + case "quarantined": + statusEmoji = "πŸ”’" // locked + default: + switch healthLevel { + case "healthy": + statusEmoji = "βœ…" + case "degraded": + statusEmoji = "⚠️ " + case "unhealthy": + statusEmoji = "❌" + } } - // Extract OAuth token validity info - oauthStatus := "-" - oauth, _ := srv["oauth"].(map[string]interface{}) - authenticated, _ := srv["authenticated"].(bool) - lastError, _ := srv["last_error"].(string) - - // Check if this is an OAuth-related server - isOAuthServer := (oauth != nil) || - containsIgnoreCase(lastError, "oauth") || - authenticated - - if isOAuthServer { - if oauth != nil { - if tokenExpiresAt, ok := oauth["token_expires_at"].(string); ok && tokenExpiresAt != "" { - if expiryTime, err := time.Parse(time.RFC3339, tokenExpiresAt); err == nil { - timeUntilExpiry := time.Until(expiryTime) - if timeUntilExpiry > 0 { - oauthStatus = formatDurationShort(timeUntilExpiry) - } else { - oauthStatus = "⚠️ EXPIRED" - } - } - } else if tokenValid, ok := oauth["token_valid"].(bool); ok { - if tokenValid { - oauthStatus = "βœ… Valid" - } else { - oauthStatus = "⚠️ Invalid" - } - } else if authenticated { - oauthStatus = "βœ… Active" - } else { - oauthStatus = "⏳ Pending" - } - } else if authenticated { - // OAuth server without config (DCR) but authenticated - oauthStatus = "βœ… Active" - } else { - // OAuth required but not authenticated yet - oauthStatus = "⏳ Pending" - } + // Format action as CLI command hint + actionHint := "-" + switch healthAction { + case "login": + actionHint = fmt.Sprintf("auth login --server=%s", name) + case "restart": + actionHint = fmt.Sprintf("upstream restart %s", name) + case "enable": + actionHint = fmt.Sprintf("upstream enable %s", name) + case "approve": + actionHint = "Approve in Web UI" + case "view_logs": + actionHint = fmt.Sprintf("upstream logs %s", name) } - fmt.Printf("%-25s %-10s %-10s %-12s %-10d %-20s %s\n", - name, enabledStr, protocol, connectedStr, toolCount, oauthStatus, status) + fmt.Printf("%-4s %-25s %-10s %-10d %-30s %s\n", + statusEmoji, name, protocol, toolCount, healthSummary, actionHint) } default: return fmt.Errorf("unknown output format: %s", upstreamOutputFormat) @@ -686,24 +703,3 @@ func runUpstreamBulkAction(action string, force bool) error { return nil } - -// formatDurationShort formats a duration into a short human-readable string for table display -func formatDurationShort(d time.Duration) string { - if d < 0 { - return "expired" - } - - days := int(d.Hours() / 24) - hours := int(d.Hours()) % 24 - - if days > 30 { - return fmt.Sprintf("%dd", days) - } else if days > 0 { - return fmt.Sprintf("%dd %dh", days, hours) - } else if hours > 0 { - return fmt.Sprintf("%dh", hours) - } else { - minutes := int(d.Minutes()) - return fmt.Sprintf("%dm", minutes) - } -} diff --git a/cmd/mcpproxy/upstream_cmd_test.go b/cmd/mcpproxy/upstream_cmd_test.go index 812987e7..0a4f7d8a 100644 --- a/cmd/mcpproxy/upstream_cmd_test.go +++ b/cmd/mcpproxy/upstream_cmd_test.go @@ -49,25 +49,22 @@ func TestOutputServers_TableFormat(t *testing.T) { t.Errorf("outputServers() returned error: %v", err) } - // Verify table headers + // Verify table headers (new unified health status format) if !strings.Contains(output, "NAME") { t.Error("Table output missing NAME header") } - if !strings.Contains(output, "ENABLED") { - t.Error("Table output missing ENABLED header") - } if !strings.Contains(output, "PROTOCOL") { t.Error("Table output missing PROTOCOL header") } - if !strings.Contains(output, "CONNECTED") { - t.Error("Table output missing CONNECTED header") - } if !strings.Contains(output, "TOOLS") { t.Error("Table output missing TOOLS header") } if !strings.Contains(output, "STATUS") { t.Error("Table output missing STATUS header") } + if !strings.Contains(output, "ACTION") { + t.Error("Table output missing ACTION header") + } // Verify server data if !strings.Contains(output, "github-server") { @@ -360,15 +357,17 @@ func TestCreateUpstreamLogger(t *testing.T) { } func TestOutputServers_BooleanFields(t *testing.T) { + // Test that unified health status is displayed correctly based on server state tests := []struct { - name string - enabled bool - connected bool + name string + healthLevel string + adminState string + expectedEmoji string }{ - {"both true", true, true}, - {"both false", false, false}, - {"enabled only", true, false}, - {"connected only", false, true}, + {"healthy enabled", "healthy", "enabled", "βœ…"}, + {"disabled", "healthy", "disabled", "⏸️"}, + {"quarantined", "healthy", "quarantined", "πŸ”’"}, + {"unhealthy", "unhealthy", "enabled", "❌"}, } for _, tt := range tests { @@ -376,11 +375,14 @@ func TestOutputServers_BooleanFields(t *testing.T) { servers := []map[string]interface{}{ { "name": "test-server", - "enabled": tt.enabled, "protocol": "stdio", - "connected": tt.connected, "tool_count": 0, - "status": "test", + "health": map[string]interface{}{ + "level": tt.healthLevel, + "admin_state": tt.adminState, + "summary": "Test status", + "action": "", + }, }, } @@ -402,11 +404,9 @@ func TestOutputServers_BooleanFields(t *testing.T) { t.Errorf("outputServers() returned error: %v", err) } - // Verify boolean conversion - if tt.enabled { - if !strings.Contains(output, "yes") { - t.Error("Expected 'yes' for enabled=true") - } + // Verify health status emoji is displayed + if !strings.Contains(output, tt.expectedEmoji) { + t.Errorf("Expected emoji '%s' for %s, output: %s", tt.expectedEmoji, tt.name, output) } }) } diff --git a/docs/designs/2025-12-10-unified-health-status.md b/docs/designs/2025-12-10-unified-health-status.md new file mode 100644 index 00000000..12ef73a9 --- /dev/null +++ b/docs/designs/2025-12-10-unified-health-status.md @@ -0,0 +1,231 @@ +# Unified Health Status Design + +**Date**: 2025-12-10 +**Status**: Ready for implementation + +## Problem Statement + +**Current issues:** +1. **Inconsistent status** - CLI, tray, and web show different health interpretations +2. **Missing OAuth visibility** - Token expiration not shown in tray/web +3. **No actionable guidance** - Users see errors but not how to fix them +4. **Conflated concepts** - Admin state (disabled/quarantined) mixed with health + +**Root cause:** Each interface calculates status independently from raw fields, leading to drift. For example: +- CLI reads `oauth_status` and shows "Token Expired" +- Tray only checks HTTP connectivity and shows "Healthy" +- Same server, different conclusions + +**Goals:** +- Single source of truth for server health in the backend +- Consistent display across CLI, tray, and web UI +- Traffic light model: healthy (green) / degraded (yellow) / unhealthy (red) +- Every degraded/unhealthy state includes an action to resolve it +- Admin state (enabled/disabled/quarantined) shown separately from health + +**Non-goals:** +- Changing OAuth flow mechanics +- Adding new OAuth features +- Redesigning the web UI layout + +## Data Model + +**New `HealthStatus` struct** (in `internal/contracts/types.go`): + +```go +type HealthStatus struct { + Level string `json:"level"` // "healthy", "degraded", "unhealthy" + AdminState string `json:"admin_state"` // "enabled", "disabled", "quarantined" + Summary string `json:"summary"` // "Connected (5 tools)", "Token expiring in 2h" + Detail string `json:"detail"` // Optional longer explanation + Action string `json:"action"` // "login", "restart", "enable", "approve", "view_logs", "" +} +``` + +**Added to existing `Server` struct:** + +```go +type Server struct { + // ... existing fields ... + Health HealthStatus `json:"health"` // New unified health status +} +``` + +**Level values:** +| Level | Meaning | View convention | +|-------|---------|-----------------| +| `healthy` | Ready to use, no issues | green | +| `degraded` | Works but needs attention soon | yellow | +| `unhealthy` | Broken, can't use until fixed | red | + +**Action types:** +| Action | Meaning | +|--------|---------| +| `""` | No action needed (healthy state) | +| `login` | OAuth authentication required | +| `restart` | Server needs restart | +| `enable` | Server is disabled | +| `approve` | Server is quarantined | +| `view_logs` | Check logs for details | + +## Health Calculation Logic + +**Location:** `internal/runtime/runtime.go` in `GetAllServers()` (or extracted to `internal/health/calculator.go`) + +**Priority order** (first match wins): + +``` +1. Admin state checks (shown instead of health when not enabled) + - quarantined β†’ AdminState: "quarantined" + - disabled β†’ AdminState: "disabled" + +2. Unhealthy (red) conditions + - connection refused/failed β†’ "unhealthy", Action: "restart" + - auth failed (bad credentials) β†’ "unhealthy", Action: "login" + - server crashed β†’ "unhealthy", Action: "restart" + - config error β†’ "unhealthy", Action: "view_logs" + - token expired β†’ "unhealthy", Action: "login" + - refresh failed (after retries)β†’ "unhealthy", Action: "login" + - user logged out β†’ "unhealthy", Action: "login" + +3. Degraded (yellow) conditions + - token expiring soon, no refresh token β†’ "degraded", Action: "login" + - connecting (in progress) β†’ "degraded", Action: "" + +4. Healthy (green) + - connected + authenticated (OAuth servers) + - connected (non-OAuth servers) + - token valid OR auto-refresh working +``` + +**OAuth-specific logic:** +| Condition | Level | Action | +|-----------|-------|--------| +| Token valid OR auto-refresh working | `healthy` | - | +| Token expiring soon, no refresh token | `degraded` | `login` | +| Token expired | `unhealthy` | `login` | +| Refresh failed (after retries) | `unhealthy` | `login` | +| User logged out | `unhealthy` | `login` | + +**Key distinction:** +- **Degraded** = works now but will break soon without action +- **Unhealthy** = broken, can't use until fixed + +## Interface Display + +Each interface renders `HealthStatus` consistently but adapted to its medium. + +### CLI + +**`mcpproxy upstream list` and `mcpproxy auth status`:** +``` +Server Health Action +─────────────────────────────────────────────────────────────────── +slack 🟒 Connected (5 tools) +github 🟑 Token expiring in 45m β†’ auth login --server=github +filesystem πŸ”΄ Connection refused β†’ upstream restart filesystem +new-server ⏸️ Quarantined β†’ Approve in Web UI +old-server ⏹️ Disabled β†’ upstream enable old-server +``` + +### Tray Menu + +``` +🟒 slack +🟑 github - Token expiring +πŸ”΄ filesystem - Error +⏸️ new-server (Quarantined) +⏹️ old-server (Disabled) +``` + +Clicking yellow/red servers opens Web UI to the relevant fix page. + +### Web UI + +| Location | Shows | Actions | +|----------|-------|---------| +| **Dashboard** | "X servers need attention" banner | Quick-fix buttons per server | +| **ServerCard** | Colored status badge + summary | Login/Restart/Reconnect based on `action` field | +| **ServerDetail** | Full health details | Same actions + logs | + +### Action Hint Mapping + +Each interface maps the `Action` field to its own UX: + +**CLI:** +``` +"login" β†’ "auth login --server=%s" +"restart" β†’ "upstream restart %s" +"enable" β†’ "upstream enable %s" +"approve" β†’ "Approve in Web UI or config" +"view_logs"β†’ "upstream logs %s" +``` + +**Tray:** +``` +"login" β†’ opens http://localhost:8080/ui/servers/{name}?action=login +"restart" β†’ triggers API call directly +"enable" β†’ triggers API call directly +"approve" β†’ opens http://localhost:8080/ui/servers/{name}?action=approve +``` + +**Web UI:** +``` +"login" β†’ Login button +"restart" β†’ Restart button +"enable" β†’ Enable toggle +"approve" β†’ Approve button +``` + +## Implementation Changes + +**Files to modify:** + +| File | Change | +|------|--------| +| `internal/contracts/types.go` | Add `HealthStatus` struct | +| `internal/runtime/runtime.go` | Calculate `Health` in `GetAllServers()` | +| `internal/httpapi/server.go` | Ensure `health` field is included in API response | +| `cmd/mcpproxy/upstream_cmd.go` | Update `upstream list` to use `Health` field | +| `cmd/mcpproxy/auth_cmd.go` | Update `auth status` to use `Health` field | +| `internal/tray/managers.go` | Update `getServerStatusDisplay()` to use `Health` field | +| `frontend/src/components/ServerCard.vue` | Use `health` for badge color + show action | +| `frontend/src/views/Dashboard.vue` | Use `health.level` to filter servers needing attention | + +**No backward compatibility needed** - all clients (CLI, tray, web) ship together in mcpproxy releases. + +## Architecture + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Backend (Runtime) β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”β”‚ +β”‚ β”‚ CalculateHealth() β†’ HealthStatus β”‚β”‚ +β”‚ β”‚ - Level: healthy/degraded/unhealthy β”‚β”‚ +β”‚ β”‚ - AdminState: enabled/disabled/quarantined β”‚β”‚ +β”‚ β”‚ - Summary: "Connected (5 tools)" β”‚β”‚ +β”‚ β”‚ - Action: login/restart/enable/approve/"" β”‚β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + GET /api/v1/servers + β”‚ + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ β”‚ β”‚ + β–Ό β–Ό β–Ό + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ CLI β”‚ β”‚ Tray β”‚ β”‚ Web UI β”‚ + β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ + β”‚ 🟒/🟑/πŸ”΄ β”‚ β”‚ 🟒/🟑/πŸ”΄ β”‚ β”‚ badges β”‚ + β”‚ + hint β”‚ β”‚ + click β”‚ β”‚ + btns β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +**Key principle:** Backend owns health calculation. Interfaces only render. + +## Success Criteria + +1. All three interfaces show identical health status for any server +2. Yellow/red states always include actionable guidance +3. OAuth token issues visible in tray and web (not just CLI) +4. Admin state (disabled/quarantined) clearly distinct from health diff --git a/docs/followups/2025-12-11-verify-oas-coverage-script.md b/docs/followups/2025-12-11-verify-oas-coverage-script.md new file mode 100644 index 00000000..aec26f26 --- /dev/null +++ b/docs/followups/2025-12-11-verify-oas-coverage-script.md @@ -0,0 +1,60 @@ +# Follow-up: verify-oas-coverage.sh Script Issues + +**Date**: 2025-12-11 +**Related Feature**: 012-unified-health-status +**Priority**: Low (script works but output is misleading) + +## Issue + +The `scripts/verify-oas-coverage.sh` script has issues that cause misleading output: + +1. **Endpoint prefix mismatch**: The script extracts routes without the `/api/v1` prefix, but the OAS file documents them with the full path. This causes false "missing" reports. + +2. **Uppercase artifact**: The sed command `\U\1` uppercases the HTTP method but also adds a "U" prefix to the path (e.g., `UGet /config` instead of `GET /config`). + +3. **Coverage calculation is incorrect**: Shows "Coverage: 100%" even when listing 37 "missing" endpoints because the comparison logic doesn't properly match routes. + +## Example Output (Problematic) + +``` +❌ Missing OAS documentation for: + UDelete /{name} + UGet /config + ... + +πŸ“Š Coverage Statistics: + Total endpoints: 37 + Documented: 37 + Missing: 37 + Coverage: 100.0% <-- This is wrong +``` + +## Root Cause + +The route extraction from Go files captures relative paths like `/config`, but the OAS file documents them as `/api/v1/config`. The comparison fails to match these. + +## Fix Applied (Partial) + +Fixed the bash syntax error where inline comments appeared after backslash line continuations (lines 45-47). This was causing "command not found" errors. + +## Remaining Work + +1. Update the sed pattern to not add "U" prefix to paths +2. Either: + - Add `/api/v1` prefix to extracted routes, OR + - Strip `/api/v1` prefix from OAS paths for comparison +3. Fix the coverage calculation logic + +## Verification Steps + +```bash +# Run the script and verify: +# 1. No "command not found" errors +# 2. Routes show as "GET /api/v1/config" not "UGet /config" +# 3. Coverage percentage matches actual missing count +./scripts/verify-oas-coverage.sh +``` + +## Files Affected + +- `scripts/verify-oas-coverage.sh` diff --git a/frontend/src/components/ServerCard.vue b/frontend/src/components/ServerCard.vue index 1408dbbe..1cccba78 100644 --- a/frontend/src/components/ServerCard.vue +++ b/frontend/src/components/ServerCard.vue @@ -10,16 +10,17 @@

- + +
- {{ server.connected ? 'Connected' : server.connecting ? 'Connecting' : 'Disconnected' }} + {{ statusText }}
@@ -65,39 +66,58 @@ Server is quarantined - +
+ - + + + + View Logs + + +
+ +
+ + + +
+

{{ serversNeedingAttention.length }} server{{ serversNeedingAttention.length !== 1 ? 's' : '' }} need{{ serversNeedingAttention.length === 1 ? 's' : '' }} attention

+
+
+ ● + {{ server.name }} + {{ server.health?.summary }} + + + +
+
+ ... 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