diff --git a/cmd/mcpproxy/connect_cmd.go b/cmd/mcpproxy/connect_cmd.go new file mode 100644 index 000000000..876ced184 --- /dev/null +++ b/cmd/mcpproxy/connect_cmd.go @@ -0,0 +1,280 @@ +package main + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + clioutput "github.com/smart-mcp-proxy/mcpproxy-go/internal/cli/output" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/connect" +) + +var ( + connectList bool + connectAll bool + connectForce bool + connectServerName string +) + +// GetConnectCommand returns the connect parent command. +func GetConnectCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "connect [client]", + Short: "Register MCPProxy in a client's MCP configuration", + Long: `Register MCPProxy as an MCP server in the configuration file of supported +AI coding clients. This modifies the client's config file to add an HTTP/SSE +entry pointing to the running MCPProxy instance. + +Supported clients: claude-code, cursor, windsurf, vscode, codex, gemini + +A backup of the original config file is created before any modification. + +Examples: + mcpproxy connect --list # Show all clients and their status + mcpproxy connect claude-code # Register in Claude Code + mcpproxy connect cursor --force # Overwrite existing entry + mcpproxy connect codex --name my-proxy # Custom server name + mcpproxy connect --all # Register in all supported clients`, + Args: cobra.MaximumNArgs(1), + RunE: runConnect, + } + + cmd.Flags().BoolVar(&connectList, "list", false, "List all clients and their connection status") + cmd.Flags().BoolVar(&connectAll, "all", false, "Connect to all supported clients") + cmd.Flags().BoolVar(&connectForce, "force", false, "Overwrite existing entry") + cmd.Flags().StringVar(&connectServerName, "name", "", "Server name in client config (default: mcpproxy)") + + return cmd +} + +// GetDisconnectCommand returns the disconnect command. +func GetDisconnectCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "disconnect ", + Short: "Remove MCPProxy from a client's MCP configuration", + Long: `Remove the MCPProxy entry from the specified client's configuration file. +A backup of the original config file is created before any modification. + +Examples: + mcpproxy disconnect claude-code + mcpproxy disconnect cursor --name my-proxy`, + Args: cobra.ExactArgs(1), + RunE: runDisconnect, + } + + cmd.Flags().StringVar(&connectServerName, "name", "", "Server name to remove (default: mcpproxy)") + + return cmd +} + +func runConnect(cmd *cobra.Command, args []string) error { + cfg, err := loadConnectConfig() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + svc := connect.NewService(cfg.Listen, cfg.APIKey) + + format := clioutput.ResolveFormat(globalOutputFormat, globalJSONOutput) + formatter, err := clioutput.NewFormatter(format) + if err != nil { + return err + } + + // --list mode + if connectList { + return printConnectStatus(svc, formatter, format) + } + + // --all mode + if connectAll { + return connectAllClients(svc, formatter, format) + } + + // Single client mode + if len(args) == 0 { + return fmt.Errorf("client ID is required (or use --list / --all)") + } + + clientID := args[0] + result, err := svc.Connect(clientID, connectServerName, connectForce) + if err != nil { + return err + } + + return printConnectResult(result, formatter, format) +} + +func runDisconnect(cmd *cobra.Command, args []string) error { + cfg, err := loadConnectConfig() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + svc := connect.NewService(cfg.Listen, cfg.APIKey) + + format := clioutput.ResolveFormat(globalOutputFormat, globalJSONOutput) + formatter, err := clioutput.NewFormatter(format) + if err != nil { + return err + } + + clientID := args[0] + result, err := svc.Disconnect(clientID, connectServerName) + if err != nil { + return err + } + + return printConnectResult(result, formatter, format) +} + +func printConnectStatus(svc *connect.Service, formatter clioutput.OutputFormatter, format string) error { + statuses := svc.GetAllStatus() + + if format == "table" { + headers := []string{"CLIENT", "STATUS", "CONFIG PATH", "CONNECTED"} + var rows [][]string + for _, s := range statuses { + status := "supported" + if !s.Supported { + status = "unsupported" + } + + connected := "-" + if s.Supported { + if s.Connected { + connected = "yes" + } else if s.Exists { + connected = "no" + } else { + connected = "no (no config)" + } + } + + cfgPath := s.ConfigPath + if len(cfgPath) > 50 { + cfgPath = "..." + cfgPath[len(cfgPath)-47:] + } + + rows = append(rows, []string{s.Name, status, cfgPath, connected}) + } + out, err := formatter.FormatTable(headers, rows) + if err != nil { + return err + } + fmt.Print(out) + return nil + } + + // JSON or YAML + out, err := formatter.Format(statuses) + if err != nil { + return err + } + fmt.Println(out) + return nil +} + +func connectAllClients(svc *connect.Service, formatter clioutput.OutputFormatter, format string) error { + clients := connect.GetAllClients() + var results []*connect.ConnectResult + var errors []string + + for _, c := range clients { + if !c.Supported { + continue + } + result, err := svc.Connect(c.ID, connectServerName, connectForce) + if err != nil { + errors = append(errors, fmt.Sprintf("%s: %v", c.Name, err)) + continue + } + results = append(results, result) + } + + if format == "table" { + headers := []string{"CLIENT", "ACTION", "MESSAGE"} + var rows [][]string + for _, r := range results { + client := connect.FindClient(r.Client) + name := r.Client + if client != nil { + name = client.Name + } + rows = append(rows, []string{name, r.Action, r.Message}) + } + for _, e := range errors { + parts := strings.SplitN(e, ": ", 2) + msg := e + clientName := "unknown" + if len(parts) == 2 { + clientName = parts[0] + msg = parts[1] + } + rows = append(rows, []string{clientName, "error", msg}) + } + out, err := formatter.FormatTable(headers, rows) + if err != nil { + return err + } + fmt.Print(out) + return nil + } + + // JSON/YAML output + out, err := formatter.Format(map[string]interface{}{ + "results": results, + "errors": errors, + }) + if err != nil { + return err + } + fmt.Println(out) + return nil +} + +func printConnectResult(result *connect.ConnectResult, formatter clioutput.OutputFormatter, format string) error { + if format == "table" { + if result.Success { + fmt.Printf("%s\n", result.Message) + if result.BackupPath != "" { + fmt.Printf("Backup: %s\n", result.BackupPath) + } + fmt.Printf("Config: %s\n", result.ConfigPath) + } else { + fmt.Printf("Failed: %s\n", result.Message) + } + return nil + } + + // JSON/YAML + out, err := formatter.Format(result) + if err != nil { + return err + } + fmt.Println(out) + return nil +} + +func loadConnectConfig() (*config.Config, error) { + if configFile != "" { + cfg, err := config.LoadFromFile(configFile) + if err != nil { + return nil, err + } + if dataDir != "" { + cfg.DataDir = dataDir + } + return cfg, nil + } + cfg, err := config.Load() + if err != nil { + return nil, err + } + if dataDir != "" { + cfg.DataDir = dataDir + } + return cfg, nil +} diff --git a/cmd/mcpproxy/main.go b/cmd/mcpproxy/main.go index 1a7235e71..f59d612d8 100644 --- a/cmd/mcpproxy/main.go +++ b/cmd/mcpproxy/main.go @@ -178,6 +178,10 @@ func main() { // Add feedback command (Spec 036) feedbackCmd := GetFeedbackCommand() + // Add connect/disconnect commands + connectCmd := GetConnectCommand() + disconnectCmd := GetDisconnectCommand() + // Add commands to root rootCmd.AddCommand(serverCmd) rootCmd.AddCommand(searchCmd) @@ -195,6 +199,8 @@ func main() { rootCmd.AddCommand(tokenCmd) rootCmd.AddCommand(telemetryCmd) rootCmd.AddCommand(feedbackCmd) + rootCmd.AddCommand(connectCmd) + rootCmd.AddCommand(disconnectCmd) // Setup --help-json for machine-readable help discovery // This must be called AFTER all commands are added diff --git a/frontend/src/App.vue b/frontend/src/App.vue index c03ec74dc..05ac9ceba 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -9,11 +9,7 @@
- - - - - +
@@ -125,20 +121,4 @@ onUnmounted(() => { }) - + diff --git a/frontend/src/components/ConnectModal.vue b/frontend/src/components/ConnectModal.vue new file mode 100644 index 000000000..4265f1805 --- /dev/null +++ b/frontend/src/components/ConnectModal.vue @@ -0,0 +1,233 @@ + + + diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 49dbac766..608d33bcf 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -3,6 +3,12 @@ import Dashboard from '@/views/Dashboard.vue' const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), + scrollBehavior() { + // Scroll main content area to top on every navigation + const main = document.querySelector('main.overflow-y-auto') + if (main) main.scrollTop = 0 + return { top: 0 } + }, routes: [ // Server edition auth routes { diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index c6495d3ef..3ac5b765f 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -1,4 +1,4 @@ -import type { APIResponse, Server, Tool, ToolApproval, SearchResult, StatusUpdate, SecretRef, MigrationAnalysis, ConfigSecretsResponse, GetToolCallsResponse, GetToolCallDetailResponse, GetServerToolCallsResponse, GetConfigResponse, ValidateConfigResponse, ConfigApplyResult, ServerTokenMetrics, GetRegistriesResponse, SearchRegistryServersResponse, RepositoryServer, GetSessionsResponse, GetSessionDetailResponse, InfoResponse, ActivityListResponse, ActivityDetailResponse, ActivitySummaryResponse, ImportResponse, AgentTokenInfo, CreateAgentTokenRequest, CreateAgentTokenResponse, RoutingInfo } from '@/types' +import type { APIResponse, Server, Tool, ToolApproval, SearchResult, StatusUpdate, SecretRef, MigrationAnalysis, ConfigSecretsResponse, GetToolCallsResponse, GetToolCallDetailResponse, GetServerToolCallsResponse, GetConfigResponse, ValidateConfigResponse, ConfigApplyResult, ServerTokenMetrics, GetRegistriesResponse, SearchRegistryServersResponse, RepositoryServer, GetSessionsResponse, GetSessionDetailResponse, InfoResponse, ActivityListResponse, ActivityDetailResponse, ActivitySummaryResponse, ImportResponse, AgentTokenInfo, CreateAgentTokenRequest, CreateAgentTokenResponse, RoutingInfo, ConnectStatusResponse, ConnectResult } from '@/types' // Event types for API service export interface APIAuthEvent { @@ -361,6 +361,19 @@ class APIService { }) } + // Docker status + async getDockerStatus(): Promise> { + return this.request('/api/v1/docker/status') + } + // Diagnostics async getDiagnostics(): Promise> { + return this.request('/api/v1/connect') + } + + async connectClient(clientId: string, serverName = 'mcpproxy', force = false): Promise> { + return this.request(`/api/v1/connect/${encodeURIComponent(clientId)}`, { + method: 'POST', + body: JSON.stringify({ server_name: serverName, force }) + }) + } + + async disconnectClient(clientId: string): Promise> { + return this.request(`/api/v1/connect/${encodeURIComponent(clientId)}`, { + method: 'DELETE', + }) + } + // Utility methods async testConnection(): Promise { try { diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 5c9a70a2c..b9baff467 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -491,3 +491,30 @@ export interface ImportResponse { failed: FailedServer[] warnings: string[] } + +// Connect feature types (client registration) + +// API returns a flat array of ClientStatus objects in the data field +export type ConnectStatusResponse = ClientStatus[] + +export interface ClientStatus { + id: string + name: string + config_path: string + exists: boolean + connected: boolean + supported: boolean + reason?: string + icon: string +} + +export interface ConnectResult { + success: boolean + client: string + config_path: string + backup_path?: string + server_name: string + action: string + message: string + error?: string +} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 7835f6587..750a82f5b 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -1,5 +1,5 @@ export * from './api' -export type { ImportResponse, ImportSummary, ImportedServer, SkippedServer, FailedServer, AgentTokenInfo, CreateAgentTokenRequest, CreateAgentTokenResponse, ToolApproval } from './api' +export type { ImportResponse, ImportSummary, ImportedServer, SkippedServer, FailedServer, AgentTokenInfo, CreateAgentTokenRequest, CreateAgentTokenResponse, ToolApproval, ConnectStatusResponse, ClientStatus, ConnectResult } from './api' // Selectively export types from contracts.ts that don't conflict with api.ts export type { UpdateInfo, diff --git a/frontend/src/utils/health.ts b/frontend/src/utils/health.ts index 6181273d4..9e30f9f68 100644 --- a/frontend/src/utils/health.ts +++ b/frontend/src/utils/health.ts @@ -54,16 +54,16 @@ export function isHealthy(health: HealthStatus | undefined, legacyConnected: boo } /** - * Check if a server is considered "connected" using health.level as source of truth. - * Falls back to legacy connected field for backward compatibility. - * - * This is the canonical function to use when determining if a server is operational. + * Check if a server is actually connected and operational. + * Uses the server.connected field (actual connection state) rather than + * health level, since health.level='healthy' includes transient states + * like 'connecting' and disabled servers. * * @param server - The server object - * @returns true if the server is connected/healthy + * @returns true if the server has an active connection */ export function isServerConnected(server: Server): boolean { - return isHealthy(server.health, server.connected) + return server.connected } /** diff --git a/frontend/src/views/Dashboard.vue b/frontend/src/views/Dashboard.vue index 963f7f375..7bb628b6f 100644 --- a/frontend/src/views/Dashboard.vue +++ b/frontend/src/views/Dashboard.vue @@ -90,545 +90,502 @@ - -
- -
-
-

Token Savings

- -
-
-
Tokens Saved
-
{{ formatNumber(tokenSavingsData.saved_tokens) }}
-
{{ tokenSavingsData.saved_tokens_percentage.toFixed(1) }}% reduction
+ +
+ + +
+

AI Agents

+ + +
+
+
+
+
+ Connected +
+
{{ connectedClientNames.join(', ') }}
-
-
Full Tool List Size
-
{{ formatNumber(tokenSavingsData.total_server_tool_list_size) }}
-
All upstream servers
+
+
Available: {{ supportedClientNames.join(', ') }}
-
-
Typical Query Result
-
{{ formatNumber(tokenSavingsData.average_query_result_size) }}
-
BM25 search size
+
+ No clients detected
-
- -
-
-

Token Distribution

-

Per-server tool list size breakdown

+ +
+ + + + + + + + Recent Sessions + +
+
- -
-
- -
+ +
+ + + + +
+
+ + + + {{ tokenSavingsData.saved_tokens_percentage.toFixed(0) }}% + tokens saved
+
- -
-
-
-
- {{ segment.name }} -
-
- {{ formatNumber(segment.value) }} - ({{ segment.percentage.toFixed(1) }}%) -
+ +
+
+ MCPProxy +
+
+
+ MCPProxy +
+
+ {{ systemStore.isRunning ? 'active' : 'stopped' }}
+
{{ uptime }}
-
-
- - - - - -
-
-
-
-

Recent Sessions

-

MCP client connections

+ +
+ +
+ + + + Docker isolation active + Docker isolation disabled — enable Docker to protect your system
- - View All → - -
- -
- -
-
- {{ sessionsError }} -
+ +
+ + + + Quarantine protection active + Quarantine disabled — enable to prevent prompt injection attacks +
-
-

No sessions yet

+ + + + + + + Activity Log +
+
-
- - - - - - - - - - - - - - - - - - - - - - - -
ClientStatusCapabilitiesTool CallsTokensStartedActions
-
{{ session.client_name || 'Unknown' }}
-
- v{{ session.client_version }} -
-
-
- {{ session.status === 'active' ? 'Active' : 'Closed' }} -
-
-
- R - S - E -
-
{{ session.tool_call_count || 0 }}{{ formatNumber(session.total_tokens || 0) }} - {{ formatRelativeTime(session.start_time) }} - - - View Activity - -
+ +
+

Upstream Servers

+ + + +
+
+
+ {{ serversStore.serverCount.connected }} + connected +
+
+ {{ serversStore.totalTools }} + tools available +
+
+ {{ disabledCount }} disabled +
+
+
+ + + +
+
+ + + + {{ serversStore.serverCount.quarantined }} + in quarantine +
+
+
+ + +
+ + + + + + Browse Registry + +
+ + + + Security Scan + soon +
- -
-
-
+ +
+ +
+ + + + Token Savings Details + {{ formatNumber(tokenSavingsData.saved_tokens) }} saved +
+
+
+
-

Recent Tool Calls

-

- Total usage: {{ formatNumber(tokenStats.totalTokens) }} tokens - ({{ recentToolCalls.length }} calls, avg {{ formatNumber(tokenStats.avgTokensPerCall) }}/call) -

+
+
+
Tokens Saved
+
{{ formatNumber(tokenSavingsData.saved_tokens) }}
+
{{ tokenSavingsData.saved_tokens_percentage.toFixed(1) }}% reduction
+
+
+
Full Tool List
+
{{ formatNumber(tokenSavingsData.total_server_tool_list_size) }}
+
All servers
+
+
+
Typical Query
+
{{ formatNumber(tokenSavingsData.average_query_result_size) }}
+
BM25 result
+
+
- - View All → - -
- -
- -
- -
- - - - {{ toolCallsError }} -
- -
- - - -

No tool calls yet

-

Tool calls will appear here once servers start executing tools

-
-
- - - - - - - - - - - - - - - - - - - - - -
TimeServerToolStatusDurationTokens
- - {{ formatRelativeTime(call.timestamp) }} - - - - {{ call.server_name }} - - - {{ call.tool_name }} - -
- {{ call.error ? 'Error' : 'Success' }} -
-
- - {{ formatDuration(call.duration) }} - - - - {{ formatNumber(call.metrics.total_tokens) }} - - - -
+ +
+
+
+ +
+
+
+
+
+
+ {{ segment.name }} +
+
+ {{ formatNumber(segment.value) }} + ({{ segment.percentage.toFixed(1) }}%) +
+
+
+
+ + + +
+ + diff --git a/internal/connect/backup.go b/internal/connect/backup.go new file mode 100644 index 000000000..e464c2900 --- /dev/null +++ b/internal/connect/backup.go @@ -0,0 +1,86 @@ +package connect + +import ( + "fmt" + "io" + "os" + "path/filepath" + "time" +) + +// backupFile creates a timestamped backup of the given file. +// Returns the backup path, or empty string if the source file does not exist. +func backupFile(path string) (string, error) { + info, err := os.Stat(path) + if os.IsNotExist(err) { + return "", nil // nothing to back up + } + if err != nil { + return "", fmt.Errorf("stat %s: %w", path, err) + } + + ts := time.Now().Format("20060102-150405") + backupPath := fmt.Sprintf("%s.bak.%s", path, ts) + + src, err := os.Open(path) + if err != nil { + return "", fmt.Errorf("open source for backup: %w", err) + } + defer src.Close() + + dst, err := os.OpenFile(backupPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, info.Mode()) + if err != nil { + return "", fmt.Errorf("create backup file: %w", err) + } + defer dst.Close() + + if _, err := io.Copy(dst, src); err != nil { + return "", fmt.Errorf("copy to backup: %w", err) + } + + return backupPath, nil +} + +// atomicWriteFile writes data to path atomically by writing to a temp file +// in the same directory and renaming. This prevents partial writes. +func atomicWriteFile(path string, data []byte, perm os.FileMode) error { + dir := filepath.Dir(path) + + // Ensure the directory exists + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("create directory %s: %w", dir, err) + } + + tmp, err := os.CreateTemp(dir, ".mcpproxy-connect-*.tmp") + if err != nil { + return fmt.Errorf("create temp file: %w", err) + } + tmpName := tmp.Name() + + // Clean up on failure + success := false + defer func() { + if !success { + _ = os.Remove(tmpName) + } + }() + + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + return fmt.Errorf("write temp file: %w", err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("close temp file: %w", err) + } + + if err := os.Chmod(tmpName, perm); err != nil { + return fmt.Errorf("chmod temp file: %w", err) + } + + if err := os.Rename(tmpName, path); err != nil { + return fmt.Errorf("rename temp to target: %w", err) + } + + success = true + return nil +} diff --git a/internal/connect/clients.go b/internal/connect/clients.go new file mode 100644 index 000000000..649bc5d92 --- /dev/null +++ b/internal/connect/clients.go @@ -0,0 +1,197 @@ +// Package connect provides functionality to register MCPProxy as an MCP server +// in various client configuration files (Claude Code, Cursor, VS Code, Windsurf, Codex, Gemini). +package connect + +import ( + "os" + "path/filepath" + "runtime" +) + +// ClientDef describes a known MCP client and its configuration file format. +type ClientDef struct { + ID string // Unique identifier, e.g. "claude-code" + Name string // Human-readable name, e.g. "Claude Code" + Format string // File format: "json" or "toml" + ServerKey string // Top-level key for server entries: "mcpServers" or "servers" + Supported bool // Whether this client supports HTTP/SSE transport + Reason string // Explanation when Supported is false + Icon string // Icon identifier for frontend use +} + +// allClients defines all known MCP client applications. +var allClients = []ClientDef{ + { + ID: "claude-code", + Name: "Claude Code", + Format: "json", + ServerKey: "mcpServers", + Supported: true, + Icon: "claude-code", + }, + { + ID: "claude-desktop", + Name: "Claude Desktop", + Format: "json", + ServerKey: "mcpServers", + Supported: false, + Reason: "Claude Desktop only supports stdio transport; HTTP/SSE not available", + Icon: "claude-desktop", + }, + { + ID: "cursor", + Name: "Cursor", + Format: "json", + ServerKey: "mcpServers", + Supported: true, + Icon: "cursor", + }, + { + ID: "windsurf", + Name: "Windsurf", + Format: "json", + ServerKey: "mcpServers", + Supported: true, + Icon: "windsurf", + }, + { + ID: "vscode", + Name: "VS Code", + Format: "json", + ServerKey: "servers", + Supported: true, + Icon: "vscode", + }, + { + ID: "codex", + Name: "Codex CLI", + Format: "toml", + ServerKey: "mcp_servers", + Supported: true, + Icon: "codex", + }, + { + ID: "gemini", + Name: "Gemini CLI", + Format: "json", + ServerKey: "mcpServers", + Supported: true, + Icon: "gemini", + }, +} + +// GetAllClients returns the definitions of all known clients. +func GetAllClients() []ClientDef { + result := make([]ClientDef, len(allClients)) + copy(result, allClients) + return result +} + +// FindClient looks up a client definition by ID. Returns nil if not found. +func FindClient(clientID string) *ClientDef { + for i := range allClients { + if allClients[i].ID == clientID { + c := allClients[i] + return &c + } + } + return nil +} + +// ConfigPath returns the expected configuration file path for the given client +// on the current operating system. homeDir overrides os.UserHomeDir when non-empty +// (useful for testing). +func ConfigPath(clientID, homeDir string) string { + if homeDir == "" { + var err error + homeDir, err = os.UserHomeDir() + if err != nil { + return "" + } + } + + switch clientID { + case "claude-code": + return filepath.Join(homeDir, ".claude.json") + + case "claude-desktop": + switch runtime.GOOS { + case "darwin": + return filepath.Join(homeDir, "Library", "Application Support", "Claude", "claude_desktop_config.json") + case "windows": + appData := os.Getenv("APPDATA") + if appData == "" { + appData = filepath.Join(homeDir, "AppData", "Roaming") + } + return filepath.Join(appData, "Claude", "claude_desktop_config.json") + default: // linux + return filepath.Join(homeDir, ".config", "Claude", "claude_desktop_config.json") + } + + case "cursor": + return filepath.Join(homeDir, ".cursor", "mcp.json") + + case "windsurf": + return filepath.Join(homeDir, ".codeium", "windsurf", "mcp_config.json") + + case "vscode": + switch runtime.GOOS { + case "darwin": + return filepath.Join(homeDir, "Library", "Application Support", "Code", "User", "mcp.json") + case "windows": + appData := os.Getenv("APPDATA") + if appData == "" { + appData = filepath.Join(homeDir, "AppData", "Roaming") + } + return filepath.Join(appData, "Code", "User", "mcp.json") + default: // linux + return filepath.Join(homeDir, ".config", "Code", "User", "mcp.json") + } + + case "codex": + return filepath.Join(homeDir, ".codex", "config.toml") + + case "gemini": + return filepath.Join(homeDir, ".gemini", "settings.json") + + default: + return "" + } +} + +// buildServerEntry returns the JSON-serializable map that should be inserted +// into the client's config file for the given mcpproxy URL. +func buildServerEntry(clientID, mcpURL string) map[string]interface{} { + switch clientID { + case "claude-code": + return map[string]interface{}{ + "type": "http", + "url": mcpURL, + } + case "cursor": + return map[string]interface{}{ + "url": mcpURL, + "type": "sse", + } + case "windsurf": + return map[string]interface{}{ + "serverUrl": mcpURL, + "type": "sse", + } + case "vscode": + return map[string]interface{}{ + "type": "http", + "url": mcpURL, + } + case "gemini": + return map[string]interface{}{ + "httpUrl": mcpURL, + } + default: + // Fallback: generic HTTP entry + return map[string]interface{}{ + "type": "http", + "url": mcpURL, + } + } +} diff --git a/internal/connect/connect.go b/internal/connect/connect.go new file mode 100644 index 000000000..1c140a326 --- /dev/null +++ b/internal/connect/connect.go @@ -0,0 +1,650 @@ +package connect + +import ( + "bytes" + "encoding/json" + "fmt" + "net/url" + "os" + "strings" + + "github.com/BurntSushi/toml" +) + +// ConnectResult describes the outcome of a connect or disconnect operation. +type ConnectResult struct { + Success bool `json:"success"` + Client string `json:"client"` + ConfigPath string `json:"config_path"` + BackupPath string `json:"backup_path,omitempty"` + ServerName string `json:"server_name"` + Action string `json:"action"` // "created", "updated", "already_exists", "removed", "not_found" + Message string `json:"message"` +} + +// ClientStatus describes the current state of a client's configuration +// with respect to an MCPProxy entry. +type ClientStatus struct { + ID string `json:"id"` + Name string `json:"name"` + ConfigPath string `json:"config_path"` + Exists bool `json:"exists"` // config file exists on disk + Connected bool `json:"connected"` // mcpproxy entry present in config + Supported bool `json:"supported"` // client supports HTTP/SSE + Reason string `json:"reason,omitempty"` // why not supported + Icon string `json:"icon"` + ServerName string `json:"server_name,omitempty"` // name under which mcpproxy is registered +} + +// Service provides connect/disconnect operations for MCP client configurations. +type Service struct { + listenAddr string // e.g. "127.0.0.1:8080" + apiKey string // optional API key + homeDir string // override for testing; empty means use os.UserHomeDir +} + +// NewService creates a Service that will inject the given listen address +// and optional API key into client configurations. +func NewService(listenAddr, apiKey string) *Service { + return &Service{ + listenAddr: listenAddr, + apiKey: apiKey, + } +} + +// NewServiceWithHome creates a Service with a custom home directory (for testing). +func NewServiceWithHome(listenAddr, apiKey, homeDir string) *Service { + return &Service{ + listenAddr: listenAddr, + apiKey: apiKey, + homeDir: homeDir, + } +} + +// mcpURL builds the MCPProxy MCP endpoint URL. +func (s *Service) mcpURL() string { + addr := s.listenAddr + // If listen address starts with ":" (no host), default to localhost + if strings.HasPrefix(addr, ":") { + addr = "127.0.0.1" + addr + } + base := fmt.Sprintf("http://%s/mcp", addr) + if s.apiKey != "" { + base += "?apikey=" + url.QueryEscape(s.apiKey) + } + return base +} + +// defaultServerName is the key used in client config files. +const defaultServerName = "mcpproxy" + +// GetAllStatus returns the connection status for every known client. +func (s *Service) GetAllStatus() []ClientStatus { + clients := GetAllClients() + statuses := make([]ClientStatus, 0, len(clients)) + + for _, c := range clients { + cfgPath := ConfigPath(c.ID, s.homeDir) + status := ClientStatus{ + ID: c.ID, + Name: c.Name, + ConfigPath: cfgPath, + Supported: c.Supported, + Reason: c.Reason, + Icon: c.Icon, + } + + if _, err := os.Stat(cfgPath); err == nil { + status.Exists = true + } + + // Check if mcpproxy entry exists in the config + if status.Exists && c.Supported { + if name, found := s.findEntry(c, cfgPath); found { + status.Connected = true + status.ServerName = name + } + } + + statuses = append(statuses, status) + } + + return statuses +} + +// Connect registers MCPProxy in the specified client's configuration file. +// serverName defaults to "mcpproxy" if empty. If force is false and an entry +// already exists, an error is returned. +func (s *Service) Connect(clientID, serverName string, force bool) (*ConnectResult, error) { + client := FindClient(clientID) + if client == nil { + return nil, fmt.Errorf("unknown client: %s", clientID) + } + if !client.Supported { + return nil, fmt.Errorf("client %s is not supported: %s", client.Name, client.Reason) + } + + if serverName == "" { + serverName = defaultServerName + } + + cfgPath := ConfigPath(clientID, s.homeDir) + if cfgPath == "" { + return nil, fmt.Errorf("cannot determine config path for %s", clientID) + } + + mcpURL := s.mcpURL() + + if client.Format == "toml" { + return s.connectTOML(client, cfgPath, serverName, mcpURL, force) + } + return s.connectJSON(client, cfgPath, serverName, mcpURL, force) +} + +// Disconnect removes the MCPProxy entry from the specified client's configuration. +func (s *Service) Disconnect(clientID, serverName string) (*ConnectResult, error) { + client := FindClient(clientID) + if client == nil { + return nil, fmt.Errorf("unknown client: %s", clientID) + } + if !client.Supported { + return nil, fmt.Errorf("client %s is not supported: %s", client.Name, client.Reason) + } + + if serverName == "" { + serverName = defaultServerName + } + + cfgPath := ConfigPath(clientID, s.homeDir) + if cfgPath == "" { + return nil, fmt.Errorf("cannot determine config path for %s", clientID) + } + + if client.Format == "toml" { + return s.disconnectTOML(client, cfgPath, serverName) + } + return s.disconnectJSON(client, cfgPath, serverName) +} + +// ---------- JSON helpers ---------- + +// connectJSON adds or updates the mcpproxy entry in a JSON config file. +func (s *Service) connectJSON(client *ClientDef, cfgPath, serverName, mcpURL string, force bool) (*ConnectResult, error) { + // Read existing config or start fresh + data, perm, err := readOrCreateJSON(cfgPath) + if err != nil { + return nil, err + } + + // Get or create the servers section + serversKey := client.ServerKey + serversMap, ok := data[serversKey].(map[string]interface{}) + if !ok { + serversMap = make(map[string]interface{}) + } + + action := "created" + if _, exists := serversMap[serverName]; exists { + if !force { + return &ConnectResult{ + Success: false, + Client: client.ID, + ConfigPath: cfgPath, + ServerName: serverName, + Action: "already_exists", + Message: fmt.Sprintf("%s already has an entry named %q; use force=true to overwrite", client.Name, serverName), + }, nil + } + action = "updated" + } + + // Create backup before modifying + backupPath, err := backupFile(cfgPath) + if err != nil { + return nil, fmt.Errorf("backup failed: %w", err) + } + + // Build the entry + entry := buildServerEntry(client.ID, mcpURL) + serversMap[serverName] = entry + data[serversKey] = serversMap + + // Write atomically + encoded, err := marshalJSONIndent(data) + if err != nil { + return nil, fmt.Errorf("marshal config: %w", err) + } + + if err := atomicWriteFile(cfgPath, encoded, perm); err != nil { + return nil, fmt.Errorf("write config: %w", err) + } + + // Verify by re-reading + if err := verifyJSONEntry(cfgPath, serversKey, serverName); err != nil { + return nil, fmt.Errorf("verification failed: %w", err) + } + + return &ConnectResult{ + Success: true, + Client: client.ID, + ConfigPath: cfgPath, + BackupPath: backupPath, + ServerName: serverName, + Action: action, + Message: fmt.Sprintf("MCPProxy registered in %s as %q", client.Name, serverName), + }, nil +} + +// disconnectJSON removes the mcpproxy entry from a JSON config file. +func (s *Service) disconnectJSON(client *ClientDef, cfgPath, serverName string) (*ConnectResult, error) { + raw, err := os.ReadFile(cfgPath) + if os.IsNotExist(err) { + return &ConnectResult{ + Success: false, + Client: client.ID, + ConfigPath: cfgPath, + ServerName: serverName, + Action: "not_found", + Message: fmt.Sprintf("Config file %s does not exist", cfgPath), + }, nil + } + if err != nil { + return nil, fmt.Errorf("read config: %w", err) + } + + var data map[string]interface{} + if err := json.Unmarshal(raw, &data); err != nil { + return nil, fmt.Errorf("parse config: %w", err) + } + + serversKey := client.ServerKey + serversMap, ok := data[serversKey].(map[string]interface{}) + if !ok { + return &ConnectResult{ + Success: false, + Client: client.ID, + ConfigPath: cfgPath, + ServerName: serverName, + Action: "not_found", + Message: fmt.Sprintf("No %s section found in %s", serversKey, client.Name), + }, nil + } + + if _, exists := serversMap[serverName]; !exists { + return &ConnectResult{ + Success: false, + Client: client.ID, + ConfigPath: cfgPath, + ServerName: serverName, + Action: "not_found", + Message: fmt.Sprintf("No entry named %q in %s", serverName, client.Name), + }, nil + } + + // Create backup + backupPath, err := backupFile(cfgPath) + if err != nil { + return nil, fmt.Errorf("backup failed: %w", err) + } + + delete(serversMap, serverName) + data[serversKey] = serversMap + + info, _ := os.Stat(cfgPath) + perm := os.FileMode(0o644) + if info != nil { + perm = info.Mode() + } + + encoded, err := marshalJSONIndent(data) + if err != nil { + return nil, fmt.Errorf("marshal config: %w", err) + } + + if err := atomicWriteFile(cfgPath, encoded, perm); err != nil { + return nil, fmt.Errorf("write config: %w", err) + } + + return &ConnectResult{ + Success: true, + Client: client.ID, + ConfigPath: cfgPath, + BackupPath: backupPath, + ServerName: serverName, + Action: "removed", + Message: fmt.Sprintf("MCPProxy entry %q removed from %s", serverName, client.Name), + }, nil +} + +// ---------- TOML helpers (Codex) ---------- + +// connectTOML adds or updates the mcpproxy entry in a TOML config file (Codex). +func (s *Service) connectTOML(client *ClientDef, cfgPath, serverName, mcpURL string, force bool) (*ConnectResult, error) { + data, perm, err := readOrCreateTOML(cfgPath) + if err != nil { + return nil, err + } + + // Get or create mcp_servers section + serversRaw, ok := data["mcp_servers"] + var serversMap map[string]interface{} + if ok { + serversMap, _ = serversRaw.(map[string]interface{}) + } + if serversMap == nil { + serversMap = make(map[string]interface{}) + } + + action := "created" + if _, exists := serversMap[serverName]; exists { + if !force { + return &ConnectResult{ + Success: false, + Client: client.ID, + ConfigPath: cfgPath, + ServerName: serverName, + Action: "already_exists", + Message: fmt.Sprintf("%s already has an entry named %q; use force=true to overwrite", client.Name, serverName), + }, nil + } + action = "updated" + } + + // Backup + backupPath, err := backupFile(cfgPath) + if err != nil { + return nil, fmt.Errorf("backup failed: %w", err) + } + + // Build Codex entry + entry := map[string]interface{}{ + "url": mcpURL, + } + serversMap[serverName] = entry + data["mcp_servers"] = serversMap + + // Encode TOML + var buf bytes.Buffer + enc := toml.NewEncoder(&buf) + if err := enc.Encode(data); err != nil { + return nil, fmt.Errorf("encode TOML: %w", err) + } + + if err := atomicWriteFile(cfgPath, buf.Bytes(), perm); err != nil { + return nil, fmt.Errorf("write config: %w", err) + } + + return &ConnectResult{ + Success: true, + Client: client.ID, + ConfigPath: cfgPath, + BackupPath: backupPath, + ServerName: serverName, + Action: action, + Message: fmt.Sprintf("MCPProxy registered in %s as %q", client.Name, serverName), + }, nil +} + +// disconnectTOML removes the mcpproxy entry from a TOML config file. +func (s *Service) disconnectTOML(client *ClientDef, cfgPath, serverName string) (*ConnectResult, error) { + raw, err := os.ReadFile(cfgPath) + if os.IsNotExist(err) { + return &ConnectResult{ + Success: false, + Client: client.ID, + ConfigPath: cfgPath, + ServerName: serverName, + Action: "not_found", + Message: fmt.Sprintf("Config file %s does not exist", cfgPath), + }, nil + } + if err != nil { + return nil, fmt.Errorf("read config: %w", err) + } + + var data map[string]interface{} + if _, err := toml.Decode(string(raw), &data); err != nil { + return nil, fmt.Errorf("parse TOML: %w", err) + } + + serversRaw, ok := data["mcp_servers"] + var serversMap map[string]interface{} + if ok { + serversMap, _ = serversRaw.(map[string]interface{}) + } + + if serversMap == nil { + return &ConnectResult{ + Success: false, + Client: client.ID, + ConfigPath: cfgPath, + ServerName: serverName, + Action: "not_found", + Message: fmt.Sprintf("No mcp_servers section found in %s", client.Name), + }, nil + } + + if _, exists := serversMap[serverName]; !exists { + return &ConnectResult{ + Success: false, + Client: client.ID, + ConfigPath: cfgPath, + ServerName: serverName, + Action: "not_found", + Message: fmt.Sprintf("No entry named %q in %s", serverName, client.Name), + }, nil + } + + backupPath, err := backupFile(cfgPath) + if err != nil { + return nil, fmt.Errorf("backup failed: %w", err) + } + + delete(serversMap, serverName) + data["mcp_servers"] = serversMap + + info, _ := os.Stat(cfgPath) + perm := os.FileMode(0o644) + if info != nil { + perm = info.Mode() + } + + var buf bytes.Buffer + enc := toml.NewEncoder(&buf) + if err := enc.Encode(data); err != nil { + return nil, fmt.Errorf("encode TOML: %w", err) + } + + if err := atomicWriteFile(cfgPath, buf.Bytes(), perm); err != nil { + return nil, fmt.Errorf("write config: %w", err) + } + + return &ConnectResult{ + Success: true, + Client: client.ID, + ConfigPath: cfgPath, + BackupPath: backupPath, + ServerName: serverName, + Action: "removed", + Message: fmt.Sprintf("MCPProxy entry %q removed from %s", serverName, client.Name), + }, nil +} + +// ---------- Internal helpers ---------- + +// readOrCreateJSON reads a JSON config file, or returns an empty map with default permissions +// if the file does not exist. +func readOrCreateJSON(path string) (map[string]interface{}, os.FileMode, error) { + perm := os.FileMode(0o644) + + raw, err := os.ReadFile(path) + if os.IsNotExist(err) { + return make(map[string]interface{}), perm, nil + } + if err != nil { + return nil, perm, fmt.Errorf("read %s: %w", path, err) + } + + info, _ := os.Stat(path) + if info != nil { + perm = info.Mode() + } + + var data map[string]interface{} + if err := json.Unmarshal(raw, &data); err != nil { + return nil, perm, fmt.Errorf("parse JSON in %s: %w", path, err) + } + + return data, perm, nil +} + +// readOrCreateTOML reads a TOML config file, or returns an empty map with default permissions. +func readOrCreateTOML(path string) (map[string]interface{}, os.FileMode, error) { + perm := os.FileMode(0o644) + + raw, err := os.ReadFile(path) + if os.IsNotExist(err) { + return make(map[string]interface{}), perm, nil + } + if err != nil { + return nil, perm, fmt.Errorf("read %s: %w", path, err) + } + + info, _ := os.Stat(path) + if info != nil { + perm = info.Mode() + } + + var data map[string]interface{} + if _, err := toml.Decode(string(raw), &data); err != nil { + return nil, perm, fmt.Errorf("parse TOML in %s: %w", path, err) + } + + return data, perm, nil +} + +// marshalJSONIndent encodes data as pretty-printed JSON with a trailing newline. +func marshalJSONIndent(data interface{}) ([]byte, error) { + buf, err := json.MarshalIndent(data, "", " ") + if err != nil { + return nil, err + } + buf = append(buf, '\n') + return buf, nil +} + +// verifyJSONEntry re-reads the config file and checks that the expected entry exists. +func verifyJSONEntry(path, serversKey, serverName string) error { + raw, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("re-read %s: %w", path, err) + } + var data map[string]interface{} + if err := json.Unmarshal(raw, &data); err != nil { + return fmt.Errorf("re-parse %s: %w", path, err) + } + serversMap, ok := data[serversKey].(map[string]interface{}) + if !ok { + return fmt.Errorf("missing %s key after write", serversKey) + } + if _, exists := serversMap[serverName]; !exists { + return fmt.Errorf("entry %q missing after write", serverName) + } + return nil +} + +// findEntry checks whether a config file contains an mcpproxy-like entry. +// It returns the server name and true if found. +func (s *Service) findEntry(client ClientDef, cfgPath string) (string, bool) { + if client.Format == "toml" { + return s.findEntryTOML(cfgPath) + } + return s.findEntryJSON(client, cfgPath) +} + +// findEntryJSON looks for an entry in a JSON config that points to our MCP URL. +func (s *Service) findEntryJSON(client ClientDef, cfgPath string) (string, bool) { + raw, err := os.ReadFile(cfgPath) + if err != nil { + return "", false + } + + var data map[string]interface{} + if err := json.Unmarshal(raw, &data); err != nil { + return "", false + } + + serversMap, ok := data[client.ServerKey].(map[string]interface{}) + if !ok { + return "", false + } + + mcpURL := s.mcpURL() + baseURL := fmt.Sprintf("http://%s/mcp", s.listenAddr) + + for name, v := range serversMap { + entry, ok := v.(map[string]interface{}) + if !ok { + continue + } + + // Check various URL fields used by different clients + for _, field := range []string{"url", "serverUrl", "httpUrl"} { + if u, ok := entry[field].(string); ok { + if u == mcpURL || u == baseURL || strings.HasPrefix(u, baseURL+"?") { + return name, true + } + } + } + + // Also match by server name + if name == defaultServerName { + return name, true + } + } + + return "", false +} + +// findEntryTOML looks for an entry in a TOML config that points to our MCP URL. +func (s *Service) findEntryTOML(cfgPath string) (string, bool) { + raw, err := os.ReadFile(cfgPath) + if err != nil { + return "", false + } + + var data map[string]interface{} + if _, err := toml.Decode(string(raw), &data); err != nil { + return "", false + } + + serversRaw, ok := data["mcp_servers"] + if !ok { + return "", false + } + + serversMap, ok := serversRaw.(map[string]interface{}) + if !ok { + return "", false + } + + mcpURL := s.mcpURL() + baseURL := fmt.Sprintf("http://%s/mcp", s.listenAddr) + + for name, v := range serversMap { + entry, ok := v.(map[string]interface{}) + if !ok { + continue + } + if u, ok := entry["url"].(string); ok { + if u == mcpURL || u == baseURL || strings.HasPrefix(u, baseURL+"?") { + return name, true + } + } + if name == defaultServerName { + return name, true + } + } + + return "", false +} diff --git a/internal/connect/connect_test.go b/internal/connect/connect_test.go new file mode 100644 index 000000000..1070fdb9c --- /dev/null +++ b/internal/connect/connect_test.go @@ -0,0 +1,798 @@ +package connect + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/BurntSushi/toml" +) + +// helper to create a service pointing at a temp home directory +func testService(t *testing.T) (*Service, string) { + t.Helper() + homeDir := t.TempDir() + svc := NewServiceWithHome("127.0.0.1:8080", "", homeDir) + return svc, homeDir +} + +func testServiceWithKey(t *testing.T) (*Service, string) { + t.Helper() + homeDir := t.TempDir() + svc := NewServiceWithHome("127.0.0.1:8080", "test-key-123", homeDir) + return svc, homeDir +} + +// ---------- JSON client tests ---------- + +func TestConnect_ClaudeCode_NewFile(t *testing.T) { + svc, _ := testService(t) + + result, err := svc.Connect("claude-code", "", false) + if err != nil { + t.Fatalf("Connect failed: %v", err) + } + if !result.Success { + t.Fatalf("Expected success, got: %s", result.Message) + } + if result.Action != "created" { + t.Errorf("Expected action=created, got %s", result.Action) + } + if result.ServerName != "mcpproxy" { + t.Errorf("Expected serverName=mcpproxy, got %s", result.ServerName) + } + if result.BackupPath != "" { + t.Errorf("Expected no backup for new file, got %s", result.BackupPath) + } + + // Verify the file was written correctly + raw, err := os.ReadFile(result.ConfigPath) + if err != nil { + t.Fatalf("Read config failed: %v", err) + } + + var data map[string]interface{} + if err := json.Unmarshal(raw, &data); err != nil { + t.Fatalf("Parse config failed: %v", err) + } + + servers, ok := data["mcpServers"].(map[string]interface{}) + if !ok { + t.Fatal("Missing mcpServers key") + } + + entry, ok := servers["mcpproxy"].(map[string]interface{}) + if !ok { + t.Fatal("Missing mcpproxy entry") + } + + if entry["type"] != "http" { + t.Errorf("Expected type=http, got %v", entry["type"]) + } + if entry["url"] != "http://127.0.0.1:8080/mcp" { + t.Errorf("Expected url=http://127.0.0.1:8080/mcp, got %v", entry["url"]) + } +} + +func TestConnect_ClaudeCode_WithAPIKey(t *testing.T) { + svc, _ := testServiceWithKey(t) + + result, err := svc.Connect("claude-code", "", false) + if err != nil { + t.Fatalf("Connect failed: %v", err) + } + if !result.Success { + t.Fatalf("Expected success, got: %s", result.Message) + } + + raw, err := os.ReadFile(result.ConfigPath) + if err != nil { + t.Fatalf("Read config failed: %v", err) + } + + var data map[string]interface{} + if err := json.Unmarshal(raw, &data); err != nil { + t.Fatalf("Parse config failed: %v", err) + } + + servers := data["mcpServers"].(map[string]interface{}) + entry := servers["mcpproxy"].(map[string]interface{}) + + expectedURL := "http://127.0.0.1:8080/mcp?apikey=test-key-123" + if entry["url"] != expectedURL { + t.Errorf("Expected url=%s, got %v", expectedURL, entry["url"]) + } +} + +func TestConnect_ExistingFile_PreservesOtherEntries(t *testing.T) { + svc, homeDir := testService(t) + + // Create an existing config with another server + cfgPath := filepath.Join(homeDir, ".claude.json") + existingConfig := map[string]interface{}{ + "mcpServers": map[string]interface{}{ + "github": map[string]interface{}{ + "type": "http", + "url": "https://api.github.com/mcp", + }, + }, + "someOtherKey": "preserved", + } + data, _ := json.MarshalIndent(existingConfig, "", " ") + if err := os.WriteFile(cfgPath, data, 0o644); err != nil { + t.Fatal(err) + } + + result, err := svc.Connect("claude-code", "", false) + if err != nil { + t.Fatalf("Connect failed: %v", err) + } + if !result.Success { + t.Fatalf("Expected success, got: %s", result.Message) + } + + // Verify both entries exist and other keys are preserved + raw, _ := os.ReadFile(cfgPath) + var config map[string]interface{} + json.Unmarshal(raw, &config) + + servers := config["mcpServers"].(map[string]interface{}) + if _, ok := servers["github"]; !ok { + t.Error("github entry was lost") + } + if _, ok := servers["mcpproxy"]; !ok { + t.Error("mcpproxy entry was not added") + } + if config["someOtherKey"] != "preserved" { + t.Error("someOtherKey was not preserved") + } +} + +func TestConnect_AlreadyExists_NoForce(t *testing.T) { + svc, _ := testService(t) + + // First connect succeeds + _, err := svc.Connect("claude-code", "", false) + if err != nil { + t.Fatalf("First connect failed: %v", err) + } + + // Second connect without force returns already_exists + result, err := svc.Connect("claude-code", "", false) + if err != nil { + t.Fatalf("Second connect failed unexpectedly: %v", err) + } + if result.Success { + t.Error("Expected failure for duplicate entry without force") + } + if result.Action != "already_exists" { + t.Errorf("Expected action=already_exists, got %s", result.Action) + } +} + +func TestConnect_AlreadyExists_WithForce(t *testing.T) { + svc, _ := testService(t) + + // First connect + _, err := svc.Connect("claude-code", "", false) + if err != nil { + t.Fatalf("First connect failed: %v", err) + } + + // Second connect with force updates + result, err := svc.Connect("claude-code", "", true) + if err != nil { + t.Fatalf("Force connect failed: %v", err) + } + if !result.Success { + t.Fatalf("Expected success for force connect, got: %s", result.Message) + } + if result.Action != "updated" { + t.Errorf("Expected action=updated, got %s", result.Action) + } + if result.BackupPath == "" { + t.Error("Expected backup path for update of existing file") + } +} + +func TestConnect_Backup_Created(t *testing.T) { + svc, homeDir := testService(t) + + // Create an existing config + cfgPath := filepath.Join(homeDir, ".claude.json") + if err := os.WriteFile(cfgPath, []byte(`{"mcpServers":{}}`), 0o644); err != nil { + t.Fatal(err) + } + + result, err := svc.Connect("claude-code", "", false) + if err != nil { + t.Fatalf("Connect failed: %v", err) + } + if !result.Success { + t.Fatalf("Expected success, got: %s", result.Message) + } + if result.BackupPath == "" { + t.Fatal("Expected backup path") + } + + // Verify backup file exists + if _, err := os.Stat(result.BackupPath); err != nil { + t.Errorf("Backup file does not exist: %s", result.BackupPath) + } + + // Verify backup has the original content + backupData, _ := os.ReadFile(result.BackupPath) + if string(backupData) != `{"mcpServers":{}}` { + t.Errorf("Backup content mismatch: %s", string(backupData)) + } +} + +func TestConnect_VSCode_ServersKey(t *testing.T) { + svc, homeDir := testService(t) + + // For VS Code, we need to create the directory structure + // ConfigPath returns OS-specific path; let's directly test the connect logic + cfgPath := ConfigPath("vscode", homeDir) + if err := os.MkdirAll(filepath.Dir(cfgPath), 0o755); err != nil { + t.Fatal(err) + } + + result, err := svc.Connect("vscode", "", false) + if err != nil { + t.Fatalf("Connect failed: %v", err) + } + if !result.Success { + t.Fatalf("Expected success, got: %s", result.Message) + } + + // Verify uses "servers" key (not "mcpServers") + raw, _ := os.ReadFile(result.ConfigPath) + var data map[string]interface{} + json.Unmarshal(raw, &data) + + if _, ok := data["servers"]; !ok { + t.Error("Expected 'servers' key for VS Code, not found") + } + if _, ok := data["mcpServers"]; ok { + t.Error("Unexpected 'mcpServers' key for VS Code") + } + + servers := data["servers"].(map[string]interface{}) + entry := servers["mcpproxy"].(map[string]interface{}) + if entry["type"] != "http" { + t.Errorf("Expected type=http for VS Code, got %v", entry["type"]) + } +} + +func TestConnect_Cursor_SSEType(t *testing.T) { + svc, _ := testService(t) + + result, err := svc.Connect("cursor", "", false) + if err != nil { + t.Fatalf("Connect failed: %v", err) + } + if !result.Success { + t.Fatalf("Expected success, got: %s", result.Message) + } + + raw, _ := os.ReadFile(result.ConfigPath) + var data map[string]interface{} + json.Unmarshal(raw, &data) + + servers := data["mcpServers"].(map[string]interface{}) + entry := servers["mcpproxy"].(map[string]interface{}) + if entry["type"] != "sse" { + t.Errorf("Expected type=sse for Cursor, got %v", entry["type"]) + } +} + +func TestConnect_Windsurf_ServerUrl(t *testing.T) { + svc, homeDir := testService(t) + + cfgPath := ConfigPath("windsurf", homeDir) + if err := os.MkdirAll(filepath.Dir(cfgPath), 0o755); err != nil { + t.Fatal(err) + } + + result, err := svc.Connect("windsurf", "", false) + if err != nil { + t.Fatalf("Connect failed: %v", err) + } + if !result.Success { + t.Fatalf("Expected success, got: %s", result.Message) + } + + raw, _ := os.ReadFile(result.ConfigPath) + var data map[string]interface{} + json.Unmarshal(raw, &data) + + servers := data["mcpServers"].(map[string]interface{}) + entry := servers["mcpproxy"].(map[string]interface{}) + if entry["serverUrl"] != "http://127.0.0.1:8080/mcp" { + t.Errorf("Expected serverUrl for Windsurf, got %v", entry["serverUrl"]) + } + if entry["type"] != "sse" { + t.Errorf("Expected type=sse for Windsurf, got %v", entry["type"]) + } +} + +func TestConnect_Gemini_HttpUrl(t *testing.T) { + svc, homeDir := testService(t) + + cfgPath := ConfigPath("gemini", homeDir) + if err := os.MkdirAll(filepath.Dir(cfgPath), 0o755); err != nil { + t.Fatal(err) + } + + result, err := svc.Connect("gemini", "", false) + if err != nil { + t.Fatalf("Connect failed: %v", err) + } + if !result.Success { + t.Fatalf("Expected success, got: %s", result.Message) + } + + raw, _ := os.ReadFile(result.ConfigPath) + var data map[string]interface{} + json.Unmarshal(raw, &data) + + servers := data["mcpServers"].(map[string]interface{}) + entry := servers["mcpproxy"].(map[string]interface{}) + if entry["httpUrl"] != "http://127.0.0.1:8080/mcp" { + t.Errorf("Expected httpUrl for Gemini, got %v", entry["httpUrl"]) + } +} + +// ---------- TOML client tests (Codex) ---------- + +func TestConnect_Codex_NewFile(t *testing.T) { + svc, homeDir := testService(t) + + cfgPath := ConfigPath("codex", homeDir) + if err := os.MkdirAll(filepath.Dir(cfgPath), 0o755); err != nil { + t.Fatal(err) + } + + result, err := svc.Connect("codex", "", false) + if err != nil { + t.Fatalf("Connect failed: %v", err) + } + if !result.Success { + t.Fatalf("Expected success, got: %s", result.Message) + } + if result.Action != "created" { + t.Errorf("Expected action=created, got %s", result.Action) + } + + // Verify TOML content + raw, _ := os.ReadFile(result.ConfigPath) + var data map[string]interface{} + if _, err := toml.Decode(string(raw), &data); err != nil { + t.Fatalf("Parse TOML failed: %v", err) + } + + servers, ok := data["mcp_servers"].(map[string]interface{}) + if !ok { + t.Fatal("Missing mcp_servers section") + } + + entry, ok := servers["mcpproxy"].(map[string]interface{}) + if !ok { + t.Fatal("Missing mcpproxy entry") + } + + if entry["url"] != "http://127.0.0.1:8080/mcp" { + t.Errorf("Expected url=http://127.0.0.1:8080/mcp, got %v", entry["url"]) + } +} + +func TestConnect_Codex_ExistingFile(t *testing.T) { + svc, homeDir := testService(t) + + cfgPath := ConfigPath("codex", homeDir) + if err := os.MkdirAll(filepath.Dir(cfgPath), 0o755); err != nil { + t.Fatal(err) + } + + // Write existing TOML config + existing := `[mcp_servers.other-server] +url = "http://other.server/mcp" +` + if err := os.WriteFile(cfgPath, []byte(existing), 0o644); err != nil { + t.Fatal(err) + } + + result, err := svc.Connect("codex", "", false) + if err != nil { + t.Fatalf("Connect failed: %v", err) + } + if !result.Success { + t.Fatalf("Expected success, got: %s", result.Message) + } + + // Verify both entries exist + raw, _ := os.ReadFile(cfgPath) + var data map[string]interface{} + toml.Decode(string(raw), &data) + + servers := data["mcp_servers"].(map[string]interface{}) + if _, ok := servers["other-server"]; !ok { + t.Error("other-server entry was lost") + } + if _, ok := servers["mcpproxy"]; !ok { + t.Error("mcpproxy entry was not added") + } +} + +func TestConnect_Codex_AlreadyExists(t *testing.T) { + svc, homeDir := testService(t) + + cfgPath := ConfigPath("codex", homeDir) + if err := os.MkdirAll(filepath.Dir(cfgPath), 0o755); err != nil { + t.Fatal(err) + } + + // First connect + _, err := svc.Connect("codex", "", false) + if err != nil { + t.Fatal(err) + } + + // Second without force + result, err := svc.Connect("codex", "", false) + if err != nil { + t.Fatalf("Second connect error: %v", err) + } + if result.Success { + t.Error("Expected failure for duplicate without force") + } + if result.Action != "already_exists" { + t.Errorf("Expected already_exists, got %s", result.Action) + } +} + +// ---------- Disconnect tests ---------- + +func TestDisconnect_JSON(t *testing.T) { + svc, _ := testService(t) + + // Connect first + _, err := svc.Connect("claude-code", "", false) + if err != nil { + t.Fatal(err) + } + + // Disconnect + result, err := svc.Disconnect("claude-code", "") + if err != nil { + t.Fatalf("Disconnect failed: %v", err) + } + if !result.Success { + t.Fatalf("Expected success, got: %s", result.Message) + } + if result.Action != "removed" { + t.Errorf("Expected action=removed, got %s", result.Action) + } + if result.BackupPath == "" { + t.Error("Expected backup for disconnect") + } + + // Verify entry is gone + raw, _ := os.ReadFile(result.ConfigPath) + var data map[string]interface{} + json.Unmarshal(raw, &data) + + servers := data["mcpServers"].(map[string]interface{}) + if _, ok := servers["mcpproxy"]; ok { + t.Error("mcpproxy entry should have been removed") + } +} + +func TestDisconnect_NotFound(t *testing.T) { + svc, _ := testService(t) + + // Try to disconnect without connecting first — file doesn't exist + result, err := svc.Disconnect("claude-code", "") + if err != nil { + t.Fatalf("Disconnect error: %v", err) + } + if result.Success { + t.Error("Expected failure when disconnecting non-existent entry") + } + if result.Action != "not_found" { + t.Errorf("Expected action=not_found, got %s", result.Action) + } +} + +func TestDisconnect_TOML(t *testing.T) { + svc, homeDir := testService(t) + + cfgPath := ConfigPath("codex", homeDir) + os.MkdirAll(filepath.Dir(cfgPath), 0o755) + + // Connect first + _, err := svc.Connect("codex", "", false) + if err != nil { + t.Fatal(err) + } + + // Disconnect + result, err := svc.Disconnect("codex", "") + if err != nil { + t.Fatalf("Disconnect failed: %v", err) + } + if !result.Success { + t.Fatalf("Expected success, got: %s", result.Message) + } + if result.Action != "removed" { + t.Errorf("Expected action=removed, got %s", result.Action) + } +} + +// ---------- Unsupported client tests ---------- + +func TestConnect_UnsupportedClient(t *testing.T) { + svc, _ := testService(t) + + _, err := svc.Connect("claude-desktop", "", false) + if err == nil { + t.Fatal("Expected error for unsupported client") + } + if !strings.Contains(err.Error(), "not supported") { + t.Errorf("Expected 'not supported' in error, got: %v", err) + } +} + +func TestConnect_UnknownClient(t *testing.T) { + svc, _ := testService(t) + + _, err := svc.Connect("nonexistent", "", false) + if err == nil { + t.Fatal("Expected error for unknown client") + } + if !strings.Contains(err.Error(), "unknown client") { + t.Errorf("Expected 'unknown client' in error, got: %v", err) + } +} + +// ---------- Custom server name tests ---------- + +func TestConnect_CustomServerName(t *testing.T) { + svc, _ := testService(t) + + result, err := svc.Connect("claude-code", "my-proxy", false) + if err != nil { + t.Fatalf("Connect failed: %v", err) + } + if !result.Success { + t.Fatalf("Expected success, got: %s", result.Message) + } + if result.ServerName != "my-proxy" { + t.Errorf("Expected serverName=my-proxy, got %s", result.ServerName) + } + + raw, _ := os.ReadFile(result.ConfigPath) + var data map[string]interface{} + json.Unmarshal(raw, &data) + + servers := data["mcpServers"].(map[string]interface{}) + if _, ok := servers["my-proxy"]; !ok { + t.Error("Expected entry under custom name 'my-proxy'") + } +} + +// ---------- GetAllStatus tests ---------- + +func TestGetAllStatus(t *testing.T) { + svc, _ := testService(t) + + statuses := svc.GetAllStatus() + if len(statuses) != len(allClients) { + t.Errorf("Expected %d statuses, got %d", len(allClients), len(statuses)) + } + + // Verify claude-desktop is not supported + for _, s := range statuses { + if s.ID == "claude-desktop" { + if s.Supported { + t.Error("claude-desktop should not be supported") + } + if s.Reason == "" { + t.Error("claude-desktop should have a reason") + } + } + } +} + +func TestGetAllStatus_AfterConnect(t *testing.T) { + svc, _ := testService(t) + + // Connect to claude-code + _, err := svc.Connect("claude-code", "", false) + if err != nil { + t.Fatal(err) + } + + statuses := svc.GetAllStatus() + for _, s := range statuses { + if s.ID == "claude-code" { + if !s.Exists { + t.Error("Expected exists=true for claude-code after connect") + } + if !s.Connected { + t.Error("Expected connected=true for claude-code after connect") + } + break + } + } +} + +// ---------- Backup utility tests ---------- + +func TestBackupFile(t *testing.T) { + dir := t.TempDir() + original := filepath.Join(dir, "test.json") + + content := []byte(`{"test": true}`) + if err := os.WriteFile(original, content, 0o644); err != nil { + t.Fatal(err) + } + + backupPath, err := backupFile(original) + if err != nil { + t.Fatalf("backupFile failed: %v", err) + } + if backupPath == "" { + t.Fatal("Expected non-empty backup path") + } + + // Verify backup content + backupContent, _ := os.ReadFile(backupPath) + if string(backupContent) != string(content) { + t.Error("Backup content mismatch") + } +} + +func TestBackupFile_NonExistent(t *testing.T) { + backupPath, err := backupFile("/nonexistent/file.json") + if err != nil { + t.Fatalf("Expected nil error for non-existent file, got: %v", err) + } + if backupPath != "" { + t.Errorf("Expected empty backup path for non-existent file, got: %s", backupPath) + } +} + +func TestAtomicWriteFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "subdir", "test.json") + + content := []byte(`{"atomic": true}`) + if err := atomicWriteFile(path, content, 0o644); err != nil { + t.Fatalf("atomicWriteFile failed: %v", err) + } + + // Verify content + read, _ := os.ReadFile(path) + if string(read) != string(content) { + t.Error("Content mismatch") + } + + // Verify directory was created + if _, err := os.Stat(filepath.Dir(path)); err != nil { + t.Error("Directory was not created") + } +} + +// ---------- ConfigPath tests ---------- + +func TestConfigPath_AllClients(t *testing.T) { + homeDir := "/test/home" + for _, c := range allClients { + path := ConfigPath(c.ID, homeDir) + if path == "" { + t.Errorf("Empty path for client %s", c.ID) + } + if !strings.HasPrefix(path, homeDir) && !strings.HasPrefix(path, "/") { + t.Errorf("Path for %s does not start with home dir: %s", c.ID, path) + } + } +} + +func TestConfigPath_UnknownClient(t *testing.T) { + path := ConfigPath("unknown", "/test/home") + if path != "" { + t.Errorf("Expected empty path for unknown client, got: %s", path) + } +} + +// ---------- mcpURL tests ---------- + +func TestMcpURL_NoAPIKey(t *testing.T) { + svc := NewService("127.0.0.1:8080", "") + url := svc.mcpURL() + if url != "http://127.0.0.1:8080/mcp" { + t.Errorf("Expected http://127.0.0.1:8080/mcp, got %s", url) + } +} + +func TestMcpURL_WithAPIKey(t *testing.T) { + svc := NewService("127.0.0.1:8080", "my-secret") + url := svc.mcpURL() + if url != "http://127.0.0.1:8080/mcp?apikey=my-secret" { + t.Errorf("Expected url with apikey, got %s", url) + } +} + +func TestMcpURL_APIKeyWithSpecialChars(t *testing.T) { + svc := NewService("127.0.0.1:8080", "key with spaces&special=chars") + url := svc.mcpURL() + if !strings.Contains(url, "apikey=") { + t.Errorf("Expected apikey param in URL, got %s", url) + } + // Should be URL-encoded + if strings.Contains(url, " ") { + t.Error("URL should not contain raw spaces") + } +} + +// ---------- Client definitions tests ---------- + +func TestFindClient(t *testing.T) { + c := FindClient("cursor") + if c == nil { + t.Fatal("Expected to find cursor client") + } + if c.Name != "Cursor" { + t.Errorf("Expected name=Cursor, got %s", c.Name) + } + + c = FindClient("nonexistent") + if c != nil { + t.Error("Expected nil for nonexistent client") + } +} + +func TestGetAllClients(t *testing.T) { + clients := GetAllClients() + if len(clients) != 7 { + t.Errorf("Expected 7 clients, got %d", len(clients)) + } + + // Verify all have non-empty IDs and names + for _, c := range clients { + if c.ID == "" { + t.Error("Client with empty ID") + } + if c.Name == "" { + t.Errorf("Client %s has empty name", c.ID) + } + } +} + +// ---------- Edge case tests ---------- + +func TestConnect_FilePermissionsPreserved(t *testing.T) { + svc, homeDir := testService(t) + + cfgPath := filepath.Join(homeDir, ".claude.json") + if err := os.WriteFile(cfgPath, []byte(`{}`), 0o600); err != nil { + t.Fatal(err) + } + + _, err := svc.Connect("claude-code", "", false) + if err != nil { + t.Fatal(err) + } + + info, err := os.Stat(cfgPath) + if err != nil { + t.Fatal(err) + } + // On macOS/Linux, check the permission bits + if info.Mode().Perm() != 0o600 { + t.Errorf("Expected permissions 0600, got %o", info.Mode().Perm()) + } +} diff --git a/internal/health/calculator.go b/internal/health/calculator.go index fead5c7c4..3a86b0dcd 100644 --- a/internal/health/calculator.go +++ b/internal/health/calculator.go @@ -164,10 +164,10 @@ func CalculateHealth(input HealthCalculatorInput, cfg *HealthCalculatorConfig) * } case "connecting", "idle": return &contracts.HealthStatus{ - Level: LevelDegraded, + Level: LevelHealthy, AdminState: StateEnabled, Summary: "Connecting...", - Action: ActionNone, // Will resolve on its own + Action: ActionNone, // Will resolve on its own — not an attention item } } diff --git a/internal/health/calculator_test.go b/internal/health/calculator_test.go index 39943f9f1..6b3bdeb02 100644 --- a/internal/health/calculator_test.go +++ b/internal/health/calculator_test.go @@ -79,7 +79,7 @@ func TestCalculateHealth_ConnectingState(t *testing.T) { result := CalculateHealth(input, nil) - assert.Equal(t, LevelDegraded, result.Level) + assert.Equal(t, LevelHealthy, result.Level, "connecting is a normal transient state, not degraded") assert.Equal(t, StateEnabled, result.AdminState) assert.Equal(t, "Connecting...", result.Summary) assert.Equal(t, ActionNone, result.Action) @@ -94,7 +94,7 @@ func TestCalculateHealth_IdleState(t *testing.T) { result := CalculateHealth(input, nil) - assert.Equal(t, LevelDegraded, result.Level) + assert.Equal(t, LevelHealthy, result.Level, "idle is a normal transient state, not degraded") assert.Equal(t, StateEnabled, result.AdminState) assert.Equal(t, "Connecting...", result.Summary) assert.Equal(t, ActionNone, result.Action) diff --git a/internal/httpapi/connect.go b/internal/httpapi/connect.go new file mode 100644 index 000000000..b1ae04e8d --- /dev/null +++ b/internal/httpapi/connect.go @@ -0,0 +1,161 @@ +package httpapi + +import ( + "encoding/json" + "fmt" + "net/http" + + "github.com/go-chi/chi/v5" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/connect" +) + +// ConnectRequest is the optional JSON body for POST /api/v1/connect/{client}. +type ConnectRequest struct { + ServerName string `json:"server_name,omitempty"` // Defaults to "mcpproxy" + Force bool `json:"force,omitempty"` // Overwrite existing entry +} + +// handleGetConnectStatus godoc +// @Summary List client connection status +// @Description Returns the connection status for all known MCP client applications. +// @Description Each entry indicates whether the client config file exists and whether +// @Description MCPProxy is currently registered in it. +// @Tags connect +// @Produce json +// @Security ApiKeyAuth +// @Security ApiKeyQuery +// @Success 200 {object} contracts.APIResponse "List of ClientStatus objects" +// @Router /api/v1/connect [get] +func (s *Server) handleGetConnectStatus(w http.ResponseWriter, r *http.Request) { + svc := s.getConnectService() + if svc == nil { + s.writeError(w, r, http.StatusServiceUnavailable, "connect service not available") + return + } + statuses := svc.GetAllStatus() + s.writeSuccess(w, statuses) +} + +// handleConnectClient godoc +// @Summary Connect MCPProxy to a client +// @Description Register MCPProxy as an MCP server in the specified client's configuration file. +// @Description Creates a backup of the existing config before modifying. +// @Tags connect +// @Accept json +// @Produce json +// @Security ApiKeyAuth +// @Security ApiKeyQuery +// @Param client path string true "Client ID (claude-code, cursor, windsurf, vscode, codex, gemini)" +// @Param body body ConnectRequest false "Optional connection parameters" +// @Success 200 {object} contracts.APIResponse "ConnectResult" +// @Failure 400 {object} contracts.ErrorResponse "Bad request" +// @Failure 404 {object} contracts.ErrorResponse "Unknown client" +// @Failure 409 {object} contracts.ErrorResponse "Already connected (use force=true)" +// @Failure 503 {object} contracts.ErrorResponse "Service unavailable" +// @Router /api/v1/connect/{client} [post] +func (s *Server) handleConnectClient(w http.ResponseWriter, r *http.Request) { + svc := s.getConnectService() + if svc == nil { + s.writeError(w, r, http.StatusServiceUnavailable, "connect service not available") + return + } + + clientID := chi.URLParam(r, "client") + if clientID == "" { + s.writeError(w, r, http.StatusBadRequest, "client ID is required") + return + } + + var req ConnectRequest + if r.Body != nil && r.ContentLength > 0 { + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + s.writeError(w, r, http.StatusBadRequest, fmt.Sprintf("invalid request body: %v", err)) + return + } + } + + result, err := svc.Connect(clientID, req.ServerName, req.Force) + if err != nil { + // Distinguish between "unknown client" and other errors + client := connect.FindClient(clientID) + if client == nil { + s.writeError(w, r, http.StatusNotFound, err.Error()) + return + } + s.writeError(w, r, http.StatusBadRequest, err.Error()) + return + } + + if !result.Success && result.Action == "already_exists" { + s.writeJSON(w, http.StatusConflict, map[string]interface{}{ + "success": false, + "data": result, + "error": result.Message, + }) + return + } + + s.writeSuccess(w, result) +} + +// handleDisconnectClient godoc +// @Summary Disconnect MCPProxy from a client +// @Description Remove the MCPProxy entry from the specified client's configuration file. +// @Description Creates a backup of the existing config before modifying. +// @Tags connect +// @Accept json +// @Produce json +// @Security ApiKeyAuth +// @Security ApiKeyQuery +// @Param client path string true "Client ID (claude-code, cursor, windsurf, vscode, codex, gemini)" +// @Param body body ConnectRequest false "Optional parameters (server_name)" +// @Success 200 {object} contracts.APIResponse "ConnectResult" +// @Failure 400 {object} contracts.ErrorResponse "Bad request" +// @Failure 404 {object} contracts.ErrorResponse "Unknown client or entry not found" +// @Failure 503 {object} contracts.ErrorResponse "Service unavailable" +// @Router /api/v1/connect/{client} [delete] +func (s *Server) handleDisconnectClient(w http.ResponseWriter, r *http.Request) { + svc := s.getConnectService() + if svc == nil { + s.writeError(w, r, http.StatusServiceUnavailable, "connect service not available") + return + } + + clientID := chi.URLParam(r, "client") + if clientID == "" { + s.writeError(w, r, http.StatusBadRequest, "client ID is required") + return + } + + var req ConnectRequest + if r.Body != nil && r.ContentLength > 0 { + _ = json.NewDecoder(r.Body).Decode(&req) // best effort + } + + result, err := svc.Disconnect(clientID, req.ServerName) + if err != nil { + client := connect.FindClient(clientID) + if client == nil { + s.writeError(w, r, http.StatusNotFound, err.Error()) + return + } + s.writeError(w, r, http.StatusBadRequest, err.Error()) + return + } + + if !result.Success && result.Action == "not_found" { + s.writeError(w, r, http.StatusNotFound, result.Message) + return + } + + s.writeSuccess(w, result) +} + +// getConnectService returns the connect service, creating it lazily from config if needed. +func (s *Server) getConnectService() *connect.Service { + if s.connectService != nil { + return s.connectService + } + return nil +} diff --git a/internal/httpapi/contracts_test.go b/internal/httpapi/contracts_test.go index 8e8d64522..1f193a46b 100644 --- a/internal/httpapi/contracts_test.go +++ b/internal/httpapi/contracts_test.go @@ -327,6 +327,9 @@ func (m *MockServerController) AddServer(_ context.Context, _ *config.ServerConf func (m *MockServerController) RemoveServer(_ context.Context, _ string) error { return nil } +func (m *MockServerController) UpdateServer(_ context.Context, _ string, _ *config.ServerConfig) error { + return nil +} // Tool-level quarantine (Spec 032) func (m *MockServerController) ListToolApprovals(_ string) ([]*storage.ToolApprovalRecord, error) { diff --git a/internal/httpapi/security_test.go b/internal/httpapi/security_test.go index 103a853f4..ba58a6a9b 100644 --- a/internal/httpapi/security_test.go +++ b/internal/httpapi/security_test.go @@ -315,6 +315,9 @@ func (m *baseController) AddServer(_ context.Context, _ *config.ServerConfig) er func (m *baseController) RemoveServer(_ context.Context, _ string) error { return nil } +func (m *baseController) UpdateServer(_ context.Context, _ string, _ *config.ServerConfig) error { + return nil +} func (m *baseController) ListActivities(_ storage.ActivityFilter) ([]*storage.ActivityRecord, int, error) { return nil, 0, nil } diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index a433324df..267225370 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -17,6 +17,7 @@ import ( "github.com/smart-mcp-proxy/mcpproxy-go/internal/auth" "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/connect" "github.com/smart-mcp-proxy/mcpproxy-go/internal/contracts" "github.com/smart-mcp-proxy/mcpproxy-go/internal/logs" "github.com/smart-mcp-proxy/mcpproxy-go/internal/management" @@ -56,6 +57,7 @@ type ServerController interface { GetAllServers() ([]map[string]interface{}, error) AddServer(ctx context.Context, serverConfig *config.ServerConfig) error // T001: Add server RemoveServer(ctx context.Context, serverName string) error // T002: Remove server + UpdateServer(ctx context.Context, serverName string, updates *config.ServerConfig) error EnableServer(serverName string, enabled bool) error RestartServer(serverName string) error ForceReconnectAllServers(reason string) error @@ -136,6 +138,7 @@ type Server struct { tokenStore TokenStore // Agent token CRUD (T022) dataDir string // Data directory for HMAC key (T022) feedbackSubmitter FeedbackSubmitter // Feedback submission (Spec 036) + connectService *connect.Service // Client connect/disconnect operations } // NewServer creates a new HTTP API server @@ -172,6 +175,11 @@ func (s *Server) SetFeedbackSubmitter(submitter FeedbackSubmitter) { s.feedbackSubmitter = submitter } +// SetConnectService configures the client connect/disconnect service. +func (s *Server) SetConnectService(svc *connect.Service) { + s.connectService = svc +} + // Router returns the underlying chi.Mux for external route registration. // This is used by the server edition to mount OAuth routes outside // the default API key authentication group. @@ -470,6 +478,7 @@ func (s *Server) setupRoutes() { r.Post("/servers/enable_all", s.handleEnableAll) r.Post("/servers/disable_all", s.handleDisableAll) r.Route("/servers/{id}", func(r chi.Router) { + r.Patch("/", s.handlePatchServer) // Partial update server config r.Delete("/", s.handleRemoveServer) // T002: Remove server r.Post("/enable", s.handleEnableServer) r.Post("/disable", s.handleDisableServer) @@ -557,6 +566,11 @@ func (s *Server) setupRoutes() { // Feedback submission (Spec 036) r.Post("/feedback", s.handleFeedback) + + // Client connect/disconnect + r.Get("/connect", s.handleGetConnectStatus) + r.Post("/connect/{client}", s.handleConnectClient) + r.Delete("/connect/{client}", s.handleDisconnectClient) }) // SSE events (protected by API key) - support both GET and HEAD @@ -1112,6 +1126,99 @@ func (s *Server) handleRemoveServer(w http.ResponseWriter, r *http.Request) { }) } +// handlePatchServer godoc +// @Summary Partially update an upstream server +// @Description Update specific fields of an existing upstream MCP server configuration. +// @Tags servers +// @Accept json +// @Produce json +// @Security ApiKeyAuth +// @Security ApiKeyQuery +// @Param id path string true "Server ID or name" +// @Param server body AddServerRequest true "Fields to update (all optional)" +// @Success 200 {object} contracts.SuccessResponse "Server updated successfully" +// @Failure 400 {object} contracts.ErrorResponse "Bad request - no fields or invalid body" +// @Failure 404 {object} contracts.ErrorResponse "Server not found" +// @Failure 500 {object} contracts.ErrorResponse "Internal server error" +// @Router /api/v1/servers/{id} [patch] +func (s *Server) handlePatchServer(w http.ResponseWriter, r *http.Request) { + serverName := chi.URLParam(r, "id") + if serverName == "" { + s.writeError(w, r, http.StatusBadRequest, "Server ID required") + return + } + + var req AddServerRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + s.writeError(w, r, http.StatusBadRequest, "Invalid request body") + return + } + + // Build partial update config - only set fields that were provided + updates := &config.ServerConfig{Name: serverName} + hasUpdates := false + + if req.URL != "" { + updates.URL = req.URL + hasUpdates = true + } + if req.Command != "" { + updates.Command = req.Command + hasUpdates = true + } + if req.Args != nil { + updates.Args = req.Args + hasUpdates = true + } + if req.Env != nil { + updates.Env = req.Env + hasUpdates = true + } + if req.Headers != nil { + updates.Headers = req.Headers + hasUpdates = true + } + if req.WorkingDir != "" { + updates.WorkingDir = req.WorkingDir + hasUpdates = true + } + if req.Protocol != "" { + updates.Protocol = req.Protocol + hasUpdates = true + } + if req.Enabled != nil { + updates.Enabled = *req.Enabled + hasUpdates = true + } + if req.Quarantined != nil { + updates.Quarantined = *req.Quarantined + hasUpdates = true + } + + if !hasUpdates { + s.writeError(w, r, http.StatusBadRequest, "No fields to update") + return + } + + logger := s.getRequestLogger(r) + + if err := s.controller.UpdateServer(r.Context(), serverName, updates); err != nil { + if strings.Contains(err.Error(), "not found") { + s.writeError(w, r, http.StatusNotFound, err.Error()) + return + } + logger.Error("Failed to update server", "server", serverName, "error", err) + s.writeError(w, r, http.StatusInternalServerError, fmt.Sprintf("Failed to update server: %v", err)) + return + } + + logger.Info("Server updated successfully", "server", serverName) + s.writeSuccess(w, map[string]interface{}{ + "message": fmt.Sprintf("Server '%s' updated successfully", serverName), + "restart_required": true, + }) +} + // handleEnableServer godoc // @Summary Enable an upstream server // @Description Enable a specific upstream MCP server diff --git a/internal/management/service.go b/internal/management/service.go index 4d46ace27..61a80b2af 100644 --- a/internal/management/service.go +++ b/internal/management/service.go @@ -232,6 +232,18 @@ func (s *service) ListServers(ctx context.Context) ([]*contracts.Server, *contra if lastError, ok := srvRaw["last_error"].(string); ok { srv.LastError = lastError } + if url, ok := srvRaw["url"].(string); ok { + srv.URL = url + } + if command, ok := srvRaw["command"].(string); ok { + srv.Command = command + } + if args, ok := srvRaw["args"].([]string); ok { + srv.Args = args + } + if workingDir, ok := srvRaw["working_dir"].(string); ok { + srv.WorkingDir = workingDir + } if authenticated, ok := srvRaw["authenticated"].(bool); ok { srv.Authenticated = authenticated } diff --git a/internal/runtime/tool_quarantine.go b/internal/runtime/tool_quarantine.go index bae4d4578..52fb0fb15 100644 --- a/internal/runtime/tool_quarantine.go +++ b/internal/runtime/tool_quarantine.go @@ -117,11 +117,15 @@ func (r *Runtime) checkToolApprovals(serverName string, tools []*config.ToolMeta } } - // Auto-approve new tools when: - // - Global quarantine is disabled, OR - // - Server has skip_quarantine=true, OR - // - Server is NOT quarantined (user trusts this server) - // Changed tools are still blocked regardless (rug pull detection). + // Quarantine enforcement levels: + // 1. enforceNewTools: block NEW tools for review (unless quarantine is disabled or server skipped) + // 2. enforceQuarantine: full quarantine mode for servers explicitly quarantined + // + // Even trusted (non-quarantined) servers should have new tools reviewed when quarantine + // is globally enabled. This prevents injection attacks via new tool additions on + // compromised servers. Only skip_quarantine=true explicitly opts out. + // Changed tools (rug pull) are always blocked when globalEnabled is true (line ~438). + enforceNewTools := globalEnabled && !serverSkipped enforceQuarantine := globalEnabled && !serverSkipped && serverQuarantined result := &ToolApprovalResult{ @@ -150,9 +154,8 @@ func (r *Runtime) checkToolApprovals(serverName string, tools []*config.ToolMeta if err != nil { // No existing record - this is a new tool. - // If the server is trusted (quarantine not enforced), auto-approve immediately. - // This prevents blocking tools on upgrade for existing trusted servers. - if !enforceQuarantine { + if !enforceNewTools { + // Quarantine disabled or server has skip_quarantine - auto-approve now := time.Now().UTC() record := &storage.ToolApprovalRecord{ ServerName: serverName, @@ -174,18 +177,19 @@ func (r *Runtime) checkToolApprovals(serverName string, tools []*config.ToolMeta continue } - r.logger.Info("New tool discovered, auto-approved (server trusted)", + r.logger.Info("New tool discovered, auto-approved (quarantine disabled or server skipped)", zap.String("server", serverName), zap.String("tool", toolName)) - // Emit activity event r.emitToolQuarantineEvent(serverName, toolName, "tool_auto_approved", "", currentHash, "", tool.Description, "", schemaJSON) continue } - // Server IS quarantined - mark tool as pending + // Quarantine enabled — new tool requires user review before use. + // This applies to ALL servers (including trusted ones) to prevent + // injection attacks via new tool additions on compromised servers. record := &storage.ToolApprovalRecord{ ServerName: serverName, ToolName: toolName, @@ -205,12 +209,12 @@ func (r *Runtime) checkToolApprovals(serverName string, tools []*config.ToolMeta r.logger.Info("New tool discovered, pending approval", zap.String("server", serverName), - zap.String("tool", toolName)) + zap.String("tool", toolName), + zap.Bool("server_quarantined", serverQuarantined)) result.BlockedTools[toolName] = true result.PendingCount++ - // Emit activity event r.emitToolQuarantineEvent(serverName, toolName, "tool_discovered", "", currentHash, "", tool.Description, "", schemaJSON) diff --git a/internal/runtime/tool_quarantine_test.go b/internal/runtime/tool_quarantine_test.go index c73d98980..91f1f033d 100644 --- a/internal/runtime/tool_quarantine_test.go +++ b/internal/runtime/tool_quarantine_test.go @@ -233,6 +233,35 @@ func TestCheckToolApprovals_PerServerSkip_AutoApproved(t *testing.T) { assert.NotEmpty(t, record.ApprovedHash) } +func TestCheckToolApprovals_TrustedServer_NewToolPending(t *testing.T) { + // When quarantine is globally enabled, new tools from trusted (non-quarantined) + // servers should still require approval. This prevents injection via new tool + // additions on compromised servers. + rt := setupQuarantineRuntime(t, nil, []*config.ServerConfig{ + {Name: "github", Enabled: true}, // trusted, NOT quarantined + }) + + tools := []*config.ToolMetadata{ + { + ServerName: "github", + Name: "new_malicious_tool", + Description: "A tool that appeared after server compromise", + ParamsJSON: `{"type":"object"}`, + Hash: "h1", + }, + } + + result, err := rt.checkToolApprovals("github", tools) + require.NoError(t, err) + assert.Equal(t, 1, result.PendingCount, "New tool on trusted server should be pending when quarantine enabled") + assert.True(t, result.BlockedTools["new_malicious_tool"], "New tool should be blocked until approved") + + // Verify storage record + record, err := rt.storageManager.GetToolApproval("github", "new_malicious_tool") + require.NoError(t, err) + assert.Equal(t, storage.ToolApprovalStatusPending, record.Status) +} + func TestCheckToolApprovals_AutoApproved_ThenChanged_StillBlocked(t *testing.T) { // Verify that even auto-approved tools get blocked if their hash changes later. // Use a shared temp dir so the second runtime reuses the same DB. diff --git a/internal/server/server.go b/internal/server/server.go index ee2734197..c3e4bd5f2 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -20,6 +20,7 @@ import ( "github.com/smart-mcp-proxy/mcpproxy-go/internal/auth" "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/connect" "github.com/smart-mcp-proxy/mcpproxy-go/internal/contracts" "github.com/smart-mcp-proxy/mcpproxy-go/internal/health" "github.com/smart-mcp-proxy/mcpproxy-go/internal/httpapi" @@ -793,14 +794,26 @@ func (s *Server) GetAllServers() ([]map[string]interface{}, error) { } } - // Extract created time and config fields + // Extract created time and config fields from stateview or fall back to storage var created time.Time - var url, command, protocol string - if serverStatus.Config != nil { - created = serverStatus.Config.Created - url = serverStatus.Config.URL - command = serverStatus.Config.Command - protocol = serverStatus.Config.Protocol + var url, command, protocol, workingDir string + var args []string + cfg := serverStatus.Config + if cfg == nil { + // Stateview Config is nil — fall back to storage for config fields + if storageManager := s.runtime.StorageManager(); storageManager != nil { + if stored, err := storageManager.GetUpstreamServer(serverStatus.Name); err == nil && stored != nil { + cfg = stored + } + } + } + if cfg != nil { + created = cfg.Created + url = cfg.URL + command = cfg.Command + args = cfg.Args + workingDir = cfg.WorkingDir + protocol = cfg.Protocol } // Calculate unified health status (Spec 013: Health is single source of truth) @@ -838,6 +851,8 @@ func (s *Server) GetAllServers() ([]map[string]interface{}, error) { "name": serverStatus.Name, "url": url, "command": command, + "args": args, + "working_dir": workingDir, "protocol": protocol, "enabled": serverStatus.Enabled, "quarantined": serverStatus.Quarantined, @@ -1041,12 +1056,94 @@ func (s *Server) AddServer(ctx context.Context, serverConfig *config.ServerConfi // Notify about upstream server change s.OnUpstreamServerChange() + // If quarantined, grant inspection exemption so tools can be discovered and reviewed + if serverConfig.Quarantined { + if supervisor := s.runtime.Supervisor(); supervisor != nil { + // Grant 1 hour exemption for initial tool review + if err := supervisor.RequestInspectionExemption(serverConfig.Name, 1*time.Hour); err != nil { + s.logger.Warn("Failed to grant inspection exemption for new quarantined server", + zap.String("server", serverConfig.Name), + zap.Error(err)) + } else { + s.logger.Info("Granted inspection exemption for new quarantined server — tools will be discoverable for review", + zap.String("server", serverConfig.Name)) + } + } + } + s.logger.Info("Server added successfully", zap.String("name", serverConfig.Name)) return nil } +// UpdateServer applies partial updates to an existing upstream server configuration. +func (s *Server) UpdateServer(ctx context.Context, serverName string, updates *config.ServerConfig) error { + s.logger.Info("Updating upstream server", zap.String("name", serverName)) + + storageManager := s.runtime.StorageManager() + existing, err := storageManager.GetUpstreamServer(serverName) + if err != nil || existing == nil { + return fmt.Errorf("server '%s' not found", serverName) + } + + // Apply non-zero/non-nil fields from updates + if updates.URL != "" { + existing.URL = updates.URL + } + if updates.Command != "" { + existing.Command = updates.Command + } + if updates.Args != nil { + existing.Args = updates.Args + } + if updates.Env != nil { + existing.Env = updates.Env + } + if updates.Headers != nil { + existing.Headers = updates.Headers + } + if updates.WorkingDir != "" { + existing.WorkingDir = updates.WorkingDir + } + if updates.Protocol != "" { + existing.Protocol = updates.Protocol + } + // Booleans are always applied since the handler only calls UpdateServer + // when the caller explicitly provided these fields + existing.Enabled = updates.Enabled + existing.Quarantined = updates.Quarantined + + // Save to storage + if err := storageManager.SaveUpstreamServer(existing); err != nil { + return fmt.Errorf("failed to save server: %w", err) + } + + // Update runtime config + currentConfig := s.runtime.Config() + if currentConfig != nil { + for i, sc := range currentConfig.Servers { + if sc.Name == serverName { + currentConfig.Servers[i] = existing + break + } + } + s.runtime.UpdateConfig(currentConfig, "") + } + + // Save configuration to file + if err := s.SaveConfiguration(); err != nil { + s.logger.Warn("Failed to save configuration after updating server", zap.Error(err)) + } + + // Notify about change + s.OnUpstreamServerChange() + + s.logger.Info("Server updated successfully", zap.String("name", serverName)) + + return nil +} + // RemoveServer removes an upstream server from the configuration. // This stops the server if running and removes it from storage. func (s *Server) RemoveServer(ctx context.Context, serverName string) error { @@ -1544,6 +1641,11 @@ func (s *Server) startCustomHTTPServer(ctx context.Context, streamableServer *se if ts := s.runtime.TelemetryService(); ts != nil { httpAPIServer.SetFeedbackSubmitter(ts) } + // Wire client connect service + if cfg := s.runtime.Config(); cfg != nil { + connectSvc := connect.NewService(cfg.Listen, cfg.APIKey) + httpAPIServer.SetConnectService(connectSvc) + } // Wire teams multi-user OAuth (no-op in personal edition) wireTeamsOAuth(s, httpAPIServer) mux.Handle("/api/", httpAPIServer) diff --git a/native/macos/MCPProxy/MCPProxy/API/APIClient.swift b/native/macos/MCPProxy/MCPProxy/API/APIClient.swift index 85ffd76d0..a4b175a61 100644 --- a/native/macos/MCPProxy/MCPProxy/API/APIClient.swift +++ b/native/macos/MCPProxy/MCPProxy/API/APIClient.swift @@ -89,6 +89,42 @@ actor APIClient { return try await fetchWrapped(path: "/api/v1/info") } + // MARK: - Docker & Diagnostics + + /// Docker status response from `GET /api/v1/docker/status`. + struct DockerStatusResponse: Codable { + let dockerAvailable: Bool + let recoveryMode: Bool? + enum CodingKeys: String, CodingKey { + case dockerAvailable = "docker_available" + case recoveryMode = "recovery_mode" + } + } + + /// Diagnostics response from `GET /api/v1/diagnostics`. + struct DiagnosticsResponse: Codable { + let dockerStatus: DockerStatusInfo? + let quarantineEnabled: Bool? + struct DockerStatusInfo: Codable { + let available: Bool + } + enum CodingKeys: String, CodingKey { + case dockerStatus = "docker_status" + case quarantineEnabled = "quarantine_enabled" + } + } + + /// Fetch Docker availability from `GET /api/v1/docker/status`. + func dockerStatus() async throws -> Bool { + let response: DockerStatusResponse = try await fetchWrapped(path: "/api/v1/docker/status") + return response.dockerAvailable + } + + /// Fetch diagnostics (includes Docker + quarantine status). + func diagnostics() async throws -> DiagnosticsResponse { + return try await fetchWrapped(path: "/api/v1/diagnostics") + } + // MARK: - Servers /// List all upstream servers from `GET /api/v1/servers`. @@ -129,7 +165,7 @@ actor APIClient { /// Approve all pending/changed tools for a server via `POST /api/v1/servers/{id}/tools/approve`. func approveTools(_ id: String) async throws { - try await postAction(path: "/api/v1/servers/\(id)/tools/approve") + try await postAction(path: "/api/v1/servers/\(id)/tools/approve", body: ["approve_all": true]) } /// Delete a server via `DELETE /api/v1/servers/{id}`. @@ -137,6 +173,148 @@ actor APIClient { try await deleteAction(path: "/api/v1/servers/\(id)") } + /// Update a server via `PATCH /api/v1/servers/{name}`. + func updateServer(_ name: String, updates: [String: Any]) async throws { + let bodyData = try JSONSerialization.data(withJSONObject: updates) + let (data, response) = try await performRequest(path: "/api/v1/servers/\(name)", method: "PATCH", body: bodyData) + if let errorResponse = try? JSONDecoder().decode(APIErrorResponse.self, from: data), + !errorResponse.success, let message = errorResponse.error { + throw APIClientError.httpError(statusCode: response.statusCode, message: message) + } + } + + // MARK: - Connect (Client Registration) + + /// Client status model returned by `GET /api/v1/connect`. + struct ClientStatus: Codable, Identifiable { + var id: String { clientId } + let clientId: String + let name: String + let configPath: String + let exists: Bool + let connected: Bool + let supported: Bool + let reason: String? + + enum CodingKeys: String, CodingKey { + case clientId = "id" + case name + case configPath = "config_path" + case exists, connected, supported, reason + } + } + + /// Result of a connect/disconnect action. + struct ConnectResult: Codable { + let success: Bool + let client: String? + let configPath: String? + let backupPath: String? + let serverName: String? + let action: String? + let message: String? + + enum CodingKeys: String, CodingKey { + case success, client, action, message + case configPath = "config_path" + case backupPath = "backup_path" + case serverName = "server_name" + } + } + + /// Response wrapper for the client list endpoint. + struct ClientListResponse: Codable { + let clients: [ClientStatus] + } + + /// Fetch all AI client statuses from `GET /api/v1/connect`. + func connectClients() async throws -> [ClientStatus] { + let data = try await fetchRaw(path: "/api/v1/connect") + let decoder = JSONDecoder() + // Try wrapped: {"success": true, "data": {"clients": [...]}} + if let wrapper = try? decoder.decode(APIResponse.self, from: data), + let payload = wrapper.data { + return payload.clients + } + // Try wrapped with direct array: {"success": true, "data": [...]} + if let wrapper = try? decoder.decode(APIResponse<[ClientStatus]>.self, from: data), + let payload = wrapper.data { + return payload + } + // Try direct decode + if let direct = try? decoder.decode(ClientListResponse.self, from: data) { + return direct.clients + } + if let direct = try? decoder.decode([ClientStatus].self, from: data) { + return direct + } + return [] + } + + /// Connect MCPProxy to a client via `POST /api/v1/connect/{clientId}`. + func connectToClient(_ clientId: String) async throws -> ConnectResult { + let data = try await postRaw(path: "/api/v1/connect/\(clientId)") + let decoder = JSONDecoder() + if let wrapper = try? decoder.decode(APIResponse.self, from: data), + let payload = wrapper.data { + return payload + } + return try decoder.decode(ConnectResult.self, from: data) + } + + /// Disconnect MCPProxy from a client via `DELETE /api/v1/connect/{clientId}`. + func disconnectFromClient(_ clientId: String) async throws -> ConnectResult { + let data = try await deleteRaw(path: "/api/v1/connect/\(clientId)") + let decoder = JSONDecoder() + if let wrapper = try? decoder.decode(APIResponse.self, from: data), + let payload = wrapper.data { + return payload + } + return try decoder.decode(ConnectResult.self, from: data) + } + + // MARK: - Sessions + + /// MCP session model from `GET /api/v1/sessions`. + struct MCPSession: Codable, Identifiable { + var id: String + let clientName: String? + let clientVersion: String? + let status: String + let hasRoots: Bool? + let hasSampling: Bool? + let toolCallCount: Int? + let totalTokens: Int? + let startTime: String? + let lastActive: String? + + enum CodingKeys: String, CodingKey { + case id + case clientName = "client_name" + case clientVersion = "client_version" + case status + case hasRoots = "has_roots" + case hasSampling = "has_sampling" + case toolCallCount = "tool_call_count" + case totalTokens = "total_tokens" + case startTime = "start_time" + case lastActive = "last_active" + } + } + + /// Response wrapper for the sessions list endpoint. + struct SessionsResponse: Codable { + let sessions: [MCPSession] + let total: Int? + let limit: Int? + } + + /// Fetch recent MCP sessions from `GET /api/v1/sessions`. + func sessions(limit: Int = 5) async throws -> [MCPSession] { + let response: SessionsResponse = try await fetchWrapped(path: "/api/v1/sessions?limit=\(limit)") + return response.sessions + } + // MARK: - Activity /// Fetch recent activity entries from `GET /api/v1/activity`. @@ -325,6 +503,17 @@ actor APIClient { } } + /// Execute a DELETE action and return the raw response data. + /// Used by views that need to inspect the full response (e.g., disconnect result). + func deleteRaw(path: String) async throws -> Data { + let (data, response) = try await performRequest(path: path, method: "DELETE") + if let errorResponse = try? JSONDecoder().decode(APIErrorResponse.self, from: data), + !errorResponse.success, let message = errorResponse.error { + throw APIClientError.httpError(statusCode: response.statusCode, message: message) + } + return data + } + // MARK: - Private Helpers /// Fetch a resource wrapped in the standard `APIResponse` envelope. @@ -351,7 +540,7 @@ actor APIClient { /// Execute a POST action that returns a success/error wrapper. @discardableResult - private func postAction(path: String, body: [String: Any]? = nil) async throws -> Data { + func postAction(path: String, body: [String: Any]? = nil) async throws -> Data { let bodyData: Data? if let body { bodyData = try JSONSerialization.data(withJSONObject: body) diff --git a/native/macos/MCPProxy/MCPProxy/API/Models.swift b/native/macos/MCPProxy/MCPProxy/API/Models.swift index b62e3703d..ffa99d1a7 100644 --- a/native/macos/MCPProxy/MCPProxy/API/Models.swift +++ b/native/macos/MCPProxy/MCPProxy/API/Models.swift @@ -203,6 +203,7 @@ struct ServerStatus: Codable, Identifiable, Equatable { let url: String? let command: String? let args: [String]? + let workingDir: String? let `protocol`: String let enabled: Bool let connected: Bool @@ -225,6 +226,7 @@ struct ServerStatus: Codable, Identifiable, Equatable { enum CodingKeys: String, CodingKey { case id, name, url, command, args + case workingDir = "working_dir" case `protocol` = "protocol" case enabled, connected, connecting, quarantined case status diff --git a/native/macos/MCPProxy/MCPProxy/Core/CoreProcessManager.swift b/native/macos/MCPProxy/MCPProxy/Core/CoreProcessManager.swift index d2b00244b..5414fa467 100644 --- a/native/macos/MCPProxy/MCPProxy/Core/CoreProcessManager.swift +++ b/native/macos/MCPProxy/MCPProxy/Core/CoreProcessManager.swift @@ -206,12 +206,8 @@ actor CoreProcessManager { let binaryPath = try resolveBinary() coreBinaryPath = binaryPath - // Generate a session API key - let apiKey = generateAPIKey() - sessionAPIKey = apiKey - - // Launch the process - try await launchCore(binaryPath: binaryPath, apiKey: apiKey) + // Launch the process (core uses its own config API key) + try await launchCore(binaryPath: binaryPath) // Wait for the socket to become available await transitionState(to: .waitingForCore) @@ -322,14 +318,15 @@ actor CoreProcessManager { // MARK: - Private: Process Launch /// Launch the mcpproxy core process. - private func launchCore(binaryPath: String, apiKey: String) async throws { + private func launchCore(binaryPath: String) async throws { let proc = Process() proc.executableURL = URL(fileURLWithPath: binaryPath) proc.arguments = ["serve"] - // Pass environment with the generated API key + // Let core use its own config API key (or auto-generate one). + // We fetch the key from core via socket after it starts. var env = ProcessInfo.processInfo.environment - env["MCPPROXY_API_KEY"] = apiKey + env.removeValue(forKey: "MCPPROXY_API_KEY") // Enable socket communication env["MCPPROXY_SOCKET"] = "true" proc.environment = env @@ -407,13 +404,13 @@ actor CoreProcessManager { /// Create API and SSE clients connected to the core via the Unix socket. private func connectToCore() async throws { - NSLog("[MCPProxy] connectToCore: creating APIClient (socket=%@, apiKey=%@)", - socketPath, sessionAPIKey != nil ? "set" : "nil") + // First connect via socket (no API key needed — socket is trusted) + NSLog("[MCPProxy] connectToCore: creating APIClient via socket=%@", socketPath) let client = APIClient( socketPath: socketPath, baseURL: "http://127.0.0.1:8080", - apiKey: sessionAPIKey + apiKey: nil ) // Verify the core is ready @@ -424,10 +421,21 @@ actor CoreProcessManager { } NSLog("[MCPProxy] connectToCore: core is ready") - // Fetch version info + // Fetch version info and extract API key from web_ui_url NSLog("[MCPProxy] connectToCore: calling /api/v1/info...") let info = try await client.info() NSLog("[MCPProxy] connectToCore: got version=%@", info.version) + + // Extract API key from web_ui_url (e.g. "http://127.0.0.1:8080/ui/?apikey=abc123") + if let urlComponents = URLComponents(string: info.webUiUrl), + let apikeyItem = urlComponents.queryItems?.first(where: { $0.name == "apikey" }), + let key = apikeyItem.value, !key.isEmpty { + sessionAPIKey = key + NSLog("[MCPProxy] connectToCore: extracted API key from core (prefix=%@...)", String(key.prefix(8))) + } else { + NSLog("[MCPProxy] connectToCore: WARNING - no API key found in web_ui_url: %@", info.webUiUrl) + } + await MainActor.run { appState.version = info.version if let update = info.update, update.available, let latest = update.latestVersion { @@ -438,7 +446,7 @@ actor CoreProcessManager { apiClient = client await MainActor.run { appState.apiClient = client } - // Create SSE client — uses TCP (not socket) for streaming compatibility + // Create SSE client — uses TCP (not socket) so needs the API key NSLog("[MCPProxy] connectToCore: creating SSEClient (TCP, apiKey=%@)", sessionAPIKey != nil ? "set" : "nil") sseClient = SSEClient( @@ -476,19 +484,24 @@ actor CoreProcessManager { private func handleSSEEvent(_ event: SSEEvent) async { switch event.event { case "status": - // Status events contain inline stats — update counters only, - // do NOT re-fetch the full server list. + // Status events contain inline stats. + // When connected count changes, re-fetch the full server list + // to get accurate counts (SSE stats can lag behind actual state). if let data = event.data.data(using: .utf8), let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let stats = json["upstream_stats"] as? [String: Any] { let connected = stats["connected_servers"] as? Int ?? 0 let total = stats["total_servers"] as? Int ?? 0 let tools = stats["total_tools"] as? Int ?? 0 - await MainActor.run { - // Only update if values changed to avoid unnecessary re-renders - if appState.connectedCount != connected { appState.connectedCount = connected } - if appState.totalServers != total { appState.totalServers = total } - if appState.totalTools != tools { appState.totalTools = tools } + let oldConnected = await MainActor.run { appState.connectedCount } + // If counts changed, do a full server refresh for accuracy + if connected != oldConnected { + await refreshServers() + } else { + await MainActor.run { + if appState.totalServers != total { appState.totalServers = total } + if appState.totalTools != tools { appState.totalTools = tools } + } } } @@ -575,7 +588,32 @@ actor CoreProcessManager { private func refreshState() async { await refreshServers() await refreshActivity() + await refreshSessions() await refreshTokenMetrics() + await refreshSecurityStatus() + } + + /// Fetch Docker and quarantine status from the API. + private func refreshSecurityStatus() async { + guard let apiClient else { return } + do { + let dockerOK = try await apiClient.dockerStatus() + await MainActor.run { + if appState.dockerAvailable != dockerOK { appState.dockerAvailable = dockerOK } + } + } catch { + // Non-fatal + } + do { + let diag = try await apiClient.diagnostics() + await MainActor.run { + if let q = diag.quarantineEnabled { + if appState.quarantineEnabled != q { appState.quarantineEnabled = q } + } + } + } catch { + // Non-fatal + } } /// Fetch the server list and update appState. @@ -600,6 +638,17 @@ actor CoreProcessManager { } } + /// Fetch recent MCP sessions and update appState. + private func refreshSessions() async { + guard let apiClient else { return } + do { + let sessions = try await apiClient.sessions(limit: 5) + await MainActor.run { appState.recentSessions = sessions } + } catch { + // Non-fatal; we'll retry on the next refresh + } + } + /// Fetch token metrics from the status endpoint and update appState. private func refreshTokenMetrics() async { guard let apiClient else { return } diff --git a/native/macos/MCPProxy/MCPProxy/Core/SocketTransport.swift b/native/macos/MCPProxy/MCPProxy/Core/SocketTransport.swift index 3a839fc37..c534dc0fb 100644 --- a/native/macos/MCPProxy/MCPProxy/Core/SocketTransport.swift +++ b/native/macos/MCPProxy/MCPProxy/Core/SocketTransport.swift @@ -29,6 +29,7 @@ final class SocketURLProtocol: URLProtocol { /// Active read task, retained for cancellation. private var socketFD: Int32 = -1 + private let socketLock = NSLock() private var readThread: Thread? private var isCancelled = false @@ -97,6 +98,10 @@ final class SocketURLProtocol: URLProtocol { // Build HTTP/1.1 request bytes let requestData = buildHTTPRequest(from: request) + NSLog("[SocketURLProtocol] startLoading: %@ %@ (%d bytes request payload)", + request.httpMethod ?? "GET", + request.url?.path ?? "/", + requestData.count) // Write request var totalWritten = 0 @@ -134,9 +139,18 @@ final class SocketURLProtocol: URLProtocol { override func stopLoading() { isCancelled = true - if socketFD >= 0 { - Darwin.close(socketFD) - socketFD = -1 + closeSocket() + } + + /// Thread-safe close of the socket file descriptor. + /// Prevents double-close race between stopLoading (CFNetwork thread) and readResponse (background thread). + private func closeSocket() { + socketLock.lock() + let fd = socketFD + socketFD = -1 + socketLock.unlock() + if fd >= 0 { + Darwin.close(fd) } } @@ -174,10 +188,56 @@ final class SocketURLProtocol: URLProtocol { } } - // Body - let body = request.httpBody ?? Data() - if !body.isEmpty && !hasContentLength { - lines.append("Content-Length: \(body.count)") + // Body — check both httpBody and httpBodyStream. + // URLSession may convert httpBody to httpBodyStream internally, + // so the URLProtocol receives httpBody == nil for POST requests. + var body = request.httpBody ?? Data() + NSLog("[SocketURLProtocol] buildHTTPRequest: method=%@, httpBody=%d bytes, httpBodyStream=%@", + method, body.count, request.httpBodyStream != nil ? "present" : "nil") + if body.isEmpty, let stream = request.httpBodyStream { + // Read the entire stream into Data. + // httpBodyStream from URLSession is memory-backed, so hasBytesAvailable + // is reliable. We also guard against read() returning -1 (error) or 0 (EOF). + stream.open() + var streamData = Data() + let bufSize = 16384 + let buf = UnsafeMutablePointer.allocate(capacity: bufSize) + defer { + buf.deallocate() + stream.close() + } + while stream.hasBytesAvailable { + let bytesRead = stream.read(buf, maxLength: bufSize) + if bytesRead > 0 { + streamData.append(buf, count: bytesRead) + } else if bytesRead == 0 { + // EOF + break + } else { + // Error — log and stop + NSLog("[SocketURLProtocol] httpBodyStream read error: %@", + stream.streamError?.localizedDescription ?? "unknown") + break + } + } + NSLog("[SocketURLProtocol] read %d bytes from httpBodyStream", streamData.count) + body = streamData + } + + // Always set Content-Length for bodies: either it was missing, or the original + // header may reference the pre-stream size which could differ. + if !body.isEmpty { + if hasContentLength { + // Replace any existing Content-Length with the actual body size. + lines = lines.map { line in + if line.lowercased().hasPrefix("content-length:") { + return "Content-Length: \(body.count)" + } + return line + } + } else { + lines.append("Content-Length: \(body.count)") + } } // Connection close to simplify reading @@ -201,10 +261,7 @@ final class SocketURLProtocol: URLProtocol { let buffer = UnsafeMutableRawPointer.allocate(byteCount: bufferSize, alignment: 1) defer { buffer.deallocate() - if socketFD >= 0 { - Darwin.close(socketFD) - socketFD = -1 - } + closeSocket() } // Phase 1: Read until we find the header/body separator (\r\n\r\n) diff --git a/native/macos/MCPProxy/MCPProxy/MCPProxyApp.swift b/native/macos/MCPProxy/MCPProxy/MCPProxyApp.swift index abea9b9f8..45fb84c43 100644 --- a/native/macos/MCPProxy/MCPProxy/MCPProxyApp.swift +++ b/native/macos/MCPProxy/MCPProxy/MCPProxyApp.swift @@ -52,6 +52,10 @@ final class AppController: NSObject, NSApplicationDelegate, NSWindowDelegate, NS NSLog("[MCPProxy] Zoom reset: fontScale=%.1f", self?.appState.fontScale ?? 0) self?.makeTextActualSize() return nil + case "n": + NSLog("[MCPProxy] Cmd+N: show add server") + self?.showAddServer() + return nil default: return event } @@ -107,6 +111,12 @@ final class AppController: NSObject, NSApplicationDelegate, NSWindowDelegate, NS name: .startCore, object: nil ) + // Listen for open web UI requests from dashboard + NotificationCenter.default.addObserver( + self, selector: #selector(openWebUI), + name: .openWebUI, object: nil + ) + // Start core Task { await startCore() @@ -192,10 +202,15 @@ final class AppController: NSObject, NSApplicationDelegate, NSWindowDelegate, NS @objc private func showAddServer() { showMainWindow() - // Post notification after a short delay so the window and ServersView - // have time to appear and register their notification observer. - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - NotificationCenter.default.post(name: .showAddServer, object: nil) + // First switch to the Servers tab so ServersView is mounted and + // its .showAddServer notification observer is registered. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + NotificationCenter.default.post(name: .switchToServers, object: nil) + } + // Then post the showAddServer notification after the tab switch completes + // and ServersView has fully registered its notification observer. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { + NotificationCenter.default.post(name: .showAddServer, object: AddServerTab.manual) } } @@ -685,7 +700,21 @@ final class AppController: NSObject, NSApplicationDelegate, NSWindowDelegate, NS case "login": try? await appState.apiClient?.loginServer(server.id) case "restart": try? await appState.apiClient?.restartServer(server.id) case "enable": try? await appState.apiClient?.enableServer(server.id) - default: openWebUI() + case "approve": + // Open macOS app to the server detail view (must be on main thread) + await MainActor.run { + showMainWindow() + NotificationCenter.default.post(name: .switchToServers, object: nil) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + NotificationCenter.default.post(name: .showServerDetail, object: server.name) + } + } + default: + // For unknown actions, open the macOS app to servers view + await MainActor.run { + showMainWindow() + NotificationCenter.default.post(name: .switchToServers, object: nil) + } } } } @@ -825,6 +854,14 @@ extension Notification.Name { static let showAddServer = Notification.Name("MCPProxy.showAddServer") /// Posted by the core status banner to start the core. static let startCore = Notification.Name("MCPProxy.startCore") + /// Posted by dashboard "Connect Clients" to open the Web UI. + static let openWebUI = Notification.Name("MCPProxy.openWebUI") + /// Posted by dashboard to switch sidebar to Activity Log view. + static let switchToActivity = Notification.Name("MCPProxy.switchToActivity") + /// Posted by dashboard to switch sidebar to Servers view. + static let switchToServers = Notification.Name("MCPProxy.switchToServers") + /// Posted by tray menu to open the detail view for a specific server (object = server name string). + static let showServerDetail = Notification.Name("MCPProxy.showServerDetail") } @main @@ -834,9 +871,12 @@ struct MCPProxyApp: App { var body: some Scene { // No SwiftUI scenes — the tray menu is pure AppKit (NSStatusItem + NSMenu). // This avoids the MenuBarExtra .menu style bug where ForEach duplicates items. - // A Settings scene can be added here for Spec B (main window). + // Settings scene intentionally hidden — Cmd+, is handled by tray menu "Open MCPProxy..." item. Settings { - EmptyView() + Text("Use the MCPProxy tray menu to access settings.") + .frame(width: 300, height: 100) + .font(.body) + .foregroundColor(.secondary) } } } diff --git a/native/macos/MCPProxy/MCPProxy/State/AppState.swift b/native/macos/MCPProxy/MCPProxy/State/AppState.swift index 0b987e258..3e6950f53 100644 --- a/native/macos/MCPProxy/MCPProxy/State/AppState.swift +++ b/native/macos/MCPProxy/MCPProxy/State/AppState.swift @@ -49,6 +49,7 @@ final class AppState: ObservableObject { // MARK: Activity & security (ActivityEntry from Models.swift) @Published var recentActivity: [ActivityEntry] = [] + @Published var recentSessions: [APIClient.MCPSession] = [] @Published var sensitiveDataAlertCount: Int = 0 @Published var quarantinedToolsCount: Int = 0 @@ -69,6 +70,11 @@ final class AppState: ObservableObject { /// which avoids the need to replace NSHostingView when the client becomes available. @Published var apiClient: APIClient? + // MARK: Security status + + @Published var dockerAvailable: Bool = false + @Published var quarantineEnabled: Bool = true + // MARK: Metadata @Published var version: String = "" diff --git a/native/macos/MCPProxy/MCPProxy/Views/ActivityView.swift b/native/macos/MCPProxy/MCPProxy/Views/ActivityView.swift index 5eeb0e732..47d23824c 100644 --- a/native/macos/MCPProxy/MCPProxy/Views/ActivityView.swift +++ b/native/macos/MCPProxy/MCPProxy/Views/ActivityView.swift @@ -87,13 +87,13 @@ struct ActivityView: View { } // MARK: - Column widths - private let colTime: CGFloat = 65 - private let colType: CGFloat = 130 - private let colServer: CGFloat = 110 - private let colDetails: CGFloat = 0 // flexible - private let colIntent: CGFloat = 80 - private let colStatus: CGFloat = 80 - private let colDuration: CGFloat = 65 + private let colTime: CGFloat = 60 + private let colType: CGFloat = 100 + private let colServer: CGFloat = 100 + private let colDetails: CGFloat = 0 // flexible (minWidth enforced in layout) + private let colIntent: CGFloat = 60 + private let colStatus: CGFloat = 70 + private let colDuration: CGFloat = 60 var body: some View { HSplitView { @@ -183,7 +183,8 @@ struct ActivityView: View { Text("Server") .frame(width: colServer, alignment: .leading) Text("Details") - .frame(maxWidth: .infinity, alignment: .leading) + .lineLimit(1) + .frame(minWidth: 80, maxWidth: .infinity, alignment: .leading) Text("Intent") .frame(width: colIntent, alignment: .center) Text("Status") @@ -477,6 +478,7 @@ struct ActivityTableRow: View { Text(entry.toolName ?? "-") .font(.scaled(.caption, scale: fontScale)) .lineLimit(1) + .truncationMode(.middle) // Sensitive data indicator if entry.hasSensitiveData == true { @@ -487,7 +489,7 @@ struct ActivityTableRow: View { .accessibilityLabel("Contains sensitive data") } } - .frame(maxWidth: .infinity, alignment: .leading) + .frame(minWidth: 80, maxWidth: .infinity, alignment: .leading) // Intent column if let op = entry.intentOperationType { diff --git a/native/macos/MCPProxy/MCPProxy/Views/AddServerView.swift b/native/macos/MCPProxy/MCPProxy/Views/AddServerView.swift index af34cd70c..698d98518 100644 --- a/native/macos/MCPProxy/MCPProxy/Views/AddServerView.swift +++ b/native/macos/MCPProxy/MCPProxy/Views/AddServerView.swift @@ -20,10 +20,16 @@ struct AddServerView: View { @Binding var isPresented: Bool @Environment(\.fontScale) var fontScale - @State private var selectedTab: AddServerTab = .importConfig + @State private var selectedTab: AddServerTab private var apiClient: APIClient? { appState.apiClient } + init(appState: AppState, isPresented: Binding, initialTab: AddServerTab = .importConfig) { + self.appState = appState + self._isPresented = isPresented + self._selectedTab = State(initialValue: initialTab) + } + var body: some View { VStack(spacing: 0) { // Header @@ -60,7 +66,7 @@ struct AddServerView: View { ManualServerForm(appState: appState, onDone: { isPresented = false }) } } - .frame(width: 520, height: 480) + .frame(width: 560, height: 560) } } @@ -227,6 +233,34 @@ struct ManualServerForm: View { let onDone: () -> Void @Environment(\.fontScale) var fontScale + enum FormField: Hashable { + case name, url, command + } + + enum SubmitPhase: Equatable { + case idle + case saving + case connecting + case success(toolCount: Int, quarantined: Bool) + case failure(error: String) + + static func == (lhs: SubmitPhase, rhs: SubmitPhase) -> Bool { + switch (lhs, rhs) { + case (.idle, .idle), (.saving, .saving), (.connecting, .connecting): + return true + case (.success(let a1, let b1), .success(let a2, let b2)): + return a1 == a2 && b1 == b2 + case (.failure(let a), .failure(let b)): + return a == b + default: + return false + } + } + } + + @FocusState private var focusedField: FormField? + @State private var fieldTouched: Set = [] + @State private var name = "" @State private var selectedProtocol = "stdio" @State private var url = "" @@ -234,97 +268,243 @@ struct ManualServerForm: View { @State private var argsText = "" @State private var envText = "" @State private var workingDir = "" - @State private var isSubmitting = false + @State private var isEnabled = true + @State private var dockerIsolation = true + @State private var quarantined = false + @State private var submitPhase: SubmitPhase = .idle + @State private var lastSavedServerName: String? // Track what was saved so Retry can clean up @State private var errorMessage: String? private var apiClient: APIClient? { appState.apiClient } - private let protocols = ["stdio", "http", "sse"] + private let protocols = ["stdio", "http"] + + private var isSubmitActive: Bool { + if case .idle = submitPhase { return false } + return true + } var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: 16) { - if let err = errorMessage { - HStack { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundStyle(.red) - Text(err) - .font(.scaled(.caption, scale: fontScale)) - Spacer() + VStack(spacing: 0) { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + if let err = errorMessage { + HStack { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.red) + Text(err) + .font(.scaled(.caption, scale: fontScale)) + Spacer() + } + .padding(.horizontal) + .padding(.vertical, 6) + .background(Color.red.opacity(0.1)) + .cornerRadius(6) } - .padding(.horizontal) - .padding(.vertical, 6) - .background(Color.red.opacity(0.1)) - .cornerRadius(6) - } - // Name - formField(label: "Name (required)") { - TextField("e.g. github-server", text: $name) - .textFieldStyle(.roundedBorder) - } + // Name + formField(label: "Name (required)") { + TextField("e.g. github-server", text: $name) + .textFieldStyle(.roundedBorder) + .focused($focusedField, equals: .name) + .onChange(of: focusedField) { newValue in + if newValue != .name && focusedField != .name { + fieldTouched.insert(.name) + } + } + if fieldTouched.contains(.name) && name.trimmingCharacters(in: .whitespaces).isEmpty { + Text("Server name is required") + .font(.caption) + .foregroundStyle(.red) + } + } - // Protocol - formField(label: "Protocol") { - Picker("", selection: $selectedProtocol) { - ForEach(protocols, id: \.self) { proto in - Text(proto).tag(proto) + // Protocol + formField(label: "Protocol") { + Picker("", selection: $selectedProtocol) { + Text("Local Command (stdio)").tag("stdio") + Text("Remote URL (HTTP)").tag("http") } + .pickerStyle(.segmented) } - .pickerStyle(.segmented) - } - // URL (for http/sse) - if selectedProtocol == "http" || selectedProtocol == "sse" { - formField(label: "URL (required)") { - TextField("https://api.example.com/mcp", text: $url) - .textFieldStyle(.roundedBorder) + // URL (for http) + if selectedProtocol == "http" { + formField(label: "URL (required)") { + TextField("https://api.example.com/mcp", text: $url) + .textFieldStyle(.roundedBorder) + .focused($focusedField, equals: .url) + .onChange(of: focusedField) { newValue in + if newValue != .url && focusedField != .url { + fieldTouched.insert(.url) + } + } + if fieldTouched.contains(.url) && url.trimmingCharacters(in: .whitespaces).isEmpty { + Text("URL is required") + .font(.caption) + .foregroundStyle(.red) + } + } + } + + // Command (for stdio) + if selectedProtocol == "stdio" { + formField(label: "Command (required)") { + TextField("e.g. npx, uvx, node", text: $command) + .textFieldStyle(.roundedBorder) + .focused($focusedField, equals: .command) + .onChange(of: focusedField) { newValue in + if newValue != .command && focusedField != .command { + fieldTouched.insert(.command) + } + } + if fieldTouched.contains(.command) && command.trimmingCharacters(in: .whitespaces).isEmpty { + Text("Command is required") + .font(.caption) + .foregroundStyle(.red) + } + } + + formField(label: "Arguments (one per line)") { + TextEditor(text: $argsText) + .font(.scaledMonospaced(.body, scale: fontScale)) + .frame(height: 60) + .border(Color.gray.opacity(0.3), width: 1) + } } - } - // Command (for stdio) - if selectedProtocol == "stdio" { - formField(label: "Command (required)") { - TextField("e.g. npx, uvx, node", text: $command) + // Working directory + formField(label: "Working Directory (optional)") { + TextField("/path/to/project", text: $workingDir) .textFieldStyle(.roundedBorder) } - formField(label: "Arguments (one per line)") { - TextEditor(text: $argsText) + // Env vars + formField(label: "Environment Variables (KEY=VALUE per line)") { + TextEditor(text: $envText) .font(.scaledMonospaced(.body, scale: fontScale)) .frame(height: 60) .border(Color.gray.opacity(0.3), width: 1) } - } - // Working directory - formField(label: "Working Directory (optional)") { - TextField("/path/to/project", text: $workingDir) - .textFieldStyle(.roundedBorder) + // Options + formField(label: "Options") { + VStack(alignment: .leading, spacing: 10) { + VStack(alignment: .leading, spacing: 2) { + Toggle("Enabled", isOn: $isEnabled) + Text("When disabled, the server will not connect or be available to AI agents.") + .font(.scaled(.caption2, scale: fontScale)) + .foregroundStyle(.tertiary) + } + + VStack(alignment: .leading, spacing: 2) { + Toggle("Docker Isolation", isOn: $dockerIsolation) + Text("Runs the server in an isolated Docker container. Prevents access to your filesystem, network, and other system resources. Recommended for untrusted servers.") + .font(.scaled(.caption2, scale: fontScale)) + .foregroundStyle(.tertiary) + } + + VStack(alignment: .leading, spacing: 2) { + Toggle("Quarantined", isOn: $quarantined) + Text("New tools must be reviewed and approved before AI agents can use them. Protects against tool poisoning attacks.") + .font(.scaled(.caption2, scale: fontScale)) + .foregroundStyle(.tertiary) + } + } + } } + .padding() + } - // Env vars - formField(label: "Environment Variables (KEY=VALUE per line)") { - TextEditor(text: $envText) - .font(.scaledMonospaced(.body, scale: fontScale)) - .frame(height: 60) - .border(Color.gray.opacity(0.3), width: 1) - } + Divider() - // Submit - HStack { - Spacer() - if isSubmitting { - ProgressView() + // Pinned submit area + VStack(spacing: 8) { + // Connection test feedback + switch submitPhase { + case .saving: + HStack(spacing: 8) { + ProgressView().controlSize(.small) + Text("Saving configuration...") + .font(.scaled(.caption, scale: fontScale)) + .foregroundStyle(.secondary) + Spacer() + } + .padding(.horizontal) + + case .connecting: + HStack(spacing: 8) { + ProgressView().controlSize(.small) + Text("Connecting to server...") + .font(.scaled(.caption, scale: fontScale)) + .foregroundStyle(.secondary) + Spacer() + } + .padding(.horizontal) + + case .success(let toolCount, let quarantined): + HStack(spacing: 8) { + Image(systemName: quarantined ? "shield.lefthalf.filled" : "checkmark.circle.fill") + .foregroundStyle(quarantined ? .orange : .green) + Text(quarantined + ? "Server added — quarantined for security review" + : "Connected (\(toolCount) tools)") + .font(.scaled(.caption, scale: fontScale)) + .foregroundStyle(quarantined ? .orange : .green) + Spacer() + } + .padding(.horizontal) + + case .failure(let error): + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .top, spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.red) + Text(error) + .font(.scaled(.caption, scale: fontScale)) + .foregroundStyle(.red) + .lineLimit(8) + .textSelection(.enabled) + Spacer() + } + + HStack { + Spacer() + Button("Save Anyway") { + onDone() + } + .buttonStyle(.bordered) + .controlSize(.small) + Button("Retry") { + submitPhase = .idle + Task { await submitServer() } + } + .buttonStyle(.borderedProminent) .controlSize(.small) + } } - Button("Add Server") { - Task { await submitServer() } + .padding(.horizontal) + + default: + EmptyView() + } + + // Show the Add Server button for all phases except failure + // (failure shows its own Save Anyway / Retry buttons above) + if case .failure = submitPhase { + // failure shows its own buttons above + } else { + HStack { + Spacer() + Button("Add Server") { + Task { await submitServer() } + } + .buttonStyle(.borderedProminent) + .disabled(!isValid || isSubmitActive) } - .buttonStyle(.borderedProminent) - .disabled(!isValid || isSubmitting) + .padding(.horizontal) } } - .padding() + .padding(.vertical, 12) } } @@ -350,16 +530,25 @@ struct ManualServerForm: View { private func submitServer() async { guard let client = apiClient else { return } errorMessage = nil - isSubmitting = true - defer { isSubmitting = false } + submitPhase = .saving + + // If a previous attempt saved a server, delete it first so we can re-add + // with potentially updated params (user may have changed name, command, etc.) + if let oldName = lastSavedServerName { + NSLog("[AddServer] Retry: deleting previously saved server '%@' before re-adding", oldName) + try? await client.deleteAction(path: "/api/v1/servers/\(oldName)") + lastSavedServerName = nil + } var config: [String: Any] = [ "name": name.trimmingCharacters(in: .whitespaces), "protocol": selectedProtocol, - "enabled": true, + "enabled": isEnabled, + "docker_isolation": dockerIsolation, + "quarantined": quarantined, ] - if selectedProtocol == "http" || selectedProtocol == "sse" { + if selectedProtocol == "http" { config["url"] = url.trimmingCharacters(in: .whitespaces) } else { config["command"] = command.trimmingCharacters(in: .whitespaces) @@ -393,11 +582,123 @@ struct ManualServerForm: View { } } + let serverName = name.trimmingCharacters(in: .whitespaces) + do { try await client.addServer(config) - onDone() + lastSavedServerName = serverName + } catch { + let errorDetail = error.localizedDescription + + // 409 "already exists" — only treat as success if WE saved it on a previous attempt. + // Otherwise it's a name collision with an existing server the user didn't create here. + if errorDetail.contains("already exists") || errorDetail.contains("409") { + if lastSavedServerName == serverName { + NSLog("[AddServer] Server already exists (our previous save succeeded), checking status") + // Fall through to connection polling below + } else { + // Name collision with an existing server — show error so user picks a different name + submitPhase = .failure(error: "Server '\(serverName)' already exists. Choose a different name.") + return + } + } else if errorDetail.contains("timed out") || errorDetail.contains("timeout") { + // Socket POST timed out — retry via TCP + NSLog("[AddServer] Socket POST timed out, retrying via TCP") + do { + var apiKey: String? + if let info = try? await client.info() { + if let comps = URLComponents(string: info.webUiUrl), + let key = comps.queryItems?.first(where: { $0.name == "apikey" })?.value { + apiKey = key + } + } + let tcpClient = APIClient(socketPath: "", baseURL: "http://127.0.0.1:8080", apiKey: apiKey) + try await tcpClient.addServer(config) + } catch let retryError { + let retryDetail = retryError.localizedDescription + // TCP also got 409 — server was saved, fall through + if retryDetail.contains("already exists") || retryDetail.contains("409") { + NSLog("[AddServer] TCP retry: already exists — checking status") + } else { + var msg = "Failed to add server: \(retryDetail)" + let logHint = await fetchServerLogHint(client: client, serverName: serverName) + if !logHint.isEmpty { msg += "\n\nServer log: \(logHint)" } + submitPhase = .failure(error: msg) + return + } + } + } else { + // Actual error — show it, user can edit fields and retry + var msg = errorDetail + let logHint = await fetchServerLogHint(client: client, serverName: serverName) + if !logHint.isEmpty { msg += "\n\nServer log: \(logHint)" } + submitPhase = .failure(error: msg) + return + } + } + + // Server saved successfully. Now poll for connection status. + // States: connecting → connected | quarantined | error + submitPhase = .connecting + + // Poll up to 3 times (every 3s) for the server to finish connecting + for attempt in 1...3 { + try? await Task.sleep(nanoseconds: 3_000_000_000) + + guard let servers = try? await client.servers(), + let server = servers.first(where: { $0.name == serverName }) else { + // Server not in list yet — keep waiting + continue + } + + let status = server.status ?? "" + let isConnecting = (server.connecting == true) || status == "connecting" + + if server.connected { + submitPhase = .success(toolCount: server.toolCount, quarantined: server.quarantined) + try? await Task.sleep(nanoseconds: 1_500_000_000) + onDone() + return + } else if server.quarantined && !isConnecting { + // Quarantined and done connecting (or waiting for exemption) + submitPhase = .success(toolCount: server.toolCount, quarantined: true) + try? await Task.sleep(nanoseconds: 1_500_000_000) + onDone() + return + } else if isConnecting && attempt < 3 { + // Still connecting — keep polling (don't show error yet) + continue + } else if let lastError = server.lastError, !lastError.isEmpty { + // Has an actual error — show it + var detail = lastError + let logHint = await fetchServerLogHint(client: client, serverName: serverName) + if !logHint.isEmpty { detail += "\n\nRecent log: \(logHint)" } + submitPhase = .failure(error: detail) + return + } + } + + // After 3 attempts (9s), server exists but still connecting — treat as success + // The server list will show the real-time status + submitPhase = .success(toolCount: 0, quarantined: true) + try? await Task.sleep(nanoseconds: 1_500_000_000) + onDone() + } + + /// Fetch the last error line from server logs to show a helpful hint. + private func fetchServerLogHint(client: APIClient, serverName: String) async -> String { + do { + let logs = try await client.serverLogs(serverName, tail: 10) + // Find the last ERROR line + let errorLines = logs.filter { $0.contains("ERROR") || $0.contains("error") || $0.contains("failed") || $0.contains("not found") } + if let lastError = errorLines.last { + // Trim to a reasonable length + let trimmed = lastError.count > 200 ? String(lastError.prefix(200)) + "..." : lastError + return trimmed + } } catch { - errorMessage = "Failed to add server: \(error.localizedDescription)" + // Can't get logs — that's fine, just return empty } + return "" } } diff --git a/native/macos/MCPProxy/MCPProxy/Views/DashboardView.swift b/native/macos/MCPProxy/MCPProxy/Views/DashboardView.swift index b4e9ff99e..31dbbe602 100644 --- a/native/macos/MCPProxy/MCPProxy/Views/DashboardView.swift +++ b/native/macos/MCPProxy/MCPProxy/Views/DashboardView.swift @@ -12,6 +12,8 @@ import SwiftUI struct DashboardView: View { @ObservedObject var appState: AppState @Environment(\.fontScale) var fontScale + @State private var showConnectClients = false + @State private var mcpSessions: [APIClient.MCPSession] = [] var body: some View { ScrollView { @@ -21,8 +23,8 @@ struct DashboardView: View { errorBanner(coreError) } - // Stats cards - statsSection + // Hub visualization + hubSection // Servers needing attention if !appState.serversNeedingAttention.isEmpty { @@ -44,50 +46,300 @@ struct DashboardView: View { .padding(20) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .task { + do { + mcpSessions = try await appState.apiClient?.sessions(limit: 20) ?? [] + } catch { + // Non-fatal; sessions will be empty + } + } + .sheet(isPresented: $showConnectClients) { + ConnectClientsSheet(appState: appState, isPresented: $showConnectClients) + } } - // MARK: - Stats Cards + // MARK: - Hub Visualization @ViewBuilder - private var statsSection: some View { - HStack(spacing: 16) { - let enabledCount = appState.servers.filter { $0.enabled }.count - StatCard( - title: "Total Servers", - value: "\(appState.totalServers)", - subtitle: "\(enabledCount) enabled", - icon: "server.rack", - color: .blue - ) + private var hubSection: some View { + VStack(spacing: 12) { + // Token savings badge — top center + if let stats = appState.tokenMetrics { + HStack(spacing: 4) { + Image(systemName: "arrow.down.right") + .font(.system(size: 10 * fontScale)) + Text("\(Int(stats.savedTokensPercentage))%") + .font(.scaled(.title2, scale: fontScale)) + .fontWeight(.bold) + Text("tokens saved") + .font(.scaled(.caption, scale: fontScale)) + } + .foregroundStyle(.green) + .padding(.horizontal, 14) + .padding(.vertical, 6) + .background(Color.green.opacity(0.1)) + .clipShape(Capsule()) + } - let connPct = appState.totalServers > 0 - ? Int(Double(appState.connectedCount) / Double(appState.totalServers) * 100) - : 0 - StatCard( - title: "Connected", - value: "\(appState.connectedCount)", - subtitle: "\(connPct)%", - icon: "link", - color: .green - ) + // Three-column hub: agents — line — shield — line — servers + HStack(alignment: .top, spacing: 0) { + // === LEFT COLUMN: AI Agents === + VStack(alignment: .leading, spacing: 8) { + // Single box with label inside + VStack(alignment: .leading, spacing: 6) { + Text("AI AGENTS") + .font(.scaled(.caption2, scale: fontScale)) + .fontWeight(.bold) + .tracking(1) + .foregroundStyle(.secondary) - StatCard( - title: "Total Tools", - value: "\(appState.totalTools)", - subtitle: "across all servers", - icon: "wrench.and.screwdriver", - color: .indigo - ) + let connectedNames = connectedClientNames(from: mcpSessions) + + if !connectedNames.isEmpty { + HStack(spacing: 6) { + Circle().fill(.green).frame(width: 8, height: 8) + Text("CONNECTED") + .font(.scaled(.caption2, scale: fontScale)) + .fontWeight(.bold) + .foregroundStyle(.secondary) + } + Text(connectedNames.joined(separator: ", ")) + .font(.scaled(.caption, scale: fontScale)) + .fontWeight(.medium) + } - let quarantined = appState.servers.filter { $0.quarantined }.count - StatCard( - title: "Quarantined", - value: "\(quarantined)", - subtitle: quarantined == 0 ? "all clear" : "needs review", - icon: "shield.lefthalf.filled", - color: quarantined > 0 ? .red : .gray - ) + let available = ["Claude Code", "Cursor", "VS Code", "Codex", "Gemini"] + .filter { name in !connectedNames.contains(name) } + if !available.isEmpty { + Text("Available: " + available.joined(separator: ", ")) + .font(.scaled(.caption2, scale: fontScale)) + .foregroundStyle(.tertiary) + } + + if connectedNames.isEmpty { + Text("No agents detected") + .font(.scaled(.caption, scale: fontScale)) + .foregroundStyle(.secondary) + } + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(nsColor: .controlBackgroundColor)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + + // Left column buttons + VStack(spacing: 6) { + Button { + showConnectClients = true + } label: { + Label("Connect Clients", systemImage: "link") + .font(.scaled(.caption, scale: fontScale)) + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + .accessibilityLabel("Connect AI clients to MCPProxy") + + Button { + NotificationCenter.default.post(name: .switchToServers, object: nil) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + NotificationCenter.default.post(name: .showAddServer, object: AddServerTab.importConfig) + } + } label: { + Label("Import from client configs", systemImage: "square.and.arrow.down") + .font(.scaled(.caption, scale: fontScale)) + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .controlSize(.small) + .accessibilityLabel("Import servers from AI client configuration files") + + // Recent Sessions link + Button { + NotificationCenter.default.post(name: .switchToActivity, object: nil) + } label: { + HStack(spacing: 4) { + Image(systemName: "clock.arrow.circlepath") + .font(.system(size: 10 * fontScale)) + Text("Recent Sessions") + .font(.scaled(.caption, scale: fontScale)) + } + } + .buttonStyle(.plain) + .foregroundStyle(.secondary) + .padding(.top, 2) + .onHover { hovering in + if hovering { + NSCursor.pointingHand.push() + } else { + NSCursor.pop() + } + } + } + } + .frame(maxWidth: .infinity) + + // Left connection line + HubConnectionLine(fontScale: fontScale) + .frame(minWidth: 60, maxWidth: .infinity) + .padding(.top, 55) + + // === CENTER COLUMN: MCPProxy logo + status === + VStack(spacing: 10) { + // App logo (MCPProxy shield with M C P circles) + if let appIcon = NSApp.applicationIconImage { + Image(nsImage: appIcon) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 80 * fontScale, height: 80 * fontScale) + .opacity(appState.coreState == .connected ? 1.0 : 0.4) + } else { + // Fallback to SF Symbol if icon not available + Image(systemName: "shield.fill") + .font(.system(size: 60 * fontScale)) + .foregroundStyle(appState.coreState == .connected ? Color.accentColor : .gray) + } + + // Status text + VStack(spacing: 2) { + Text("MCPPROXY") + .font(.scaled(.caption2, scale: fontScale)) + .fontWeight(.bold) + .tracking(1) + .foregroundStyle(.primary) + Text(appState.coreState == .connected ? "active" : "stopped") + .font(.scaled(.caption, scale: fontScale)) + .fontWeight(.medium) + .foregroundStyle(appState.coreState == .connected ? .green : .red) + } + + // Security status badges + VStack(alignment: .leading, spacing: 6) { + // Docker isolation + let dockerAvailable = appState.dockerAvailable + HStack(spacing: 6) { + Image(systemName: dockerAvailable ? "checkmark.shield.fill" : "exclamationmark.triangle.fill") + .font(.system(size: 10 * fontScale)) + .foregroundStyle(dockerAvailable ? .green : .orange) + Text(dockerAvailable ? "Docker isolation active" : "Docker isolation disabled") + .font(.scaled(.caption2, scale: fontScale)) + .foregroundStyle(dockerAvailable ? .green : .orange) + } + + // Quarantine protection + let quarantineActive = appState.quarantineEnabled + HStack(spacing: 6) { + Image(systemName: quarantineActive ? "checkmark.shield.fill" : "exclamationmark.triangle.fill") + .font(.system(size: 10 * fontScale)) + .foregroundStyle(quarantineActive ? .green : .orange) + Text(quarantineActive ? "Quarantine protection active" : "Quarantine protection disabled") + .font(.scaled(.caption2, scale: fontScale)) + .foregroundStyle(quarantineActive ? .green : .orange) + } + + // Activity Log link + Button { + NotificationCenter.default.post(name: .switchToActivity, object: nil) + } label: { + HStack(spacing: 6) { + Image(systemName: "eye") + .font(.system(size: 10 * fontScale)) + Text("Activity Log") + .font(.scaled(.caption2, scale: fontScale)) + } + } + .buttonStyle(.plain) + .foregroundStyle(.secondary) + .onHover { hovering in + if hovering { + NSCursor.pointingHand.push() + } else { + NSCursor.pop() + } + } + } + .padding(.top, 4) + } + .frame(width: 200) + + // Right connection line + HubConnectionLine(fontScale: fontScale) + .frame(minWidth: 60, maxWidth: .infinity) + .padding(.top, 55) + + // === RIGHT COLUMN: Upstream Servers === + VStack(alignment: .leading, spacing: 8) { + // Single box with label inside + VStack(alignment: .leading, spacing: 6) { + Text("UPSTREAM SERVERS") + .font(.scaled(.caption2, scale: fontScale)) + .fontWeight(.bold) + .tracking(1) + .foregroundStyle(.secondary) + + let disabled = appState.servers.filter({ !$0.enabled }).count + let quarantined = appState.servers.filter { $0.quarantined }.count + + // "13 connected / 10 disabled" on one line + HStack(spacing: 4) { + Circle().fill(.green).frame(width: 8, height: 8) + Text("\(appState.connectedCount)") + .font(.scaled(.title, scale: fontScale)) + .fontWeight(.bold) + Text("connected") + .font(.scaled(.caption, scale: fontScale)) + .foregroundStyle(.secondary) + if disabled > 0 { + Text("/") + .font(.scaled(.caption, scale: fontScale)) + .foregroundStyle(.tertiary) + Text("\(disabled) disabled") + .font(.scaled(.caption, scale: fontScale)) + .foregroundStyle(.secondary) + } + } + + // "197 tools" + Text("\(appState.totalTools) tools") + .font(.scaled(.caption, scale: fontScale)) + + if quarantined > 0 { + HStack(spacing: 4) { + Image(systemName: "lock.shield") + .font(.system(size: 10 * fontScale)) + .foregroundStyle(.orange) + Text("\(quarantined) in quarantine") + .font(.scaled(.caption2, scale: fontScale)) + .foregroundStyle(.orange) + } + } + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(nsColor: .controlBackgroundColor)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + + // Right column buttons + VStack(spacing: 6) { + Button { + NotificationCenter.default.post(name: .switchToServers, object: nil) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + NotificationCenter.default.post(name: .showAddServer, object: nil) + } + } label: { + Label("Add Server", systemImage: "plus") + .font(.scaled(.caption, scale: fontScale)) + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + .accessibilityLabel("Add a new upstream MCP server") + } + } + .frame(maxWidth: .infinity) + } } + .padding(.vertical, 8) } // MARK: - Servers Needing Attention @@ -211,7 +463,32 @@ struct DashboardView: View { .foregroundStyle(.secondary) } - let sessions = deriveSessions() + // Deduplicate by client name: prefer session with most tool calls, then most recent + let rawSessions = mcpSessions.isEmpty ? appState.recentSessions : mcpSessions + let sessions: [APIClient.MCPSession] = { + var byClient: [String: APIClient.MCPSession] = [:] + for s in rawSessions { + let key = s.clientName ?? "unknown" + if let existing = byClient[key] { + let existingCalls = existing.toolCallCount ?? 0 + let newCalls = s.toolCallCount ?? 0 + // Prefer active sessions, then most tool calls, then most recent + if s.status == "active" && existing.status != "active" { + byClient[key] = s + } else if newCalls > existingCalls { + byClient[key] = s + } else if newCalls == existingCalls { + let existingTime = existing.lastActive ?? existing.startTime ?? "" + let newTime = s.lastActive ?? s.startTime ?? "" + if newTime > existingTime { byClient[key] = s } + } + } else { + byClient[key] = s + } + } + return byClient.values + .sorted { ($0.toolCallCount ?? 0) > ($1.toolCallCount ?? 0) } + }() if sessions.isEmpty { HStack { Spacer() @@ -233,7 +510,7 @@ struct DashboardView: View { .frame(width: 80, alignment: .leading) Text("Tool Calls") .frame(width: 80, alignment: .trailing) - Text("Started") + Text("Last Active") .frame(maxWidth: .infinity, alignment: .trailing) } .font(.scaled(.caption, scale: fontScale).weight(.semibold)) @@ -246,23 +523,23 @@ struct DashboardView: View { ForEach(sessions) { session in HStack(spacing: 0) { - Text(session.displayId) - .font(.scaledMonospaced(.caption, scale: fontScale)) + Text(sessionDisplayName(session)) + .font(.scaled(.caption, scale: fontScale)) .lineLimit(1) .frame(width: 180, alignment: .leading) DashboardStatusBadge( - label: session.hasErrors ? "Error" : "Active", - color: session.hasErrors ? .red : .green, + label: session.status == "active" ? "Active" : (session.status == "closed" ? "Closed" : session.status.capitalized), + color: session.status == "active" ? .green : .gray, fontScale: fontScale ) .frame(width: 80, alignment: .leading) - Text("\(session.toolCallCount)") + Text("\(session.toolCallCount ?? 0)") .font(.scaledMonospacedDigit(.caption, scale: fontScale)) .frame(width: 80, alignment: .trailing) - Text(session.relativeTime) + Text(sessionRelativeTime(session.lastActive ?? session.startTime)) .font(.scaled(.caption, scale: fontScale)) .foregroundStyle(.secondary) .frame(maxWidth: .infinity, alignment: .trailing) @@ -382,30 +659,60 @@ struct DashboardView: View { return "\(count)" } - /// Derive session summaries from recent activity entries grouped by sessionId. - private func deriveSessions() -> [SessionSummary] { - var grouped: [String: [ActivityEntry]] = [:] - for entry in appState.recentActivity { - let key = entry.sessionId ?? "unknown" - grouped[key, default: []].append(entry) + /// Extract human-readable client names from real MCP sessions. + /// Uses client_name field when available, falls back to session ID heuristics. + private func connectedClientNames(from sessions: [APIClient.MCPSession]) -> [String] { + let activeSessions = sessions.filter { $0.status == "active" } + if activeSessions.isEmpty { return [] } + var names: Set = [] + for session in activeSessions { + if let clientName = session.clientName, !clientName.isEmpty { + // Use known display names for common clients + let lower = clientName.lowercased() + if lower.contains("claude") { names.insert("Claude Code") } + else if lower.contains("cursor") { names.insert("Cursor") } + else if lower.contains("vscode") || lower.contains("copilot") { names.insert("VS Code") } + else if lower.contains("codex") { names.insert("Codex") } + else if lower.contains("gemini") { names.insert("Gemini") } + else if lower.contains("antigravity") { names.insert("Antigravity") } + else if lower.contains("windsurf") { names.insert("Windsurf") } + else { names.insert(clientName) } + } else { + // Fallback to session ID heuristics + let id = session.id.lowercased() + if id.contains("claude") { names.insert("Claude Code") } + else if id.contains("cursor") { names.insert("Cursor") } + else if id.contains("vscode") || id.contains("copilot") { names.insert("VS Code") } + else { names.insert("MCP Client") } + } } + return names.sorted() + } - var sessions: [SessionSummary] = [] - for (sessionId, entries) in grouped { - let toolCalls = entries.filter { $0.type == "tool_call" || $0.type == "internal_tool_call" } - let hasErrors = entries.contains { $0.status == "error" } - let earliest = entries.compactMap { parseISO8601($0.timestamp) }.min() ?? Date.distantPast - sessions.append(SessionSummary( - sessionId: sessionId, - toolCallCount: toolCalls.count, - hasErrors: hasErrors, - startedAt: earliest - )) + /// Display name for a session, preferring client_name with version. + private func sessionDisplayName(_ session: APIClient.MCPSession) -> String { + if let name = session.clientName, !name.isEmpty { + if let version = session.clientVersion, !version.isEmpty { + return "\(name) \(version)" + } + return name + } + // Fallback to truncated session ID + if session.id.count > 20 { + return String(session.id.prefix(20)) + "..." } + return session.id + } - // Sort by most recent first - sessions.sort { $0.startedAt > $1.startedAt } - return Array(sessions.prefix(6)) + /// Relative time string from an ISO 8601 timestamp. + private func sessionRelativeTime(_ timestamp: String?) -> String { + guard let timestamp else { return "-" } + guard let date = parseISO8601(timestamp) else { return "-" } + let interval = Date().timeIntervalSince(date) + if interval < 60 { return "just now" } + if interval < 3600 { return "\(Int(interval / 60))m ago" } + if interval < 86400 { return "\(Int(interval / 3600))h ago" } + return "\(Int(interval / 86400))d ago" } private func parseISO8601(_ string: String) -> Date? { @@ -417,33 +724,6 @@ struct DashboardView: View { } } -// MARK: - Session Summary Model - -private struct SessionSummary: Identifiable { - let sessionId: String - let toolCallCount: Int - let hasErrors: Bool - let startedAt: Date - - var id: String { sessionId } - - var displayId: String { - if sessionId == "unknown" { return "unknown" } - if sessionId.count > 16 { - return String(sessionId.prefix(16)) + "..." - } - return sessionId - } - - var relativeTime: String { - let interval = Date().timeIntervalSince(startedAt) - if interval < 60 { return "just now" } - if interval < 3600 { return "\(Int(interval / 60))m ago" } - if interval < 86400 { return "\(Int(interval / 3600))h ago" } - return "\(Int(interval / 86400))d ago" - } -} - // MARK: - Stat Card private struct StatCard: View { @@ -530,6 +810,66 @@ private struct TokenDistributionBar: View { } } +// MARK: - Hub Connection Line (animated green dots on a fat line) + +private struct HubConnectionLine: View { + var fontScale: CGFloat = 1.0 + @State private var dotOffset: CGFloat = 4 + @State private var lineWidth: CGFloat = 100 + + var body: some View { + GeometryReader { geometry in + let w = geometry.size.width + let h = geometry.size.height + let midY = h / 2 + + ZStack { + // Fat green line + Rectangle() + .fill(Color.green.opacity(0.25)) + .frame(height: 4) + .position(x: w / 2, y: midY) + + // Animated green dot + Circle() + .fill(Color.green) + .frame(width: 8, height: 8) + .position(x: dotOffset, y: midY) + .opacity(0.9) + + // Static endpoint dots + Circle() + .fill(Color.green.opacity(0.7)) + .frame(width: 6, height: 6) + .position(x: 4, y: midY) + Circle() + .fill(Color.green.opacity(0.7)) + .frame(width: 6, height: 6) + .position(x: w - 4, y: midY) + } + .onAppear { + lineWidth = w + // Trigger once immediately + dotOffset = 4 + withAnimation(.linear(duration: 2.0)) { + dotOffset = w - 4 + } + // Then repeat every 20 seconds + Timer.scheduledTimer(withTimeInterval: 20.0, repeats: true) { _ in + dotOffset = 4 + withAnimation(.linear(duration: 2.0)) { + dotOffset = w - 4 + } + } + } + .onChange(of: geometry.size.width) { newWidth in + lineWidth = newWidth + } + } + .frame(height: 20) + } +} + // MARK: - Dashboard Status Badge private struct DashboardStatusBadge: View { @@ -749,3 +1089,279 @@ private struct AttentionRow: View { } } } + +// MARK: - Connect Clients Sheet + +private struct ConnectClientsSheet: View { + @ObservedObject var appState: AppState + @Binding var isPresented: Bool + @State private var clients: [APIClient.ClientStatus] = [] + @State private var loading = true + @State private var errorMessage: String? + @State private var successMessage: String? + @State private var actionInProgress: String? + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + // Header + VStack(alignment: .leading, spacing: 4) { + Text("Connect MCPProxy to AI Agents") + .font(.headline) + Text("Register MCPProxy in your AI tools' MCP config files so they can discover and use your upstream servers.") + .font(.caption) + .foregroundStyle(.secondary) + } + + Divider() + + if loading { + HStack { + Spacer() + ProgressView("Loading clients...") + Spacer() + } + .padding(.vertical, 20) + } else if clients.isEmpty { + HStack { + Spacer() + VStack(spacing: 8) { + Image(systemName: "questionmark.circle") + .font(.title2) + .foregroundStyle(.secondary) + Text("No AI clients detected") + .font(.subheadline) + .foregroundStyle(.secondary) + Text("The connect API may not be available in this version.") + .font(.caption) + .foregroundStyle(.tertiary) + } + Spacer() + } + .padding(.vertical, 20) + } else { + ScrollView { + VStack(spacing: 0) { + ForEach(clients) { client in + clientRow(client) + if client.id != clients.last?.id { + Divider().padding(.leading, 8) + } + } + } + } + .frame(maxHeight: 300) + } + + // Status messages + if let msg = successMessage { + HStack(spacing: 6) { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + Text(msg) + .font(.caption) + .foregroundStyle(.green) + } + } + + if let err = errorMessage { + HStack(spacing: 6) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.red) + Text(err) + .font(.caption) + .foregroundStyle(.red) + } + } + + Divider() + + // Footer + HStack { + Button("Refresh") { + loadClients() + } + .controlSize(.small) + + Spacer() + + Button("Close") { + isPresented = false + } + .keyboardShortcut(.cancelAction) + } + } + .padding(20) + .frame(width: 500) + .onAppear { loadClients() } + } + + @ViewBuilder + private func clientRow(_ client: APIClient.ClientStatus) -> some View { + HStack(spacing: 12) { + // Client icon + Image(systemName: clientIcon(for: client.clientId)) + .font(.title3) + .foregroundStyle(client.connected ? .green : .secondary) + .frame(width: 24) + + // Client info + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + Text(client.name) + .font(.subheadline) + .fontWeight(.medium) + if client.connected { + Text("Connected") + .font(.caption2) + .fontWeight(.semibold) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.green.opacity(0.15)) + .foregroundStyle(.green) + .clipShape(Capsule()) + } + } + Text(client.configPath) + .font(.caption2) + .foregroundStyle(.tertiary) + .lineLimit(1) + .truncationMode(.middle) + } + + Spacer() + + // Action button + if !client.supported { + Text(client.reason ?? "Not supported") + .font(.caption2) + .foregroundStyle(.secondary) + } else if client.connected { + Button { + disconnect(client.clientId) + } label: { + if actionInProgress == client.clientId { + ProgressView() + .controlSize(.small) + } else { + Text("Disconnect") + } + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(actionInProgress != nil) + } else { + Button { + connect(client.clientId) + } label: { + if actionInProgress == client.clientId { + ProgressView() + .controlSize(.small) + } else { + Text("Connect") + } + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + .disabled(actionInProgress != nil) + } + } + .padding(.horizontal, 8) + .padding(.vertical, 8) + } + + private func clientIcon(for clientId: String) -> String { + switch clientId { + case "claude-code", "claude-desktop": + return "brain" + case "cursor": + return "cursorarrow.rays" + case "vscode", "copilot": + return "chevron.left.forwardslash.chevron.right" + case "windsurf": + return "wind" + default: + return "app.connected.to.app.below.fill" + } + } + + private func loadClients() { + loading = true + errorMessage = nil + successMessage = nil + Task { + guard let client = appState.apiClient else { + await MainActor.run { + loading = false + errorMessage = "Core is not connected" + } + return + } + do { + let result = try await client.connectClients() + await MainActor.run { + clients = result + loading = false + } + } catch { + await MainActor.run { + loading = false + errorMessage = error.localizedDescription + } + } + } + } + + private func connect(_ clientId: String) { + errorMessage = nil + successMessage = nil + actionInProgress = clientId + Task { + guard let client = appState.apiClient else { return } + do { + let result = try await client.connectToClient(clientId) + await MainActor.run { + actionInProgress = nil + if result.success { + successMessage = result.message ?? "Connected to \(clientId)" + } else { + errorMessage = result.message ?? "Failed to connect" + } + } + // Reload the list to reflect changes + loadClients() + } catch { + await MainActor.run { + actionInProgress = nil + errorMessage = error.localizedDescription + } + } + } + } + + private func disconnect(_ clientId: String) { + errorMessage = nil + successMessage = nil + actionInProgress = clientId + Task { + guard let client = appState.apiClient else { return } + do { + let result = try await client.disconnectFromClient(clientId) + await MainActor.run { + actionInProgress = nil + if result.success { + successMessage = result.message ?? "Disconnected from \(clientId)" + } else { + errorMessage = result.message ?? "Failed to disconnect" + } + } + // Reload the list to reflect changes + loadClients() + } catch { + await MainActor.run { + actionInProgress = nil + errorMessage = error.localizedDescription + } + } + } + } +} diff --git a/native/macos/MCPProxy/MCPProxy/Views/MainWindow.swift b/native/macos/MCPProxy/MCPProxy/Views/MainWindow.swift index 6e4aba108..a9ae6d13e 100644 --- a/native/macos/MCPProxy/MCPProxy/Views/MainWindow.swift +++ b/native/macos/MCPProxy/MCPProxy/Views/MainWindow.swift @@ -68,6 +68,12 @@ struct MainWindow: View { .accessibilityIdentifier("detail-view") } .frame(minWidth: 800, minHeight: 500) + .onReceive(NotificationCenter.default.publisher(for: .switchToActivity)) { _ in + selectedItem = .activity + } + .onReceive(NotificationCenter.default.publisher(for: .switchToServers)) { _ in + selectedItem = .servers + } } // MARK: - Core Status Banner diff --git a/native/macos/MCPProxy/MCPProxy/Views/SecretsView.swift b/native/macos/MCPProxy/MCPProxy/Views/SecretsView.swift index e84a1da1c..c3b071ac8 100644 --- a/native/macos/MCPProxy/MCPProxy/Views/SecretsView.swift +++ b/native/macos/MCPProxy/MCPProxy/Views/SecretsView.swift @@ -260,6 +260,11 @@ struct SecretRow: View { let onDelete: () -> Void @Environment(\.fontScale) var fontScale + @State private var showDeleteConfirmation = false + @State private var showSetValueSheet = false + @State private var secretValue = "" + @State private var isSaving = false + private var apiClient: APIClient? { appState.apiClient } var body: some View { @@ -296,19 +301,78 @@ struct SecretRow: View { } if entry.secretRef.type == "keyring" { + // Set/Update value button + Button { + secretValue = "" + showSetValueSheet = true + } label: { + Image(systemName: entry.isSet ? "pencil" : "plus.circle.fill") + .foregroundColor(entry.isSet ? .secondary : .blue) + } + .buttonStyle(.borderless) + .help(entry.isSet ? "Update secret value" : "Set secret value") + + // Delete button with confirmation Button(role: .destructive) { - Task { - try? await apiClient?.deleteAction(path: "/api/v1/secrets/\(entry.secretRef.name)") - onDelete() - } + showDeleteConfirmation = true } label: { Image(systemName: "trash") .foregroundStyle(.red) } .buttonStyle(.borderless) + .help("Delete secret") } } .padding(.vertical, 4) + .alert("Delete Secret", isPresented: $showDeleteConfirmation) { + Button("Cancel", role: .cancel) { } + Button("Delete", role: .destructive) { + Task { + try? await apiClient?.deleteAction(path: "/api/v1/secrets/\(entry.secretRef.name)") + onDelete() + } + } + } message: { + Text("Are you sure you want to delete \"\(entry.secretRef.name)\"? This action cannot be undone.") + } + .sheet(isPresented: $showSetValueSheet) { + VStack(spacing: 16) { + Text(entry.isSet ? "Update Secret" : "Set Secret Value") + .font(.headline) + + Text(entry.secretRef.name) + .font(.subheadline) + .foregroundStyle(.secondary) + + SecureField("Enter secret value...", text: $secretValue) + .textFieldStyle(.roundedBorder) + .frame(width: 300) + + HStack { + Button("Cancel") { + showSetValueSheet = false + } + .keyboardShortcut(.cancelAction) + + Button(isSaving ? "Saving..." : "Save") { + Task { + isSaving = true + try? await apiClient?.postAction( + path: "/api/v1/secrets", + body: ["name": entry.secretRef.name, "value": secretValue, "type": "keyring"] + ) + isSaving = false + showSetValueSheet = false + secretValue = "" + onDelete() // Triggers reload + } + } + .keyboardShortcut(.defaultAction) + .disabled(secretValue.isEmpty || isSaving) + } + } + .padding(24) + } } } diff --git a/native/macos/MCPProxy/MCPProxy/Views/ServerDetailView.swift b/native/macos/MCPProxy/MCPProxy/Views/ServerDetailView.swift index 295263535..952e922fc 100644 --- a/native/macos/MCPProxy/MCPProxy/Views/ServerDetailView.swift +++ b/native/macos/MCPProxy/MCPProxy/Views/ServerDetailView.swift @@ -25,11 +25,12 @@ enum ServerDetailTab: String, CaseIterable { // MARK: - Server Detail View struct ServerDetailView: View { - let server: ServerStatus + let initialServer: ServerStatus @ObservedObject var appState: AppState let onDismiss: () -> Void @Environment(\.fontScale) var fontScale + @State private var server: ServerStatus @State private var selectedTab: ServerDetailTab = .tools @State private var tools: [ServerTool] = [] @State private var logLines: [String] = [] @@ -38,6 +39,30 @@ struct ServerDetailView: View { @State private var isApproving = false @State private var actionMessage: String? + init(server: ServerStatus, appState: AppState, onDismiss: @escaping () -> Void) { + self.initialServer = server + self.appState = appState + self.onDismiss = onDismiss + self._server = State(initialValue: server) + } + + // Edit mode state for Config tab + @State private var isEditing = false + @State private var editURL = "" + @State private var editCommand = "" + @State private var editArgs = "" + @State private var editWorkingDir = "" + @State private var editEnvVars = "" + @State private var editEnabled = true + @State private var editQuarantined = false + @State private var editDockerIsolation = false + @State private var editSkipQuarantine = false + @State private var isSavingEdit = false + @State private var editError: String? + + // Logs auto-refresh timer + @State private var logRefreshTimer: Timer? + private var apiClient: APIClient? { appState.apiClient } var body: some View { @@ -100,12 +125,51 @@ struct ServerDetailView: View { .controlSize(.small) } + if server.quarantined { + Button { + Task { + do { + try await apiClient?.approveTools(server.id) + try await apiClient?.unquarantineServer(server.id) + actionMessage = "Server approved and activated" + await refreshServer() + } catch { + actionMessage = "Failed to approve: \(error.localizedDescription)" + } + } + } label: { + Label("Approve Server", systemImage: "checkmark.shield") + } + .buttonStyle(.borderedProminent) + .tint(.green) + .controlSize(.small) + } else { + // Allow re-quarantining an approved server + Button { + Task { + do { + try await apiClient?.postAction(path: "/api/v1/servers/\(server.id)/quarantine") + actionMessage = "Server quarantined" + await refreshServer() + } catch { + actionMessage = "Failed to quarantine: \(error.localizedDescription)" + } + } + } label: { + Label("Quarantine", systemImage: "shield.lefthalf.filled") + } + .buttonStyle(.bordered) + .controlSize(.small) + } + if server.enabled { let disableLabel = server.protocol == "stdio" ? "Stop" : "Disable" let disabledMsg = server.protocol == "stdio" ? "stopped" : "disabled" Button(disableLabel) { - Task { await performAction { try await apiClient?.disableServer(server.id) } + Task { + await performAction { try await apiClient?.disableServer(server.id) } actionMessage = "\(server.name) \(disabledMsg)" + await refreshServer() } } .buttonStyle(.bordered) @@ -205,6 +269,11 @@ struct ServerDetailView: View { Text(server.connected ? "This server has no tools" : "Connect the server to see tools") .font(.scaled(.caption, scale: fontScale)) .foregroundStyle(.tertiary) + Button("Reload") { + Task { await loadTools() } + } + .buttonStyle(.bordered) + .controlSize(.small) } .frame(maxWidth: .infinity, maxHeight: .infinity) } else { @@ -260,6 +329,7 @@ struct ServerDetailView: View { @ViewBuilder private var logsTab: some View { VStack(alignment: .leading, spacing: 0) { + lastErrorBanner HStack { Text("Server Logs") .font(.scaled(.subheadline, scale: fontScale).bold()) @@ -329,6 +399,34 @@ struct ServerDetailView: View { } } .task { await loadLogs() } + .onAppear { startLogRefresh() } + .onDisappear { stopLogRefresh() } + } + + /// Banner showing last error at top of Logs tab. + @ViewBuilder + private var lastErrorBanner: some View { + if let lastError = server.lastError, !lastError.isEmpty { + HStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.red) + VStack(alignment: .leading, spacing: 2) { + Text("Last Error") + .font(.scaled(.caption, scale: fontScale).bold()) + .foregroundStyle(.red) + Text(lastError) + .font(.scaledMonospaced(.caption, scale: fontScale)) + .foregroundStyle(.red.opacity(0.8)) + .textSelection(.enabled) + } + Spacer() + } + .padding(8) + .background(Color.red.opacity(0.1)) + .cornerRadius(6) + .padding(.horizontal) + .padding(.top, 4) + } } /// Render a single log line with color-coded level and word wrapping. @@ -359,64 +457,148 @@ struct ServerDetailView: View { @ViewBuilder private var configTab: some View { - ScrollView { - VStack(alignment: .leading, spacing: 16) { - configSection(title: "General") { - configRow(label: "Name", value: server.name) - configRow(label: "Protocol", value: server.protocol) - configRow(label: "Enabled", value: server.enabled ? "Yes" : "No") - if server.quarantined { - configRow(label: "Quarantined", value: "Yes") + VStack(spacing: 0) { + // Edit/Save/Cancel toolbar + HStack { + Spacer() + if isEditing { + if isSavingEdit { + ProgressView().controlSize(.small) + Text("Saving...") + .font(.scaled(.caption, scale: fontScale)) + } + Button("Cancel") { + isEditing = false + editError = nil } + .buttonStyle(.bordered) + .controlSize(.small) + Button("Save") { + Task { await saveEdits() } + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + .disabled(isSavingEdit) + } else { + Button { + startEditing() + } label: { + Label("Edit", systemImage: "pencil") + } + .buttonStyle(.bordered) + .controlSize(.small) } + } + .padding(.horizontal) + .padding(.vertical, 8) - if server.protocol == "http" || server.protocol == "sse" { - configSection(title: "Connection") { - configRow(label: "URL", value: server.url ?? "N/A") - } + if let err = editError { + HStack { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.red) + Text(err) + .font(.scaled(.caption, scale: fontScale)) + .foregroundStyle(.red) + Spacer() + Button("Dismiss") { editError = nil } + .buttonStyle(.borderless) + .font(.scaled(.caption, scale: fontScale)) } + .padding(.horizontal) + .padding(.vertical, 4) + .background(Color.red.opacity(0.1)) + } - if server.protocol == "stdio" { - configSection(title: "Process") { - configRow(label: "Command", value: server.command ?? "N/A") - if let args = server.args, !args.isEmpty { - configRow(label: "Args", value: args.joined(separator: " ")) + Divider() + + ScrollView { + VStack(alignment: .leading, spacing: 16) { + configSection(title: "General") { + configRow(label: "Name", value: server.name) + configRow(label: "Protocol", value: server.protocol) + if isEditing { + configToggleRow(label: "Enabled", isOn: $editEnabled, + hint: "When disabled, the server will not connect or be available to AI agents.") + configToggleRow(label: "Quarantined", isOn: $editQuarantined, + hint: "New tools must be reviewed and approved before AI agents can use them. Protects against tool poisoning attacks.") + configToggleRow(label: "Docker Isolation", isOn: $editDockerIsolation, + hint: "Runs the server in an isolated Docker container. Prevents access to your filesystem, network, and other system resources. Recommended for untrusted servers.") + configToggleRow(label: "Skip Quarantine", isOn: $editSkipQuarantine) + } else { + configRow(label: "Enabled", value: server.enabled ? "Yes" : "No") + if server.quarantined { + configRow(label: "Quarantined", value: "Yes") + } } } - } - configSection(title: "Status") { - configRow(label: "Connected", value: server.connected ? "Yes" : "No") - if let connectedAt = server.connectedAt { - configRow(label: "Connected At", value: connectedAt) - } - if let reconnectCount = server.reconnectCount, reconnectCount > 0 { - configRow(label: "Reconnect Count", value: "\(reconnectCount)") + if server.protocol == "http" || server.protocol == "sse" || server.protocol == "streamable-http" { + configSection(title: "Connection") { + if isEditing { + configEditRow(label: "URL", text: $editURL, placeholder: "https://api.example.com/mcp") + } else { + configRow(label: "URL", value: server.url ?? "N/A") + } + } } - configRow(label: "Tool Count", value: "\(server.toolCount)") - if let tokenSize = server.toolListTokenSize { - configRow(label: "Token Size", value: "\(tokenSize)") + + if server.protocol == "stdio" { + configSection(title: "Process") { + if isEditing { + configEditRow(label: "Command", text: $editCommand, placeholder: "e.g. npx, uvx") + configEditRow(label: "Args (one per line)", text: $editArgs, placeholder: "arg1\narg2", multiline: true) + configEditRow(label: "Working Dir", text: $editWorkingDir, placeholder: "/path/to/project") + } else { + configRow(label: "Command", value: server.command ?? server.name) + if let args = server.args, !args.isEmpty { + configRow(label: "Args", value: args.joined(separator: " ")) + } + if let wd = server.workingDir, !wd.isEmpty { + configRow(label: "Working Dir", value: wd) + } + } + } } - if let lastError = server.lastError { - configRow(label: "Last Error", value: lastError) + + if isEditing { + configSection(title: "Environment Variables") { + configEditRow(label: "KEY=VALUE per line", text: $editEnvVars, placeholder: "API_KEY=abc123\nDEBUG=true", multiline: true) + } } - } - if let health = server.health { - configSection(title: "Health") { - configRow(label: "Level", value: health.level) - configRow(label: "Admin State", value: health.adminState) - configRow(label: "Summary", value: health.summary) - if let detail = health.detail, !detail.isEmpty { - configRow(label: "Detail", value: detail) + configSection(title: "Status") { + configRow(label: "Connected", value: server.connected ? "Yes" : "No") + if let connectedAt = server.connectedAt { + configRow(label: "Connected At", value: connectedAt) + } + if let reconnectCount = server.reconnectCount, reconnectCount > 0 { + configRow(label: "Reconnect Count", value: "\(reconnectCount)") + } + configRow(label: "Tool Count", value: "\(server.toolCount)") + if let tokenSize = server.toolListTokenSize { + configRow(label: "Token Size", value: "\(tokenSize)") } - if let action = health.action, !action.isEmpty { - configRow(label: "Action", value: action) + if let lastError = server.lastError { + configRow(label: "Last Error", value: lastError) + } + } + + if let health = server.health { + configSection(title: "Health") { + configRow(label: "Level", value: health.level) + configRow(label: "Admin State", value: health.adminState) + configRow(label: "Summary", value: health.summary) + if let detail = health.detail, !detail.isEmpty { + configRow(label: "Detail", value: detail) + } + if let action = health.action, !action.isEmpty { + configRow(label: "Action", value: action) + } } } } + .padding() } - .padding() } } @@ -439,7 +621,7 @@ struct ServerDetailView: View { Text(label) .font(.scaled(.subheadline, scale: fontScale)) .foregroundStyle(.secondary) - .frame(width: 120, alignment: .trailing) + .frame(width: 140, alignment: .trailing) Text(value) .font(.scaledMonospaced(.subheadline, scale: fontScale)) .textSelection(.enabled) @@ -447,6 +629,48 @@ struct ServerDetailView: View { } } + @ViewBuilder + private func configEditRow(label: String, text: Binding, placeholder: String, multiline: Bool = false) -> some View { + HStack(alignment: .top) { + Text(label) + .font(.scaled(.subheadline, scale: fontScale)) + .foregroundStyle(.secondary) + .frame(width: 140, alignment: .trailing) + if multiline { + TextEditor(text: text) + .font(.scaledMonospaced(.subheadline, scale: fontScale)) + .frame(height: 60) + .border(Color(nsColor: .separatorColor), width: 1) + } else { + TextField(placeholder, text: text) + .font(.scaledMonospaced(.subheadline, scale: fontScale)) + .textFieldStyle(.roundedBorder) + } + Spacer() + } + } + + @ViewBuilder + private func configToggleRow(label: String, isOn: Binding, hint: String? = nil) -> some View { + VStack(alignment: .leading, spacing: 2) { + HStack { + Text(label) + .font(.scaled(.subheadline, scale: fontScale)) + .foregroundStyle(.secondary) + .frame(width: 140, alignment: .trailing) + Toggle("", isOn: isOn) + .labelsHidden() + Spacer() + } + if let hint = hint { + Text(hint) + .font(.scaled(.caption2, scale: fontScale)) + .foregroundStyle(.tertiary) + .padding(.leading, 148) + } + } + } + // MARK: - Action Banner @ViewBuilder @@ -481,13 +705,17 @@ struct ServerDetailView: View { // MARK: - Data Loading private func loadTools() async { - guard let client = apiClient else { return } + guard let client = apiClient else { + NSLog("[ServerDetail] loadTools: no apiClient") + return + } isLoadingTools = true defer { isLoadingTools = false } do { tools = try await client.serverTools(server.name) + NSLog("[ServerDetail] loadTools: loaded %d tools for %@", tools.count, server.name) } catch { - // Silently fail -- tools just won't display + NSLog("[ServerDetail] loadTools FAILED for %@: %@", server.name, error.localizedDescription) } } @@ -526,6 +754,115 @@ struct ServerDetailView: View { actionMessage = "Error: \(error.localizedDescription)" } } + + /// Refresh server status from API to update the view after mutations. + private func refreshServer() async { + guard let client = apiClient else { return } + do { + let servers = try await client.servers() + if let updated = servers.first(where: { $0.name == server.name }) { + server = updated + } + } catch { + // Silently fail — view keeps showing stale data + } + } + + // MARK: - Edit Mode + + private func startEditing() { + editURL = server.url ?? "" + editCommand = server.command ?? "" + editArgs = (server.args ?? []).joined(separator: "\n") + editWorkingDir = server.workingDir ?? "" + editEnvVars = "" // env vars not in ServerStatus model, would need config API + editEnabled = server.enabled + editQuarantined = server.quarantined + editDockerIsolation = false // read from config if available + editSkipQuarantine = false // read from config if available + editError = nil + isEditing = true + } + + private func saveEdits() async { + guard let client = apiClient else { return } + isSavingEdit = true + editError = nil + + var updates: [String: Any] = [:] + + // String fields — only include if changed + if server.protocol == "stdio" { + let cmd = editCommand.trimmingCharacters(in: .whitespaces) + if cmd.isEmpty { + editError = "Command is required for stdio servers" + isSavingEdit = false + return + } + if cmd != (server.command ?? "") { updates["command"] = cmd } + let args = editArgs.components(separatedBy: "\n").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty } + updates["args"] = args + } else { + let url = editURL.trimmingCharacters(in: .whitespaces) + if url.isEmpty { + editError = "URL is required for HTTP servers" + isSavingEdit = false + return + } + if url != (server.url ?? "") { updates["url"] = url } + } + + let wd = editWorkingDir.trimmingCharacters(in: .whitespaces) + if !wd.isEmpty { updates["working_dir"] = wd } + + // Parse env vars + if !editEnvVars.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + var env: [String: String] = [:] + for line in editEnvVars.components(separatedBy: "\n") { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.isEmpty { continue } + let parts = trimmed.components(separatedBy: "=") + if parts.count >= 2 { + env[parts[0].trimmingCharacters(in: .whitespaces)] = parts.dropFirst().joined(separator: "=") + } + } + if !env.isEmpty { updates["env"] = env } + } + + // Boolean toggles + if editEnabled != server.enabled { updates["enabled"] = editEnabled } + if editQuarantined != server.quarantined { updates["quarantined"] = editQuarantined } + + if updates.isEmpty { + isEditing = false + isSavingEdit = false + return + } + + do { + try await client.updateServer(server.name, updates: updates) + isEditing = false + actionMessage = "Server configuration updated. Restart may be required." + } catch { + editError = "Failed to save: \(error.localizedDescription)" + } + + isSavingEdit = false + } + + // MARK: - Log Auto-Refresh + + private func startLogRefresh() { + logRefreshTimer?.invalidate() + logRefreshTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { _ in + Task { await loadLogs() } + } + } + + private func stopLogRefresh() { + logRefreshTimer?.invalidate() + logRefreshTimer = nil + } } // MARK: - Tool Row (Expandable Disclosure) diff --git a/native/macos/MCPProxy/MCPProxy/Views/ServersView.swift b/native/macos/MCPProxy/MCPProxy/Views/ServersView.swift index 1eea63ed5..4f616e2c2 100644 --- a/native/macos/MCPProxy/MCPProxy/Views/ServersView.swift +++ b/native/macos/MCPProxy/MCPProxy/Views/ServersView.swift @@ -19,6 +19,7 @@ struct ServersView: View { @State private var loadTask: Task? @State private var selectedServer: ServerStatus? @State private var showAddServer = false + @State private var addServerInitialTab: AddServerTab = .manual var body: some View { VStack(alignment: .leading, spacing: 0) { @@ -33,7 +34,8 @@ struct ServersView: View { } } .sheet(isPresented: $showAddServer) { - AddServerView(appState: appState, isPresented: $showAddServer) + AddServerView(appState: appState, isPresented: $showAddServer, initialTab: addServerInitialTab) + .id(addServerInitialTab) } } @@ -53,6 +55,7 @@ struct ServersView: View { .foregroundStyle(.secondary) Button { + addServerInitialTab = .importConfig showAddServer = true } label: { Image(systemName: "plus") @@ -78,6 +81,7 @@ struct ServersView: View { // Prominent "Add Server" button bar HStack { Button { + addServerInitialTab = .importConfig showAddServer = true } label: { Label("Add Server", systemImage: "plus.circle.fill") @@ -113,20 +117,30 @@ struct ServersView: View { Spacer() Image(systemName: "server.rack") .font(.system(size: 48 * fontScale)) - .foregroundStyle(.tertiary) + .foregroundStyle(.secondary) Text("No Servers Configured") - .font(.scaled(.title3, scale: fontScale)) + .font(.scaled(.headline, scale: fontScale)) + Text("Add your first MCP server or import from an existing AI tool configuration.") + .font(.scaled(.subheadline, scale: fontScale)) .foregroundStyle(.secondary) - Text("Add your first MCP server to get started") - .font(.scaled(.body, scale: fontScale)) - .foregroundStyle(.tertiary) - Button { - showAddServer = true - } label: { - Label("Add Your First Server", systemImage: "plus.circle.fill") + .multilineTextAlignment(.center) + .frame(maxWidth: 300) + HStack(spacing: 12) { + Button { + addServerInitialTab = .manual + showAddServer = true + } label: { + Label("Add Server", systemImage: "plus.circle.fill") + } + .buttonStyle(.borderedProminent) + Button { + addServerInitialTab = .importConfig + showAddServer = true + } label: { + Label("Import", systemImage: "square.and.arrow.down") + } + .buttonStyle(.bordered) } - .buttonStyle(.borderedProminent) - .controlSize(.large) Spacer() } .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -153,9 +167,22 @@ struct ServersView: View { .onChange(of: appState.serversVersion) { _ in triggerLoad() } - .onReceive(NotificationCenter.default.publisher(for: .showAddServer)) { _ in + .onReceive(NotificationCenter.default.publisher(for: .showAddServer)) { notification in + if let tab = notification.object as? AddServerTab { + addServerInitialTab = tab + } else { + addServerInitialTab = .manual + } showAddServer = true } + .onReceive(NotificationCenter.default.publisher(for: .showServerDetail)) { notification in + guard let serverName = notification.object as? String else { return } + // Find the server by name in the current list or appState + if let server = servers.first(where: { $0.name == serverName }) + ?? appState.servers.first(where: { $0.name == serverName }) { + selectedServer = server + } + } } private func triggerLoad() { @@ -704,6 +731,7 @@ struct ServerTableView: NSViewRepresentable { label.lineBreakMode = .byTruncatingTail label.translatesAutoresizingMaskIntoConstraints = false cell.addSubview(label) + cell.toolTip = server.health?.detail ?? server.health?.summary ?? "" NSLayoutConstraint.activate([ label.leadingAnchor.constraint(equalTo: cell.leadingAnchor, constant: 4), label.trailingAnchor.constraint(equalTo: cell.trailingAnchor, constant: -4), diff --git a/native/macos/MCPProxy/MCPProxy/Views/TokensView.swift b/native/macos/MCPProxy/MCPProxy/Views/TokensView.swift index d5f93fe7f..7fec89610 100644 --- a/native/macos/MCPProxy/MCPProxy/Views/TokensView.swift +++ b/native/macos/MCPProxy/MCPProxy/Views/TokensView.swift @@ -187,6 +187,7 @@ struct TokensView: View { struct TokenRow: View { let token: AgentToken let onRevoke: () -> Void + @State private var showRevokeConfirmation = false @Environment(\.fontScale) var fontScale var body: some View { @@ -245,7 +246,7 @@ struct TokenRow: View { } Button(role: .destructive) { - onRevoke() + showRevokeConfirmation = true } label: { Image(systemName: "trash") } @@ -253,6 +254,14 @@ struct TokenRow: View { .help("Revoke this token") } .padding(.vertical, 4) + .alert("Revoke Token", isPresented: $showRevokeConfirmation) { + Button("Cancel", role: .cancel) { } + Button("Revoke", role: .destructive) { + onRevoke() + } + } message: { + Text("Are you sure you want to revoke \"\(token.name)\"? This action cannot be undone.") + } } private func formattedDate(_ isoString: String) -> String { diff --git a/oas/docs.go b/oas/docs.go index b196cad20..39dc709af 100644 --- a/oas/docs.go +++ b/oas/docs.go @@ -6,10 +6,10 @@ import "github.com/swaggo/swag/v2" const docTemplate = `{ "schemes": {{ marshal .Schemes }}, - "components": {"schemas":{"config.Config":{"properties":{"activity_cleanup_interval_min":{"description":"Background cleanup interval in minutes (default: 60)","type":"integer"},"activity_max_records":{"description":"Max records before pruning (default: 100000)","type":"integer"},"activity_max_response_size":{"description":"Response truncation limit in bytes (default: 65536)","type":"integer"},"activity_retention_days":{"description":"Activity logging settings (RFC-003)","type":"integer"},"allow_server_add":{"type":"boolean"},"allow_server_remove":{"type":"boolean"},"api_key":{"description":"Security settings","type":"string"},"call_tool_timeout":{"type":"string"},"check_server_repo":{"description":"Repository detection settings","type":"boolean"},"code_execution_max_tool_calls":{"description":"Max tool calls per execution (0 = unlimited, default: 0)","type":"integer"},"code_execution_pool_size":{"description":"JavaScript runtime pool size (default: 10)","type":"integer"},"code_execution_timeout_ms":{"description":"Timeout in milliseconds (default: 120000, max: 600000)","type":"integer"},"data_dir":{"type":"string"},"debug_search":{"type":"boolean"},"disable_management":{"type":"boolean"},"docker_isolation":{"$ref":"#/components/schemas/config.DockerIsolationConfig"},"docker_recovery":{"$ref":"#/components/schemas/config.DockerRecoveryConfig"},"enable_code_execution":{"description":"Code execution settings","type":"boolean"},"enable_prompts":{"description":"Prompts settings","type":"boolean"},"enable_socket":{"description":"Enable Unix socket/named pipe for local IPC (default: true)","type":"boolean"},"enable_tray":{"description":"Deprecated: EnableTray is unused and has no runtime effect. Kept for backward compatibility.","type":"boolean"},"environment":{"$ref":"#/components/schemas/secureenv.EnvConfig"},"features":{"$ref":"#/components/schemas/config.FeatureFlags"},"intent_declaration":{"$ref":"#/components/schemas/config.IntentDeclarationConfig"},"listen":{"type":"string"},"logging":{"$ref":"#/components/schemas/config.LogConfig"},"mcpServers":{"items":{"$ref":"#/components/schemas/config.ServerConfig"},"type":"array","uniqueItems":false},"oauth_expiry_warning_hours":{"description":"Health status settings","type":"number"},"quarantine_enabled":{"description":"Tool-level quarantine settings (Spec 032)\nQuarantineEnabled controls whether tool-level quarantine is active.\nWhen nil (default), quarantine is enabled (secure by default).\nSet to explicit false to disable tool-level quarantine.","type":"boolean"},"read_only_mode":{"type":"boolean"},"registries":{"description":"Registries configuration for MCP server discovery","items":{"$ref":"#/components/schemas/config.RegistryEntry"},"type":"array","uniqueItems":false},"require_mcp_auth":{"description":"Require authentication on /mcp endpoint (default: false)","type":"boolean"},"routing_mode":{"description":"Routing mode (Spec 031): how MCP tools are exposed to clients\nValid values: \"retrieve_tools\" (default), \"direct\", \"code_execution\"","type":"string"},"sensitive_data_detection":{"$ref":"#/components/schemas/config.SensitiveDataDetectionConfig"},"telemetry":{"$ref":"#/components/schemas/config.TelemetryConfig"},"tls":{"$ref":"#/components/schemas/config.TLSConfig"},"tokenizer":{"$ref":"#/components/schemas/config.TokenizerConfig"},"tool_response_limit":{"type":"integer"},"tools_limit":{"type":"integer"},"top_k":{"description":"Deprecated: TopK is superseded by ToolsLimit and has no runtime effect. Kept for backward compatibility.","type":"integer"},"tray_endpoint":{"description":"Tray endpoint override (unix:// or npipe://)","type":"string"}},"type":"object"},"config.CustomPattern":{"properties":{"category":{"description":"Category (defaults to \"custom\")","type":"string"},"keywords":{"description":"Keywords to match (mutually exclusive with Regex)","items":{"type":"string"},"type":"array","uniqueItems":false},"name":{"description":"Unique identifier for this pattern","type":"string"},"regex":{"description":"Regex pattern (mutually exclusive with Keywords)","type":"string"},"severity":{"description":"Risk level: critical, high, medium, low","type":"string"}},"type":"object"},"config.DockerIsolationConfig":{"description":"Docker isolation settings","properties":{"cpu_limit":{"description":"CPU limit for containers","type":"string"},"default_images":{"additionalProperties":{"type":"string"},"description":"Map of runtime type to Docker image","type":"object"},"enabled":{"description":"Global enable/disable for Docker isolation","type":"boolean"},"extra_args":{"description":"Additional docker run arguments","items":{"type":"string"},"type":"array","uniqueItems":false},"log_driver":{"description":"Docker log driver (default: json-file)","type":"string"},"log_max_files":{"description":"Maximum number of log files (default: 3)","type":"string"},"log_max_size":{"description":"Maximum size of log files (default: 100m)","type":"string"},"memory_limit":{"description":"Memory limit for containers","type":"string"},"network_mode":{"description":"Docker network mode (default: bridge)","type":"string"},"registry":{"description":"Custom registry (defaults to docker.io)","type":"string"},"timeout":{"description":"Container startup timeout","type":"string"}},"type":"object"},"config.DockerRecoveryConfig":{"description":"Docker recovery settings","properties":{"enabled":{"description":"Enable Docker recovery monitoring (default: true)","type":"boolean"},"max_retries":{"description":"Maximum retry attempts (0 = unlimited)","type":"integer"},"notify_on_failure":{"description":"Show notification on recovery failure (default: true)","type":"boolean"},"notify_on_retry":{"description":"Show notification on each retry (default: false)","type":"boolean"},"notify_on_start":{"description":"Show notification when recovery starts (default: true)","type":"boolean"},"notify_on_success":{"description":"Show notification on successful recovery (default: true)","type":"boolean"},"persistent_state":{"description":"Save recovery state across restarts (default: true)","type":"boolean"}},"type":"object"},"config.FeatureFlags":{"description":"Deprecated: Features flags are unused and have no runtime effect. Kept for backward compatibility.","properties":{"enable_async_storage":{"type":"boolean"},"enable_caching":{"type":"boolean"},"enable_contract_tests":{"type":"boolean"},"enable_debug_logging":{"description":"Development features","type":"boolean"},"enable_docker_isolation":{"type":"boolean"},"enable_event_bus":{"type":"boolean"},"enable_health_checks":{"type":"boolean"},"enable_metrics":{"type":"boolean"},"enable_oauth":{"description":"Security features","type":"boolean"},"enable_observability":{"description":"Observability features","type":"boolean"},"enable_quarantine":{"type":"boolean"},"enable_runtime":{"description":"Runtime features","type":"boolean"},"enable_search":{"description":"Storage features","type":"boolean"},"enable_sse":{"type":"boolean"},"enable_tracing":{"type":"boolean"},"enable_tray":{"type":"boolean"},"enable_web_ui":{"description":"UI features","type":"boolean"}},"type":"object"},"config.IntentDeclarationConfig":{"description":"Intent declaration settings (Spec 018)","properties":{"strict_server_validation":{"description":"StrictServerValidation controls whether server annotation mismatches\ncause rejection (true) or just warnings (false).\nDefault: true (reject mismatches)","type":"boolean"}},"type":"object"},"config.IsolationConfig":{"description":"Per-server isolation settings","properties":{"enabled":{"description":"Enable Docker isolation for this server (nil = inherit global)","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.LogConfig":{"description":"Logging configuration","properties":{"compress":{"type":"boolean"},"enable_console":{"type":"boolean"},"enable_file":{"type":"boolean"},"filename":{"type":"string"},"json_format":{"type":"boolean"},"level":{"type":"string"},"log_dir":{"description":"Custom log directory","type":"string"},"max_age":{"description":"days","type":"integer"},"max_backups":{"description":"number of backup files","type":"integer"},"max_size":{"description":"MB","type":"integer"}},"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.RegistryEntry":{"properties":{"count":{"description":"number or string","type":"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"},"config.SensitiveDataDetectionConfig":{"description":"Sensitive data detection settings (Spec 026)","properties":{"categories":{"additionalProperties":{"type":"boolean"},"description":"Enable/disable specific detection categories","type":"object"},"custom_patterns":{"description":"User-defined detection patterns","items":{"$ref":"#/components/schemas/config.CustomPattern"},"type":"array","uniqueItems":false},"enabled":{"description":"Enable sensitive data detection (default: true)","type":"boolean"},"entropy_threshold":{"description":"Shannon entropy threshold for high-entropy detection (default: 4.5)","type":"number"},"max_payload_size_kb":{"description":"Max size to scan before truncating (default: 1024)","type":"integer"},"scan_requests":{"description":"Scan tool call arguments (default: true)","type":"boolean"},"scan_responses":{"description":"Scan tool responses (default: true)","type":"boolean"},"sensitive_keywords":{"description":"Keywords to flag","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"},"shared":{"description":"Server edition: shared with all users","type":"boolean"},"skip_quarantine":{"description":"Skip tool-level quarantine for this server","type":"boolean"},"updated":{"type":"string"},"url":{"type":"string"},"working_dir":{"description":"Working directory for stdio servers","type":"string"}},"type":"object"},"config.TLSConfig":{"description":"TLS configuration","properties":{"certs_dir":{"description":"Directory for certificates","type":"string"},"enabled":{"description":"Enable HTTPS","type":"boolean"},"hsts":{"description":"Enable HTTP Strict Transport Security","type":"boolean"},"require_client_cert":{"description":"Enable mTLS","type":"boolean"}},"type":"object"},"config.TelemetryConfig":{"description":"Telemetry settings (Spec 036)","properties":{"anonymous_id":{"description":"Auto-generated UUIDv4","type":"string"},"enabled":{"description":"Default: true (opt-out)","type":"boolean"},"endpoint":{"description":"Override for testing","type":"string"}},"type":"object"},"config.TokenizerConfig":{"description":"Tokenizer configuration for token counting","properties":{"default_model":{"description":"Default model for tokenization (e.g., \"gpt-4\")","type":"string"},"enabled":{"description":"Enable token counting","type":"boolean"},"encoding":{"description":"Default encoding (e.g., \"cl100k_base\")","type":"string"}},"type":"object"},"configimport.FailedServer":{"properties":{"details":{"type":"string"},"error":{"type":"string"},"name":{"type":"string"}},"type":"object"},"configimport.ImportSummary":{"properties":{"failed":{"type":"integer"},"imported":{"type":"integer"},"skipped":{"type":"integer"},"total":{"type":"integer"}},"type":"object"},"configimport.SkippedServer":{"properties":{"name":{"type":"string"},"reason":{"description":"\"already_exists\", \"filtered_out\", \"invalid_name\"","type":"string"}},"type":"object"},"contracts.APIResponse":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"},"contracts.ActivityDetailResponse":{"properties":{"activity":{"$ref":"#/components/schemas/contracts.ActivityRecord"}},"type":"object"},"contracts.ActivityListResponse":{"properties":{"activities":{"items":{"$ref":"#/components/schemas/contracts.ActivityRecord"},"type":"array","uniqueItems":false},"limit":{"type":"integer"},"offset":{"type":"integer"},"total":{"type":"integer"}},"type":"object"},"contracts.ActivityRecord":{"properties":{"arguments":{"description":"Tool call arguments","type":"object"},"detection_types":{"description":"List of detection types found","items":{"type":"string"},"type":"array","uniqueItems":false},"duration_ms":{"description":"Execution duration in milliseconds","type":"integer"},"error_message":{"description":"Error details if status is \"error\"","type":"string"},"has_sensitive_data":{"description":"Sensitive data detection fields (Spec 026)","type":"boolean"},"id":{"description":"Unique identifier (ULID format)","type":"string"},"max_severity":{"description":"Highest severity level detected (critical, high, medium, low)","type":"string"},"metadata":{"description":"Additional context-specific data","type":"object"},"request_id":{"description":"HTTP request ID for correlation","type":"string"},"response":{"description":"Tool response (potentially truncated)","type":"string"},"response_truncated":{"description":"True if response was truncated","type":"boolean"},"server_name":{"description":"Name of upstream MCP server","type":"string"},"session_id":{"description":"MCP session ID for correlation","type":"string"},"source":{"$ref":"#/components/schemas/contracts.ActivitySource"},"status":{"description":"Result status: \"success\", \"error\", \"blocked\"","type":"string"},"timestamp":{"description":"When activity occurred","type":"string"},"tool_name":{"description":"Name of tool called","type":"string"},"type":{"$ref":"#/components/schemas/contracts.ActivityType"}},"type":"object"},"contracts.ActivitySource":{"description":"How activity was triggered: \"mcp\", \"cli\", \"api\"","type":"string","x-enum-varnames":["ActivitySourceMCP","ActivitySourceCLI","ActivitySourceAPI"]},"contracts.ActivitySummaryResponse":{"properties":{"blocked_count":{"description":"Count of blocked activities","type":"integer"},"end_time":{"description":"End of the period (RFC3339)","type":"string"},"error_count":{"description":"Count of error activities","type":"integer"},"period":{"description":"Time period (1h, 24h, 7d, 30d)","type":"string"},"start_time":{"description":"Start of the period (RFC3339)","type":"string"},"success_count":{"description":"Count of successful activities","type":"integer"},"top_servers":{"description":"Top servers by activity count","items":{"$ref":"#/components/schemas/contracts.ActivityTopServer"},"type":"array","uniqueItems":false},"top_tools":{"description":"Top tools by activity count","items":{"$ref":"#/components/schemas/contracts.ActivityTopTool"},"type":"array","uniqueItems":false},"total_count":{"description":"Total activity count","type":"integer"}},"type":"object"},"contracts.ActivityTopServer":{"properties":{"count":{"description":"Activity count","type":"integer"},"name":{"description":"Server name","type":"string"}},"type":"object"},"contracts.ActivityTopTool":{"properties":{"count":{"description":"Activity count","type":"integer"},"server":{"description":"Server name","type":"string"},"tool":{"description":"Tool name","type":"string"}},"type":"object"},"contracts.ActivityType":{"description":"Type of activity","type":"string","x-enum-varnames":["ActivityTypeToolCall","ActivityTypePolicyDecision","ActivityTypeQuarantineChange","ActivityTypeServerChange"]},"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.DCRStatus":{"properties":{"attempted":{"type":"boolean"},"error":{"type":"string"},"status_code":{"type":"integer"},"success":{"type":"boolean"}},"type":"object"},"contracts.DeprecatedConfigWarning":{"properties":{"field":{"type":"string"},"message":{"type":"string"},"replacement":{"type":"string"}},"type":"object"},"contracts.Diagnostics":{"properties":{"deprecated_configs":{"description":"Deprecated config fields found","items":{"$ref":"#/components/schemas/contracts.DeprecatedConfigWarning"},"type":"array","uniqueItems":false},"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"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"},"contracts.GetConfigResponse":{"properties":{"config":{"description":"The configuration object","type":"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\", \"set_secret\", \"configure\", 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":{"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.MetadataStatus":{"properties":{"authorization_servers":{"items":{"type":"string"},"type":"array","uniqueItems":false},"error":{"type":"string"},"found":{"type":"boolean"},"url_checked":{"type":"string"}},"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.OAuthErrorDetails":{"description":"Structured discovery/failure details","properties":{"authorization_server_metadata":{"$ref":"#/components/schemas/contracts.MetadataStatus"},"dcr_status":{"$ref":"#/components/schemas/contracts.DCRStatus"},"protected_resource_metadata":{"$ref":"#/components/schemas/contracts.MetadataStatus"},"server_url":{"type":"string"}},"type":"object"},"contracts.OAuthFlowError":{"properties":{"correlation_id":{"description":"Flow tracking ID for log correlation","type":"string"},"debug_hint":{"description":"CLI command for log lookup","type":"string"},"details":{"$ref":"#/components/schemas/contracts.OAuthErrorDetails"},"error_code":{"description":"Machine-readable error code (e.g., OAUTH_NO_METADATA)","type":"string"},"error_type":{"description":"Category of OAuth runtime failure","type":"string"},"message":{"description":"Human-readable error description","type":"string"},"request_id":{"description":"HTTP request ID (from PR #237)","type":"string"},"server_name":{"description":"Server that failed OAuth","type":"string"},"success":{"description":"Always false","type":"boolean"},"suggestion":{"description":"Actionable remediation hint","type":"string"}},"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.OAuthStartResponse":{"properties":{"auth_url":{"description":"Authorization URL (always included for manual use)","type":"string"},"browser_error":{"description":"Error message if browser launch failed","type":"string"},"browser_opened":{"description":"Whether browser launch succeeded","type":"boolean"},"correlation_id":{"description":"UUID for tracking this flow","type":"string"},"message":{"description":"Human-readable status message","type":"string"},"server_name":{"description":"Name of the server being authenticated","type":"string"},"success":{"description":"Always true for successful start","type":"boolean"}},"type":"object"},"contracts.QuarantineStats":{"description":"Tool quarantine metrics for this server","properties":{"changed_count":{"description":"Number of tools whose description/schema changed since approval","type":"integer"},"pending_count":{"description":"Number of newly discovered tools awaiting approval","type":"integer"}},"type":"object"},"contracts.Registry":{"properties":{"count":{"description":"number or string","type":"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":{"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"},"quarantine":{"$ref":"#/components/schemas/contracts.QuarantineStats"},"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":{"type":"object"},"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":{"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":{"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)","type":"object"},"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"},"httpapi.AddServerRequest":{"properties":{"args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"command":{"type":"string"},"enabled":{"type":"boolean"},"env":{"additionalProperties":{"type":"string"},"type":"object"},"headers":{"additionalProperties":{"type":"string"},"type":"object"},"name":{"type":"string"},"protocol":{"type":"string"},"quarantined":{"type":"boolean"},"url":{"type":"string"},"working_dir":{"type":"string"}},"type":"object"},"httpapi.CanonicalConfigPath":{"properties":{"description":{"description":"Brief description","type":"string"},"exists":{"description":"Whether the file exists","type":"boolean"},"format":{"description":"Format identifier (e.g., \"claude_desktop\")","type":"string"},"name":{"description":"Display name (e.g., \"Claude Desktop\")","type":"string"},"os":{"description":"Operating system (darwin, windows, linux)","type":"string"},"path":{"description":"Full path to the config file","type":"string"}},"type":"object"},"httpapi.CanonicalConfigPathsResponse":{"properties":{"os":{"description":"Current operating system","type":"string"},"paths":{"description":"List of canonical config paths","items":{"$ref":"#/components/schemas/httpapi.CanonicalConfigPath"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.ImportFromPathRequest":{"properties":{"format":{"description":"Optional format hint","type":"string"},"path":{"description":"File path to import from","type":"string"},"server_names":{"description":"Optional: import only these servers","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.ImportRequest":{"properties":{"content":{"description":"Raw JSON or TOML content","type":"string"},"format":{"description":"Optional format hint","type":"string"},"server_names":{"description":"Optional: import only these servers","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.ImportResponse":{"properties":{"failed":{"items":{"$ref":"#/components/schemas/configimport.FailedServer"},"type":"array","uniqueItems":false},"format":{"type":"string"},"format_name":{"type":"string"},"imported":{"items":{"$ref":"#/components/schemas/httpapi.ImportedServerResponse"},"type":"array","uniqueItems":false},"skipped":{"items":{"$ref":"#/components/schemas/configimport.SkippedServer"},"type":"array","uniqueItems":false},"summary":{"$ref":"#/components/schemas/configimport.ImportSummary"},"warnings":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.ImportedServerResponse":{"properties":{"args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"command":{"type":"string"},"fields_skipped":{"items":{"type":"string"},"type":"array","uniqueItems":false},"name":{"type":"string"},"original_name":{"type":"string"},"protocol":{"type":"string"},"source_format":{"type":"string"},"url":{"type":"string"},"warnings":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"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"},"secureenv.EnvConfig":{"description":"Environment configuration for secure variable filtering","properties":{"allowed_system_vars":{"items":{"type":"string"},"type":"array","uniqueItems":false},"custom_vars":{"additionalProperties":{"type":"string"},"type":"object"},"enhance_path":{"description":"Enable PATH enhancement for Launchd scenarios","type":"boolean"},"inherit_system_safe":{"type":"boolean"}},"type":"object"},"telemetry.FeedbackContext":{"properties":{"arch":{"type":"string"},"connected_server_count":{"type":"integer"},"edition":{"type":"string"},"os":{"type":"string"},"routing_mode":{"type":"string"},"server_count":{"type":"integer"},"version":{"type":"string"}},"type":"object"},"telemetry.FeedbackRequest":{"properties":{"category":{"description":"bug, feature, other","type":"string"},"context":{"$ref":"#/components/schemas/telemetry.FeedbackContext"},"email":{"type":"string"},"message":{"type":"string"}},"type":"object"},"telemetry.FeedbackResponse":{"properties":{"error":{"type":"string"},"issue_url":{"type":"string"},"success":{"type":"boolean"}},"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":{"properties":{"activity_cleanup_interval_min":{"description":"Background cleanup interval in minutes (default: 60)","type":"integer"},"activity_max_records":{"description":"Max records before pruning (default: 100000)","type":"integer"},"activity_max_response_size":{"description":"Response truncation limit in bytes (default: 65536)","type":"integer"},"activity_retention_days":{"description":"Activity logging settings (RFC-003)","type":"integer"},"allow_server_add":{"type":"boolean"},"allow_server_remove":{"type":"boolean"},"api_key":{"description":"Security settings","type":"string"},"call_tool_timeout":{"type":"string"},"check_server_repo":{"description":"Repository detection settings","type":"boolean"},"code_execution_max_tool_calls":{"description":"Max tool calls per execution (0 = unlimited, default: 0)","type":"integer"},"code_execution_pool_size":{"description":"JavaScript runtime pool size (default: 10)","type":"integer"},"code_execution_timeout_ms":{"description":"Timeout in milliseconds (default: 120000, max: 600000)","type":"integer"},"data_dir":{"type":"string"},"debug_search":{"type":"boolean"},"disable_management":{"type":"boolean"},"docker_isolation":{"$ref":"#/components/schemas/config.DockerIsolationConfig"},"docker_recovery":{"$ref":"#/components/schemas/config.DockerRecoveryConfig"},"enable_code_execution":{"description":"Code execution settings","type":"boolean"},"enable_prompts":{"description":"Prompts settings","type":"boolean"},"enable_socket":{"description":"Enable Unix socket/named pipe for local IPC (default: true)","type":"boolean"},"enable_tray":{"description":"Deprecated: EnableTray is unused and has no runtime effect. Kept for backward compatibility.","type":"boolean"},"environment":{"$ref":"#/components/schemas/secureenv.EnvConfig"},"features":{"$ref":"#/components/schemas/config.FeatureFlags"},"intent_declaration":{"$ref":"#/components/schemas/config.IntentDeclarationConfig"},"listen":{"type":"string"},"logging":{"$ref":"#/components/schemas/config.LogConfig"},"mcpServers":{"items":{"$ref":"#/components/schemas/config.ServerConfig"},"type":"array","uniqueItems":false},"oauth_expiry_warning_hours":{"description":"Health status settings","type":"number"},"quarantine_enabled":{"description":"Tool-level quarantine settings (Spec 032)\nQuarantineEnabled controls whether tool-level quarantine is active.\nWhen nil (default), quarantine is enabled (secure by default).\nSet to explicit false to disable tool-level quarantine.","type":"boolean"},"read_only_mode":{"type":"boolean"},"registries":{"description":"Registries configuration for MCP server discovery","items":{"$ref":"#/components/schemas/config.RegistryEntry"},"type":"array","uniqueItems":false},"require_mcp_auth":{"description":"Require authentication on /mcp endpoint (default: false)","type":"boolean"},"routing_mode":{"description":"Routing mode (Spec 031): how MCP tools are exposed to clients\nValid values: \"retrieve_tools\" (default), \"direct\", \"code_execution\"","type":"string"},"sensitive_data_detection":{"$ref":"#/components/schemas/config.SensitiveDataDetectionConfig"},"telemetry":{"$ref":"#/components/schemas/config.TelemetryConfig"},"tls":{"$ref":"#/components/schemas/config.TLSConfig"},"tokenizer":{"$ref":"#/components/schemas/config.TokenizerConfig"},"tool_response_limit":{"type":"integer"},"tools_limit":{"type":"integer"},"top_k":{"description":"Deprecated: TopK is superseded by ToolsLimit and has no runtime effect. Kept for backward compatibility.","type":"integer"},"tray_endpoint":{"description":"Tray endpoint override (unix:// or npipe://)","type":"string"}},"type":"object"},"config.CustomPattern":{"properties":{"category":{"description":"Category (defaults to \"custom\")","type":"string"},"keywords":{"description":"Keywords to match (mutually exclusive with Regex)","items":{"type":"string"},"type":"array","uniqueItems":false},"name":{"description":"Unique identifier for this pattern","type":"string"},"regex":{"description":"Regex pattern (mutually exclusive with Keywords)","type":"string"},"severity":{"description":"Risk level: critical, high, medium, low","type":"string"}},"type":"object"},"config.DockerIsolationConfig":{"description":"Docker isolation settings","properties":{"cpu_limit":{"description":"CPU limit for containers","type":"string"},"default_images":{"additionalProperties":{"type":"string"},"description":"Map of runtime type to Docker image","type":"object"},"enabled":{"description":"Global enable/disable for Docker isolation","type":"boolean"},"extra_args":{"description":"Additional docker run arguments","items":{"type":"string"},"type":"array","uniqueItems":false},"log_driver":{"description":"Docker log driver (default: json-file)","type":"string"},"log_max_files":{"description":"Maximum number of log files (default: 3)","type":"string"},"log_max_size":{"description":"Maximum size of log files (default: 100m)","type":"string"},"memory_limit":{"description":"Memory limit for containers","type":"string"},"network_mode":{"description":"Docker network mode (default: bridge)","type":"string"},"registry":{"description":"Custom registry (defaults to docker.io)","type":"string"},"timeout":{"description":"Container startup timeout","type":"string"}},"type":"object"},"config.DockerRecoveryConfig":{"description":"Docker recovery settings","properties":{"enabled":{"description":"Enable Docker recovery monitoring (default: true)","type":"boolean"},"max_retries":{"description":"Maximum retry attempts (0 = unlimited)","type":"integer"},"notify_on_failure":{"description":"Show notification on recovery failure (default: true)","type":"boolean"},"notify_on_retry":{"description":"Show notification on each retry (default: false)","type":"boolean"},"notify_on_start":{"description":"Show notification when recovery starts (default: true)","type":"boolean"},"notify_on_success":{"description":"Show notification on successful recovery (default: true)","type":"boolean"},"persistent_state":{"description":"Save recovery state across restarts (default: true)","type":"boolean"}},"type":"object"},"config.FeatureFlags":{"description":"Deprecated: Features flags are unused and have no runtime effect. Kept for backward compatibility.","properties":{"enable_async_storage":{"type":"boolean"},"enable_caching":{"type":"boolean"},"enable_contract_tests":{"type":"boolean"},"enable_debug_logging":{"description":"Development features","type":"boolean"},"enable_docker_isolation":{"type":"boolean"},"enable_event_bus":{"type":"boolean"},"enable_health_checks":{"type":"boolean"},"enable_metrics":{"type":"boolean"},"enable_oauth":{"description":"Security features","type":"boolean"},"enable_observability":{"description":"Observability features","type":"boolean"},"enable_quarantine":{"type":"boolean"},"enable_runtime":{"description":"Runtime features","type":"boolean"},"enable_search":{"description":"Storage features","type":"boolean"},"enable_sse":{"type":"boolean"},"enable_tracing":{"type":"boolean"},"enable_tray":{"type":"boolean"},"enable_web_ui":{"description":"UI features","type":"boolean"}},"type":"object"},"config.IntentDeclarationConfig":{"description":"Intent declaration settings (Spec 018)","properties":{"strict_server_validation":{"description":"StrictServerValidation controls whether server annotation mismatches\ncause rejection (true) or just warnings (false).\nDefault: true (reject mismatches)","type":"boolean"}},"type":"object"},"config.IsolationConfig":{"description":"Per-server isolation settings","properties":{"enabled":{"description":"Enable Docker isolation for this server (nil = inherit global)","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.LogConfig":{"description":"Logging configuration","properties":{"compress":{"type":"boolean"},"enable_console":{"type":"boolean"},"enable_file":{"type":"boolean"},"filename":{"type":"string"},"json_format":{"type":"boolean"},"level":{"type":"string"},"log_dir":{"description":"Custom log directory","type":"string"},"max_age":{"description":"days","type":"integer"},"max_backups":{"description":"number of backup files","type":"integer"},"max_size":{"description":"MB","type":"integer"}},"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.RegistryEntry":{"properties":{"count":{"description":"number or string","type":"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"},"config.SensitiveDataDetectionConfig":{"description":"Sensitive data detection settings (Spec 026)","properties":{"categories":{"additionalProperties":{"type":"boolean"},"description":"Enable/disable specific detection categories","type":"object"},"custom_patterns":{"description":"User-defined detection patterns","items":{"$ref":"#/components/schemas/config.CustomPattern"},"type":"array","uniqueItems":false},"enabled":{"description":"Enable sensitive data detection (default: true)","type":"boolean"},"entropy_threshold":{"description":"Shannon entropy threshold for high-entropy detection (default: 4.5)","type":"number"},"max_payload_size_kb":{"description":"Max size to scan before truncating (default: 1024)","type":"integer"},"scan_requests":{"description":"Scan tool call arguments (default: true)","type":"boolean"},"scan_responses":{"description":"Scan tool responses (default: true)","type":"boolean"},"sensitive_keywords":{"description":"Keywords to flag","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"},"shared":{"description":"Server edition: shared with all users","type":"boolean"},"skip_quarantine":{"description":"Skip tool-level quarantine for this server","type":"boolean"},"updated":{"type":"string"},"url":{"type":"string"},"working_dir":{"description":"Working directory for stdio servers","type":"string"}},"type":"object"},"config.TLSConfig":{"description":"TLS configuration","properties":{"certs_dir":{"description":"Directory for certificates","type":"string"},"enabled":{"description":"Enable HTTPS","type":"boolean"},"hsts":{"description":"Enable HTTP Strict Transport Security","type":"boolean"},"require_client_cert":{"description":"Enable mTLS","type":"boolean"}},"type":"object"},"config.TelemetryConfig":{"description":"Telemetry settings (Spec 036)","properties":{"anonymous_id":{"description":"Auto-generated UUIDv4","type":"string"},"enabled":{"description":"Default: true (opt-out)","type":"boolean"},"endpoint":{"description":"Override for testing","type":"string"}},"type":"object"},"config.TokenizerConfig":{"description":"Tokenizer configuration for token counting","properties":{"default_model":{"description":"Default model for tokenization (e.g., \"gpt-4\")","type":"string"},"enabled":{"description":"Enable token counting","type":"boolean"},"encoding":{"description":"Default encoding (e.g., \"cl100k_base\")","type":"string"}},"type":"object"},"configimport.FailedServer":{"properties":{"details":{"type":"string"},"error":{"type":"string"},"name":{"type":"string"}},"type":"object"},"configimport.ImportSummary":{"properties":{"failed":{"type":"integer"},"imported":{"type":"integer"},"skipped":{"type":"integer"},"total":{"type":"integer"}},"type":"object"},"configimport.SkippedServer":{"properties":{"name":{"type":"string"},"reason":{"description":"\"already_exists\", \"filtered_out\", \"invalid_name\"","type":"string"}},"type":"object"},"contracts.APIResponse":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"},"contracts.ActivityDetailResponse":{"properties":{"activity":{"$ref":"#/components/schemas/contracts.ActivityRecord"}},"type":"object"},"contracts.ActivityListResponse":{"properties":{"activities":{"items":{"$ref":"#/components/schemas/contracts.ActivityRecord"},"type":"array","uniqueItems":false},"limit":{"type":"integer"},"offset":{"type":"integer"},"total":{"type":"integer"}},"type":"object"},"contracts.ActivityRecord":{"properties":{"arguments":{"description":"Tool call arguments","type":"object"},"detection_types":{"description":"List of detection types found","items":{"type":"string"},"type":"array","uniqueItems":false},"duration_ms":{"description":"Execution duration in milliseconds","type":"integer"},"error_message":{"description":"Error details if status is \"error\"","type":"string"},"has_sensitive_data":{"description":"Sensitive data detection fields (Spec 026)","type":"boolean"},"id":{"description":"Unique identifier (ULID format)","type":"string"},"max_severity":{"description":"Highest severity level detected (critical, high, medium, low)","type":"string"},"metadata":{"description":"Additional context-specific data","type":"object"},"request_id":{"description":"HTTP request ID for correlation","type":"string"},"response":{"description":"Tool response (potentially truncated)","type":"string"},"response_truncated":{"description":"True if response was truncated","type":"boolean"},"server_name":{"description":"Name of upstream MCP server","type":"string"},"session_id":{"description":"MCP session ID for correlation","type":"string"},"source":{"$ref":"#/components/schemas/contracts.ActivitySource"},"status":{"description":"Result status: \"success\", \"error\", \"blocked\"","type":"string"},"timestamp":{"description":"When activity occurred","type":"string"},"tool_name":{"description":"Name of tool called","type":"string"},"type":{"$ref":"#/components/schemas/contracts.ActivityType"}},"type":"object"},"contracts.ActivitySource":{"description":"How activity was triggered: \"mcp\", \"cli\", \"api\"","type":"string","x-enum-varnames":["ActivitySourceMCP","ActivitySourceCLI","ActivitySourceAPI"]},"contracts.ActivitySummaryResponse":{"properties":{"blocked_count":{"description":"Count of blocked activities","type":"integer"},"end_time":{"description":"End of the period (RFC3339)","type":"string"},"error_count":{"description":"Count of error activities","type":"integer"},"period":{"description":"Time period (1h, 24h, 7d, 30d)","type":"string"},"start_time":{"description":"Start of the period (RFC3339)","type":"string"},"success_count":{"description":"Count of successful activities","type":"integer"},"top_servers":{"description":"Top servers by activity count","items":{"$ref":"#/components/schemas/contracts.ActivityTopServer"},"type":"array","uniqueItems":false},"top_tools":{"description":"Top tools by activity count","items":{"$ref":"#/components/schemas/contracts.ActivityTopTool"},"type":"array","uniqueItems":false},"total_count":{"description":"Total activity count","type":"integer"}},"type":"object"},"contracts.ActivityTopServer":{"properties":{"count":{"description":"Activity count","type":"integer"},"name":{"description":"Server name","type":"string"}},"type":"object"},"contracts.ActivityTopTool":{"properties":{"count":{"description":"Activity count","type":"integer"},"server":{"description":"Server name","type":"string"},"tool":{"description":"Tool name","type":"string"}},"type":"object"},"contracts.ActivityType":{"description":"Type of activity","type":"string","x-enum-varnames":["ActivityTypeToolCall","ActivityTypePolicyDecision","ActivityTypeQuarantineChange","ActivityTypeServerChange"]},"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.DCRStatus":{"properties":{"attempted":{"type":"boolean"},"error":{"type":"string"},"status_code":{"type":"integer"},"success":{"type":"boolean"}},"type":"object"},"contracts.DeprecatedConfigWarning":{"properties":{"field":{"type":"string"},"message":{"type":"string"},"replacement":{"type":"string"}},"type":"object"},"contracts.Diagnostics":{"properties":{"deprecated_configs":{"description":"Deprecated config fields found","items":{"$ref":"#/components/schemas/contracts.DeprecatedConfigWarning"},"type":"array","uniqueItems":false},"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"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"},"contracts.GetConfigResponse":{"properties":{"config":{"description":"The configuration object","type":"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\", \"set_secret\", \"configure\", 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":{"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.MetadataStatus":{"properties":{"authorization_servers":{"items":{"type":"string"},"type":"array","uniqueItems":false},"error":{"type":"string"},"found":{"type":"boolean"},"url_checked":{"type":"string"}},"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.OAuthErrorDetails":{"description":"Structured discovery/failure details","properties":{"authorization_server_metadata":{"$ref":"#/components/schemas/contracts.MetadataStatus"},"dcr_status":{"$ref":"#/components/schemas/contracts.DCRStatus"},"protected_resource_metadata":{"$ref":"#/components/schemas/contracts.MetadataStatus"},"server_url":{"type":"string"}},"type":"object"},"contracts.OAuthFlowError":{"properties":{"correlation_id":{"description":"Flow tracking ID for log correlation","type":"string"},"debug_hint":{"description":"CLI command for log lookup","type":"string"},"details":{"$ref":"#/components/schemas/contracts.OAuthErrorDetails"},"error_code":{"description":"Machine-readable error code (e.g., OAUTH_NO_METADATA)","type":"string"},"error_type":{"description":"Category of OAuth runtime failure","type":"string"},"message":{"description":"Human-readable error description","type":"string"},"request_id":{"description":"HTTP request ID (from PR #237)","type":"string"},"server_name":{"description":"Server that failed OAuth","type":"string"},"success":{"description":"Always false","type":"boolean"},"suggestion":{"description":"Actionable remediation hint","type":"string"}},"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.OAuthStartResponse":{"properties":{"auth_url":{"description":"Authorization URL (always included for manual use)","type":"string"},"browser_error":{"description":"Error message if browser launch failed","type":"string"},"browser_opened":{"description":"Whether browser launch succeeded","type":"boolean"},"correlation_id":{"description":"UUID for tracking this flow","type":"string"},"message":{"description":"Human-readable status message","type":"string"},"server_name":{"description":"Name of the server being authenticated","type":"string"},"success":{"description":"Always true for successful start","type":"boolean"}},"type":"object"},"contracts.QuarantineStats":{"description":"Tool quarantine metrics for this server","properties":{"changed_count":{"description":"Number of tools whose description/schema changed since approval","type":"integer"},"pending_count":{"description":"Number of newly discovered tools awaiting approval","type":"integer"}},"type":"object"},"contracts.Registry":{"properties":{"count":{"description":"number or string","type":"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":{"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"},"quarantine":{"$ref":"#/components/schemas/contracts.QuarantineStats"},"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":{"type":"object"},"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":{"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":{"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)","type":"object"},"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"},"httpapi.AddServerRequest":{"properties":{"args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"command":{"type":"string"},"enabled":{"type":"boolean"},"env":{"additionalProperties":{"type":"string"},"type":"object"},"headers":{"additionalProperties":{"type":"string"},"type":"object"},"name":{"type":"string"},"protocol":{"type":"string"},"quarantined":{"type":"boolean"},"url":{"type":"string"},"working_dir":{"type":"string"}},"type":"object"},"httpapi.CanonicalConfigPath":{"properties":{"description":{"description":"Brief description","type":"string"},"exists":{"description":"Whether the file exists","type":"boolean"},"format":{"description":"Format identifier (e.g., \"claude_desktop\")","type":"string"},"name":{"description":"Display name (e.g., \"Claude Desktop\")","type":"string"},"os":{"description":"Operating system (darwin, windows, linux)","type":"string"},"path":{"description":"Full path to the config file","type":"string"}},"type":"object"},"httpapi.CanonicalConfigPathsResponse":{"properties":{"os":{"description":"Current operating system","type":"string"},"paths":{"description":"List of canonical config paths","items":{"$ref":"#/components/schemas/httpapi.CanonicalConfigPath"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.ConnectRequest":{"properties":{"force":{"description":"Overwrite existing entry","type":"boolean"},"server_name":{"description":"Defaults to \"mcpproxy\"","type":"string"}},"type":"object"},"httpapi.ImportFromPathRequest":{"properties":{"format":{"description":"Optional format hint","type":"string"},"path":{"description":"File path to import from","type":"string"},"server_names":{"description":"Optional: import only these servers","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.ImportRequest":{"properties":{"content":{"description":"Raw JSON or TOML content","type":"string"},"format":{"description":"Optional format hint","type":"string"},"server_names":{"description":"Optional: import only these servers","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.ImportResponse":{"properties":{"failed":{"items":{"$ref":"#/components/schemas/configimport.FailedServer"},"type":"array","uniqueItems":false},"format":{"type":"string"},"format_name":{"type":"string"},"imported":{"items":{"$ref":"#/components/schemas/httpapi.ImportedServerResponse"},"type":"array","uniqueItems":false},"skipped":{"items":{"$ref":"#/components/schemas/configimport.SkippedServer"},"type":"array","uniqueItems":false},"summary":{"$ref":"#/components/schemas/configimport.ImportSummary"},"warnings":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.ImportedServerResponse":{"properties":{"args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"command":{"type":"string"},"fields_skipped":{"items":{"type":"string"},"type":"array","uniqueItems":false},"name":{"type":"string"},"original_name":{"type":"string"},"protocol":{"type":"string"},"source_format":{"type":"string"},"url":{"type":"string"},"warnings":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"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"},"secureenv.EnvConfig":{"description":"Environment configuration for secure variable filtering","properties":{"allowed_system_vars":{"items":{"type":"string"},"type":"array","uniqueItems":false},"custom_vars":{"additionalProperties":{"type":"string"},"type":"object"},"enhance_path":{"description":"Enable PATH enhancement for Launchd scenarios","type":"boolean"},"inherit_system_safe":{"type":"boolean"}},"type":"object"},"telemetry.FeedbackContext":{"properties":{"arch":{"type":"string"},"connected_server_count":{"type":"integer"},"edition":{"type":"string"},"os":{"type":"string"},"routing_mode":{"type":"string"},"server_count":{"type":"integer"},"version":{"type":"string"}},"type":"object"},"telemetry.FeedbackRequest":{"properties":{"category":{"description":"bug, feature, other","type":"string"},"context":{"$ref":"#/components/schemas/telemetry.FeedbackContext"},"email":{"type":"string"},"message":{"type":"string"}},"type":"object"},"telemetry.FeedbackResponse":{"properties":{"error":{"type":"string"},"issue_url":{"type":"string"},"success":{"type":"boolean"}},"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/activity":{"get":{"description":"Returns paginated list of activity records with optional filtering","parameters":[{"description":"Filter by activity type(s), comma-separated for multiple (Spec 024)","in":"query","name":"type","schema":{"enum":["tool_call","policy_decision","quarantine_change","server_change","system_start","system_stop","internal_tool_call","config_change"],"type":"string"}},{"description":"Filter by server name","in":"query","name":"server","schema":{"type":"string"}},{"description":"Filter by tool name","in":"query","name":"tool","schema":{"type":"string"}},{"description":"Filter by MCP session ID","in":"query","name":"session_id","schema":{"type":"string"}},{"description":"Filter by status","in":"query","name":"status","schema":{"enum":["success","error","blocked"],"type":"string"}},{"description":"Filter by intent operation type (Spec 018)","in":"query","name":"intent_type","schema":{"enum":["read","write","destructive"],"type":"string"}},{"description":"Filter by HTTP request ID for log correlation (Spec 021)","in":"query","name":"request_id","schema":{"type":"string"}},{"description":"Include successful call_tool_* internal tool calls (default: false, excluded to avoid duplicates)","in":"query","name":"include_call_tool","schema":{"type":"boolean"}},{"description":"Filter by sensitive data detection (true=has detections, false=no detections)","in":"query","name":"sensitive_data","schema":{"type":"boolean"}},{"description":"Filter by specific detection type (e.g., 'aws_access_key', 'credit_card')","in":"query","name":"detection_type","schema":{"type":"string"}},{"description":"Filter by severity level","in":"query","name":"severity","schema":{"enum":["critical","high","medium","low"],"type":"string"}},{"description":"Filter by agent token name (Spec 028)","in":"query","name":"agent","schema":{"type":"string"}},{"description":"Filter by auth type (Spec 028)","in":"query","name":"auth_type","schema":{"enum":["admin","agent"],"type":"string"}},{"description":"Filter activities after this time (RFC3339)","in":"query","name":"start_time","schema":{"type":"string"}},{"description":"Filter activities before this time (RFC3339)","in":"query","name":"end_time","schema":{"type":"string"}},{"description":"Maximum records to return (1-100, default 50)","in":"query","name":"limit","schema":{"type":"integer"}},{"description":"Pagination offset (default 0)","in":"query","name":"offset","schema":{"type":"integer"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Unauthorized"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Internal Server Error"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"List activity records","tags":["Activity"]}},"/api/v1/activity/export":{"get":{"description":"Exports activity records in JSON Lines or CSV format for compliance","parameters":[{"description":"Export format: json (default) or csv","in":"query","name":"format","schema":{"type":"string"}},{"description":"Filter by activity type","in":"query","name":"type","schema":{"type":"string"}},{"description":"Filter by server name","in":"query","name":"server","schema":{"type":"string"}},{"description":"Filter by tool name","in":"query","name":"tool","schema":{"type":"string"}},{"description":"Filter by MCP session ID","in":"query","name":"session_id","schema":{"type":"string"}},{"description":"Filter by status","in":"query","name":"status","schema":{"type":"string"}},{"description":"Filter activities after this time (RFC3339)","in":"query","name":"start_time","schema":{"type":"string"}},{"description":"Filter activities before this time (RFC3339)","in":"query","name":"end_time","schema":{"type":"string"}},{"description":"Maximum records to export (1-50000, default 10000)","in":"query","name":"limit","schema":{"type":"integer"}},{"description":"Pagination offset (default 0)","in":"query","name":"offset","schema":{"type":"integer"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"type":"string"}},"application/x-ndjson":{"schema":{"type":"string"}},"text/csv":{"schema":{"type":"string"}}},"description":"Streamed activity records"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Unauthorized"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Internal Server Error"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"Export activity records","tags":["Activity"]}},"/api/v1/activity/summary":{"get":{"description":"Returns aggregated activity statistics for a time period","parameters":[{"description":"Time period: 1h, 24h (default), 7d, 30d","in":"query","name":"period","schema":{"type":"string"}},{"description":"Group by: server, tool (optional)","in":"query","name":"group_by","schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Unauthorized"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Internal Server Error"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"Get activity summary statistics","tags":["Activity"]}},"/api/v1/activity/{id}":{"get":{"description":"Returns full details for a single activity record","parameters":[{"description":"Activity record ID (ULID)","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"OK"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Unauthorized"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Not Found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Internal Server Error"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"Get activity record details","tags":["Activity"]}},"/api/v1/annotations/coverage":{"get":{"description":"Reports how many upstream tools have MCP annotations vs don't, broken down by server","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Annotation coverage report"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get annotation coverage report","tags":["annotations"]}},"/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/feedback":{"post":{"description":"Submit a bug report, feature request, or general feedback","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/telemetry.FeedbackRequest"}}},"description":"Feedback request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/telemetry.FeedbackResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"additionalProperties":{"type":"string"},"type":"object"}}},"description":"Bad Request"},"429":{"content":{"application/json":{"schema":{"additionalProperties":{"type":"string"},"type":"object"}}},"description":"Too Many Requests"},"500":{"content":{"application/json":{"schema":{"additionalProperties":{"type":"string"},"type":"object"}}},"description":"Internal Server Error"}},"security":[{"ApiKeyAuth":[]}],"summary":"Submit feedback","tags":["feedback"]}},"/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\nUse refresh=true query parameter to force an immediate update check against GitHub","parameters":[{"description":"Force immediate update check against GitHub","in":"query","name":"refresh","schema":{"type":"boolean"}}],"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"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/routing":{"get":{"description":"Get the current routing mode and available MCP endpoints","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Routing mode information"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get routing mode information","tags":["status"]}},"/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"]},"post":{"description":"Add a new MCP upstream server to the configuration. New servers are quarantined by default for security.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.AddServerRequest"}}},"description":"Server configuration","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server added successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request - invalid configuration"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Conflict - server with this name already exists"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Add a new upstream server","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/import":{"post":{"description":"Import MCP server configurations from a Claude Desktop, Claude Code, Cursor IDE, Codex CLI, or Gemini CLI configuration file","parameters":[{"description":"If true, return preview without importing","in":"query","name":"preview","schema":{"type":"boolean"}},{"description":"Force format (claude-desktop, claude-code, cursor, codex, gemini)","in":"query","name":"format","schema":{"type":"string"}},{"description":"Comma-separated list of server names to import","in":"query","name":"server_names","schema":{"type":"string"}}],"requestBody":{"content":{"multipart/form-data":{"schema":{"type":"file"}}},"description":"Configuration file to import","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ImportResponse"}}},"description":"Import result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request - invalid file or format"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Import servers from uploaded configuration file","tags":["servers"]}},"/api/v1/servers/import/json":{"post":{"description":"Import MCP server configurations from raw JSON or TOML content (useful for pasting configurations)","parameters":[{"description":"If true, return preview without importing","in":"query","name":"preview","schema":{"type":"boolean"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ImportRequest"}}},"description":"Import request with content","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ImportResponse"}}},"description":"Import result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request - invalid content or format"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Import servers from JSON/TOML content","tags":["servers"]}},"/api/v1/servers/import/path":{"post":{"description":"Import MCP server configurations by reading a file from the server's filesystem","parameters":[{"description":"If true, return preview without importing","in":"query","name":"preview","schema":{"type":"boolean"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ImportFromPathRequest"}}},"description":"Import request with file path","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ImportResponse"}}},"description":"Import result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request - invalid path or format"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"File not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Import servers from a file path","tags":["servers"]}},"/api/v1/servers/import/paths":{"get":{"description":"Returns well-known configuration file paths for supported formats with existence check","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.CanonicalConfigPathsResponse"}}},"description":"Canonical config paths"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get canonical config file paths","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}":{"delete":{"description":"Remove an MCP upstream server from the configuration. This stops the server if running and removes it from config.","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 removed 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":"Remove an upstream server","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}/discover-tools":{"post":{"description":"Manually trigger tool discovery and indexing for a specific upstream MCP server. This forces an immediate refresh of the server's tool cache.","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":"Tool discovery triggered 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":"Failed to discover tools"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Discover tools for a specific 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. Returns structured OAuth start response with correlation ID for tracking.","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.OAuthStartResponse"}}},"description":"OAuth login initiated successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.OAuthFlowError"}}},"description":"OAuth error (client_id required, DCR failed, etc.)"},"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"]}}}, + "paths": {"/api/v1/activity":{"get":{"description":"Returns paginated list of activity records with optional filtering","parameters":[{"description":"Filter by activity type(s), comma-separated for multiple (Spec 024)","in":"query","name":"type","schema":{"enum":["tool_call","policy_decision","quarantine_change","server_change","system_start","system_stop","internal_tool_call","config_change"],"type":"string"}},{"description":"Filter by server name","in":"query","name":"server","schema":{"type":"string"}},{"description":"Filter by tool name","in":"query","name":"tool","schema":{"type":"string"}},{"description":"Filter by MCP session ID","in":"query","name":"session_id","schema":{"type":"string"}},{"description":"Filter by status","in":"query","name":"status","schema":{"enum":["success","error","blocked"],"type":"string"}},{"description":"Filter by intent operation type (Spec 018)","in":"query","name":"intent_type","schema":{"enum":["read","write","destructive"],"type":"string"}},{"description":"Filter by HTTP request ID for log correlation (Spec 021)","in":"query","name":"request_id","schema":{"type":"string"}},{"description":"Include successful call_tool_* internal tool calls (default: false, excluded to avoid duplicates)","in":"query","name":"include_call_tool","schema":{"type":"boolean"}},{"description":"Filter by sensitive data detection (true=has detections, false=no detections)","in":"query","name":"sensitive_data","schema":{"type":"boolean"}},{"description":"Filter by specific detection type (e.g., 'aws_access_key', 'credit_card')","in":"query","name":"detection_type","schema":{"type":"string"}},{"description":"Filter by severity level","in":"query","name":"severity","schema":{"enum":["critical","high","medium","low"],"type":"string"}},{"description":"Filter by agent token name (Spec 028)","in":"query","name":"agent","schema":{"type":"string"}},{"description":"Filter by auth type (Spec 028)","in":"query","name":"auth_type","schema":{"enum":["admin","agent"],"type":"string"}},{"description":"Filter activities after this time (RFC3339)","in":"query","name":"start_time","schema":{"type":"string"}},{"description":"Filter activities before this time (RFC3339)","in":"query","name":"end_time","schema":{"type":"string"}},{"description":"Maximum records to return (1-100, default 50)","in":"query","name":"limit","schema":{"type":"integer"}},{"description":"Pagination offset (default 0)","in":"query","name":"offset","schema":{"type":"integer"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Unauthorized"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Internal Server Error"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"List activity records","tags":["Activity"]}},"/api/v1/activity/export":{"get":{"description":"Exports activity records in JSON Lines or CSV format for compliance","parameters":[{"description":"Export format: json (default) or csv","in":"query","name":"format","schema":{"type":"string"}},{"description":"Filter by activity type","in":"query","name":"type","schema":{"type":"string"}},{"description":"Filter by server name","in":"query","name":"server","schema":{"type":"string"}},{"description":"Filter by tool name","in":"query","name":"tool","schema":{"type":"string"}},{"description":"Filter by MCP session ID","in":"query","name":"session_id","schema":{"type":"string"}},{"description":"Filter by status","in":"query","name":"status","schema":{"type":"string"}},{"description":"Filter activities after this time (RFC3339)","in":"query","name":"start_time","schema":{"type":"string"}},{"description":"Filter activities before this time (RFC3339)","in":"query","name":"end_time","schema":{"type":"string"}},{"description":"Maximum records to export (1-50000, default 10000)","in":"query","name":"limit","schema":{"type":"integer"}},{"description":"Pagination offset (default 0)","in":"query","name":"offset","schema":{"type":"integer"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"type":"string"}},"application/x-ndjson":{"schema":{"type":"string"}},"text/csv":{"schema":{"type":"string"}}},"description":"Streamed activity records"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Unauthorized"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Internal Server Error"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"Export activity records","tags":["Activity"]}},"/api/v1/activity/summary":{"get":{"description":"Returns aggregated activity statistics for a time period","parameters":[{"description":"Time period: 1h, 24h (default), 7d, 30d","in":"query","name":"period","schema":{"type":"string"}},{"description":"Group by: server, tool (optional)","in":"query","name":"group_by","schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Unauthorized"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Internal Server Error"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"Get activity summary statistics","tags":["Activity"]}},"/api/v1/activity/{id}":{"get":{"description":"Returns full details for a single activity record","parameters":[{"description":"Activity record ID (ULID)","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"OK"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Unauthorized"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Not Found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Internal Server Error"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"Get activity record details","tags":["Activity"]}},"/api/v1/annotations/coverage":{"get":{"description":"Reports how many upstream tools have MCP annotations vs don't, broken down by server","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Annotation coverage report"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get annotation coverage report","tags":["annotations"]}},"/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/connect":{"get":{"description":"Returns the connection status for all known MCP client applications.\nEach entry indicates whether the client config file exists and whether\nMCPProxy is currently registered in it.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"List of ClientStatus objects"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"List client connection status","tags":["connect"]}},"/api/v1/connect/{client}":{"delete":{"description":"Remove the MCPProxy entry from the specified client's configuration file.\nCreates a backup of the existing config before modifying.","parameters":[{"description":"Client ID (claude-code, cursor, windsurf, vscode, codex, gemini)","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ConnectRequest"}}},"description":"Optional parameters (server_name)"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"ConnectResult"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unknown client or entry not found"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Disconnect MCPProxy from a client","tags":["connect"]},"post":{"description":"Register MCPProxy as an MCP server in the specified client's configuration file.\nCreates a backup of the existing config before modifying.","parameters":[{"description":"Client ID (claude-code, cursor, windsurf, vscode, codex, gemini)","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ConnectRequest"}}},"description":"Optional connection parameters"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"ConnectResult"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unknown client"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Already connected (use force=true)"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Connect MCPProxy to a client","tags":["connect"]}},"/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/feedback":{"post":{"description":"Submit a bug report, feature request, or general feedback","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/telemetry.FeedbackRequest"}}},"description":"Feedback request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/telemetry.FeedbackResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"additionalProperties":{"type":"string"},"type":"object"}}},"description":"Bad Request"},"429":{"content":{"application/json":{"schema":{"additionalProperties":{"type":"string"},"type":"object"}}},"description":"Too Many Requests"},"500":{"content":{"application/json":{"schema":{"additionalProperties":{"type":"string"},"type":"object"}}},"description":"Internal Server Error"}},"security":[{"ApiKeyAuth":[]}],"summary":"Submit feedback","tags":["feedback"]}},"/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\nUse refresh=true query parameter to force an immediate update check against GitHub","parameters":[{"description":"Force immediate update check against GitHub","in":"query","name":"refresh","schema":{"type":"boolean"}}],"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"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/routing":{"get":{"description":"Get the current routing mode and available MCP endpoints","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Routing mode information"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get routing mode information","tags":["status"]}},"/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"]},"post":{"description":"Add a new MCP upstream server to the configuration. New servers are quarantined by default for security.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.AddServerRequest"}}},"description":"Server configuration","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server added successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request - invalid configuration"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Conflict - server with this name already exists"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Add a new upstream server","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/import":{"post":{"description":"Import MCP server configurations from a Claude Desktop, Claude Code, Cursor IDE, Codex CLI, or Gemini CLI configuration file","parameters":[{"description":"If true, return preview without importing","in":"query","name":"preview","schema":{"type":"boolean"}},{"description":"Force format (claude-desktop, claude-code, cursor, codex, gemini)","in":"query","name":"format","schema":{"type":"string"}},{"description":"Comma-separated list of server names to import","in":"query","name":"server_names","schema":{"type":"string"}}],"requestBody":{"content":{"multipart/form-data":{"schema":{"type":"file"}}},"description":"Configuration file to import","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ImportResponse"}}},"description":"Import result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request - invalid file or format"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Import servers from uploaded configuration file","tags":["servers"]}},"/api/v1/servers/import/json":{"post":{"description":"Import MCP server configurations from raw JSON or TOML content (useful for pasting configurations)","parameters":[{"description":"If true, return preview without importing","in":"query","name":"preview","schema":{"type":"boolean"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ImportRequest"}}},"description":"Import request with content","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ImportResponse"}}},"description":"Import result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request - invalid content or format"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Import servers from JSON/TOML content","tags":["servers"]}},"/api/v1/servers/import/path":{"post":{"description":"Import MCP server configurations by reading a file from the server's filesystem","parameters":[{"description":"If true, return preview without importing","in":"query","name":"preview","schema":{"type":"boolean"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ImportFromPathRequest"}}},"description":"Import request with file path","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ImportResponse"}}},"description":"Import result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request - invalid path or format"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"File not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Import servers from a file path","tags":["servers"]}},"/api/v1/servers/import/paths":{"get":{"description":"Returns well-known configuration file paths for supported formats with existence check","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.CanonicalConfigPathsResponse"}}},"description":"Canonical config paths"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get canonical config file paths","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}":{"delete":{"description":"Remove an MCP upstream server from the configuration. This stops the server if running and removes it from config.","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 removed 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":"Remove an upstream server","tags":["servers"]},"patch":{"description":"Update specific fields of an existing upstream MCP server configuration.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.AddServerRequest"}}},"description":"Fields to update (all optional)","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Server updated successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request - no fields or invalid body"},"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":"Partially update an upstream server","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}/discover-tools":{"post":{"description":"Manually trigger tool discovery and indexing for a specific upstream MCP server. This forces an immediate refresh of the server's tool cache.","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":"Tool discovery triggered 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":"Failed to discover tools"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Discover tools for a specific 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. Returns structured OAuth start response with correlation ID for tracking.","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.OAuthStartResponse"}}},"description":"OAuth login initiated successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.OAuthFlowError"}}},"description":"OAuth error (client_id required, DCR failed, etc.)"},"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"]}}}, "openapi": "3.1.0" }` diff --git a/oas/swagger.yaml b/oas/swagger.yaml index 0ddf83c59..f09b7a3da 100644 --- a/oas/swagger.yaml +++ b/oas/swagger.yaml @@ -1648,6 +1648,15 @@ components: type: array uniqueItems: false type: object + httpapi.ConnectRequest: + properties: + force: + description: Overwrite existing entry + type: boolean + server_name: + description: Defaults to "mcpproxy" + type: string + type: object httpapi.ImportFromPathRequest: properties: format: @@ -2378,6 +2387,128 @@ paths: summary: Validate configuration tags: - config + /api/v1/connect: + get: + description: |- + Returns the connection status for all known MCP client applications. + Each entry indicates whether the client config file exists and whether + MCPProxy is currently registered in it. + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/contracts.APIResponse' + description: List of ClientStatus objects + security: + - ApiKeyAuth: [] + - ApiKeyQuery: [] + summary: List client connection status + tags: + - connect + /api/v1/connect/{client}: + delete: + description: |- + Remove the MCPProxy entry from the specified client's configuration file. + Creates a backup of the existing config before modifying. + parameters: + - description: Client ID (claude-code, cursor, windsurf, vscode, codex, gemini) + in: path + name: client + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/httpapi.ConnectRequest' + description: Optional parameters (server_name) + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/contracts.APIResponse' + description: ConnectResult + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/contracts.ErrorResponse' + description: Bad request + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/contracts.ErrorResponse' + description: Unknown client or entry not found + "503": + content: + application/json: + schema: + $ref: '#/components/schemas/contracts.ErrorResponse' + description: Service unavailable + security: + - ApiKeyAuth: [] + - ApiKeyQuery: [] + summary: Disconnect MCPProxy from a client + tags: + - connect + post: + description: |- + Register MCPProxy as an MCP server in the specified client's configuration file. + Creates a backup of the existing config before modifying. + parameters: + - description: Client ID (claude-code, cursor, windsurf, vscode, codex, gemini) + in: path + name: client + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/httpapi.ConnectRequest' + description: Optional connection parameters + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/contracts.APIResponse' + description: ConnectResult + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/contracts.ErrorResponse' + description: Bad request + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/contracts.ErrorResponse' + description: Unknown client + "409": + content: + application/json: + schema: + $ref: '#/components/schemas/contracts.ErrorResponse' + description: Already connected (use force=true) + "503": + content: + application/json: + schema: + $ref: '#/components/schemas/contracts.ErrorResponse' + description: Service unavailable + security: + - ApiKeyAuth: [] + - ApiKeyQuery: [] + summary: Connect MCPProxy to a client + tags: + - connect /api/v1/diagnostics: get: description: Get comprehensive health diagnostics including upstream errors, @@ -2890,6 +3021,53 @@ paths: summary: Remove an upstream server tags: - servers + patch: + description: Update specific fields of an existing upstream MCP server configuration. + parameters: + - description: Server ID or name + in: path + name: id + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/httpapi.AddServerRequest' + description: Fields to update (all optional) + required: true + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/contracts.SuccessResponse' + description: Server updated successfully + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/contracts.ErrorResponse' + description: Bad request - no fields or invalid body + "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: Partially update an upstream server + tags: + - servers /api/v1/servers/{id}/disable: post: description: Disable a specific upstream MCP server diff --git a/specs/039-connect-and-dashboard/autonomous_summary.md b/specs/039-connect-and-dashboard/autonomous_summary.md new file mode 100644 index 000000000..4addf1fa4 --- /dev/null +++ b/specs/039-connect-and-dashboard/autonomous_summary.md @@ -0,0 +1,102 @@ +# Autonomous Implementation Summary + +**Feature**: 039 — Connect Clients & Dashboard Visual Redesign +**Date**: 2026-03-28 +**Status**: Complete + +## What Was Built + +### Feature 1: Connect Clients (Backend + Frontend + CLI) + +**Purpose**: Allow MCPProxy to register itself as an MCP server in AI client configuration files. + +**Backend** (`internal/connect/`): +- `clients.go` — 7 client definitions (Claude Code, Claude Desktop, Cursor, Windsurf, VS Code, Codex CLI, Gemini CLI) with OS-specific config paths +- `connect.go` — Core `Service` with `Connect()`, `Disconnect()`, `GetAllStatus()` supporting JSON and TOML formats +- `backup.go` — Timestamped backup and atomic write utilities +- `connect_test.go` — 32 unit tests covering all clients, formats, edge cases + +**REST API** (`internal/httpapi/connect.go`): +- `GET /api/v1/connect` — List all client statuses (config exists, mcpproxy registered) +- `POST /api/v1/connect/{client}` — Register MCPProxy in client config (with backup) +- `DELETE /api/v1/connect/{client}` — Remove MCPProxy from client config (with backup) + +**CLI** (`cmd/mcpproxy/connect_cmd.go`): +- `mcpproxy connect ` — Connect to specific client +- `mcpproxy connect --list` — Show status table +- `mcpproxy connect --all` — Connect all supported clients +- `mcpproxy disconnect ` — Remove from client + +**Safety**: Every config modification creates a timestamped backup (`.bak.YYYYMMDD-HHMMSS`), uses atomic writes (temp file + rename), and verifies the result after writing. + +### Feature 2: Dashboard Visual Redesign + +**Purpose**: Replace the data-table dashboard with a visual hub showing MCPProxy as a central node connecting AI clients to upstream servers. + +**Layout** (3-column grid): +- **Left**: AI Clients panel — shows detected clients with connect status (green/gray dots) +- **Center**: MCPProxy hub diamond with status, uptime, token savings badge, activity/session stats +- **Right**: Upstream servers stats (connected count, tools, quarantine), action buttons +- **Bottom**: Collapsible token savings detail with pie chart + +**New components**: +- `ConnectModal.vue` — Modal with per-client connect/disconnect buttons, "Connect All" +- Updated `Dashboard.vue` — Complete rewrite with hub visualization, SVG connection lines, CSS glow animation +- Added API methods and TypeScript types to `api.ts` and `types/api.ts` + +## Files Changed + +### New Files +| File | Purpose | +|------|---------| +| `internal/connect/clients.go` | Client definitions and config paths | +| `internal/connect/connect.go` | Core connect/disconnect logic | +| `internal/connect/backup.go` | Backup and atomic write | +| `internal/connect/connect_test.go` | 32 unit tests | +| `internal/httpapi/connect.go` | REST API handlers | +| `cmd/mcpproxy/connect_cmd.go` | CLI commands | +| `frontend/src/components/ConnectModal.vue` | Connect modal UI | +| `specs/039-connect-and-dashboard/spec.md` | Feature specification | +| `specs/039-connect-and-dashboard/plan.md` | Implementation plan | + +### Modified Files +| File | Change | +|------|--------| +| `internal/httpapi/server.go` | Added connect routes and service field | +| `internal/server/server.go` | Wire connect service from config | +| `cmd/mcpproxy/main.go` | Register connect/disconnect commands | +| `frontend/src/views/Dashboard.vue` | Complete rewrite — hub visualization | +| `frontend/src/services/api.ts` | Added connect API methods | +| `frontend/src/types/api.ts` | Added connect types | +| `frontend/src/types/index.ts` | Re-exports | + +## Verification Results + +### Backend Tests +- `go test -race ./internal/connect/...` — 32/32 PASS +- `go test -race ./internal/httpapi/...` — PASS +- `go build ./...` — Clean build + +### API Verification (curl) +- `GET /api/v1/connect` — Returns 7 clients with correct status +- `POST /api/v1/connect/cursor` — Creates backup, adds mcpproxy entry, returns success +- `DELETE /api/v1/connect/cursor` — Creates backup, removes entry, returns success +- Verified config files modified correctly (existing entries preserved) + +### CLI Verification +- `mcpproxy connect --list` — Shows all 7 clients with status table +- Correctly detects Claude Code and Codex as already connected +- Claude Desktop correctly marked as unsupported (stdio only) + +### Frontend Verification +- `npm run build` — TypeScript check + Vite build pass +- Dashboard loads with hub visualization +- Client statuses displayed correctly +- All existing functionality preserved (banners, token savings, etc.) + +## Assumptions Made +1. HTTP transport used for all clients (no stdio generation) +2. API key appended as URL query param for clients that don't support custom headers +3. Claude Desktop excluded from connect (stdio-only) +4. Windsurf uses `~/.codeium/windsurf/mcp_config.json` +5. VS Code uses `servers` key (not `mcpServers`) diff --git a/specs/039-connect-and-dashboard/plan.md b/specs/039-connect-and-dashboard/plan.md new file mode 100644 index 000000000..7018879bd --- /dev/null +++ b/specs/039-connect-and-dashboard/plan.md @@ -0,0 +1,76 @@ +# Implementation Plan: Connect Clients & Dashboard Visual Redesign + +**Spec**: 039-connect-and-dashboard +**Created**: 2026-03-28 + +## Architecture Decisions + +1. **New package `internal/connect/`** for client config manipulation logic +2. **New API handlers in `internal/httpapi/connect.go`** for REST endpoints +3. **New CLI command `cmd/mcpproxy/connect.go`** for CLI interface +4. **Dashboard.vue rewrite** — full replacement of the dashboard component +5. **ConnectModal.vue** — new modal component for the connect UI +6. **No new storage** — all state derived from filesystem checks at request time + +## Implementation Tasks + +### Task 1: Backend — Connect Package (`internal/connect/`) +**Branch**: Works in `039-connect-and-dashboard` worktree +**Files**: +- `internal/connect/connect.go` — main Connect/Disconnect/Status logic +- `internal/connect/clients.go` — client definitions (paths, formats, entry templates) +- `internal/connect/backup.go` — backup and atomic write utilities +- `internal/connect/connect_test.go` — unit tests + +**Implementation**: +1. Define `ClientDef` struct: ID, Name, configPath(), supported, format (json/toml), serverKey +2. Define all 7 clients with their paths per OS +3. `Status()` — check each client config, return whether mcpproxy entry exists +4. `Connect(clientID, serverName, listenURL, force)` — backup + modify + verify +5. `Disconnect(clientID, serverName)` — backup + remove entry + verify +6. Handle JSON (most clients) and TOML (Codex) separately +7. Handle VS Code's `servers` key vs others' `mcpServers` key + +**Tests**: Unit tests with temp directories, mock config files + +### Task 2: Backend — REST API Endpoints (`internal/httpapi/connect.go`) +**Files**: +- `internal/httpapi/connect.go` — HTTP handlers +- Route registration in `internal/httpapi/server.go` + +**Endpoints**: +- `GET /api/v1/connect` — list all clients with status +- `POST /api/v1/connect/{client}` — connect MCPProxy to client +- `DELETE /api/v1/connect/{client}` — disconnect MCPProxy from client + +### Task 3: Backend — CLI Command (`cmd/mcpproxy/connect.go`) +**Files**: +- `cmd/mcpproxy/connect.go` — Cobra command + +**Commands**: +- `mcpproxy connect ` — connect to specific client +- `mcpproxy connect --list` — show status table +- `mcpproxy connect --all` — connect all supported +- `mcpproxy disconnect ` — remove from client + +### Task 4: Frontend — Dashboard Visual Redesign +**Files**: +- `frontend/src/views/Dashboard.vue` — complete rewrite +- `frontend/src/components/ConnectModal.vue` — new connect UI modal +- `frontend/src/services/api.ts` — add connect API methods + +**Layout**: Three-column hub visualization per spec wireframe + +### Task 5: Integration Testing & Verification +- Build backend, run go tests +- Build frontend, verify in browser +- Test connect API with curl +- Visual verification with mcpproxy-ui-test + +## Execution Strategy + +Tasks 1-3 (backend) are sequential (each builds on prior). +Task 4 (frontend) can run in parallel with Tasks 1-3 after API contract is defined. +Task 5 runs after all others complete. + +**Worktree plan**: Single feature worktree `039-connect-and-dashboard` since frontend depends on backend API. diff --git a/specs/039-connect-and-dashboard/spec.md b/specs/039-connect-and-dashboard/spec.md new file mode 100644 index 000000000..f0a218eaa --- /dev/null +++ b/specs/039-connect-and-dashboard/spec.md @@ -0,0 +1,239 @@ +# Feature Specification: Connect Clients & Dashboard Visual Redesign + +**Feature Branch**: `039-connect-and-dashboard` +**Created**: 2026-03-28 +**Status**: Approved +**Input**: Two related features: (1) "Connect" feature that modifies AI client configs to register MCPProxy as an MCP server, (2) Visual dashboard redesign showing MCPProxy as a central hub connecting clients to upstream servers. + +## Assumptions (No Clarification Needed) + +1. **Connect modifies user-level configs only** — not workspace-level or project-level configs +2. **Backup before modify** — always create `.bak` timestamped backup before touching any client config +3. **HTTP transport preferred** — MCPProxy exposes `/mcp` endpoint, so we use `url: "http://127.0.0.1:{port}/mcp"` for all clients that support HTTP/SSE +4. **Stdio fallback not needed** — all modern clients support HTTP transport; we won't generate stdio configs +5. **API key included in URL** — if MCPProxy has an API key configured, include it as query param in the URL for clients that don't support headers +6. **Dashboard replaces current view** — the new visual layout replaces the existing Dashboard.vue entirely +7. **No new backend storage** — connect feature uses filesystem operations only; dashboard uses existing API endpoints +8. **Supported clients for connect**: Claude Code, Claude Desktop, Cursor IDE, Windsurf, VS Code (Copilot), Codex CLI, Gemini CLI +9. **Windsurf path**: `~/.codeium/windsurf/mcp_config.json` (same JSON format as Cursor) +10. **VS Code path**: `~/Library/Application Support/Code/User/mcp.json` on macOS (uses `servers` key, not `mcpServers`) + +## Feature 1: Connect Clients + +### Overview + +A single REST API endpoint + CLI command + Web UI button that writes MCPProxy's connection details into a client's MCP configuration file. The inverse of "import" — instead of pulling configs from clients, we push our config to clients. + +### Backend: REST API + +**Endpoint**: `POST /api/v1/connect/{client}` + +**Path parameter**: `client` — one of: `claude-code`, `claude-desktop`, `cursor`, `windsurf`, `vscode`, `codex`, `gemini` + +**Request body** (optional): +```json +{ + "server_name": "mcpproxy", + "force": false +} +``` + +- `server_name`: name to register as (default: "mcpproxy") +- `force`: overwrite if entry already exists (default: false) + +**Response** (success): +```json +{ + "success": true, + "client": "claude-code", + "config_path": "/Users/user/.claude.json", + "backup_path": "/Users/user/.claude.json.bak.20260328-161800", + "server_name": "mcpproxy", + "action": "created", + "message": "MCPProxy registered in Claude Code configuration" +} +``` + +**Response** (already exists, force=false): +```json +{ + "success": false, + "error": "already_exists", + "client": "claude-code", + "config_path": "/Users/user/.claude.json", + "server_name": "mcpproxy", + "message": "MCPProxy is already registered in Claude Code. Use force=true to overwrite." +} +``` + +**Endpoint**: `GET /api/v1/connect` + +Returns status of all supported clients: +```json +{ + "listen_url": "http://127.0.0.1:8080/mcp", + "clients": [ + { + "id": "claude-code", + "name": "Claude Code", + "config_path": "/Users/user/.claude.json", + "exists": true, + "connected": true, + "icon": "claude-code" + }, + { + "id": "cursor", + "name": "Cursor IDE", + "config_path": "/Users/user/.cursor/mcp.json", + "exists": true, + "connected": false, + "icon": "cursor" + } + ] +} +``` + +- `exists`: config file exists on disk +- `connected`: MCPProxy is already registered in that config + +**Endpoint**: `DELETE /api/v1/connect/{client}` + +Removes MCPProxy entry from client config (with backup). Disconnect operation. + +### Client Config Formats + +| Client | Key | MCPProxy Entry | +|--------|-----|----------------| +| Claude Code | `mcpServers.mcpproxy` | `{"type": "http", "url": "http://127.0.0.1:8080/mcp"}` | +| Claude Desktop | `mcpServers.mcpproxy` | `{"command": "curl", "args": ["http://127.0.0.1:8080/mcp"]}` — **Skip**: Desktop only supports stdio. Mark as "not supported" | +| Cursor | `mcpServers.mcpproxy` | `{"url": "http://127.0.0.1:8080/mcp", "type": "sse"}` | +| Windsurf | `mcpServers.mcpproxy` | `{"serverUrl": "http://127.0.0.1:8080/mcp", "type": "sse"}` | +| VS Code | `servers.mcpproxy` | `{"type": "http", "url": "http://127.0.0.1:8080/mcp"}` | +| Codex | `[mcp_servers.mcpproxy]` | `url = "http://127.0.0.1:8080/mcp"` (TOML) | +| Gemini | `mcpServers.mcpproxy` | `{"httpUrl": "http://127.0.0.1:8080/mcp"}` | + +**Claude Desktop note**: Since it only supports stdio, we skip it from the connect feature. The GET endpoint returns it with `supported: false` and reason "stdio only — use import instead". + +### Safety + +1. **Backup**: Before any write, copy original to `{path}.bak.{YYYYMMDD-HHMMSS}` +2. **Atomic write**: Write to temp file, then rename +3. **Preserve formatting**: Read, parse, modify, marshal with indent +4. **Config file creation**: If config file doesn't exist but parent dir does, create it with minimal valid content +5. **Validation**: After write, re-read and verify the entry exists + +### CLI Command + +```bash +mcpproxy connect claude-code # Register in Claude Code +mcpproxy connect --list # Show all client statuses +mcpproxy connect --all # Register in all supported clients +mcpproxy disconnect cursor # Remove from Cursor +``` + +## Feature 2: Dashboard Visual Redesign + +### Layout (matching wireframe) + +The dashboard shows MCPProxy as a central hub with three zones: + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ [Telemetry Banner] [Attention Banner] (kept as-is) │ +├──────────────┬──────────────────────┬───────────────────────────┤ +│ │ │ │ +│ AI Agents │ MCPProxy Hub │ Upstream Servers │ +│ (left) │ (center) │ (right) │ +│ │ │ │ +│ ┌─────────┐ │ ┌──────────────┐ │ ┌─────────────────────┐ │ +│ │ Claude │→│ │ ◇ MCPProxy │ │→ │ 20 connected │ │ +│ │ Code │ │ │ active │ │ │ 200 tools │ │ +│ └─────────┘ │ │ (uptime 22h)│ │ │ 3 disabled │ │ +│ ┌─────────┐ │ │ │ │ └─────────────────────┘ │ +│ │Antigrav │→│ │ 96% tokens │ │ ┌─────────────────────┐ │ +│ │ity │ │ │ saved │ │→ │ 2 in quarantine │ │ +│ └─────────┘ │ └──────────────┘ │ └─────────────────────┘ │ +│ │ │ │ +│ [connect] │ │ [Add server] │ +│ [import] │ [Activity Log] │ [Security Scan] │ +│ [sessions] │ (1279 records) │ (coming soon) │ +│ │ │ │ +├──────────────┴──────────────────────┴───────────────────────────┤ +│ [Token Savings collapsed / Hints panel] │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### Left Panel: AI Agents / Clients + +- **Connected clients**: Show recent MCP sessions with client name, status badge +- **Action buttons**: + - "Connect Clients" → opens connect modal (Feature 1 UI) + - "Import Servers" → opens existing AddServerModal import tab + - "Recent Sessions" → links to /sessions + +### Center Panel: MCPProxy Hub + +- **Diamond/hexagon shape** with MCPProxy logo or icon +- **Status**: "active" with green glow, or "stopped" with red +- **Uptime**: calculated from server start time +- **Token savings badge**: "96% tokens saved" in accent circle above +- **Activity log stat**: "Activity Log (N records)" below — links to /activity + +### Right Panel: Upstream Servers + +- **Stats cards**: + - Connected count / total tools (from server store) + - Disabled count + - Quarantined count (from server store) +- **Action buttons**: + - "Add Server" → opens AddServerModal + - "Security Scan (coming soon)" → disabled badge/placeholder + +### Data Sources (all existing) + +| Data | API Endpoint | Store | +|------|-------------|-------| +| Server counts | `GET /api/v1/servers` | `serversStore.serverCount` | +| Token savings | `GET /api/v1/stats/tokens` | fetched in dashboard | +| Recent sessions | `GET /api/v1/sessions?limit=5` | fetched in dashboard | +| Activity count | `GET /api/v1/activity/summary` | fetched in dashboard | +| Uptime | `GET /api/v1/status` | `systemStore.status` | +| Client connect status | `GET /api/v1/connect` | new fetch in dashboard | + +## User Scenarios & Testing + +### Story 1 - Connect Claude Code via Web UI (P1) + +A user opens the dashboard, sees Claude Code in the clients panel, clicks "Connect", and MCPProxy registers itself in `~/.claude.json`. + +**Acceptance**: +1. Given MCPProxy is running, When user clicks Connect on Claude Code, Then `~/.claude.json` is backed up and mcpproxy entry is added +2. Given mcpproxy is already in the config, When user clicks Connect, Then a message says "already connected" +3. Given the config file doesn't exist, When user clicks Connect, Then a new config file is created + +### Story 2 - Connect All Clients via CLI (P2) + +```bash +mcpproxy connect --all +``` + +**Acceptance**: +1. Given Claude Code and Cursor configs exist, When `connect --all`, Then both are updated with backup +2. Given VS Code config doesn't exist, When `connect --all`, Then it's created in the correct location + +### Story 3 - Dashboard Visual Overview (P1) + +User opens the Web UI and sees the hub visualization with live data. + +**Acceptance**: +1. Given 20 servers connected with 200 tools, When dashboard loads, Then right panel shows "20 connected / 200 tools" +2. Given 2 servers in quarantine, When dashboard loads, Then quarantine stat is visible +3. Given 3 active sessions, When dashboard loads, Then left panel shows client names + +### Story 4 - Disconnect Client (P2) + +User clicks disconnect on a connected client. + +**Acceptance**: +1. Given mcpproxy is in Claude Code config, When disconnect clicked, Then entry is removed with backup +2. Status updates to show "not connected" diff --git a/specs/040-server-ux/checklists/requirements.md b/specs/040-server-ux/checklists/requirements.md new file mode 100644 index 000000000..17551f351 --- /dev/null +++ b/specs/040-server-ux/checklists/requirements.md @@ -0,0 +1,38 @@ +# Specification Quality Checklist: Add/Edit Server UX Improvements + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-03-30 +**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 + +- All items pass. Spec is ready for planning. +- 21 functional requirements covering 5 user stories. +- 8 success criteria, all measurable and technology-agnostic. +- 7 edge cases identified. +- Assumptions documented for backend prerequisites and Apple HIG decisions. diff --git a/specs/040-server-ux/contracts/patch-server.yaml b/specs/040-server-ux/contracts/patch-server.yaml new file mode 100644 index 000000000..0b8d41ab7 --- /dev/null +++ b/specs/040-server-ux/contracts/patch-server.yaml @@ -0,0 +1,84 @@ +# PATCH /api/v1/servers/{name} +# Update an existing server's configuration (partial update) + +paths: + /api/v1/servers/{name}: + patch: + summary: Update server configuration + description: | + Partially update an existing server's configuration. + Only fields included in the request body are changed. + Server name cannot be changed (it's the primary key). + parameters: + - name: name + in: path + required: true + schema: + type: string + description: Server name (primary key) + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/PatchServerRequest' + responses: + '200': + description: Server updated successfully + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + example: true + data: + type: object + properties: + message: + type: string + example: "Server 'github-server' updated successfully" + restart_required: + type: boolean + description: Whether the server needs a restart for changes to take effect + '400': + description: Invalid request (empty body or validation error) + '404': + description: Server not found + '409': + description: Conflict (e.g., trying to set both url and command) + +components: + schemas: + PatchServerRequest: + type: object + description: All fields optional. Only included fields are updated. + properties: + url: + type: string + description: Server URL (for http protocol) + command: + type: string + description: Command to run (for stdio protocol) + args: + type: array + items: + type: string + description: Command arguments (for stdio) + env: + type: object + additionalProperties: + type: string + description: Environment variables + working_dir: + type: string + description: Working directory + enabled: + type: boolean + quarantined: + type: boolean + docker_isolation: + type: boolean + skip_quarantine: + type: boolean diff --git a/specs/040-server-ux/data-model.md b/specs/040-server-ux/data-model.md new file mode 100644 index 000000000..29aa6e40a --- /dev/null +++ b/specs/040-server-ux/data-model.md @@ -0,0 +1,64 @@ +# Data Model: Add/Edit Server UX + +## Entities + +### ServerConfiguration (existing, modified) + +| Field | Type | Mutable | Notes | +|-------|------|---------|-------| +| name | string | NO | Primary key, immutable after creation | +| protocol | string | YES | "stdio" or "http" (auto-detected variant) | +| url | string | YES | Required for http protocol | +| command | string | YES | Required for stdio protocol | +| args | []string | YES | Command arguments for stdio | +| env | map[string]string | YES | Environment variables | +| working_dir | string | YES | Working directory | +| enabled | bool | YES | Default true | +| quarantined | bool | YES | Default true for new servers | +| docker_isolation | bool | YES | Docker container isolation | +| skip_quarantine | bool | YES | Skip tool-level quarantine | + +### ImportPreview (new, transient) + +| Field | Type | Notes | +|-------|------|-------| +| name | string | Server name from config file | +| protocol | string | Detected protocol | +| exists | bool | Already exists in MCPProxy | +| selected | bool | User-toggled checkbox (client-side only) | +| skip_reason | string | Why it would be skipped (e.g., "already exists") | + +### ConnectionTestResult (new, transient) + +| Field | Type | Notes | +|-------|------|-------| +| phase | enum | saving, connecting, success, failure | +| error_message | string | Actual backend error on failure | +| tool_count | int | Number of tools discovered on success | + +### ValidationError (new, transient, client-side only) + +| Field | Type | Notes | +|-------|------|-------| +| field | string | Field identifier (name, url, command) | +| message | string | Human-readable error ("Server name is required") | + +## State Transitions + +### Add Server Flow +``` +idle → submitting(saving) → submitting(connecting) → success | failure +failure → submitting(saving) [retry] | idle [save anyway completes] +``` + +### Edit Config Tab +``` +readonly → editing → saving → readonly | editing(validation error) +editing → readonly [cancel] +``` + +### Import Flow +``` +browsing → previewing(loading) → previewing(loaded) → importing → results +previewing(loaded) → browsing [cancel] +``` diff --git a/specs/040-server-ux/plan.md b/specs/040-server-ux/plan.md new file mode 100644 index 000000000..ee19c55fd --- /dev/null +++ b/specs/040-server-ux/plan.md @@ -0,0 +1,73 @@ +# Implementation Plan: Add/Edit Server UX Improvements + +**Branch**: `040-server-ux` | **Date**: 2026-03-30 | **Spec**: [spec.md](spec.md) +**Input**: Feature specification from `/specs/040-server-ux/spec.md` + +## Summary + +Improve the macOS tray app's server management UX: fix Add Server sheet (larger, validation, connection feedback, simplified protocols), add Edit Server capability to the Config tab, improve Import flow with preview step, enhance error visibility with log coloring and tooltips, and fix keyboard shortcuts. + +## Technical Context + +**Language/Version**: Swift 5.9 (macOS tray app), Go 1.24 (backend PATCH endpoint) +**Primary Dependencies**: SwiftUI, AppKit (NSTableView, NSOpenPanel), Chi router (Go) +**Storage**: BBolt (config.db), JSON config file (~/.mcpproxy/mcp_config.json) +**Testing**: mcpproxy-ui-test MCP tool (Swift UI verification), go test (backend) +**Target Platform**: macOS 13+ (arm64/x86_64) +**Project Type**: Desktop app + backend API +**Performance Goals**: Sheet opens <200ms, validation feedback <100ms, connection test feedback within 10s +**Constraints**: Must follow Apple HIG patterns. Tray app must not maintain its own state (constitution III). +**Scale/Scope**: 7 Swift view files, 1 Go API file, ~15 functional requirements + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +| Principle | Status | Notes | +|-----------|--------|-------| +| I. Performance at Scale | PASS | UI changes only, no impact on search/indexing | +| II. Actor-Based Concurrency | PASS | Swift async/await for API calls, no new concurrency patterns | +| III. Configuration-Driven | PASS | Edit saves via REST API to core config. Tray has no local state. | +| IV. Security by Default | PASS | New servers still quarantined by default. Edit exposes quarantine toggle. | +| V. TDD | PASS | Go PATCH endpoint tested. Swift verified via mcpproxy-ui-test. | +| VI. Documentation Hygiene | PASS | CLAUDE.md updated if API changes. | + +No violations. No complexity tracking needed. + +## Project Structure + +### Documentation (this feature) + +```text +specs/040-server-ux/ +├── plan.md # This file +├── spec.md # Feature specification +├── research.md # Phase 0 research +├── data-model.md # Phase 1 data model +├── quickstart.md # Phase 1 quickstart +├── contracts/ # Phase 1 API contracts +│ └── patch-server.yaml +└── checklists/ + └── requirements.md +``` + +### Source Code (repository root) + +```text +# Backend (Go) - PATCH endpoint +internal/httpapi/server.go # Add handlePatchServer handler + +# macOS Tray App (Swift) +native/macos/MCPProxy/MCPProxy/ +├── Views/ +│ ├── AddServerView.swift # Sheet size, validation, connection test, protocol picker +│ ├── ServerDetailView.swift # Editable Config tab +│ ├── ServersView.swift # Empty state, status tooltip +│ └── DashboardView.swift # Accessibility labels, import button tab param +├── API/ +│ ├── APIClient.swift # updateServer(), import timeout +│ └── Models.swift # UpdateServerRequest if needed +└── MCPProxyApp.swift # Cmd+N command group +``` + +**Structure Decision**: Modifications to existing files only. No new files needed except the PATCH API contract. diff --git a/specs/040-server-ux/quickstart.md b/specs/040-server-ux/quickstart.md new file mode 100644 index 000000000..ac3c4a7f8 --- /dev/null +++ b/specs/040-server-ux/quickstart.md @@ -0,0 +1,64 @@ +# Quickstart: Add/Edit Server UX Improvements + +## Prerequisites + +- macOS 13+ with Xcode 15+ +- Go 1.24+ toolchain +- Running MCPProxy instance (`mcpproxy serve`) +- mcpproxy-ui-test binary at `/tmp/mcpproxy-ui-test` + +## Build & Test + +### Backend (PATCH endpoint) + +```bash +# Run PATCH endpoint tests +go test -race ./internal/httpapi/ -run TestPatchServer -v + +# Build core with changes +go build -o /tmp/MCPProxy.app/Contents/Resources/bin/mcpproxy ./cmd/mcpproxy + +# Verify endpoint +API_KEY=$(jq -r '.api_key' ~/.mcpproxy/mcp_config.json) +curl -s -X PATCH -H "X-API-Key: $API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"enabled": false}' \ + http://127.0.0.1:8080/api/v1/servers/everything-server +``` + +### Swift Tray App + +```bash +cd native/macos/MCPProxy +SDK=$(xcrun --sdk macosx --show-sdk-path) +swiftc -target arm64-apple-macosx13.0 -sdk "$SDK" -module-name MCPProxy -emit-executable -O \ + -o /tmp/MCPProxy-new \ + $(find MCPProxy -name "*.swift" -not -path "*/Tests/*" | sort | tr '\n' ' ') + +# Deploy +cp /tmp/MCPProxy-new /tmp/MCPProxy.app/Contents/MacOS/MCPProxy +``` + +### UI Verification + +```bash +# Screenshot Add Server sheet +mcpproxy-ui-test screenshot_window --bundle-id com.smartmcpproxy.mcpproxy.dev + +# List menu items +mcpproxy-ui-test list_menu_items --bundle-id com.smartmcpproxy.mcpproxy.dev + +# Test Cmd+N +mcpproxy-ui-test send_keypress --key cmd+n --bundle-id com.smartmcpproxy.mcpproxy.dev +``` + +## Key Files + +| File | Change Summary | +|------|----------------| +| `internal/httpapi/server.go` | PATCH handler for server updates | +| `AddServerView.swift` | Sheet size, validation, connection test, protocols | +| `ServerDetailView.swift` | Editable Config tab | +| `ServersView.swift` | Empty state, status tooltips | +| `APIClient.swift` | updateServer(), import timeout | +| `MCPProxyApp.swift` | Cmd+N keyboard shortcut | diff --git a/specs/040-server-ux/research.md b/specs/040-server-ux/research.md new file mode 100644 index 000000000..5afd5100c --- /dev/null +++ b/specs/040-server-ux/research.md @@ -0,0 +1,54 @@ +# Research: Add/Edit Server UX Improvements + +## R1: PATCH /api/v1/servers/{name} Backend Pattern + +**Decision**: Add PATCH handler to existing Chi router in `internal/httpapi/server.go`, reusing `AddServerRequest` struct for the request body. Only non-nil fields are applied. + +**Rationale**: The existing `handleAddServer` (POST) already validates and creates servers. PATCH reuses the same request struct but applies partial updates to the existing config. This follows REST conventions and minimizes new code. + +**Alternatives considered**: +- PUT (full replacement): Rejected — user shouldn't need to send all fields to change one. +- New UpdateServerRequest struct: Rejected — AddServerRequest already has omitempty tags on all fields, making it suitable for partial updates. + +## R2: SwiftUI Inline Validation Pattern + +**Decision**: Use `@FocusState` to track which field has focus. On focus change, validate the previously-focused field. Show red `Text` below the field with `.foregroundStyle(.red)` and `.font(.caption)`. + +**Rationale**: Apple HIG recommends preventing errors (disabled button) AND showing inline feedback. SwiftUI's `@FocusState` + `.onChange(of:)` provides clean field-blur detection. This is the standard SwiftUI pattern for form validation on macOS. + +**Alternatives considered**: +- Shake animation: Not a standard macOS pattern per Apple HIG research. +- Alert on submit: Too disruptive for simple validation. +- Only disabled button: Current approach — insufficient feedback per user complaints. + +## R3: NSOpenPanel from SwiftUI + +**Decision**: Use `NSOpenPanel` directly from a SwiftUI button action via `@MainActor` async call. NSOpenPanel.runModal() works from SwiftUI context on macOS. + +**Rationale**: NSOpenPanel is the standard macOS file picker. It works directly from SwiftUI without needing a NSViewRepresentable wrapper. Simply call `panel.beginSheetModal(for: NSApp.keyWindow!)` from the button's async action. + +**Alternatives considered**: +- fileImporter SwiftUI modifier: Limited customization, doesn't support accessory views. +- Drag and drop: Supplements but doesn't replace file picker (out of scope). + +## R4: Connection Test Phased Feedback + +**Decision**: After form submission, show inline status text that transitions through phases: +1. "Saving configuration..." (API POST call) +2. "Connecting to server..." (poll server status via SSE or 2s delay + status check) +3. Success: "Connected (N tools)" / Failure: actual error + Save Anyway/Retry + +**Rationale**: The current flow calls `addServer()` which saves AND triggers connection. The API returns immediately after saving. Connection happens asynchronously. To show connection feedback, we poll the server status endpoint after a brief delay, or listen for the SSE event for the new server. + +**Alternatives considered**: +- Synchronous connection test: Backend doesn't support this — connection is async. +- WebSocket for real-time feedback: Over-engineering for this use case. + +## R5: Import Preview + +**Decision**: Use the existing `?preview=true` query parameter on `POST /api/v1/servers/import/path`. This returns the list of servers that would be imported without actually importing them. Display these in a list with checkboxes, then submit the actual import with `server_names` filter for selected servers only. + +**Rationale**: The preview API already exists in the backend. The Swift client just needs to call it and display results before confirming. + +**Alternatives considered**: +- Client-side config parsing: Rejected — the backend already handles multiple config formats (Claude Desktop, Cursor, Codex TOML, etc.). Duplicating parsing logic in Swift would be fragile. diff --git a/specs/040-server-ux/spec.md b/specs/040-server-ux/spec.md new file mode 100644 index 000000000..3801caaa2 --- /dev/null +++ b/specs/040-server-ux/spec.md @@ -0,0 +1,185 @@ +# Feature Specification: Add/Edit Server UX Improvements + +**Feature Branch**: `040-server-ux` +**Created**: 2026-03-30 +**Status**: Draft +**Input**: Improve Add Server, Edit Server, and Import Server UX in the MCPProxy macOS tray app + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Add a New Server with Validation Feedback (Priority: P1) + +A user wants to add a new MCP server to MCPProxy. They open the Add Server sheet, fill in the form, and expect clear feedback on what's missing or wrong before submitting. After submitting, they want to see whether the connection succeeded or failed, with actionable error details. + +**Why this priority**: Adding servers is the core onboarding action. Poor validation and no connection feedback are the two biggest UX complaints. + +**Independent Test**: Open Add Server, leave fields empty (verify red validation labels), fill correctly, submit, verify connection feedback appears inline. + +**Acceptance Scenarios**: + +1. **Given** the Add Server sheet is open and Name field is empty, **When** user tabs away from Name, **Then** a red "Server name is required" label appears below the field. +2. **Given** "Remote URL (HTTP)" is selected and URL is empty, **When** user tabs away from URL, **Then** a red "URL is required" label appears. +3. **Given** a valid form is submitted, **When** the backend is connecting, **Then** an inline spinner with "Connecting to server..." text is shown. +4. **Given** a server connection fails, **When** the error is returned, **Then** the actual error message is shown in red with "Save Anyway" and "Retry" buttons. +5. **Given** a server connection succeeds, **Then** "Connected (N tools discovered)" appears in green and the sheet auto-closes after 2 seconds. +6. **Given** the protocol picker is shown, **Then** only two options appear: "Local Command (stdio)" and "Remote URL (HTTP)". +7. **Given** the form has many fields (stdio mode), **Then** the Add Server button is always visible without scrolling (pinned at bottom). + +--- + +### User Story 2 - Edit an Existing Server Configuration (Priority: P1) + +A user has an existing server with a typo in the URL or needs to change environment variables. They navigate to the server's detail view, click "Edit" on the Config tab, modify fields, and save. + +**Why this priority**: Currently zero edit capability in the GUI. Users must delete and re-add servers or manually edit JSON config files. + +**Independent Test**: Navigate to server detail view, click Edit on Config tab, modify a field, save, verify change persists. + +**Acceptance Scenarios**: + +1. **Given** a server's Config tab is shown, **When** user clicks "Edit", **Then** text labels become editable fields and toggle switches appear. +2. **Given** Config tab is in edit mode, **When** user modifies URL and clicks "Save", **Then** the configuration is updated and the view shows the new value. +3. **Given** Config tab is in edit mode, **When** user clicks "Cancel", **Then** all changes are discarded and view reverts to read-only. +4. **Given** Config tab is in edit mode, **Then** Server Name is not editable (displayed as label). +5. **Given** Config tab is in edit mode, **Then** toggles for Enabled, Quarantined, Docker Isolation, Skip Quarantine are visible. +6. **Given** user saves with empty required field, **Then** inline validation errors are shown and save is blocked. + +--- + +### User Story 3 - Import Servers with Preview (Priority: P2) + +A user wants to import servers from their Claude Code or Cursor configuration. They open the Import tab, select a config, preview what will be imported, and confirm. + +**Why this priority**: Import is important for onboarding but used less frequently than add/edit. + +**Independent Test**: Open Import tab, click Import for a config file, verify preview list with checkboxes, toggle servers, confirm import, check results. + +**Acceptance Scenarios**: + +1. **Given** Import tab shows a discovered config, **When** user clicks "Import", **Then** a preview list appears with checkboxes (checked by default) showing server name and protocol. +2. **Given** preview list, **When** a server already exists in MCPProxy, **Then** it is unchecked and marked "already exists". +3. **Given** preview list, **When** user clicks "Import Selected", **Then** only checked servers are imported. +4. **Given** import completes, **Then** per-server results show imported/skipped/failed with reasons. +5. **Given** Import tab, **When** user's config is not in auto-discovered list, **Then** "Browse Other File..." button opens a file picker. + +--- + +### User Story 4 - View Connection Errors and Server Logs (Priority: P2) + +A user has a server that won't connect and wants to understand why by viewing errors and logs from within the app. + +**Why this priority**: Currently errors are invisible. Users see "Connecting..." indefinitely or generic timeout messages. + +**Independent Test**: Configure server with invalid URL, observe error in server list tooltip and detail view, view logs in Logs tab. + +**Acceptance Scenarios**: + +1. **Given** a server has health.level == "unhealthy", **When** user hovers over status in server table, **Then** tooltip shows health.detail error. +2. **Given** Logs tab is open, **Then** logs auto-refresh every 3 seconds. +3. **Given** log lines with ERROR or WARN levels, **Then** they are color-coded red or yellow. +4. **Given** a server has a last_error, **Then** the Logs tab shows it prominently at top. + +--- + +### User Story 5 - Keyboard Shortcuts and Polish (Priority: P3) + +A user expects Cmd+N to work from the main window, contextual tab defaults, accessibility labels, and proper empty states. + +**Why this priority**: Polish items that improve experience but don't block core workflows. + +**Independent Test**: Press Cmd+N from main window (verify Add Server opens), click Dashboard import button (verify Import tab pre-selected), check accessibility labels. + +**Acceptance Scenarios**: + +1. **Given** main window is focused, **When** user presses Cmd+N, **Then** Add Server sheet opens with Manual tab. +2. **Given** Dashboard import button is clicked, **Then** Add Server sheet opens with Import tab pre-selected. +3. **Given** empty server list, **Then** onboarding empty state view with Add Server and Import buttons is shown. +4. **Given** Dashboard action buttons, **Then** each has an accessibilityLabel. + +--- + +### Edge Cases + +- Duplicate server name on Add: show "Server name already exists" error inline. +- PATCH with empty body: return 400 "No fields to update". +- Import preview returns 0 servers: show "No servers found in this config file". +- Connection test timeout (>10s): show timeout error with "Save Anyway" to save config. +- Edit while server reconnects: warn changes may require restart, offer restart after save. +- NSOpenPanel cancelled: return to Import tab unchanged. +- Config tab "Command: N/A" for disabled stdio servers: read from config, not runtime state. + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: Add Server sheet MUST be 560x560 with submit button pinned outside ScrollView, always visible. +- **FR-002**: Protocol picker MUST show "Local Command (stdio)" and "Remote URL (HTTP)". Backend auto-detects transport variant. +- **FR-003**: Form MUST show inline red validation text below empty required fields on field blur. +- **FR-004**: Submit MUST show phased progress: "Saving..." then "Connecting..." then success/failure with actual error. +- **FR-005**: Failure state MUST show "Save Anyway" (saves without connection) and "Retry" buttons. +- **FR-006**: Cmd+N MUST open Add Server sheet from the main window. +- **FR-007**: ServerDetailView Config tab MUST have "Edit" button toggling editable mode. +- **FR-008**: Edit mode MUST support: URL/Command, Args, Working Dir, Env Vars, Enabled, Quarantined, Docker Isolation, Skip Quarantine. +- **FR-009**: Edit mode MUST have Save (calls PATCH API) and Cancel buttons. +- **FR-010**: Server Name MUST NOT be editable in edit mode. +- **FR-011**: Backend MUST provide PATCH /api/v1/servers/{name} accepting partial updates. +- **FR-012**: Import MUST show preview with checkboxes before committing. +- **FR-013**: Import results MUST show per-server status with reasons. +- **FR-014**: Import tab MUST include "Browse Other File..." button using NSOpenPanel. +- **FR-015**: Import timeout MUST be 120 seconds. +- **FR-016**: Server table MUST show tooltip with health.detail on unhealthy server status hover. +- **FR-017**: Logs tab MUST auto-refresh every 3s and color-code ERROR/WARN lines. +- **FR-018**: Empty server list MUST show onboarding empty state. +- **FR-019**: Dashboard buttons MUST have accessibility labels. +- **FR-020**: Default tab: Manual for Cmd+N/Add Server, Import for Dashboard import button. +- **FR-021**: Config tab MUST show command from config (not runtime) so disabled servers display correctly. + +### Key Entities + +- **Server Configuration**: Name (immutable key), protocol (stdio/http), URL or command+args, env vars, working directory, enabled, quarantined, docker isolation, skip quarantine. +- **Import Preview**: List of servers from config file — name, protocol, exists-in-MCPProxy flag, selected-for-import flag. +- **Connection Test Result**: Status (connecting/success/failure), error message, tools discovered count. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: Users see connection success/failure feedback within 10 seconds of clicking Add Server. +- **SC-002**: Users can edit any server configuration field without deleting and re-adding. +- **SC-003**: All required fields show inline validation errors when empty — zero silent rejections. +- **SC-004**: Import preview shows all discovered servers before changes, with deselect capability. +- **SC-005**: Connection errors display actual backend error, not generic timeout text. +- **SC-006**: Cmd+N opens Add Server from main window on first attempt. +- **SC-007**: Empty server list guides new users to add or import their first server. +- **SC-008**: Unhealthy server status shows error detail on hover. + +## Assumptions + +- Backend auto-detection of HTTP transport variants is already implemented. +- Existing AddServerRequest struct shape works for PATCH updates. +- Import preview uses existing ?preview=true query parameter. +- NSOpenPanel presentable from SwiftUI via AppKit bridge. +- Server name is immutable primary key. +- Sheet remains the correct Apple HIG pattern for Add Server; inline editing in detail view for Edit. +- "Security Scan: soon" placeholder will be removed. + +## Commit Message Conventions *(mandatory)* + +When committing changes for this feature, follow these guidelines: + +### Issue References +- Use: `Related #[issue-number]` +- Do NOT use: `Fixes`, `Closes`, `Resolves` (auto-close) + +### Example Commit Message +``` +feat(040): [brief description] + +Related #[issue-number] + +## Changes +- [key changes] + +## Testing +- [test results] +``` diff --git a/specs/040-server-ux/tasks.md b/specs/040-server-ux/tasks.md new file mode 100644 index 000000000..fd1415aff --- /dev/null +++ b/specs/040-server-ux/tasks.md @@ -0,0 +1,166 @@ +# Tasks: Add/Edit Server UX Improvements + +**Input**: Design documents from `/specs/040-server-ux/` +**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/ + +**Organization**: Tasks grouped by user story for independent implementation. + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel (different files, no dependencies) +- **[Story]**: Which user story (US1-US5) +- Exact file paths included + +--- + +## Phase 1: Setup + +**Purpose**: Backend PATCH endpoint (foundation for Edit Server) + +- [ ] T001 Add PATCH /api/v1/servers/{name} handler in internal/httpapi/server.go +- [ ] T002 Write Go test for PATCH endpoint in internal/httpapi/server_test.go +- [ ] T003 Add updateServer() method to Swift APIClient in native/macos/MCPProxy/MCPProxy/API/APIClient.swift + +**Checkpoint**: PATCH API working, Swift client can call it + +--- + +## Phase 2: User Story 1 - Add Server with Validation (Priority: P1) MVP + +**Goal**: Improved Add Server sheet with proper size, validation, protocol simplification, and connection feedback. + +**Independent Test**: Open Add Server, leave fields empty (verify red labels), fill correctly, submit (verify connection feedback). + +- [ ] T004 [US1] Increase sheet size to 560x560 and pin submit button outside ScrollView in native/macos/MCPProxy/MCPProxy/Views/AddServerView.swift +- [ ] T005 [US1] Simplify protocol picker to ["Local Command (stdio)", "Remote URL (HTTP)"] in native/macos/MCPProxy/MCPProxy/Views/AddServerView.swift +- [ ] T006 [US1] Add @FocusState tracking and inline red validation text below required fields in native/macos/MCPProxy/MCPProxy/Views/AddServerView.swift +- [ ] T007 [US1] Implement phased connection test feedback (Saving → Connecting → Success/Failure) in native/macos/MCPProxy/MCPProxy/Views/AddServerView.swift +- [ ] T008 [US1] Add "Save Anyway" and "Retry" buttons for connection failure state in native/macos/MCPProxy/MCPProxy/Views/AddServerView.swift +- [ ] T009 [US1] Build Swift tray app binary and verify Add Server flow with mcpproxy-ui-test tool + +**Checkpoint**: Add Server sheet fully functional with validation and connection feedback + +--- + +## Phase 3: User Story 2 - Edit Server Configuration (Priority: P1) + +**Goal**: Editable Config tab in ServerDetailView with Save/Cancel, field editing, and toggle switches. + +**Independent Test**: Navigate to server detail, click Edit, modify field, save, verify change persists. + +- [ ] T010 [US2] Add "Edit" button and edit mode state to Config tab in native/macos/MCPProxy/MCPProxy/Views/ServerDetailView.swift +- [ ] T011 [US2] Convert Config tab read-only labels to editable TextFields and Toggles in edit mode in native/macos/MCPProxy/MCPProxy/Views/ServerDetailView.swift +- [ ] T012 [US2] Add Save (calls PATCH API) and Cancel buttons to Config tab edit mode in native/macos/MCPProxy/MCPProxy/Views/ServerDetailView.swift +- [ ] T013 [US2] Add inline validation for required fields in edit mode in native/macos/MCPProxy/MCPProxy/Views/ServerDetailView.swift +- [ ] T014 [US2] Fix "Command: N/A" bug — read command from config, not runtime state in native/macos/MCPProxy/MCPProxy/Views/ServerDetailView.swift +- [ ] T015 [US2] Build and verify Edit Server flow with mcpproxy-ui-test tool + +**Checkpoint**: Edit Server fully functional, changes persist via PATCH API + +--- + +## Phase 4: User Story 3 - Import with Preview (Priority: P2) + +**Goal**: Import shows preview with checkboxes, per-server results, browse button, and longer timeout. + +**Independent Test**: Open Import tab, click Import, verify preview list with checkboxes, confirm, check results. + +- [ ] T016 [US3] Add preview step — call import API with preview=true, show server list with checkboxes in native/macos/MCPProxy/MCPProxy/Views/AddServerView.swift +- [ ] T017 [US3] Show per-server import results (imported/skipped/failed with reasons) in native/macos/MCPProxy/MCPProxy/Views/AddServerView.swift +- [ ] T018 [US3] Add "Browse Other File..." button with NSOpenPanel in native/macos/MCPProxy/MCPProxy/Views/AddServerView.swift +- [ ] T019 [US3] Increase URLSession timeout for import requests to 120s in native/macos/MCPProxy/MCPProxy/API/APIClient.swift +- [ ] T020 [US3] Build and verify Import flow with mcpproxy-ui-test tool + +**Checkpoint**: Import flow with preview, results, and file browser working + +--- + +## Phase 5: User Story 4 - Connection Errors and Logs (Priority: P2) + +**Goal**: Error visibility via tooltips, color-coded logs, auto-refresh. + +**Independent Test**: Configure invalid server URL, observe error tooltip in server list, view colored logs. + +- [ ] T021 [P] [US4] Add tooltip showing health.detail on hover over unhealthy server status in native/macos/MCPProxy/MCPProxy/Views/ServersView.swift +- [ ] T022 [P] [US4] Add log auto-refresh (3s interval) to Logs tab in native/macos/MCPProxy/MCPProxy/Views/ServerDetailView.swift +- [ ] T023 [US4] Color-code ERROR (red) and WARN (yellow) log lines in Logs tab in native/macos/MCPProxy/MCPProxy/Views/ServerDetailView.swift +- [ ] T024 [US4] Show last_error prominently at top of Logs tab when server is unhealthy in native/macos/MCPProxy/MCPProxy/Views/ServerDetailView.swift +- [ ] T025 [US4] Build and verify error display with mcpproxy-ui-test tool + +**Checkpoint**: Connection errors visible in table and detail view + +--- + +## Phase 6: User Story 5 - Keyboard Shortcuts and Polish (Priority: P3) + +**Goal**: Cmd+N from main window, contextual tab defaults, empty state, accessibility. + +**Independent Test**: Cmd+N opens Add Server, Dashboard import opens Import tab, empty state visible. + +- [ ] T026 [US5] Add Cmd+N keyboard shortcut via SwiftUI .commands in native/macos/MCPProxy/MCPProxy/MCPProxyApp.swift +- [ ] T027 [P] [US5] Add default tab parameter (manual vs import) to AddServerView and wire from Dashboard import button in native/macos/MCPProxy/MCPProxy/Views/AddServerView.swift and DashboardView.swift +- [ ] T028 [P] [US5] Add empty state view for server list in native/macos/MCPProxy/MCPProxy/Views/ServersView.swift +- [ ] T029 [P] [US5] Add .accessibilityLabel() to Dashboard action buttons in native/macos/MCPProxy/MCPProxy/Views/DashboardView.swift +- [ ] T030 [US5] Remove "Security Scan: soon" placeholder from Dashboard in native/macos/MCPProxy/MCPProxy/Views/DashboardView.swift +- [ ] T031 [US5] Build and verify all polish items with mcpproxy-ui-test tool + +**Checkpoint**: All polish items complete + +--- + +## Phase 7: Polish & Cross-Cutting + +**Purpose**: Final verification and documentation + +- [ ] T032 Full regression build — compile Swift tray app + Go core binary +- [ ] T033 Run full mcpproxy-ui-test verification of all 5 user stories +- [ ] T034 Update CLAUDE.md if API endpoints changed +- [ ] T035 Commit all changes with descriptive commit message + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Phase 1 (Setup)**: No dependencies — start immediately +- **Phase 2 (US1 Add Server)**: Can start after T003 (Swift APIClient) +- **Phase 3 (US2 Edit Server)**: Depends on Phase 1 (PATCH endpoint + Swift client) +- **Phase 4 (US3 Import)**: Independent — can start after Phase 1 +- **Phase 5 (US4 Errors/Logs)**: Independent — no API dependencies +- **Phase 6 (US5 Polish)**: Independent — can start anytime +- **Phase 7 (Polish)**: After all stories complete + +### Parallel Opportunities + +- US1 (Add Server) and US4 (Errors/Logs) can run in parallel (different files) +- US5 (Polish) tasks are all parallelizable with other work +- T021-T024 within US4 can run in parallel (different views) + +--- + +## Implementation Strategy + +### MVP (User Story 1 + 2 Only) + +1. Phase 1: PATCH endpoint + Swift client (T001-T003) +2. Phase 2: Add Server improvements (T004-T009) +3. Phase 3: Edit Server (T010-T015) +4. **STOP and VALIDATE**: Test Add + Edit independently +5. Ship as MVP — users can now add AND edit servers with proper UX + +### Full Delivery + +6. Phase 4: Import preview (T016-T020) +7. Phase 5: Error visibility (T021-T025) +8. Phase 6: Polish (T026-T031) +9. Phase 7: Final verification (T032-T035) + +--- + +## Notes + +- All Swift changes go through the same build command (swiftc) +- All Go changes in single file (server.go) +- mcpproxy-ui-test verification after each phase +- Commit after each phase checkpoint diff --git a/web/frontend/dist/index.html b/web/frontend/dist/index.html index cffc7894c..377e9b693 100644 --- a/web/frontend/dist/index.html +++ b/web/frontend/dist/index.html @@ -5,8 +5,8 @@ MCPProxy Control Panel - - + +