Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
87 changes: 45 additions & 42 deletions cmd/mcpproxy/auth_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
150 changes: 73 additions & 77 deletions cmd/mcpproxy/upstream_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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,
},
}
}

Expand All @@ -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)
Expand Down Expand Up @@ -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)
}
}
Loading