From 77054a4f22a5d737a7ea6da69afd9f9c520cc7be Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Sun, 23 Aug 2026 21:21:18 +0300 Subject: [PATCH 1/9] feat(security): informational Pass-1 scan on every admission + one-shot baseline sweep The free in-process TPA Pass-1 scan effectively never ran: the only automatic trigger required trust_mode="scan" (never the default) plus quarantined plus never-scanned plus no approval baseline, and nothing ever scanned existing servers. Telemetry showed 12 of 157 capable installs had ever scanned. Two informational paths now populate the verdict without touching gating: - Admission: a newly added, enabled server gets one Pass-1 scan in any trust mode. Servers the scan-mode admission gate owns are skipped so nothing is scanned twice, and the known-server set is seeded from the startup config so a restart is not mistaken for a wave of new admissions. - Baseline sweep: on startup, behind a persisted BBolt marker, enabled servers with no scan summary are swept through the same path. Backgrounded, delayed so upstreams can connect, serialized, cancellable, and it declines to burn the marker when every candidate failed. Results are stored through the normal scan-summary path, so badges light up; nothing quarantines, approves, or unquarantines. Kill switch: security.auto_baseline_scan (default on, env MCPPROXY_AUTO_BASELINE_SCAN). --- docs/configuration.md | 2 + docs/features/security-quarantine.md | 18 + internal/config/auto_baseline_scan_test.go | 48 +++ internal/config/config.go | 46 +++ internal/config/loader.go | 13 + internal/server/scan_admission_test.go | 13 + internal/server/scan_informational.go | 372 +++++++++++++++++++++ internal/server/scan_informational_test.go | 328 ++++++++++++++++++ internal/server/server.go | 36 ++ internal/storage/baseline_sweep_test.go | 56 ++++ internal/storage/manager.go | 64 ++++ internal/storage/models.go | 21 ++ oas/docs.go | 2 +- oas/swagger.yaml | 16 + 14 files changed, 1034 insertions(+), 1 deletion(-) create mode 100644 internal/config/auto_baseline_scan_test.go create mode 100644 internal/server/scan_informational.go create mode 100644 internal/server/scan_informational_test.go create mode 100644 internal/storage/baseline_sweep_test.go diff --git a/docs/configuration.md b/docs/configuration.md index 8fc5bec94..72dadd2a5 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -640,6 +640,7 @@ block** — off by default, best-effort, and unable to change the baseline verdi "integrity_check_on_restart": false, "scanner_registry_url": "", "tpa_bundle_path": "", + "auto_baseline_scan": true, "deep_scan": { "enabled": false, "fetch_package_source": true, @@ -653,6 +654,7 @@ block** — off by default, best-effort, and unable to change the baseline verdi | Field | Type | Default | Description | |-------|------|---------|-------------| | `tpa_bundle_path` | string | `""` (embedded) | Filesystem path to the tpa-db `scanner-bundle.json` the offline TPA scanner runs. Empty uses the corpus embedded in the build. Env override: `MCPPROXY_TPA_BUNDLE_PATH`, which wins over this field on every path (loader, hot-reload, `/api/v1/config/apply`). Re-read on config hot-reload and honoured in every transport, stdio included. A bundle that fails to read/parse/version-check/compile — or that contributes zero runnable rules — is refused and the previously active corpus stays live; the reason is surfaced as `signature_bundle.load_error` in `GET /api/v1/security/overview` and in `mcpproxy security overview`. | +| `auto_baseline_scan` | boolean | `true` | Kill switch for the **automatic informational baseline scan**. When on (the default), every newly added server — in any trust mode — gets one free in-process Pass-1 TPA scan, and once per installation a background sweep scans pre-existing enabled servers that have never been scanned (marker persisted in BBolt, so it runs exactly once and never delays startup). The result only populates the security badge / scan summary: it **never** quarantines, approves, or otherwise gates a server, and the `trust_mode: "scan"` admission gate is a separate path this flag does not affect. Disabled servers are skipped. Set to `false` to suppress all automatic scans; manual scans keep working. Hot-reloadable — the flag is read live at each decision point. Env override: `MCPPROXY_AUTO_BASELINE_SCAN` (`true`/`1`/`false`/`0`), which wins over this field on every path. | | `deep_scan.enabled` | boolean | `false` | Master opt-in for the heavy layer. When `false`, no Docker scanner runs and no source extraction is attempted — only the in-process baseline scanner executes. | | `deep_scan.fetch_package_source` | boolean | `true` (when deep scan is on) | Whether the scanner fetches (never executes) the published source of `npx`/`uvx` package-runner servers when no local source is available. Set `false` for air-gapped deployments. | | `deep_scan.disable_no_new_privileges` | boolean | `false` | Omits `--security-opt no-new-privileges` from scanner container runs (snap-docker/AppArmor escape hatch). | diff --git a/docs/features/security-quarantine.md b/docs/features/security-quarantine.md index 309021335..7ea6c5844 100644 --- a/docs/features/security-quarantine.md +++ b/docs/features/security-quarantine.md @@ -308,6 +308,24 @@ a full-config apply is never blocked by a legacy value it did not introduce. Should an unvalidated value ever reach the runtime anyway, resolution still fails closed to `manual`. +### Automatic informational baseline scan + +Independently of `trust_mode`, MCPProxy runs the free in-process Pass-1 TPA scan +so every server ends up with a security verdict instead of an empty badge: + +- **On admission** — a newly added, enabled server gets one baseline scan in any + trust mode. Servers the `scan`-mode admission gate already owns are skipped so + nothing is scanned twice. +- **Once per installation** — a background sweep at startup scans enabled servers + that have never been scanned (for installs that predate this behaviour). It is + serialized, never delays startup, is cancelled on shutdown, and a persisted + marker keeps it one-shot. + +These scans are **informational**: the verdict fills in the scan summary and the +UI badge and never quarantines, approves, or blocks anything. Disable with +`security.auto_baseline_scan: false` (env: `MCPPROXY_AUTO_BASELINE_SCAN`). +The `trust_mode: "scan"` gate above is a separate path and is unaffected. + ### Signature bundle (offline TPA corpus) The `scan` mode runs an offline TPA signature corpus (the tpa-db diff --git a/internal/config/auto_baseline_scan_test.go b/internal/config/auto_baseline_scan_test.go new file mode 100644 index 000000000..8e76ba42b --- /dev/null +++ b/internal/config/auto_baseline_scan_test.go @@ -0,0 +1,48 @@ +package config + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestIsAutoBaselineScanEnabled(t *testing.T) { + boolPtr := func(b bool) *bool { return &b } + + t.Run("nil security block defaults to enabled", func(t *testing.T) { + var sec *SecurityConfig + assert.True(t, sec.IsAutoBaselineScanEnabled()) + }) + + t.Run("unset field defaults to enabled", func(t *testing.T) { + assert.True(t, (&SecurityConfig{}).IsAutoBaselineScanEnabled()) + }) + + t.Run("explicit false disables", func(t *testing.T) { + assert.False(t, (&SecurityConfig{AutoBaselineScan: boolPtr(false)}).IsAutoBaselineScanEnabled()) + }) + + t.Run("explicit true enables", func(t *testing.T) { + assert.True(t, (&SecurityConfig{AutoBaselineScan: boolPtr(true)}).IsAutoBaselineScanEnabled()) + }) + + t.Run("env override outranks the file value", func(t *testing.T) { + t.Setenv(EnvAutoBaselineScan, "false") + assert.False(t, (&SecurityConfig{AutoBaselineScan: boolPtr(true)}).IsAutoBaselineScanEnabled()) + + t.Setenv(EnvAutoBaselineScan, "0") + assert.False(t, (&SecurityConfig{}).IsAutoBaselineScanEnabled()) + + t.Setenv(EnvAutoBaselineScan, "true") + assert.True(t, (&SecurityConfig{AutoBaselineScan: boolPtr(false)}).IsAutoBaselineScanEnabled()) + + t.Setenv(EnvAutoBaselineScan, "1") + assert.True(t, (&SecurityConfig{AutoBaselineScan: boolPtr(false)}).IsAutoBaselineScanEnabled()) + }) + + t.Run("unrecognized env value is ignored", func(t *testing.T) { + t.Setenv(EnvAutoBaselineScan, "maybe") + assert.False(t, (&SecurityConfig{AutoBaselineScan: boolPtr(false)}).IsAutoBaselineScanEnabled()) + assert.True(t, (&SecurityConfig{}).IsAutoBaselineScanEnabled()) + }) +} diff --git a/internal/config/config.go b/internal/config/config.go index cefb38efa..4e1ac5584 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1944,6 +1944,14 @@ func IsValidTrustMode(s string) bool { // loader's env pass. const EnvTPABundlePath = "MCPPROXY_TPA_BUNDLE_PATH" +// EnvAutoBaselineScan is the environment kill-switch for the automatic, +// informational Pass-1 baseline scan (`security.auto_baseline_scan`). Like the +// bundle path, precedence is enforced in the accessor +// (SecurityConfig.IsAutoBaselineScanEnabled) rather than only in the loader, so +// a config posted to /api/v1/config/apply cannot defeat the operator's env +// setting. Accepts "true"/"1" and "false"/"0"; any other value is ignored. +const EnvAutoBaselineScan = "MCPPROXY_AUTO_BASELINE_SCAN" + // TrustModeNormalization records one per-server trust_mode value that the load // path rewrote because it was not in the accepted vocabulary. type TrustModeNormalization struct { @@ -2758,6 +2766,21 @@ type SecurityConfig struct { // baseline scanner runs. A deep-scan failure NEVER changes the baseline verdict // (FR-007/FR-008). DeepScan *DeepScanConfig `json:"deep_scan,omitempty" mapstructure:"deep-scan"` + + // AutoBaselineScan is the kill-switch for the AUTOMATIC, informational + // Pass-1 baseline scan: the free in-process TPA scan mcpproxy runs for every + // newly admitted server (any trust mode) and, once per installation, over + // pre-existing servers that have never been scanned. + // + // Informational ONLY: the resulting verdict populates the security badge and + // the scan summary, and NEVER gates quarantine or approval. The + // trust_mode:"scan" admission gate is a separate path and is unaffected by + // this flag. + // + // Default (nil) is ENABLED. Set to false to suppress every automatic scan + // (manual scans keep working). Env override: MCPPROXY_AUTO_BASELINE_SCAN, + // which wins over this field on every path. + AutoBaselineScan *bool `json:"auto_baseline_scan,omitempty" mapstructure:"auto-baseline-scan" swaggertype:"boolean"` } // DeepScanConfig configures the opt-in "deep scan" layer (Spec 077 US3): @@ -2813,6 +2836,29 @@ func (sc *SecurityConfig) EffectiveTPABundlePath() string { return sc.TPABundlePath } +// IsAutoBaselineScanEnabled reports whether mcpproxy may run the automatic, +// informational Pass-1 baseline scan (new-server admission scan + the one-shot +// baseline sweep). Default is ENABLED: a nil SecurityConfig, or an unset +// auto_baseline_scan, means on — the scan is free, in-process, and drives no +// gating, so an install that never touched the security block still gets its +// badges populated. +// +// MCPPROXY_AUTO_BASELINE_SCAN outranks the file value on every path (loader, +// hot-reload, /api/v1/config/apply) because the precedence is resolved here +// rather than only in the loader's env pass. +func (sc *SecurityConfig) IsAutoBaselineScanEnabled() bool { + switch os.Getenv(EnvAutoBaselineScan) { + case "true", "1": + return true + case "false", "0": + return false + } + if sc == nil || sc.AutoBaselineScan == nil { + return true + } + return *sc.AutoBaselineScan +} + // DeepScanScanners returns the optional per-scanner allow-list for the deep-scan // layer, or nil when unset (all enabled deep scanners are eligible). func (sc *SecurityConfig) DeepScanScanners() []string { diff --git a/internal/config/loader.go b/internal/config/loader.go index 6be8cf3ed..e8a334605 100644 --- a/internal/config/loader.go +++ b/internal/config/loader.go @@ -684,6 +684,19 @@ func applyTLSEnvOverrides(cfg *Config) { cfg.Security.TPABundlePath = value } + // Override the automatic informational baseline-scan kill switch from + // environment. Materializes the security block so an install with no + // `security` key can still be opted out (or explicitly back in). + // IsAutoBaselineScanEnabled re-reads the same variable, so the env value + // also wins on paths that never pass through the loader. + if value := os.Getenv(EnvAutoBaselineScan); value != "" { + if cfg.Security == nil { + cfg.Security = &SecurityConfig{} + } + enabled := value == trueValue || value == "1" + cfg.Security.AutoBaselineScan = &enabled + } + // Override retrieve_tools serialization mode from environment (Spec 085). // Explicit MCPPROXY_* alias per the established loader convention; the // value is validated by cfg.Validate() right after these overrides apply. diff --git a/internal/server/scan_admission_test.go b/internal/server/scan_admission_test.go index 40f902151..fc2938edf 100644 --- a/internal/server/scan_admission_test.go +++ b/internal/server/scan_admission_test.go @@ -26,6 +26,11 @@ type fakeSecurityScanner struct { summaries map[string]*scanner.ScanSummary hasBaseline map[string]bool approveErr error + // scanResult is the summary a StartScan publishes for a server, mimicking + // the real service where a completed scan makes GetScanSummary non-nil. + // Absent ⇒ the scan leaves the summary nil. + scanResult map[string]*scanner.ScanSummary + startScanErr error approveCalls []string startScanCalls []string @@ -35,6 +40,7 @@ func newFakeSecurityScanner() *fakeSecurityScanner { return &fakeSecurityScanner{ summaries: map[string]*scanner.ScanSummary{}, hasBaseline: map[string]bool{}, + scanResult: map[string]*scanner.ScanSummary{}, } } @@ -57,7 +63,14 @@ func (f *fakeSecurityScanner) ApproveServer(_ context.Context, serverName string func (f *fakeSecurityScanner) StartScan(_ context.Context, serverName string, _ bool, _ []string, _ string) (*scanner.ScanJob, error) { f.mu.Lock() defer f.mu.Unlock() + if f.startScanErr != nil { + return nil, f.startScanErr + } f.startScanCalls = append(f.startScanCalls, serverName) + // Mirror the real service: a scan that ran leaves a readable summary behind. + if result, ok := f.scanResult[serverName]; ok { + f.summaries[serverName] = result + } return nil, nil } diff --git a/internal/server/scan_informational.go b/internal/server/scan_informational.go new file mode 100644 index 000000000..18b723630 --- /dev/null +++ b/internal/server/scan_informational.go @@ -0,0 +1,372 @@ +package server + +import ( + "context" + "time" + + "go.uber.org/zap" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/httpapi" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/storage" +) + +// Informational Pass-1 baseline scanning. +// +// The spec-086 admission scan (maybeStartAdmissionScan) only fires for +// trust_mode:"scan" servers, which is never the default — so on a stock install +// nothing ever triggered the free, in-process TPA baseline scan and the security +// badges stayed empty forever. This file adds the two paths that fix that: +// +// 1. every NEW server, in ANY trust mode, gets one baseline scan when it is +// admitted (maybeStartInformationalScans, driven by servers.changed), and +// 2. once per installation, pre-existing never-scanned servers are swept +// (runBaselineSweep, driven by startup behind a persisted marker). +// +// Both are INFORMATIONAL: the result is stored through the normal scan-summary +// path so the UI verdict/badge lights up, and it drives NO gating whatsoever. +// Quarantine and approval semantics are untouched — servers the scan-mode +// admission gate owns are deliberately skipped here so they are never scanned +// twice, and the settle handler (maybeAutoApproveScanSettled) can only approve +// scan-mode quarantined servers, which this path never scans. + +const ( + // informationalScanSettleTimeout bounds how long a serialized informational + // scan waits for its verdict before moving to the next server. The wait is + // what serializes the sweep — without it every scan would be launched at + // once, since StartScan returns as soon as the job is created. + informationalScanSettleTimeout = 2 * time.Minute + // informationalScanPollInterval is how often the settle wait re-reads the + // scan summary. + informationalScanPollInterval = 250 * time.Millisecond + // baselineSweepStartDelay holds the one-shot sweep back until upstream + // servers have had a chance to connect. A scan of a still-connecting server + // cannot export its tool definitions and fails outright, which would burn + // the one-shot marker on an empty result. + baselineSweepStartDelay = 45 * time.Second +) + +// isTerminalScanStatus reports whether a scan summary status means the scan has +// finished (successfully or not). "scanning" and "not_scanned" are transient. +func isTerminalScanStatus(status string) bool { + switch status { + case "clean", "warnings", "dangerous", "failed": + return true + default: + return false + } +} + +// scanModeAdmissionOwns reports whether the spec-086 trust_mode:"scan" admission +// gate is responsible for this server's baseline scan. Those servers must NOT be +// picked up by the informational path: the gating path already scans them, and +// double-scanning would race the settle-driven auto-approval. +func scanModeAdmissionOwns(sc *config.ServerConfig, hasApprovalBaseline bool) bool { + if sc == nil { + return false + } + return sc.EffectiveTrustMode() == config.TrustModeScan && sc.Quarantined && !hasApprovalBaseline +} + +// informationalScansEnabled resolves the security.auto_baseline_scan kill switch +// (default ON) against the live config, and requires a scanner service. +func (s *Server) informationalScansEnabled() bool { + if s.securityScanner == nil { + return false + } + var sec *config.SecurityConfig + if cfg := s.runtime.Config(); cfg != nil { + sec = cfg.Security + } + return sec.IsAutoBaselineScanEnabled() +} + +// informationalScanContext returns the context informational scans run under: +// the live server context, so an in-flight scan or sweep is cancelled on +// shutdown. Falls back to context.Background() before the server has started +// (and in unit tests that drive the hooks directly). +func (s *Server) informationalScanContext() context.Context { + s.mu.RLock() + ctx := s.serverCtx + s.mu.RUnlock() + if ctx == nil { + return context.Background() + } + return ctx +} + +// seedKnownServers records the servers present at process start so the +// servers.changed handler can tell a genuinely NEW admission from the set that +// was already configured. Seeding from the startup config (rather than from the +// first servers.changed) is what makes "the very first server a fresh install +// adds" count as new. +func (s *Server) seedKnownServers(servers []*config.ServerConfig) { + s.infoScanMu.Lock() + defer s.infoScanMu.Unlock() + if s.infoScanKnown == nil { + s.infoScanKnown = make(map[string]bool, len(servers)) + } + for _, sc := range servers { + if sc != nil && sc.Name != "" { + s.infoScanKnown[sc.Name] = true + } + } +} + +// listStoredServers returns a fresh snapshot of configured servers from storage. +// Storage — not runtime.Config().Servers — because Config() hands back a shared +// snapshot whose entries other goroutines mutate in place (see the same note on +// maybeStartAdmissionScans). +func (s *Server) listStoredServers() []*config.ServerConfig { + sm := s.runtime.StorageManager() + if sm == nil { + return nil + } + servers, err := sm.ListUpstreamServers() + if err != nil { + s.logger.Debug("informational scan: failed to list servers", zap.Error(err)) + return nil + } + return servers +} + +// maybeStartInformationalScans is the servers.changed hook for change 1: any +// server that appears for the first time since process start is a new admission +// and gets one informational baseline scan, regardless of trust mode. Servers +// that were already configured at startup are the baseline sweep's job and are +// only recorded here. +func (s *Server) maybeStartInformationalScans(ctx context.Context) { + if !s.informationalScansEnabled() { + return + } + servers := s.listStoredServers() + if len(servers) == 0 { + return + } + + var candidates []*config.ServerConfig + s.infoScanMu.Lock() + if s.infoScanKnown == nil { + s.infoScanKnown = make(map[string]bool, len(servers)) + } + for _, sc := range servers { + if sc == nil || sc.Name == "" { + continue + } + if s.infoScanKnown[sc.Name] { + continue + } + s.infoScanKnown[sc.Name] = true + candidates = append(candidates, sc) + } + s.infoScanMu.Unlock() + + for _, sc := range candidates { + s.startInformationalScan(ctx, sc, "admission") + } +} + +// claimInformationalScan applies the eligibility rules and, when the server +// qualifies, claims it so no other path scans it again this process. Returns +// false (without claiming) when the server must be skipped. +func (s *Server) claimInformationalScan(ctx context.Context, sc *config.ServerConfig) bool { + if sc == nil || sc.Name == "" || s.securityScanner == nil { + return false + } + // Disabled servers are never scanned: the scan would have to start the + // server to export its tool definitions. + if !sc.Enabled { + return false + } + // Already scanned (or a scan is in flight): GetScanSummary returns nil only + // when no scan job exists at all — the one "never scanned" signal. + if summary := s.securityScanner.GetScanSummary(ctx, sc.Name); summary != nil { + return false + } + // Leave the gating admission path's servers alone (no double scan). + if scanModeAdmissionOwns(sc, s.securityScanner.HasApprovalBaseline(sc.Name)) { + return false + } + + s.infoScanMu.Lock() + defer s.infoScanMu.Unlock() + if s.infoScanQueued == nil { + s.infoScanQueued = make(map[string]bool) + } + if s.infoScanQueued[sc.Name] { + return false + } + s.infoScanQueued[sc.Name] = true + return true +} + +// releaseInformationalScan drops a claim so a later servers.changed can retry a +// scan that failed to start. +func (s *Server) releaseInformationalScan(name string) { + s.infoScanMu.Lock() + defer s.infoScanMu.Unlock() + delete(s.infoScanQueued, name) +} + +// startInformationalScan claims and runs one informational scan in the +// background. The scan itself is serialized against every other informational +// scan (see runInformationalScan), so a burst of admissions never fans out into +// concurrent scans. +func (s *Server) startInformationalScan(ctx context.Context, sc *config.ServerConfig, reason string) { + if !s.claimInformationalScan(ctx, sc) { + return + } + name := sc.Name + s.logger.Info("queueing informational baseline scan", + zap.String("server", name), + zap.String("reason", reason), + zap.String("trust_mode", string(sc.EffectiveTrustMode()))) + go func() { + if _, err := s.runInformationalScan(ctx, name); err != nil { + s.logger.Debug("informational baseline scan did not run", + zap.String("server", name), + zap.Error(err)) + s.releaseInformationalScan(name) + } + }() +} + +// runInformationalScan launches one Pass-1 scan and waits for its verdict, +// returning the number of findings it produced. The mutex serializes every +// informational scan in the process (admission scans and sweep alike), which is +// what keeps the sweep from launching every server's scan at once. +func (s *Server) runInformationalScan(ctx context.Context, name string) (int, error) { + s.infoScanRunMu.Lock() + defer s.infoScanRunMu.Unlock() + + if err := ctx.Err(); err != nil { + return 0, err + } + if _, err := s.securityScanner.StartScan(ctx, name, false, nil, ""); err != nil { + return 0, err + } + return s.waitForInformationalScan(ctx, name), nil +} + +// waitForInformationalScan blocks until the server's scan summary reaches a +// terminal status (or the timeout / shutdown fires) and returns its finding +// count. A timeout is not an error: the scan keeps running in the background, +// the wait only exists to serialize the queue. +func (s *Server) waitForInformationalScan(ctx context.Context, name string) int { + timeout := s.infoScanSettleTimeout + if timeout <= 0 { + return 0 + } + deadline := time.NewTimer(timeout) + defer deadline.Stop() + ticker := time.NewTicker(informationalScanPollInterval) + defer ticker.Stop() + + for { + if summary := s.securityScanner.GetScanSummary(ctx, name); summary != nil && isTerminalScanStatus(summary.Status) { + if summary.FindingCounts != nil { + return summary.FindingCounts.Total + } + return 0 + } + select { + case <-ctx.Done(): + return 0 + case <-deadline.C: + s.logger.Debug("informational baseline scan did not settle in time", + zap.String("server", name), + zap.Duration("timeout", timeout)) + return 0 + case <-ticker.C: + } + } +} + +// runBaselineSweep is change 2: the one-shot, post-upgrade catch-up. On startup, +// if the persisted marker is absent, every enabled server that has never been +// scanned is swept through the informational path (serialized), then the marker +// is persisted so the sweep never runs again. Cancellable: a shutdown mid-sweep +// leaves the marker unwritten so the next start resumes it. +func (s *Server) runBaselineSweep(ctx context.Context) { + if !s.informationalScansEnabled() { + return + } + sm := s.runtime.StorageManager() + if sm == nil { + return + } + if s.infoScanSweepDelay > 0 { + timer := time.NewTimer(s.infoScanSweepDelay) + defer timer.Stop() + select { + case <-ctx.Done(): + return + case <-timer.C: + } + } + state, err := sm.LoadBaselineSweepState() + if err != nil { + // Unknown marker state: do NOT sweep. Re-running the sweep on every + // start would be worse than skipping it once. + s.logger.Warn("baseline sweep: could not read sweep marker, skipping", zap.Error(err)) + return + } + if state != nil { + s.logger.Debug("baseline sweep: already completed, skipping", + zap.String("version", state.Version), + zap.Time("completed_at", state.CompletedAt)) + return + } + + servers := s.listStoredServers() + scanned := 0 + findings := 0 + failed := 0 + for _, sc := range servers { + if ctx.Err() != nil { + s.logger.Info("baseline sweep cancelled before completion; will resume on next start", + zap.Int("servers_scanned", scanned)) + return + } + if !s.claimInformationalScan(ctx, sc) { + continue + } + n, err := s.runInformationalScan(ctx, sc.Name) + if err != nil { + failed++ + s.releaseInformationalScan(sc.Name) + s.logger.Debug("baseline sweep: scan did not run", + zap.String("server", sc.Name), + zap.Error(err)) + continue + } + scanned++ + findings += n + } + if ctx.Err() != nil { + s.logger.Info("baseline sweep cancelled before completion; will resume on next start", + zap.Int("servers_scanned", scanned)) + return + } + + // Burn the one-shot marker only when the sweep actually achieved something: + // scanned at least one server, or had nothing to scan at all. A sweep where + // every candidate failed (servers still connecting, unreachable) is left + // unmarked so the next start retries it. + if scanned > 0 || failed == 0 { + if err := sm.SaveBaselineSweepState(&storage.BaselineSweepState{ + Version: httpapi.GetBuildVersion(), + CompletedAt: time.Now(), + ServersScanned: scanned, + Findings: findings, + }); err != nil { + s.logger.Warn("baseline sweep: failed to persist sweep marker", zap.Error(err)) + } + } + + s.logger.Info("baseline sweep completed", + zap.Int("servers_scanned", scanned), + zap.Int("findings", findings), + zap.Int("servers_failed", failed), + zap.Int("servers_considered", len(servers))) +} diff --git a/internal/server/scan_informational_test.go b/internal/server/scan_informational_test.go new file mode 100644 index 000000000..ece9cc3e6 --- /dev/null +++ b/internal/server/scan_informational_test.go @@ -0,0 +1,328 @@ +package server + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/runtime" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/security/scanner" +) + +// newInformationalTestServer builds a Server whose config + storage carry the +// given servers and whose securityScanner is the supplied fake. The known-server +// set is left EMPTY on purpose, so every configured server looks like a new +// admission to maybeStartInformationalScans (production seeds it from the +// startup config in NewServerWithConfigPath). +func newInformationalTestServer(t *testing.T, fake securityScannerService, sec *config.SecurityConfig, servers ...*config.ServerConfig) *Server { + t.Helper() + cfg := config.DefaultConfig() + cfg.DataDir = t.TempDir() + cfg.Servers = servers + cfg.Security = sec + rt, err := runtime.New(cfg, "", zap.NewNop()) + require.NoError(t, err) + t.Cleanup(func() { _ = rt.Close() }) + for _, sc := range servers { + if sc != nil { + require.NoError(t, rt.StorageManager().SaveUpstreamServer(sc)) + } + } + return &Server{ + logger: zap.NewNop(), + runtime: rt, + securityScanner: fake, + admissionScanKicked: make(map[string]bool), + infoScanKnown: make(map[string]bool), + infoScanQueued: make(map[string]bool), + infoScanSettleTimeout: 2 * time.Second, + } +} + +func enabledServer(name string, mode config.TrustMode) *config.ServerConfig { + return &config.ServerConfig{Name: name, TrustMode: string(mode), Enabled: true} +} + +func waitForStartedScans(t *testing.T, fake *fakeSecurityScanner, want []string) { + t.Helper() + require.Eventually(t, func() bool { return len(fake.startedScans()) == len(want) }, 3*time.Second, 5*time.Millisecond) + assert.ElementsMatch(t, want, fake.startedScans()) +} + +// (a) A brand-new MANUAL-trust server — the default trust mode, which the +// spec-086 admission gate never touches — must get one informational scan. +func TestInformationalAdmissionScan_ManualTrustNewServer(t *testing.T) { + fake := newFakeSecurityScanner() + fake.scanResult["srv"] = &scanner.ScanSummary{Status: "clean"} + s := newInformationalTestServer(t, fake, nil, enabledServer("srv", config.TrustModeManual)) + + s.maybeStartInformationalScans(context.Background()) + waitForStartedScans(t, fake, []string{"srv"}) + + // A second servers.changed must neither re-scan nor treat it as new again. + s.maybeStartInformationalScans(context.Background()) + time.Sleep(50 * time.Millisecond) + assert.Equal(t, []string{"srv"}, fake.startedScans()) + + // It must not have been approved or unquarantined: informational only. + assert.Empty(t, fake.approvedServers()) +} + +// A server that was already configured at process start is the sweep's job, not +// the admission path's — otherwise every restart would re-scan the whole set. +func TestInformationalAdmissionScan_PreexistingServerNotRescanned(t *testing.T) { + fake := newFakeSecurityScanner() + pre := enabledServer("old", config.TrustModeManual) + s := newInformationalTestServer(t, fake, nil, pre) + s.seedKnownServers([]*config.ServerConfig{pre}) + + s.maybeStartInformationalScans(context.Background()) + time.Sleep(50 * time.Millisecond) + assert.Empty(t, fake.startedScans()) +} + +// An already-scanned server is never re-scanned by the informational path. +func TestInformationalAdmissionScan_AlreadyScannedSkipped(t *testing.T) { + fake := newFakeSecurityScanner() + fake.summaries["srv"] = &scanner.ScanSummary{Status: "warnings"} + s := newInformationalTestServer(t, fake, nil, enabledServer("srv", config.TrustModeAuto)) + + s.maybeStartInformationalScans(context.Background()) + time.Sleep(50 * time.Millisecond) + assert.Empty(t, fake.startedScans()) +} + +// (b) The scan-mode gating path is unchanged: it still kicks exactly one scan +// for a quarantined, never-scanned scan-mode server, and the informational path +// deliberately leaves that server alone so it is never scanned twice. +func TestInformationalScan_ScanModeAdmissionPathUnchanged(t *testing.T) { + t.Run("informational path skips the server the gating path owns", func(t *testing.T) { + fake := newFakeSecurityScanner() + gated := &config.ServerConfig{ + Name: "gated", + TrustMode: string(config.TrustModeScan), + Quarantined: true, + Enabled: true, + } + s := newInformationalTestServer(t, fake, nil, gated) + + s.maybeStartInformationalScans(context.Background()) + time.Sleep(50 * time.Millisecond) + assert.Empty(t, fake.startedScans(), "the scan-mode admission gate owns this server") + + // The gating path itself still behaves exactly as before. + s.maybeStartAdmissionScans(context.Background()) + waitForStartedScans(t, fake, []string{"gated"}) + }) + + t.Run("gating path first: informational path does not double-scan", func(t *testing.T) { + fake := newFakeSecurityScanner() + fake.scanResult["gated"] = &scanner.ScanSummary{Status: "clean"} + gated := &config.ServerConfig{ + Name: "gated", + TrustMode: string(config.TrustModeScan), + Quarantined: true, + Enabled: true, + } + s := newInformationalTestServer(t, fake, nil, gated) + + s.maybeStartAdmissionScans(context.Background()) + waitForStartedScans(t, fake, []string{"gated"}) + + s.maybeStartInformationalScans(context.Background()) + time.Sleep(50 * time.Millisecond) + assert.Equal(t, []string{"gated"}, fake.startedScans()) + }) + + t.Run("scan-mode server that is NOT quarantined is informational", func(t *testing.T) { + fake := newFakeSecurityScanner() + fake.scanResult["srv"] = &scanner.ScanSummary{Status: "clean"} + s := newInformationalTestServer(t, fake, nil, enabledServer("srv", config.TrustModeScan)) + + s.maybeStartInformationalScans(context.Background()) + waitForStartedScans(t, fake, []string{"srv"}) + assert.Empty(t, fake.approvedServers()) + }) +} + +// (d) Disabled servers are skipped by both paths — a scan would have to start +// the server to export its tool definitions. +func TestInformationalScan_DisabledServersSkipped(t *testing.T) { + fake := newFakeSecurityScanner() + disabled := &config.ServerConfig{Name: "off", TrustMode: string(config.TrustModeManual), Enabled: false} + s := newInformationalTestServer(t, fake, nil, disabled) + + s.maybeStartInformationalScans(context.Background()) + s.runBaselineSweep(context.Background()) + time.Sleep(50 * time.Millisecond) + + assert.Empty(t, fake.startedScans()) + // The sweep still completes (nothing to do) and records its marker. + state, err := s.runtime.StorageManager().LoadBaselineSweepState() + require.NoError(t, err) + require.NotNil(t, state) + assert.Equal(t, 0, state.ServersScanned) +} + +// (c) The sweep runs once; the persisted marker prevents any re-run, even for a +// fresh process whose in-memory dedupe maps are empty. +func TestBaselineSweep_RunsOnceThenMarkerBlocksRerun(t *testing.T) { + fake := newFakeSecurityScanner() + fake.scanResult["a"] = &scanner.ScanSummary{ + Status: "warnings", + FindingCounts: &scanner.FindingCounts{Warning: 2, Total: 2}, + } + fake.scanResult["b"] = &scanner.ScanSummary{ + Status: "clean", + FindingCounts: &scanner.FindingCounts{Total: 0}, + } + a := enabledServer("a", config.TrustModeManual) + b := enabledServer("b", config.TrustModeAuto) + s := newInformationalTestServer(t, fake, nil, a, b) + s.seedKnownServers([]*config.ServerConfig{a, b}) + + s.runBaselineSweep(context.Background()) + assert.ElementsMatch(t, []string{"a", "b"}, fake.startedScans()) + + state, err := s.runtime.StorageManager().LoadBaselineSweepState() + require.NoError(t, err) + require.NotNil(t, state, "the sweep must persist its one-shot marker") + assert.Equal(t, 2, state.ServersScanned) + assert.Equal(t, 2, state.Findings) + + // Simulate a restart: fresh in-memory state, no scan summaries yet. Only the + // persisted marker can stop the sweep now. + restarted := &Server{ + logger: zap.NewNop(), + runtime: s.runtime, + securityScanner: fake, + admissionScanKicked: make(map[string]bool), + infoScanKnown: make(map[string]bool), + infoScanQueued: make(map[string]bool), + infoScanSettleTimeout: time.Second, + } + fake.mu.Lock() + fake.summaries = map[string]*scanner.ScanSummary{} + fake.mu.Unlock() + + restarted.runBaselineSweep(context.Background()) + assert.ElementsMatch(t, []string{"a", "b"}, fake.startedScans(), "the marker must prevent a second sweep") +} + +// A sweep cancelled by shutdown must NOT persist the marker, so the next start +// resumes it. +func TestBaselineSweep_CancelledDoesNotPersistMarker(t *testing.T) { + fake := newFakeSecurityScanner() + s := newInformationalTestServer(t, fake, nil, enabledServer("a", config.TrustModeManual)) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + s.runBaselineSweep(ctx) + + assert.Empty(t, fake.startedScans()) + state, err := s.runtime.StorageManager().LoadBaselineSweepState() + require.NoError(t, err) + assert.Nil(t, state) +} + +// A sweep where every candidate failed (e.g. servers still connecting) must not +// burn the one-shot marker — the next start has to retry. +func TestBaselineSweep_AllScansFailedKeepsMarkerUnset(t *testing.T) { + fake := newFakeSecurityScanner() + fake.startScanErr = errors.New("server is disconnected") + s := newInformationalTestServer(t, fake, nil, enabledServer("a", config.TrustModeManual)) + + s.runBaselineSweep(context.Background()) + + state, err := s.runtime.StorageManager().LoadBaselineSweepState() + require.NoError(t, err) + assert.Nil(t, state, "a sweep that scanned nothing must stay retryable") +} + +// A scan that fails to start releases its claim so a later servers.changed can +// retry it. +func TestInformationalScan_FailedStartIsRetryable(t *testing.T) { + fake := newFakeSecurityScanner() + fake.startScanErr = errors.New("server unreachable") + s := newInformationalTestServer(t, fake, nil, enabledServer("srv", config.TrustModeManual)) + + s.maybeStartInformationalScans(context.Background()) + require.Eventually(t, func() bool { + s.infoScanMu.Lock() + defer s.infoScanMu.Unlock() + return !s.infoScanQueued["srv"] + }, 2*time.Second, 5*time.Millisecond) + + fake.mu.Lock() + fake.startScanErr = nil + fake.mu.Unlock() + + // The server is no longer "new", so the sweep is what retries it. + s.runBaselineSweep(context.Background()) + assert.Equal(t, []string{"srv"}, fake.startedScans()) +} + +// (e) The security.auto_baseline_scan kill switch stops BOTH informational +// paths, while leaving the trust_mode:"scan" admission gate untouched. +func TestInformationalScan_KillSwitch(t *testing.T) { + disabled := false + sec := &config.SecurityConfig{AutoBaselineScan: &disabled} + + fake := newFakeSecurityScanner() + gated := &config.ServerConfig{ + Name: "gated", + TrustMode: string(config.TrustModeScan), + Quarantined: true, + Enabled: true, + } + s := newInformationalTestServer(t, fake, sec, enabledServer("srv", config.TrustModeManual), gated) + + s.maybeStartInformationalScans(context.Background()) + s.runBaselineSweep(context.Background()) + time.Sleep(50 * time.Millisecond) + assert.Empty(t, fake.startedScans(), "the kill switch must suppress every automatic informational scan") + + state, err := s.runtime.StorageManager().LoadBaselineSweepState() + require.NoError(t, err) + assert.Nil(t, state, "a suppressed sweep must not burn the one-shot marker") + + // The gating path is a separate contract and keeps working. + s.maybeStartAdmissionScans(context.Background()) + waitForStartedScans(t, fake, []string{"gated"}) +} + +func TestScanModeAdmissionOwns(t *testing.T) { + tests := []struct { + name string + sc *config.ServerConfig + hasBaseline bool + want bool + }{ + {"nil", nil, false, false}, + {"scan+quarantined+no baseline", &config.ServerConfig{TrustMode: "scan", Quarantined: true}, false, true}, + {"scan+quarantined+baseline (re-quarantine)", &config.ServerConfig{TrustMode: "scan", Quarantined: true}, true, false}, + {"scan+not quarantined", &config.ServerConfig{TrustMode: "scan"}, false, false}, + {"manual+quarantined", &config.ServerConfig{TrustMode: "manual", Quarantined: true}, false, false}, + {"empty trust mode (manual) + quarantined", &config.ServerConfig{Quarantined: true}, false, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, scanModeAdmissionOwns(tt.sc, tt.hasBaseline)) + }) + } +} + +func TestIsTerminalScanStatus(t *testing.T) { + for _, status := range []string{"clean", "warnings", "dangerous", "failed"} { + assert.True(t, isTerminalScanStatus(status), status) + } + for _, status := range []string{"", "scanning", "not_scanned", "queued"} { + assert.False(t, isTerminalScanStatus(status), status) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 9057fc855..09af448dd 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -107,6 +107,19 @@ type Server struct { admissionScanMu sync.Mutex admissionScanKicked map[string]bool + // Informational Pass-1 baseline scanning (see scan_informational.go). + // infoScanKnown holds every server name observed since process start, so a + // servers.changed carrying a name that is not in it is a NEW admission; + // infoScanQueued dedupes the one informational scan per server per process. + // Both are guarded by infoScanMu. infoScanRunMu serializes scan EXECUTION + // across the admission path and the sweep. + infoScanMu sync.Mutex + infoScanKnown map[string]bool + infoScanQueued map[string]bool + infoScanRunMu sync.Mutex + infoScanSettleTimeout time.Duration + infoScanSweepDelay time.Duration + // Spec 024: Shutdown info for lifecycle events shutdownReason string shutdownSignal string @@ -212,7 +225,18 @@ func NewServerWithConfigPath(cfg *config.Config, configPath string, logger *zap. serveErrCh: make(chan error, 1), observability: obsManager, admissionScanKicked: make(map[string]bool), + + infoScanKnown: make(map[string]bool), + infoScanQueued: make(map[string]bool), + infoScanSettleTimeout: informationalScanSettleTimeout, + infoScanSweepDelay: baselineSweepStartDelay, } + // Record the servers this process started with: they are the baseline + // sweep's job, and anything that shows up later is a NEW admission that gets + // its own informational scan. Seeded from the startup config (available + // synchronously) rather than from the first servers.changed, so the very + // first server a fresh install adds still counts as new. + server.seedKnownServers(cfg.Servers) mcpProxy := NewMCPProxyServer( rt.StorageManager(), @@ -483,6 +507,11 @@ func (s *Server) listenForRoutingModeRefresh() { // one-shot admission scan for any scan-mode, still-quarantined, // never-scanned server. Idempotent (see maybeStartAdmissionScans). s.maybeStartAdmissionScans(context.Background()) + // Every NEWLY admitted server, in ANY trust mode, also gets one + // INFORMATIONAL Pass-1 baseline scan so its security badge is + // populated. It drives no gating and deliberately skips the servers + // the scan-mode admission gate above already owns. + s.maybeStartInformationalScans(s.informationalScanContext()) case runtime.EventTypeConfigReloaded: // Spec 077 US3: config hot-reload (file edit or /api/v1/config/apply) // must re-gate the scanner so a security.deep_scan.* toggle takes @@ -2498,6 +2527,13 @@ func (s *Server) startCustomHTTPServer(ctx context.Context, streamableServer *se mgmtSvc.SetScanSummaryEnricher(&scanSummaryEnricherAdapter{scanner: secService}) } s.securityScanner = secService + // One-shot post-upgrade baseline sweep: scan enabled servers that have + // never been scanned so their badges stop reading "not scanned" on an + // install that predates automatic scanning. Backgrounded (never delays + // startup), serialized through the informational scan path, cancelled + // with the server context, and gated by a persisted marker so it runs + // exactly once. + go s.runBaselineSweep(ctx) } // Wire server edition multi-user OAuth (no-op in personal edition) wireServerEditionOAuth(s, httpAPIServer) diff --git a/internal/storage/baseline_sweep_test.go b/internal/storage/baseline_sweep_test.go new file mode 100644 index 000000000..50b91eab5 --- /dev/null +++ b/internal/storage/baseline_sweep_test.go @@ -0,0 +1,56 @@ +package storage_test + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/storage" +) + +// The one-shot informational baseline sweep is gated purely by the presence of +// this marker, so absence must read as (nil, nil) and a saved marker must +// survive a reopen of the database. +func TestBaselineSweepMarker_RoundTrip(t *testing.T) { + dir := t.TempDir() + + mgr, err := storage.NewManager(dir, zap.NewNop().Sugar()) + require.NoError(t, err) + + state, err := mgr.LoadBaselineSweepState() + require.NoError(t, err) + assert.Nil(t, state, "absent marker must read as nil, not an error") + + now := time.Now().UTC().Truncate(time.Second) + require.NoError(t, mgr.SaveBaselineSweepState(&storage.BaselineSweepState{ + Version: "v1.2.3", + CompletedAt: now, + ServersScanned: 4, + Findings: 7, + })) + + require.NoError(t, mgr.Close()) + + reopened, err := storage.NewManager(dir, zap.NewNop().Sugar()) + require.NoError(t, err) + t.Cleanup(func() { _ = reopened.Close() }) + + state, err = reopened.LoadBaselineSweepState() + require.NoError(t, err) + require.NotNil(t, state) + assert.Equal(t, "v1.2.3", state.Version) + assert.True(t, state.CompletedAt.Equal(now)) + assert.Equal(t, 4, state.ServersScanned) + assert.Equal(t, 7, state.Findings) +} + +func TestBaselineSweepMarker_NilStateRejected(t *testing.T) { + mgr, err := storage.NewManager(t.TempDir(), zap.NewNop().Sugar()) + require.NoError(t, err) + t.Cleanup(func() { _ = mgr.Close() }) + + assert.Error(t, mgr.SaveBaselineSweepState(nil)) +} diff --git a/internal/storage/manager.go b/internal/storage/manager.go index 465f485aa..567069ad4 100644 --- a/internal/storage/manager.go +++ b/internal/storage/manager.go @@ -798,6 +798,70 @@ func (m *Manager) ClearDockerRecoveryState() error { }) } +// Informational baseline scan sweep marker + +// SaveBaselineSweepState persists the one-shot informational baseline sweep +// marker. Once this record exists the sweep never runs again on this +// installation, so it is written only after a sweep actually completed. +func (m *Manager) SaveBaselineSweepState(state *BaselineSweepState) error { + if state == nil { + return fmt.Errorf("baseline sweep state is nil") + } + + m.mu.Lock() + defer m.mu.Unlock() + + return m.db.db.Update(func(tx *bbolt.Tx) error { + bucket, err := tx.CreateBucketIfNotExists([]byte(MetaBucket)) + if err != nil { + return fmt.Errorf("failed to create meta bucket: %w", err) + } + + data, err := json.Marshal(state) + if err != nil { + return fmt.Errorf("failed to marshal baseline sweep state: %w", err) + } + + return bucket.Put([]byte(BaselineSweepDoneKey), data) + }) +} + +// LoadBaselineSweepState returns the one-shot baseline sweep marker, or +// (nil, nil) when the sweep has never completed on this installation. A read +// error is returned as an error — callers must treat "unknown" as "do not +// sweep" rather than re-running the sweep on every start. +func (m *Manager) LoadBaselineSweepState() (*BaselineSweepState, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + var state *BaselineSweepState + + err := m.db.db.View(func(tx *bbolt.Tx) error { + bucket := tx.Bucket([]byte(MetaBucket)) + if bucket == nil { + return nil + } + + data := bucket.Get([]byte(BaselineSweepDoneKey)) + if data == nil { + return nil + } + + parsed := &BaselineSweepState{} + if err := json.Unmarshal(data, parsed); err != nil { + return err + } + state = parsed + return nil + }) + + if err != nil { + return nil, fmt.Errorf("failed to load baseline sweep state: %w", err) + } + + return state, nil +} + // Maintenance operations // Backup creates a backup of the database diff --git a/internal/storage/models.go b/internal/storage/models.go index 5947196a6..dbdfd549a 100644 --- a/internal/storage/models.go +++ b/internal/storage/models.go @@ -119,8 +119,29 @@ type OnboardingState struct { const ( SchemaVersionKey = "schema" DockerRecoveryStateKey = "docker_recovery_state" + // BaselineSweepDoneKey marks that the one-shot informational baseline scan + // sweep has already run on this installation. Presence of the record — not + // its contents — is what suppresses a re-run, so the sweep stays one-shot + // across restarts and upgrades. + BaselineSweepDoneKey = "baseline_sweep_done" ) +// BaselineSweepState records the outcome of the one-shot informational baseline +// scan sweep (the post-upgrade catch-up that scans pre-existing servers which +// have never been scanned). Stored in MetaBucket under BaselineSweepDoneKey. +// Absence of the record means "the sweep has never completed here". +type BaselineSweepState struct { + // Version is the mcpproxy build version that completed the sweep. Recorded + // for diagnostics; the sweep does not re-run on a version change. + Version string `json:"version,omitempty"` + // CompletedAt is when the sweep finished. + CompletedAt time.Time `json:"completed_at"` + // ServersScanned is how many servers the sweep actually scanned. + ServersScanned int `json:"servers_scanned"` + // Findings is the total number of findings the sweep's scans produced. + Findings int `json:"findings"` +} + // Current schema version const CurrentSchemaVersion = 3 diff --git a/oas/docs.go b/oas/docs.go index d58aaa6a2..91a2419f5 100644 --- a/oas/docs.go +++ b/oas/docs.go @@ -6,7 +6,7 @@ import "github.com/swaggo/swag/v2" const docTemplate = `{ "schemes": {{ marshal .Schemes }}, - "components": {"schemas":{"config.ConcurrencyDefaults":{"description":"ServerConcurrencyDefaults is scope (b) of FR-020: the blanket per-server\ndefault set inherited by every server that does not override a setting.\nAbsent (the default) = no per-server limiting unless a server configures\nit explicitly. File/API-configured only — no env scheme (FR-022).","properties":{"max_concurrent_requests":{"type":"integer"},"queue_size":{"type":"integer"},"queue_timeout":{"type":"string"}},"type":"object"},"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_max_size_mb":{"description":"Max total activity-log size in MB before pruning oldest (default: 256, 0=disabled)","type":"integer"},"activity_retention_days":{"description":"Activity logging settings (RFC-003)","type":"integer"},"aggregate_upstream_prompts":{"description":"AggregateUpstreamPrompts, when true, aggregates every connected upstream\nserver's advertised MCP prompts into mcpproxy's own prompts/list\n(exposed as \"\u003cserver\u003e__\u003cprompt\u003e\"). OFF by default: users are safe by\ndefault and opt in deliberately. EnablePrompts still governs the built-in\nprompts + the prompts capability; this flag gates ONLY the upstream\naggregation performed by RefreshPrompts. Hot-reloadable.","type":"boolean"},"allow_private_registry_fetch":{"description":"AllowPrivateRegistryFetch opts out of the registry SSRF guard (MCP-1076,\nCWE-918). By default (false) registry fetches refuse any host that is — or\nresolves to — a non-routable address (loopback, RFC1918/CGNAT private,\nlink-local incl. the 169.254.169.254 cloud-metadata endpoint), so a\nmalicious or typo'd registry source cannot turn the daemon into a\nrequest-forgery vector against internal services.\n\nThis opt-out is BLANKET (all-or-nothing): setting it true disables the\nguard for EVERY non-routable range at once — loopback, RFC1918/CGNAT\nprivate, link-local AND the 169.254.169.254 cloud-metadata endpoint. There\nis no way to allow only loopback; enabling it for a localhost dev registry\nalso re-opens the cloud-metadata SSRF vector. Set true ONLY when you\nintentionally run a trusted registry mirror on an internal/private address,\nideally on a host with no cloud-metadata exposure. The change takes effect\nonly on daemon (re)start or config reload.","type":"boolean"},"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_parallel":{"description":"Default concurrency for call_tools() batches (1-32, default: 8)","type":"integer"},"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"},"forward_proxy_env":{"description":"ForwardProxyEnv opts in to forwarding the ambient HTTP(S)/ALL/NO/FTP proxy\nenvironment variables to spawned stdio upstream servers (MCP-2769). OFF by\ndefault: proxy URLs commonly embed credentials (http://user:pass@proxy), so\nforwarding them to every upstream is a credential-leak risk. When enabled,\nvalues are forwarded with their userinfo (credentials) redacted.","type":"boolean"},"health_check_interval":{"description":"Discovery \u0026 health-check cadence (spec 074, #608). Both are *Duration\ntri-state pointers: nil = inherit the built-in default; a pointer to 0s =\nthe loop is disabled; a positive value = that interval. Defaults live only\nin the resolvers (ResolveHealthCheckInterval / ResolveToolDiscoveryInterval)\nso an unset key behaves exactly as before this feature (SC-005). Validated\nin Validate(): health-check ∈ {0} ∪ [5s,1h]; tool-discovery ∈ {0} ∪ [30s,24h].","type":"string"},"http_idle_timeout":{"description":"HTTPIdleTimeout caps how long an idle keep-alive connection is kept open.\nUnset = 180s. \"0s\" removes the dedicated idle deadline, but net/http then\nfalls back to ReadTimeout — idle is fully unbounded only when\nhttp_read_timeout is also \"0s\". Requires a restart.","type":"string"},"http_read_timeout":{"description":"HTTPReadTimeout caps how long reading a whole request (headers + body)\nmay take. Unset = 120s; \"0s\" disables it. Requires a restart.","type":"string"},"http_write_timeout":{"description":"HTTPWriteTimeout caps how long producing a whole response may take on\nnon-streaming endpoints (REST, Web UI, health). Unset = 120s; \"0s\"\ndisables it globally. MCP and SSE /events routes are exempt by design.","type":"string"},"init_timeout":{"description":"InitTimeout is the global default deadline for an upstream's MCP\n` + "`" + `initialize` + "`" + ` handshake (MCP-3322 / GH #760). *Duration tri-state: nil =\ninherit the built-in 30s default; a positive value = that deadline. A\nper-server InitTimeout overrides this. Resolved by ResolveInitTimeout;\nvalidated to {0} ∪ [1s, 30m] in Validate(). Servers doing legitimate\nfirst-run warmup (cache/index build) before answering ` + "`" + `initialize` + "`" + ` can\nraise this so they are not killed mid-startup.","type":"string"},"instructions":{"description":"Instructions text returned in the MCP initialize response to guide AI agents.\nWhen empty, a built-in default is used that explains retrieve_tools workflow.","type":"string"},"intent_declaration":{"$ref":"#/components/schemas/config.IntentDeclarationConfig"},"listen":{"type":"string"},"logging":{"$ref":"#/components/schemas/config.LogConfig"},"max_concurrent_requests":{"description":"Concurrency limits (spec 093, GH #955). Scope (a) of FR-020: the GLOBAL\nAGGREGATE limiter — one proxy-wide cap on concurrently running upstream\ntool calls, with its own bounded wait queue. Tri-state pointers: absent =\nthe limiter does not exist (default, zero behavior change); an explicit 0\nmax also disables it; positive = that cap. This scope is NEVER a\nper-server inheritance source — per-server values come from\nServerConcurrencyDefaults / the per-server overrides — but a server's\neffective concurrency is bounded by BOTH its own limiter and this one.\nResolved by ResolveGlobalConcurrency; hot-reloadable; overridable via\nMCPPROXY_MAX_CONCURRENT_REQUESTS / _QUEUE_SIZE / _QUEUE_TIMEOUT (FR-022).","type":"integer"},"max_result_size_chars":{"description":"Advertised on every tool as ` + "`" + `_meta.anthropic/maxResultSizeChars` + "`" + `; raises Claude Code's inline-response ceiling from 50k to up to 500k chars. Set to 0 to disable.","type":"integer"},"mcpServers":{"items":{"$ref":"#/components/schemas/config.ServerConfig"},"type":"array","uniqueItems":false},"oauth_expiry_warning_hours":{"description":"Health status settings","type":"number"},"observability":{"$ref":"#/components/schemas/config.ObservabilityConfig"},"output_sanitisation":{"$ref":"#/components/schemas/config.OutputSanitisationConfig"},"output_validation":{"$ref":"#/components/schemas/config.OutputValidationConfig"},"profiles":{"description":"Profiles are optional named, server-scoped views exposed at /mcp/p/\u003cname\u003e\n(Spec 057). Absent/empty is fully supported — /mcp is unchanged and configs\nwithout this key serialize byte-identically (SC-004).","items":{"$ref":"#/components/schemas/config.ProfileConfig"},"type":"array","uniqueItems":false},"quarantine_enabled":{"description":"QuarantineEnabled controls whether quarantine is active. It gates two\nthings together:\n 1. Server-level auto-quarantine for newly added servers (issue #370).\n When true, servers added via the upstream_servers MCP tool or the\n REST API default to quarantined=true; when false, they default to\n quarantined=false. Explicit per-request values always win.\n 2. Tool-level quarantine (Spec 032): per-tool SHA-256 approval of\n tool descriptions/schemas.\nWhen nil (default), quarantine is enabled (secure by default). Set to\nexplicit false to opt out of both. Per-server SkipQuarantine still\napplies for the tool-level check on individual servers.","type":"boolean"},"queue_size":{"type":"integer"},"queue_timeout":{"type":"string"},"read_only_mode":{"type":"boolean"},"registries":{"description":"Registries configuration for MCP server discovery","items":{"$ref":"#/components/schemas/config.RegistryEntry"},"type":"array","uniqueItems":false},"registries_locked":{"description":"RegistriesLocked is an enterprise stub knob (MCP-866): when true, runtime\nadditions of custom registries (e.g. ` + "`" + `registry add-source` + "`" + `, the REST/MCP\nadd-source surface) are rejected so an administrator can pin the discovery\nsources. Built-in defaults are unaffected. Documented but otherwise inert\nbeyond the add-source rejection.","type":"boolean"},"require_mcp_auth":{"description":"Require authentication on /mcp endpoint (default: false)","type":"boolean"},"reveal_secret_headers":{"description":"RevealSecretHeaders, when true, disables the redaction of the\nsecret-bearing server fields — sensitive header values (Authorization,\nX-API-Key, Cookie, …), env-var secrets, and URL query credentials — in\nresponses from the ` + "`" + `upstream_servers` + "`" + ` MCP tool, the ` + "`" + `/api/v1/servers` + "`" + `\nREST API, and the SSE event stream. It also lets URL secrets echoed\ninto last_error / health.detail through unscrubbed.\n\nDefault false — sensitive values are surfaced masked as\n` + "`" + `••••\u003clast2\u003e (\u003cN\u003e chars)` + "`" + ` (error strings use ` + "`" + `***REDACTED***` + "`" + `) so an\nMCP agent cannot read Bearer tokens / API keys / URL secrets out of\nanother upstream's config (PR #425, issue #872). ${env:…}/${keyring:…}\nreferences are labels, not secrets, and pass through unchanged.\n\nThe Web UI / macOS tray edit forms work without seeing the real\nvalues: PATCH /api/v1/servers/{id} deep-merges (omitted keys are\npreserved, see ` + "`" + `headers_remove` + "`" + ` / ` + "`" + `env_remove` + "`" + ` for explicit\ndeletes), so clients compute a diff and only send the keys that\nactually changed. Redacted-but-unchanged values never round-trip\n— the backend keeps the real string. Set this to true if a\ndownstream tool genuinely needs raw values in the response.","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"},"security":{"$ref":"#/components/schemas/config.SecurityConfig"},"sensitive_data_detection":{"$ref":"#/components/schemas/config.SensitiveDataDetectionConfig"},"server_concurrency_defaults":{"$ref":"#/components/schemas/config.ConcurrencyDefaults"},"telemetry":{"$ref":"#/components/schemas/config.TelemetryConfig"},"tls":{"$ref":"#/components/schemas/config.TLSConfig"},"tokenizer":{"$ref":"#/components/schemas/config.TokenizerConfig"},"tool_discovery_interval":{"type":"string"},"tool_response_limit":{"type":"integer"},"tool_response_mode":{"description":"Tool response mode (Spec 085): how retrieve_tools serializes results.\nValid values: \"\" (= full), \"full\" (default: today's schema-bearing\nentries), \"compact\" (signature + first-sentence entries). Orthogonal to\nrouting_mode — routing_mode selects the tool SURFACE, this selects the\nSERIALIZATION within the retrieve_tools surface. Serialization-only: it\nnever affects the query, ranking, or result set. Hot-reloadable.","type":"string"},"tool_response_session_risk_warning":{"description":"ToolResponseSessionRiskWarning controls whether the prose ` + "`" + `warning` + "`" + ` field\nis included in the ` + "`" + `session_risk` + "`" + ` object returned by ` + "`" + `retrieve_tools` + "`" + `.\nThe structured fields (level, lethal_trifecta, has_open_world_tools, etc.)\nare always included. Default: false (quiet for LLM clients) — see issue #406.\nMost tools lack annotations, so the MCP-spec defaults treat them as fully\npermissive across all three risk axes, which makes the prose warning fire\non almost every call and wastes tokens.","type":"boolean"},"tools_limit":{"type":"integer"},"toon_min_savings_pct":{"description":"ToonMinSavingsPct is the minimum byte-savings percentage (validated\n1-90; 0/unset → 15) the complete TOON emission (marker + hint + body)\nmust achieve over the exact passthrough emission for adaptive mode to\nencode a block. Byte savings approximate token savings for the tabular\npayload class; the spec-083 profiler reports true token deltas.\nGlobal-only (no per-server override, FR-001).","type":"integer"},"toon_output":{"description":"ToonOutput selects the TOON encoding mode for call_tool_* result text\nblocks (spec 084): \"off\" (default — responses byte-identical to\npre-feature behavior), \"adaptive\" (encode only tabular-uniform payloads\nthat beat compact JSON by ToonMinSavingsPct), or \"always\"\n(benchmark/debug only — encodes every JSON-parseable block and can\nINCREASE token cost). Per-server override: ServerConfig.ToonOutput.\nResolved by ResolveToonOutput; hot-reloadable.","type":"string"},"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"},"trusted_hosts":{"description":"TrustedHosts lists non-loopback Host header values accepted on loopback\nlisteners (GH #898). DNS-rebinding protection rejects requests whose Host\nheader is not a loopback address when mcpproxy listens on loopback; a\nreverse proxy (nginx → 127.0.0.1) forwarding the public domain in Host\ntrips it. Entries are hostnames, case-insensitive; an entry without a\nport matches any port, with a port it must match exactly; a leading dot\n(\".example.com\") is a subdomain wildcard. The single entry \"*\" disables\nHost and Origin validation entirely. The same list also validates the\nOrigin header when present (MCP spec DNS-rebinding defense). Empty\n(default) keeps full protection. Env override: MCPPROXY_TRUSTED_HOSTS\n(comma-separated).","items":{"type":"string"},"type":"array","uniqueItems":false},"update_check":{"$ref":"#/components/schemas/config.UpdateCheckConfig"}},"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.DeepScanConfig":{"description":"DeepScan is the opt-in \"deep scan\" layer (Spec 077 US3). It subsumes the\ndeprecated top-level scanner_fetch_package_source / scanner_disable_no_new_privileges\nkeys (migrated on load) and gates the heavy Docker-based scanners + source\nextraction. Disabled by default (FR-006): only the deterministic in-process\nbaseline scanner runs. A deep-scan failure NEVER changes the baseline verdict\n(FR-007/FR-008).","properties":{"disable_no_new_privileges":{"description":"DisableNoNewPrivileges, when true, omits the ` + "`" + `--security-opt\nno-new-privileges` + "`" + ` flag from scanner container runs (snap-docker/AppArmor\nescape hatch). Absorbs the deprecated top-level\nscanner_disable_no_new_privileges. Default false.","type":"boolean"},"enabled":{"description":"Enabled is the master opt-in for the heavy layer (FR-006). Default false.","type":"boolean"},"fetch_package_source":{"description":"FetchPackageSource controls whether the scanner fetches the PUBLISHED\nsource of package-runner servers (npx/uvx) — without executing it — when\nno local source is available. Absorbs the deprecated top-level\nscanner_fetch_package_source. Default (nil) is ENABLED within deep scan.","type":"boolean"},"scanners":{"description":"Scanners optionally restricts which deep scanners may run under the\numbrella (by scanner id). Empty ⇒ all enabled deep scanners are eligible.","items":{"type":"string"},"type":"array","uniqueItems":false}},"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"},"enable_cache_volume":{"description":"Mount shared cache volumes for faster restarts (default: true)","type":"boolean"},"enabled":{"description":"Global enable/disable for Docker isolation (legacy; superseded by Mode)","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"},"mode":{"description":"Isolation mode: \"docker\" | \"sandbox\" | \"none\" (MCP-34.2). Unset per-server inherits the global mode; unset globally falls back to the legacy \"enabled\" flag (true ⇒ docker, false ⇒ none)","type":"string","x-enum-varnames":["IsolationModeDocker","IsolationModeSandbox","IsolationModeNone"]},"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; legacy, superseded by Mode)","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"},"mode":{"$ref":"#/components/schemas/config.IsolationMode"},"network_mode":{"description":"Custom network mode for this server","type":"string"},"working_dir":{"description":"Custom working directory in container","type":"string"}},"type":"object"},"config.IsolationMode":{"description":"Isolation mode: \"docker\" | \"sandbox\" | \"none\" (MCP-34.2). Unset per-server inherits the global mode; unset globally falls back to the legacy \"enabled\" flag (true ⇒ docker, false ⇒ none)","type":"string","x-enum-varnames":["IsolationModeDocker","IsolationModeSandbox","IsolationModeNone"]},"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.MetricsExporterConfig":{"description":"Metrics gates the Prometheus /metrics scrape endpoint (MCP-32). Disabled\nby default — operators opt in for k8s/enterprise deployments.","properties":{"enabled":{"description":"Enabled exposes /metrics on the existing HTTP listener when true.","type":"boolean"}},"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.ObservabilityConfig":{"description":"Observability settings (Spec 069): usage aggregate cache/persistence cadence.","properties":{"metrics":{"$ref":"#/components/schemas/config.MetricsExporterConfig"},"tracing":{"$ref":"#/components/schemas/config.TracingExporterConfig"},"usage_cache_ttl":{"description":"UsageCacheTTL bounds the freshness of the usage endpoint's read cache for\nwide windows (FR-005). Default 5s.","type":"string"},"usage_persist_interval":{"description":"UsagePersistInterval is how often the actor-owned usage aggregate snapshot\nis flushed to storage. Default 30s.","type":"string"}},"type":"object"},"config.OutputSanitisationConfig":{"description":"Output sanitisation settings (Spec 054 Track B)","properties":{"max_redactions":{"description":"cap on redactions per response; default 100","type":"integer"},"response_action":{"description":"\"spotlight\" | \"redact\" | \"block\"; default \"spotlight\"","type":"string"},"spotlight_untrusted":{"description":"wrap untrusted output in spotlight markers; default true","type":"boolean"},"strip_classes":{"description":"classes to strip: ansi/c0c1/bidi/zero_width","items":{"type":"string"},"type":"array","uniqueItems":false},"strip_control_chars":{"description":"strip control-character classes; default false","type":"boolean"}},"type":"object"},"config.OutputValidationConfig":{"description":"Output-schema validation settings (Spec 056)","properties":{"max_bytes":{"description":"structured payload byte cap; default 5\u003c\u003c20","type":"integer"},"max_depth":{"description":"nesting depth cap; default 64","type":"integer"},"missing_structured_content":{"description":"\"allow\" | \"block\"; default \"allow\"","type":"string"},"mode":{"description":"\"off\" | \"warn\" | \"strict\"; default \"warn\"","type":"string"}},"type":"object"},"config.ProfileConfig":{"properties":{"name":{"description":"URL slug, validated","type":"string"},"servers":{"description":"references to mcpServers[].name","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"},"provenance":{"description":"Provenance is the trust tag for this registry (MCP-866):\nRegistryProvenanceOfficial for built-in defaults, RegistryProvenanceCustom\nfor user-added registries. It is authoritatively (re)computed by the\nregistries merge from whether the ID is a shipped default — a user cannot\nclaim \"official\" by writing it into their config.","type":"string"},"requires_key":{"description":"RequiresKey marks a registry that needs an API key to be queried. When\ntrue and no key is configured, the registry is skipped/marked unavailable\nrather than failing the whole search (FR-008).","type":"boolean"},"servers_url":{"type":"string"},"tags":{"items":{"type":"string"},"type":"array","uniqueItems":false},"url":{"type":"string"}},"type":"object"},"config.SecurityConfig":{"description":"Security scanner settings (Spec 039)","properties":{"deep_scan":{"$ref":"#/components/schemas/config.DeepScanConfig"},"integrity_check_interval":{"type":"string"},"integrity_check_on_restart":{"type":"boolean"},"runtime_read_only":{"type":"boolean"},"runtime_tmpfs_size":{"type":"string"},"scan_timeout_default":{"type":"string"},"scanner_disable_no_new_privileges":{"description":"Deprecated (Spec 077 US3): migrated on load into DeepScan.DisableNoNewPrivileges\n(see migrateDeepScanConfig). Retained only so existing configs that still carry\nthe top-level key parse; consumers MUST read the effective value via\nSecurityConfig.IsDisableNoNewPrivileges. Cleared after migration.\n\nScannerDisableNoNewPrivileges, when true, omits the\n` + "`" + `--security-opt no-new-privileges` + "`" + ` flag from scanner container runs.\n\nBackground: snap-installed Docker on Ubuntu confines dockerd under the\n` + "`" + `snap.docker.dockerd` + "`" + ` AppArmor profile. When runc tries to transition\nthe container into the inner ` + "`" + `docker-default` + "`" + ` profile to exec the\nentrypoint, AppArmor refuses the transition because NO_NEW_PRIVS\nforbids privilege/profile changes on exec — the result is EPERM\n(\"operation not permitted\") and every scanner fails immediately.\n\nSet this to true ONLY on hosts hitting that incompatibility. Scanner\ncontainers still run with read-only rootfs, tmpfs /tmp, no-network by\ndefault, and read-only source mounts, so the marginal isolation loss\nis small. The preferred fix remains replacing snap docker with a\ndistro-packaged docker.","type":"boolean"},"scanner_fetch_package_source":{"description":"Deprecated (Spec 077 US3): migrated on load into DeepScan.FetchPackageSource\n(see migrateDeepScanConfig). Retained only so existing configs that still carry\nthe top-level key parse; consumers MUST read the effective value via\nSecurityConfig.EffectiveFetchPackageSource. Cleared after migration.\n\nScannerFetchPackageSource controls whether the scanner fetches the\nPUBLISHED source of package-runner servers (npx/uvx) — without executing\nit — when no local source is available (no Docker container, no local\npackage cache, no working_dir). This is the primary quarantine/scan\ntarget: a quarantined-on-add server is never run locally, so without this\nthe scan degrades to tool-definitions-only (no real source-level\nanalysis). See MCP-2206.\n\nFetching uses ` + "`" + `npm pack --ignore-scripts` + "`" + ` (npm) and ` + "`" + `uv pip download` + "`" + ` /\n` + "`" + `pip download` + "`" + ` with ` + "`" + `--only-binary=:all:` + "`" + ` (Python), which only download +\nunpack archives and NEVER run install, build, or setup.py — a scanner must\nnot execute the untrusted code it is scanning. The Python\n` + "`" + `--only-binary=:all:` + "`" + ` flag is required because downloading an sdist would\ninvoke its build backend (setup.py); packages with no wheel fall back to\ntool-definitions-only instead. Extraction is hardened against path\ntraversal and decompression bombs.\n\nDefault (nil) is ENABLED. Set to false on air-gapped deployments to\nforbid the scanner's network egress; such servers then fall back to the\ntool-definitions-only scan with no regression.","type":"boolean"},"scanner_registry_url":{"type":"string"},"tpa_bundle_path":{"description":"TPABundlePath is the filesystem path to the tpa-db scanner-bundle.json\nthe offline TPA scanner runs (spec 086 FR-019: the signature-DB location\nMUST be configuration-driven, not hardcoded). Empty (the default) runs the\ncorpus embedded in this build.\n\nEnv override: MCPPROXY_TPA_BUNDLE_PATH. Hot-reloadable — the path is\nre-read on every config.reloaded event via\nscanner.Service.ApplySecurityConfig, so a corpus refresh needs no restart.\nA configured bundle that fails to read/parse/version-check/compile is\nREFUSED and the previously active corpus stays live (fail-closed, never\nfail-empty); the reason is logged and surfaced in the security overview's\nsignature_bundle.load_error.","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},"auto_approve_tool_changes":{"description":"AutoApproveToolChanges is the per-server intent to auto-approve tool\nchanges/additions (disabling per-server rug-pull protection). Supersedes\nskip_quarantine. MCP-2930 only ACCEPTS, persists, and migrates this flag — it\nis NOT yet consulted at runtime; auto-approval is still governed by\nSkipQuarantine until the trust-baseline behavior change (MCP-2931) migrates the\nruntime consumers onto it.\nTri-state pointer (mirrors QuarantineEnabled): nil = unset (inherit/migrate\nfrom legacy skip_quarantine), explicit true/false = honored as-is so an\nexplicit auto_approve_tool_changes:false overrides a legacy skip_quarantine:true.\nRead via IsAutoApproveToolChanges().","type":"boolean"},"command":{"type":"string"},"created":{"type":"string"},"disabled_tools":{"description":"Denylist: these tools are hidden; mutually exclusive with enabled_tools","items":{"type":"string"},"type":"array","uniqueItems":false},"enabled":{"type":"boolean"},"enabled_tools":{"description":"Allowlist: only these tools are exposed; mutually exclusive with disabled_tools","items":{"type":"string"},"type":"array","uniqueItems":false},"env":{"additionalProperties":{"type":"string"},"type":"object"},"expose_prompts":{"description":"ExposePrompts overrides whether this server's advertised MCP prompts are\naggregated into mcpproxy's prompts/list. nil (default) inherits the\ndefault-aggregate behavior (included if the server advertises\nCapabilities.Prompts); false excludes it regardless of capability.","type":"boolean"},"headers":{"additionalProperties":{"type":"string"},"description":"For HTTP servers","type":"object"},"health_check_interval":{"description":"Per-server discovery \u0026 health-check overrides (spec 074). Same *Duration\ntri-state as the global keys: nil = inherit the global value (or default),\npointer to 0s = disabled for this server, positive = that interval.\nHealthCheckInterval is fully wired into the per-server health loop;\nToolDiscoveryInterval is accepted/validated and round-trips for\nforward-compat, but the periodic index sweep is governed by the global\ncadence in this iteration (see spec 074 plan §C).","type":"string"},"init_timeout":{"description":"InitTimeout overrides the global init_timeout for this server's MCP\n` + "`" + `initialize` + "`" + ` handshake deadline (MCP-3322 / GH #760). *Duration tri-state:\nnil = inherit the global value (or 30s default), positive = that deadline.\nResolved by Config.ResolveInitTimeout; validated to {0} ∪ [1s, 30m]. Raise\nthis for upstreams that do legitimate first-run warmup (e.g. caching many\nchannels/users) before responding to ` + "`" + `initialize` + "`" + `.","type":"string"},"isolation":{"$ref":"#/components/schemas/config.IsolationConfig"},"launcher_wait_timeout":{"description":"LauncherWaitTimeout caps how long mcpproxy will wait for a locally-launched\nHTTP/SSE upstream's URL to become reachable after Spawn(). Only consulted\nwhen the server is configured with both Command and an HTTP/SSE URL — i.e.,\nmcpproxy starts the process AND connects via network. Stdio servers ignore\nthis field. Zero or unset → 30s default.","type":"string"},"max_concurrent_requests":{"description":"Per-server concurrency overrides — scope (c) of FR-020 (spec 093, #955).\nTri-state per setting, exactly like HealthCheckInterval: absent = inherit\nthe per-server default set (server_concurrency_defaults), explicit 0 =\ndisable that setting for this server (0 max = no per-server limiter at\nall; 0 queue_size = no pending capacity, shed immediately at the cap),\npositive = override. The global aggregate limiter is never inherited from\nhere — it applies on top, so effective concurrency is min(per-server,\nglobal). Resolved by Config.ResolveServerConcurrency.","type":"integer"},"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"},"queue_size":{"type":"integer"},"queue_timeout":{"type":"string"},"reconnect_on_use":{"description":"Attempt reconnection when a tool call targets a disconnected server","type":"boolean"},"shared":{"description":"Server edition: shared with all users","type":"boolean"},"skip_quarantine":{"description":"SkipQuarantine is DEPRECATED (MCP-2930): use AutoApproveToolChanges instead.\nKept for back-compat parsing; on config load a legacy skip_quarantine:true is\nmigrated to auto_approve_tool_changes:true only when the new field is unset\n(see normalizeServerQuarantineFlags).","type":"boolean"},"source_registry_id":{"description":"SourceRegistryID records which registry this server was added from (empty\nfor manually-configured servers). MCP-866: surfaced in the approval /\nquarantine view so a reviewer can see a server's origin.","type":"string"},"source_registry_provenance":{"description":"SourceRegistryProvenance records the source registry's provenance at add\ntime (RegistryProvenanceOfficial / RegistryProvenanceCustom). It is purely\ninformational (MCP-1072) — surfaced so a reviewer can see a server's origin\n— and no longer gates quarantine or skip_quarantine.","type":"string"},"tool_discovery_interval":{"type":"string"},"toon_output":{"description":"ToonOutput overrides the global toon_output mode for this server's\ntools (spec 084, FR-001). Plain string, not a pointer: \"\"/absent =\ninherit the global value; \"off\"|\"adaptive\"|\"always\" = override (\"off\"\nis the explicit force-off). Resolved by Config.ResolveToonOutput.","type":"string"},"trust_mode":{"description":"TrustMode is the per-server trust tier: auto|scan|manual. Supersedes\nauto_approve_tool_changes (spec 086). An empty value is derived from the\nlegacy fields at load via normalizeServerQuarantineFlags; the single\nresolution point is EffectiveTrustMode(), which treats an empty or\nunrecognized value as manual (secure by default). Read via\nEffectiveTrustMode(), never the raw string.","type":"string"},"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"},"anonymous_id_created_at":{"description":"Spec 042 (Tier 2) additions — all default-zero, all backwards-compatible.","type":"string"},"enabled":{"description":"Default: true (opt-out)","type":"boolean"},"endpoint":{"description":"Override for testing","type":"string"},"last_reported_version":{"description":"Upgrade funnel","type":"string"},"last_startup_outcome":{"description":"success|port_conflict|db_locked|...","type":"string"},"notice_shown":{"description":"First-run notice flag","type":"boolean"}},"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"},"config.TracingExporterConfig":{"description":"Tracing gates the OpenTelemetry OTLP trace exporter (MCP-32). Disabled by\ndefault.","properties":{"enabled":{"description":"Enabled turns on OTLP trace export for tool calls and upstream hops.","type":"boolean"},"endpoint":{"description":"Endpoint is the collector address as host:port (no scheme), e.g.\n\"localhost:4318\" for http or \"localhost:4317\" for grpc.","type":"string"},"protocol":{"description":"Protocol selects the OTLP transport: \"http\" or \"grpc\".","type":"string"},"sample_rate":{"description":"SampleRate is the head-based trace sampling ratio in [0,1]. Default 0.1.","type":"number"}},"type":"object"},"config.UpdateCheckConfig":{"description":"Update-check settings (Spec 079 FR-012): config-file control of the\nbackground upgrade-awareness checker (internal/updatecheck). nil =\nenabled on the stable channel (existing default behavior). The existing\nenvironment switches keep working and WIN over these keys (FR-014):\nMCPPROXY_DISABLE_AUTO_UPDATE=true force-disables even when\nenabled=true, and MCPPROXY_ALLOW_PRERELEASE_UPDATES=true force-selects\nthe rc channel even when channel=stable.","properties":{"channel":{"description":"Channel selects which releases are offered as updates: \"stable\"\n(default; prereleases never offered) or \"rc\" (prereleases included).\nEmpty resolves to stable. Validated in ValidateDetailed.\n\nNOTE: for a RELEASED build the running binary's own version is\nauthoritative and overrides this field — a stable build is never\noffered an RC (even with channel=rc), and an RC build always tracks the\nrc channel. This field only takes effect on dev/unstamped builds. See\ninternal/updatecheck.Checker.IncludePrereleases.","type":"string"},"enabled":{"description":"Enabled gates all update checking. Tri-state: nil/absent = enabled\n(default true, matching pre-079 behavior). When false, no network\ncheck is performed and no upgrade nudge appears on any surface\n(FR-015) — /api/v1/info omits the update object entirely.","type":"boolean"}},"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"},"connect.ConnectResult":{"description":"The full result; its action mirrors the top-level one","properties":{"action":{"description":"\"created\", \"updated\", \"already_exists\", \"removed\", \"not_found\"","type":"string"},"backup_path":{"type":"string"},"client":{"type":"string"},"config_path":{"type":"string"},"message":{"type":"string"},"server_name":{"type":"string"},"success":{"type":"boolean"}},"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"},"parent_id":{"description":"Correlation id of the parent call (the code_execution whose sandbox issued this sub-call)","type":"string"},"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 transport session ID (regenerated on every reconnect)","type":"string"},"source":{"$ref":"#/components/schemas/contracts.ActivitySource"},"status":{"description":"Result status: \"success\", \"error\", \"blocked\", \"rejected\"","type":"string"},"timestamp":{"description":"When activity occurred","type":"string"},"tool_name":{"description":"Name of tool called","type":"string"},"type":{"$ref":"#/components/schemas/contracts.ActivityType"},"work_session_id":{"description":"Spec 082: one client, one project, across reconnects","type":"string"}},"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"},"rejected_count":{"description":"RejectedCount is the number of calls shed by a concurrency limiter before\nthey reached an upstream (spec 093). Counted separately from errors: it is\nproxy backpressure, not an upstream fault, and it is the signal an\noperator right-sizes max_concurrent_requests against.","type":"integer"},"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.AddFromRegistryRequest":{"properties":{"enabled":{"description":"defaults to true when nil","type":"boolean"},"env":{"additionalProperties":{"type":"string"},"description":"overrides + required-input values","type":"object"},"name":{"description":"optional name override","type":"string"}},"type":"object"},"contracts.AddRegistrySourceRequest":{"properties":{"id":{"description":"derived from the host when empty","type":"string"},"name":{"description":"defaults to the id","type":"string"},"protocol":{"description":"defaults to modelcontextprotocol/registry","type":"string"},"url":{"description":"required https registry URL","type":"string"}},"type":"object"},"contracts.ConfigApplyResult":{"properties":{"applied_immediately":{"type":"boolean"},"changed_fields":{"items":{"type":"string"},"type":"array","uniqueItems":false},"requires_restart":{"type":"boolean"},"restart_reason":{"type":"string"},"success":{"type":"boolean"},"validation_errors":{"items":{"$ref":"#/components/schemas/contracts.ValidationError"},"type":"array","uniqueItems":false}},"type":"object"},"contracts.DCRStatus":{"properties":{"attempted":{"type":"boolean"},"error":{"type":"string"},"status_code":{"type":"integer"},"success":{"type":"boolean"}},"type":"object"},"contracts.DeepScanDescriptor":{"description":"DeepScan reports the opt-in \"deep scan\" layer status (Spec 077 US3),\nSEPARATELY from the baseline verdict above. Always emitted on a computed\nsummary — when deep scan is off (the default) it reports enabled=false\nplus any enabled-but-skipped Docker scanners. It never influences Status.","properties":{"available":{"type":"boolean"},"enabled":{"type":"boolean"},"ran":{"type":"boolean"},"scanners_failed":{"items":{"$ref":"#/components/schemas/contracts.DeepScanScannerFailure"},"type":"array","uniqueItems":false},"skipped_scanners":{"description":"SkippedScanners lists Docker scanners the user enabled that are skipped\nbecause security.deep_scan.enabled is false (informational).","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"contracts.DeepScanScannerFailure":{"properties":{"id":{"type":"string"},"reason":{"type":"string"}},"type":"object"},"contracts.DeprecatedConfigWarning":{"properties":{"field":{"type":"string"},"message":{"type":"string"},"replacement":{"type":"string"}},"type":"object"},"contracts.Diagnostic":{"description":"Spec 044 — structured diagnostic error and stable error code. Both\nare populated when the server is in a failed state and the error\nhas been classified by internal/diagnostics. Healthy servers omit\nthese fields.","properties":{"cause":{"type":"string"},"code":{"type":"string"},"detected_at":{"type":"string"},"docs_url":{"type":"string"},"fix_steps":{"items":{"$ref":"#/components/schemas/contracts.DiagnosticFixStep"},"type":"array","uniqueItems":false},"severity":{"type":"string"},"user_message":{"type":"string"}},"type":"object"},"contracts.DiagnosticFixStep":{"properties":{"command":{"type":"string"},"destructive":{"type":"boolean"},"fixer_key":{"type":"string"},"label":{"type":"string"},"type":{"type":"string"},"url":{"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.EditRegistrySourceRequest":{"properties":{"name":{"description":"new display name","type":"string"},"servers_url":{"description":"explicit servers-collection URL","type":"string"},"url":{"description":"new base/servers https URL","type":"string"}},"type":"object"},"contracts.ErrorResponse":{"properties":{"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"},"contracts.FindingCounts":{"properties":{"dangerous":{"description":"Tool poisoning, active prompt injection","type":"integer"},"info":{"description":"Low-severity CVEs, informational","type":"integer"},"total":{"type":"integer"},"warning":{"description":"Rug pull, supply chain CVEs with exploits","type":"integer"}},"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.GlobalToolsResponse":{"properties":{"failed_servers":{"items":{"type":"string"},"type":"array","uniqueItems":false},"partial":{"type":"boolean"},"stats":{"$ref":"#/components/schemas/contracts.GlobalToolsStats"},"tools":{"items":{"$ref":"#/components/schemas/contracts.Tool"},"type":"array","uniqueItems":false}},"type":"object"},"contracts.GlobalToolsStats":{"properties":{"disabled":{"type":"integer"},"enabled":{"type":"integer"},"pending_approval":{"type":"integer"},"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"},"launched_by":{"description":"LaunchedBy is the durable launch provenance of the running core (Spec\n092 FR-001a): \"tray\" when a tray spawned it, \"installer\" when the macOS\nPKG postinstall did, \"\" when user-launched or unknown. Always present\n(possibly empty) so a tray can distinguish \"old core, not mine\" from\n\"old core I may supersede\".","type":"string"},"listen_addr":{"description":"Listen address (e.g., \"127.0.0.1:8080\")","type":"string"},"pid":{"description":"PID is the operating-system process id of the running core (Spec 092\nFR-002). A tray that merely ATTACHED to a core holds no Process handle\nfor it, so without this there is no mechanism at all to stop a stale\ncore — the consent action would have nothing to act on and could only\nprint instructions. Paired with LaunchedBy it is what lets a newer tray\nsupersede a core an older tray started.","type":"integer"},"update":{"$ref":"#/components/schemas/contracts.UpdateInfo"},"update_policy":{"$ref":"#/components/schemas/contracts.UpdatePolicy"},"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"},"extra_args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"image":{"type":"string"},"memory_limit":{"type":"string"},"network_mode":{"type":"string"},"timeout":{"type":"string"},"working_dir":{"type":"string"}},"type":"object"},"contracts.IsolationDefaults":{"description":"IsolationDefaults exposes the resolved baseline values that\nwould apply when no per-server override is set. Populated on\nlist/get responses; never consumed on PATCH requests.","properties":{"extra_args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"image":{"type":"string"},"network_mode":{"type":"string"},"runtime_type":{"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"},"work_session_id":{"type":"string"},"workspace_name":{"description":"Workspace / work session (Spec 082). WorkspaceName is the project's\nbasename — the full local path is never exposed. WorkSessionID groups the\nreconnects that make up one stretch of user work.","type":"string"}},"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.PreflightPolicy":{"properties":{"exclude_destructive":{"type":"boolean"},"exclude_open_world":{"type":"boolean"},"read_only_only":{"type":"boolean"}},"type":"object"},"contracts.PreflightReason":{"type":"string","x-enum-varnames":["PreflightReasonServerInitializing","PreflightReasonServerUnhealthy","PreflightReasonServerDisabled","PreflightReasonServerQuarantined","PreflightReasonToolPendingApproval","PreflightReasonToolChanged","PreflightReasonToolBlockedByUser","PreflightReasonOAuthRequired","PreflightReasonHashMismatch","PreflightReasonServerNotInScope","PreflightReasonToolDeniedByConfig","PreflightReasonMissingAnnotation","PreflightReasonPolicyFiltered","PreflightReasonNotFound","PreflightReasonServerNotConfigured"]},"contracts.PreflightRequest":{"properties":{"policy":{"$ref":"#/components/schemas/contracts.PreflightPolicy"},"profile":{"description":"Profile evaluates under a named profile's server scope. Unknown: 400.","type":"string"},"tools":{"description":"Tools is 1..100 entries BEFORE dedup; duplicates are collapsed, and\nduplicate ids carrying different pins are a validation error.","items":{"$ref":"#/components/schemas/contracts.PreflightToolRef"},"type":"array","uniqueItems":false},"wait_ms":{"description":"WaitMS polls local state for up to this many milliseconds (cap 10000)\nwhile every failure is retryable-class.","type":"integer"}},"type":"object"},"contracts.PreflightResponse":{"properties":{"checked_at":{"type":"string"},"tools":{"description":"Tools are ordered by first occurrence of each unique id in the request.","items":{"$ref":"#/components/schemas/contracts.PreflightToolResult"},"type":"array","uniqueItems":false},"verdict":{"$ref":"#/components/schemas/contracts.PreflightVerdict"},"waited_ms":{"description":"WaitedMS is present when wait_ms was requested (0 when the wait\nsemaphore was exhausted and the request resolved immediately).","type":"integer"}},"type":"object"},"contracts.PreflightStatus":{"type":"string","x-enum-varnames":["PreflightStatusReady","PreflightStatusUnavailable"]},"contracts.PreflightToolRef":{"properties":{"id":{"description":"ID is a canonical \"\u003cserver\u003e:\u003ctool\u003e\" id. A malformed id is answered with a\nper-ID not_found carrying a format hint, never a request-level error.","type":"string"},"pin_hash":{"description":"PinHash is \"sha256/v{N}:{hex}\" — the schema version is embedded so a\nproxy-side hash-algorithm bump is distinguishable from upstream drift.","type":"string"}},"type":"object"},"contracts.PreflightToolResult":{"properties":{"action":{"type":"string"},"detail":{"type":"string"},"did_you_mean":{"description":"DidYouMean carries up to 3 nearest caller-visible ids on not_found. It\nnever crosses a scope boundary and never names a quarantined server's\ntools.","items":{"type":"string"},"type":"array","uniqueItems":false},"hash":{"description":"Hash is the tool's current pin (\"sha256/v{N}:{hex}\") — operator tier,\nready results only. Never disclosed to an agent token.","type":"string"},"id":{"type":"string"},"reason":{"$ref":"#/components/schemas/contracts.PreflightReason"},"remediation":{"type":"string"},"retryable":{"type":"boolean"},"status":{"$ref":"#/components/schemas/contracts.PreflightStatus"}},"type":"object"},"contracts.PreflightVerdict":{"type":"string","x-enum-varnames":["PreflightVerdictReady","PreflightVerdictDegradedRetryable","PreflightVerdictBlocked","PreflightVerdictUnknownIDs"]},"contracts.QuarantineStats":{"description":"Tool quarantine metrics for this server","properties":{"blocked_count":{"description":"Number of disabled (blocked) tools","type":"integer"},"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.RefreshRegistryResponse":{"properties":{"cleared":{"description":"number of cached entries dropped","type":"integer"},"registry_id":{"type":"string"}},"type":"object"},"contracts.Registry":{"properties":{"count":{"description":"number or string","type":"string"},"description":{"type":"string"},"id":{"type":"string"},"name":{"type":"string"},"protocol":{"type":"string"},"provenance":{"description":"Provenance is the trust tag (MCP-866): \"official/trusted\" for built-in\ndefaults, \"custom/unverified\" for user-added registries.","type":"string"},"servers_url":{"type":"string"},"tags":{"items":{"type":"string"},"type":"array","uniqueItems":false},"trusted":{"description":"Trusted indicates whether this is an official, shipped-by-default\nregistry. Trust is derived from membership in the default set, never\nfrom self-assertion in config.","type":"boolean"},"url":{"type":"string"}},"type":"object"},"contracts.RegistryCacheInfo":{"properties":{"age_seconds":{"type":"number"},"stale":{"type":"boolean"}},"type":"object"},"contracts.RegistryUnavailable":{"properties":{"reason":{"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":{"cache":{"$ref":"#/components/schemas/contracts.RegistryCacheInfo"},"query":{"type":"string"},"registry_id":{"type":"string"},"servers":{"items":{"$ref":"#/components/schemas/contracts.RepositoryServer"},"type":"array","uniqueItems":false},"tag":{"type":"string"},"total":{"type":"integer"},"unavailable":{"$ref":"#/components/schemas/contracts.RegistryUnavailable"}},"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.SecurityScanSummary":{"description":"Latest security scan results summary","properties":{"deep_scan":{"$ref":"#/components/schemas/contracts.DeepScanDescriptor"},"finding_counts":{"$ref":"#/components/schemas/contracts.FindingCounts"},"last_scan_at":{"type":"string"},"risk_score":{"description":"0-100","type":"integer"},"scanners_failed":{"type":"integer"},"scanners_run":{"description":"Scanner coverage for the primary (baseline) scan pass — informational only.\nSpec 077 US3 (FR-008/FR-014): Status is derived SOLELY from the\ndeterministic baseline findings; a failed Docker deep scanner no longer\ndowngrades a clean verdict. That failure is surfaced via DeepScan instead.","type":"integer"},"scanners_total":{"type":"integer"},"status":{"description":"\"clean\", \"warnings\", \"dangerous\", \"failed\", \"not_scanned\", \"scanning\"","type":"string"}},"type":"object"},"contracts.Server":{"properties":{"args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"authenticated":{"description":"OAuth authentication status","type":"boolean"},"auto_approve_tool_changes":{"description":"AutoApproveToolChanges mirrors config.ServerConfig.AutoApproveToolChanges\n(MCP-2930): the per-server intent to auto-approve new/changed tools past\nthe trust baseline. Tri-state *bool — nil means \"never set\" (omitted from\nthe payload), so the Web UI toggle (MCP-2932) can distinguish unset from\nan explicit false. Read-only on the GET path; PATCH/POST accept it via\nAddServerRequest.","type":"boolean"},"command":{"type":"string"},"connected":{"type":"boolean"},"connected_at":{"type":"string"},"connecting":{"type":"boolean"},"created":{"type":"string"},"diagnostic":{"$ref":"#/components/schemas/contracts.Diagnostic"},"enabled":{"type":"boolean"},"env":{"additionalProperties":{"type":"string"},"type":"object"},"error_code":{"type":"string"},"expose_prompts":{"description":"ExposePrompts mirrors config.ServerConfig.ExposePrompts (F9): the per-server\nprompt-aggregation override. Tri-state *bool — nil/omitted means \"inherit\ndefault aggregation\". Surfaced on GET so a caller that PATCHed the override\ncan read it back; PATCH/POST accept it via AddServerRequest.","type":"boolean"},"headers":{"additionalProperties":{"type":"string"},"type":"object"},"health":{"$ref":"#/components/schemas/contracts.HealthStatus"},"id":{"type":"string"},"init_timeout":{"description":"InitTimeout mirrors config.ServerConfig.InitTimeout (MCP-3322 / GH #760):\nthe per-server MCP ` + "`" + `initialize` + "`" + ` handshake deadline override. Serialized as\na duration string (e.g. \"120s\"); nil/omitted means \"inherit the global\ndefault\". Surfaced on the GET path so clients can read back a configured\noverride; PATCH/POST accept it via AddServerRequest.","type":"string"},"isolation":{"$ref":"#/components/schemas/contracts.IsolationConfig"},"isolation_defaults":{"$ref":"#/components/schemas/contracts.IsolationDefaults"},"last_error":{"type":"string"},"last_reconnect_at":{"type":"string"},"last_retry_time":{"type":"string"},"max_concurrent_requests":{"description":"Spec 093 (GH #955) — per-server concurrency overrides, scope (c) of\nFR-020. Each setting is tri-state: nil (omitted) means \"inherit\nserver_concurrency_defaults\", 0 disables that setting for this server,\npositive overrides it. Surfaced on the GET path so a caller can read back\nwhat it set; PATCH/POST accept them via AddServerRequest. The effective\nconcurrency for a server is additionally bounded by the global aggregate\nlimiter, which is NOT an inheritance source for these fields.","type":"integer"},"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"},"queue_size":{"type":"integer"},"queue_timeout":{"type":"string"},"reconnect_count":{"type":"integer"},"reconnect_on_use":{"description":"Attempt reconnection when a tool call targets this disconnected server","type":"boolean"},"retry_count":{"type":"integer"},"security_scan":{"$ref":"#/components/schemas/contracts.SecurityScanSummary"},"should_retry":{"type":"boolean"},"source_registry_id":{"description":"MCP-901 — registry provenance of an upstream that was added from a\nregistry. SourceRegistryID names the source registry (empty for\nmanually-configured servers); SourceRegistryProvenance is the trust tag\nrecorded at add time (\"official/trusted\" or \"custom/unverified\"). Both\nare projected from config.ServerConfig so the approval/quarantine view\ncan render an \"added from \u003cregistry\u003e · unverified\" origin badge. Optional\nand omitted when empty — clients that pre-date this treat them as absent.","type":"string"},"source_registry_provenance":{"type":"string"},"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"},"trust_mode":{"description":"TrustMode mirrors config.ServerConfig.TrustMode (spec 086): the per-server\ntrust tier (\"auto\"/\"scan\"/\"manual\"). Surfaced on the GET path so clients can\nread back the persisted mode; PATCH/POST accept it via AddServerRequest.\nOmitted when empty (server predates the field / relies on legacy flags).","type":"string"},"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"},"approval_status":{"type":"string"},"config_denied":{"description":"ConfigDenied is true when the tool is denied by the server's static\nenabled_tools / disabled_tools config. The user cannot override this toggle.","type":"boolean"},"description":{"type":"string"},"disabled":{"description":"Disabled mirrors ToolApprovalRecord.Disabled so per-tool enable state is\navailable without a second round-trip to the approvals endpoint. Absent\nin the JSON when false (default) to keep responses compact.","type":"boolean"},"hash":{"description":"Hash is the tool's current stored hash rendered in the preflight pin\nformat \"sha256/v{N}:{hex}\" (Spec 098 FR-011), where N is the approval\nrecord's HashSchemaVersion. It is the authoring surface for\n` + "`" + `POST /api/v1/preflight` + "`" + ` pins and ` + "`" + `mcpproxy tools preflight --pin` + "`" + `:\ncopy the value straight into a pin.\n\nDisclosure is OPERATOR TIER ONLY — same rule as the preflight per-tool\nresult. The field is omitted for agent-token callers and for tools with\nno stored hash (no approval record yet, or a record written before\nhashes existed).","type":"string"},"held_reason":{"description":"HeldReason, HeldVerdict and HeldSignals mirror the same-named fields on\nstorage.ToolApprovalRecord: the offline-scan evidence that made\ntrust_mode: scan hold this tool for review (spec 086 FR-018). HeldSignals\nnames the matched deterministic check ids, e.g.\n\"tpa.TPA-2026-0001.hidden_instruction\", so a reviewer can see WHY the tool\nis held. All three are omitted for tools that are not held by the scan gate\n(including every record written before the field existed).","type":"string"},"held_signals":{"items":{"type":"string"},"type":"array","uniqueItems":false},"held_verdict":{"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"},"install_channel":{"description":"Detected install channel (homebrew, dmg, deb, rpm, docker, go-install, windows-installer, tarball, unknown) — Spec 079 FR-008","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"},"nudges_suppressed":{"description":"UI surfaces must stay quiet (CI / non-interactive context); machine-readable fields still report the facts — Spec 079 FR-019","type":"boolean"},"release_url":{"description":"URL to the release page","type":"string"},"update_command":{"description":"One-line update command for the channel; only set when an update is available and the channel has one — Spec 079 FR-009","type":"string"}},"type":"object"},"contracts.UpdatePolicy":{"description":"UpdatePolicy is the effective, hot-reloadable update policy (Spec 092\nFR-015). Always present: the ` + "`" + `update` + "`" + ` object above is omitted both when\nupdate checking is disabled AND when no check has produced a result\nyet, so its absence cannot tell a client whether it is allowed to run\nits own (e.g. Sparkle feed) check. This field states the answer.","properties":{"channel":{"description":"Channel is the tracked release channel: \"stable\" or \"rc\".","type":"string"},"enabled":{"description":"Enabled is the effective automatic-check kill switch: update_check.enabled\nwith MCPPROXY_DISABLE_AUTO_UPDATE=true winning over it. A user-initiated\n\"Check for Updates\" stays available regardless.","type":"boolean"},"nudges_suppressed":{"description":"NudgesSuppressed asks UI surfaces to stay quiet (CI / non-interactive)\nwhile machine-readable fields keep reporting the facts.","type":"boolean"}},"type":"object"},"contracts.UpstreamError":{"properties":{"error_message":{"type":"string"},"server_name":{"type":"string"},"timestamp":{"type":"string"}},"type":"object"},"contracts.UsageAggregateResponse":{"properties":{"freshness_ms":{"description":"age of the underlying snapshot in ms","type":"integer"},"generated_at":{"type":"string"},"other":{"$ref":"#/components/schemas/contracts.UsageOtherBucket"},"timeline":{"items":{"$ref":"#/components/schemas/contracts.UsageTimeBucket"},"type":"array","uniqueItems":false},"token_source":{"description":"\"bytes\" (size-based proxy, FR-006)","type":"string"},"tokens_saved":{"description":"echoed from ServerTokenMetrics (FR-007)","type":"integer"},"tokens_saved_percentage":{"type":"number"},"tools":{"items":{"$ref":"#/components/schemas/contracts.UsageToolStat"},"type":"array","uniqueItems":false},"window":{"type":"string"}},"type":"object"},"contracts.UsageOtherBucket":{"description":"present only when the list was truncated to top-N","properties":{"calls":{"type":"integer"},"tools_folded":{"type":"integer"},"total_resp_bytes":{"type":"integer"}},"type":"object"},"contracts.UsageTimeBucket":{"properties":{"calls":{"type":"integer"},"errors":{"type":"integer"},"start":{"type":"string"},"total_resp_bytes":{"type":"integer"}},"type":"object"},"contracts.UsageToolStat":{"properties":{"avg_req_bytes":{"description":"null when no sized request calls","type":"integer"},"avg_resp_bytes":{"description":"null when sized_calls == 0 (only legacy 0-byte calls)","type":"integer"},"blocked":{"type":"integer"},"calls":{"type":"integer"},"error_rate":{"type":"number"},"errors":{"type":"integer"},"last_used":{"type":"string"},"p50_ms":{"type":"integer"},"p95_ms":{"type":"integer"},"rejected":{"description":"spec 093: shed by a concurrency limit; never executed, so excluded from calls/latency","type":"integer"},"server":{"type":"string"},"sized_calls":{"description":"calls with known response size (basis for avg_resp_bytes)","type":"integer"},"tool":{"type":"string"},"total_req_bytes":{"type":"integer"},"total_resp_bytes":{"type":"integer"}},"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},"auto_approve_tool_changes":{"description":"AutoApproveToolChanges is the per-server intent to auto-approve\nnew/changed tools past the trust baseline (MCP-2930). Tri-state *bool:\na nil pointer means \"leave unchanged\" on PATCH; a present value\n(including false) is applied. Mirrors config.ServerConfig's *bool\nsemantics — do NOT collapse to a plain bool, or an omitted field would\nsilently reset a previously-set value.","type":"boolean"},"command":{"type":"string"},"enabled":{"type":"boolean"},"env":{"additionalProperties":{"type":"string"},"type":"object"},"expose_prompts":{"description":"ExposePrompts is the per-server override for prompt aggregation (F9):\nwhether this server's advertised MCP prompts are merged into mcpproxy's\nprompts/list. Tri-state *bool mirroring config.ServerConfig.ExposePrompts —\na nil pointer means \"leave unchanged\" on PATCH (and \"inherit the default\naggregate behavior\" on create); a present value (including false) is applied.","type":"boolean"},"headers":{"additionalProperties":{"type":"string"},"type":"object"},"init_timeout":{"description":"InitTimeout is the per-server MCP ` + "`" + `initialize` + "`" + ` handshake deadline override\n(MCP-3322 / GH #760), serialized as a duration string (e.g. \"120s\"). A nil\npointer means \"leave unchanged\" on PATCH; a present value is applied.\nMirrors config.ServerConfig.InitTimeout's *Duration tri-state.","type":"string"},"isolation":{"$ref":"#/components/schemas/httpapi.IsolationRequest"},"max_concurrent_requests":{"description":"MaxConcurrentRequests / QueueSize / QueueTimeout are the per-server\nconcurrency overrides (spec 093 / GH #955, FR-020 scope (c)). Each is\ntri-state: a nil pointer means \"leave unchanged\" on PATCH and \"inherit\nserver_concurrency_defaults\" on create; an explicit 0 disables that\nsetting for this server; a positive value overrides it. Do NOT collapse\nthem to plain values — an omitted field would then silently reset a\nconfigured limit.","type":"integer"},"name":{"type":"string"},"protocol":{"type":"string"},"quarantined":{"type":"boolean"},"queue_size":{"type":"integer"},"queue_timeout":{"type":"string"},"reconnect_on_use":{"type":"boolean"},"trust_mode":{"description":"TrustMode is the per-server trust tier (spec 086): \"auto\", \"scan\", or\n\"manual\". Empty means \"leave unchanged\" on PATCH (and inherit the migrated\ndefault on create). A non-empty value is applied to ServerConfig.TrustMode\nand resolved by EffectiveTrustMode (an unrecognized value fails closed to\nmanual). This is the REST seam for changing the trust tier via\nPOST/PATCH /api/v1/servers.","type":"string"},"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.ConnectConflictResponse":{"properties":{"action":{"description":"already_exists | precondition_failed","type":"string"},"data":{"$ref":"#/components/schemas/connect.ConnectResult"},"error":{"description":"Human-readable message","type":"string"},"success":{"description":"Always false","type":"boolean"}},"type":"object"},"httpapi.ConnectRequest":{"properties":{"force":{"description":"Overwrite existing entry","type":"boolean"},"precondition_token":{"description":"PreconditionToken is the opaque token from the preview this write was\nconfirmed against (Spec 091 FR-005). When present, the core rechecks it\nat write time and responds 409 with action \"precondition_failed\" —\nwriting nothing — if the config or the entry MCPProxy would write has\ndrifted since; the caller then re-previews instead of retrying. Absent\nmeans exactly the pre-091 behavior. A replace-classified flow sends this\nTOGETHER with force=true: the token, not the absence of force, is the\noverwrite safety.","type":"string"},"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"},"rename":{"additionalProperties":{"type":"string"},"description":"Rename maps a server name → new name. Applied after parsing so the\ncaller can disambiguate cross-source name collisions (Spec 046 v2 —\ne.g. \"mcpproxy\" → \"mcpproxy_claude_code\"). Keys are matched against\neither the raw source name (OriginalName) or the sanitized name shown\nin the preview (Server.Name); these differ for names that need\nsanitizing (e.g. \"Figma Desktop\" → \"Figma_Desktop\"). Keys not present\nin the imported set are ignored.","type":"object"},"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"},"httpapi.IsolationRequest":{"description":"Isolation carries per-server Docker isolation overrides (image,\nnetwork_mode, extra_args, working_dir, enabled). A nil pointer\nmeans \"do not touch isolation config\"; an empty-but-present\nobject on PATCH intentionally clears the overrides.","properties":{"enabled":{"type":"boolean"},"extra_args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"image":{"type":"string"},"network_mode":{"type":"string"},"working_dir":{"type":"string"}},"type":"object"},"httpapi.OnboardingMarkRequest":{"properties":{"connect_step_status":{"description":"ConnectStepStatus is one of: \"\", \"completed\", \"skipped\". Empty\npreserves the existing value. The stored enum is wider (Spec 080\nFR-001): a \"skipped\" request for a previously untouched connect step\nis upgraded server-side to \"completed_external\" when the install\nshows positive evidence of an external connection (Spec 080 FR-002).\n\"completed_external\" is NOT accepted from clients — it must never be\npersisted without that server-verified evidence (edge case: \"never\nguess completed_external without positive evidence\").","type":"string"},"engaged":{"description":"Engaged marks the wizard as engaged (completed or explicitly skipped).\nOnce true, the wizard does not auto-show again.","type":"boolean"},"mark_shown":{"description":"MarkShown records the wizard's first display time if not already set.","type":"boolean"},"server_step_status":{"description":"ServerStepStatus is one of: \"\", \"completed\", \"skipped\". Empty\npreserves the existing value.","type":"string"}},"type":"object"},"httpapi.SetActiveProfileRequest":{"properties":{"active_profile":{"type":"string"},"profile":{"type":"string"}},"type":"object"},"httpapi.UndoConnectRequest":{"properties":{"backup_name":{"description":"BackupName is the bare filename (filepath.Base) of the backup returned as\nbackup_path by the preceding connect — a name, never a path. Undo resolves\nthe full path server-side by joining it with the client's own config\ndirectory, so a client-supplied value can never contribute a directory\ncomponent (traversal is impossible by construction). Empty means the\nconnect created the file (no prior file existed), so undo removes it.","type":"string"},"server_name":{"description":"Defaults to \"mcpproxy\"","type":"string"}},"type":"object"},"httpapi.UpdateFailureRequest":{"properties":{"stage":{"description":"Stage is the failure stage of the update session.","enum":["appcast","download","install","other"],"type":"string"}},"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"},"forward_proxy_env":{"description":"ForwardProxyEnv opts in to forwarding the ambient HTTP(S)/ALL/NO/FTP proxy\nenvironment variables to spawned upstream servers (MCP-2769). It is OFF by\ndefault and deliberately kept out of the AllowedSystemVars default list:\nproxy URLs frequently carry credentials (http://user:pass@proxy), so\nforwarding them to every stdio upstream is a credential-leak risk. When\nenabled, values are forwarded with their userinfo (credentials) redacted.","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.ConcurrencyDefaults":{"description":"ServerConcurrencyDefaults is scope (b) of FR-020: the blanket per-server\ndefault set inherited by every server that does not override a setting.\nAbsent (the default) = no per-server limiting unless a server configures\nit explicitly. File/API-configured only — no env scheme (FR-022).","properties":{"max_concurrent_requests":{"type":"integer"},"queue_size":{"type":"integer"},"queue_timeout":{"type":"string"}},"type":"object"},"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_max_size_mb":{"description":"Max total activity-log size in MB before pruning oldest (default: 256, 0=disabled)","type":"integer"},"activity_retention_days":{"description":"Activity logging settings (RFC-003)","type":"integer"},"aggregate_upstream_prompts":{"description":"AggregateUpstreamPrompts, when true, aggregates every connected upstream\nserver's advertised MCP prompts into mcpproxy's own prompts/list\n(exposed as \"\u003cserver\u003e__\u003cprompt\u003e\"). OFF by default: users are safe by\ndefault and opt in deliberately. EnablePrompts still governs the built-in\nprompts + the prompts capability; this flag gates ONLY the upstream\naggregation performed by RefreshPrompts. Hot-reloadable.","type":"boolean"},"allow_private_registry_fetch":{"description":"AllowPrivateRegistryFetch opts out of the registry SSRF guard (MCP-1076,\nCWE-918). By default (false) registry fetches refuse any host that is — or\nresolves to — a non-routable address (loopback, RFC1918/CGNAT private,\nlink-local incl. the 169.254.169.254 cloud-metadata endpoint), so a\nmalicious or typo'd registry source cannot turn the daemon into a\nrequest-forgery vector against internal services.\n\nThis opt-out is BLANKET (all-or-nothing): setting it true disables the\nguard for EVERY non-routable range at once — loopback, RFC1918/CGNAT\nprivate, link-local AND the 169.254.169.254 cloud-metadata endpoint. There\nis no way to allow only loopback; enabling it for a localhost dev registry\nalso re-opens the cloud-metadata SSRF vector. Set true ONLY when you\nintentionally run a trusted registry mirror on an internal/private address,\nideally on a host with no cloud-metadata exposure. The change takes effect\nonly on daemon (re)start or config reload.","type":"boolean"},"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_parallel":{"description":"Default concurrency for call_tools() batches (1-32, default: 8)","type":"integer"},"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"},"forward_proxy_env":{"description":"ForwardProxyEnv opts in to forwarding the ambient HTTP(S)/ALL/NO/FTP proxy\nenvironment variables to spawned stdio upstream servers (MCP-2769). OFF by\ndefault: proxy URLs commonly embed credentials (http://user:pass@proxy), so\nforwarding them to every upstream is a credential-leak risk. When enabled,\nvalues are forwarded with their userinfo (credentials) redacted.","type":"boolean"},"health_check_interval":{"description":"Discovery \u0026 health-check cadence (spec 074, #608). Both are *Duration\ntri-state pointers: nil = inherit the built-in default; a pointer to 0s =\nthe loop is disabled; a positive value = that interval. Defaults live only\nin the resolvers (ResolveHealthCheckInterval / ResolveToolDiscoveryInterval)\nso an unset key behaves exactly as before this feature (SC-005). Validated\nin Validate(): health-check ∈ {0} ∪ [5s,1h]; tool-discovery ∈ {0} ∪ [30s,24h].","type":"string"},"http_idle_timeout":{"description":"HTTPIdleTimeout caps how long an idle keep-alive connection is kept open.\nUnset = 180s. \"0s\" removes the dedicated idle deadline, but net/http then\nfalls back to ReadTimeout — idle is fully unbounded only when\nhttp_read_timeout is also \"0s\". Requires a restart.","type":"string"},"http_read_timeout":{"description":"HTTPReadTimeout caps how long reading a whole request (headers + body)\nmay take. Unset = 120s; \"0s\" disables it. Requires a restart.","type":"string"},"http_write_timeout":{"description":"HTTPWriteTimeout caps how long producing a whole response may take on\nnon-streaming endpoints (REST, Web UI, health). Unset = 120s; \"0s\"\ndisables it globally. MCP and SSE /events routes are exempt by design.","type":"string"},"init_timeout":{"description":"InitTimeout is the global default deadline for an upstream's MCP\n` + "`" + `initialize` + "`" + ` handshake (MCP-3322 / GH #760). *Duration tri-state: nil =\ninherit the built-in 30s default; a positive value = that deadline. A\nper-server InitTimeout overrides this. Resolved by ResolveInitTimeout;\nvalidated to {0} ∪ [1s, 30m] in Validate(). Servers doing legitimate\nfirst-run warmup (cache/index build) before answering ` + "`" + `initialize` + "`" + ` can\nraise this so they are not killed mid-startup.","type":"string"},"instructions":{"description":"Instructions text returned in the MCP initialize response to guide AI agents.\nWhen empty, a built-in default is used that explains retrieve_tools workflow.","type":"string"},"intent_declaration":{"$ref":"#/components/schemas/config.IntentDeclarationConfig"},"listen":{"type":"string"},"logging":{"$ref":"#/components/schemas/config.LogConfig"},"max_concurrent_requests":{"description":"Concurrency limits (spec 093, GH #955). Scope (a) of FR-020: the GLOBAL\nAGGREGATE limiter — one proxy-wide cap on concurrently running upstream\ntool calls, with its own bounded wait queue. Tri-state pointers: absent =\nthe limiter does not exist (default, zero behavior change); an explicit 0\nmax also disables it; positive = that cap. This scope is NEVER a\nper-server inheritance source — per-server values come from\nServerConcurrencyDefaults / the per-server overrides — but a server's\neffective concurrency is bounded by BOTH its own limiter and this one.\nResolved by ResolveGlobalConcurrency; hot-reloadable; overridable via\nMCPPROXY_MAX_CONCURRENT_REQUESTS / _QUEUE_SIZE / _QUEUE_TIMEOUT (FR-022).","type":"integer"},"max_result_size_chars":{"description":"Advertised on every tool as ` + "`" + `_meta.anthropic/maxResultSizeChars` + "`" + `; raises Claude Code's inline-response ceiling from 50k to up to 500k chars. Set to 0 to disable.","type":"integer"},"mcpServers":{"items":{"$ref":"#/components/schemas/config.ServerConfig"},"type":"array","uniqueItems":false},"oauth_expiry_warning_hours":{"description":"Health status settings","type":"number"},"observability":{"$ref":"#/components/schemas/config.ObservabilityConfig"},"output_sanitisation":{"$ref":"#/components/schemas/config.OutputSanitisationConfig"},"output_validation":{"$ref":"#/components/schemas/config.OutputValidationConfig"},"profiles":{"description":"Profiles are optional named, server-scoped views exposed at /mcp/p/\u003cname\u003e\n(Spec 057). Absent/empty is fully supported — /mcp is unchanged and configs\nwithout this key serialize byte-identically (SC-004).","items":{"$ref":"#/components/schemas/config.ProfileConfig"},"type":"array","uniqueItems":false},"quarantine_enabled":{"description":"QuarantineEnabled controls whether quarantine is active. It gates two\nthings together:\n 1. Server-level auto-quarantine for newly added servers (issue #370).\n When true, servers added via the upstream_servers MCP tool or the\n REST API default to quarantined=true; when false, they default to\n quarantined=false. Explicit per-request values always win.\n 2. Tool-level quarantine (Spec 032): per-tool SHA-256 approval of\n tool descriptions/schemas.\nWhen nil (default), quarantine is enabled (secure by default). Set to\nexplicit false to opt out of both. Per-server SkipQuarantine still\napplies for the tool-level check on individual servers.","type":"boolean"},"queue_size":{"type":"integer"},"queue_timeout":{"type":"string"},"read_only_mode":{"type":"boolean"},"registries":{"description":"Registries configuration for MCP server discovery","items":{"$ref":"#/components/schemas/config.RegistryEntry"},"type":"array","uniqueItems":false},"registries_locked":{"description":"RegistriesLocked is an enterprise stub knob (MCP-866): when true, runtime\nadditions of custom registries (e.g. ` + "`" + `registry add-source` + "`" + `, the REST/MCP\nadd-source surface) are rejected so an administrator can pin the discovery\nsources. Built-in defaults are unaffected. Documented but otherwise inert\nbeyond the add-source rejection.","type":"boolean"},"require_mcp_auth":{"description":"Require authentication on /mcp endpoint (default: false)","type":"boolean"},"reveal_secret_headers":{"description":"RevealSecretHeaders, when true, disables the redaction of the\nsecret-bearing server fields — sensitive header values (Authorization,\nX-API-Key, Cookie, …), env-var secrets, and URL query credentials — in\nresponses from the ` + "`" + `upstream_servers` + "`" + ` MCP tool, the ` + "`" + `/api/v1/servers` + "`" + `\nREST API, and the SSE event stream. It also lets URL secrets echoed\ninto last_error / health.detail through unscrubbed.\n\nDefault false — sensitive values are surfaced masked as\n` + "`" + `••••\u003clast2\u003e (\u003cN\u003e chars)` + "`" + ` (error strings use ` + "`" + `***REDACTED***` + "`" + `) so an\nMCP agent cannot read Bearer tokens / API keys / URL secrets out of\nanother upstream's config (PR #425, issue #872). ${env:…}/${keyring:…}\nreferences are labels, not secrets, and pass through unchanged.\n\nThe Web UI / macOS tray edit forms work without seeing the real\nvalues: PATCH /api/v1/servers/{id} deep-merges (omitted keys are\npreserved, see ` + "`" + `headers_remove` + "`" + ` / ` + "`" + `env_remove` + "`" + ` for explicit\ndeletes), so clients compute a diff and only send the keys that\nactually changed. Redacted-but-unchanged values never round-trip\n— the backend keeps the real string. Set this to true if a\ndownstream tool genuinely needs raw values in the response.","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"},"security":{"$ref":"#/components/schemas/config.SecurityConfig"},"sensitive_data_detection":{"$ref":"#/components/schemas/config.SensitiveDataDetectionConfig"},"server_concurrency_defaults":{"$ref":"#/components/schemas/config.ConcurrencyDefaults"},"telemetry":{"$ref":"#/components/schemas/config.TelemetryConfig"},"tls":{"$ref":"#/components/schemas/config.TLSConfig"},"tokenizer":{"$ref":"#/components/schemas/config.TokenizerConfig"},"tool_discovery_interval":{"type":"string"},"tool_response_limit":{"type":"integer"},"tool_response_mode":{"description":"Tool response mode (Spec 085): how retrieve_tools serializes results.\nValid values: \"\" (= full), \"full\" (default: today's schema-bearing\nentries), \"compact\" (signature + first-sentence entries). Orthogonal to\nrouting_mode — routing_mode selects the tool SURFACE, this selects the\nSERIALIZATION within the retrieve_tools surface. Serialization-only: it\nnever affects the query, ranking, or result set. Hot-reloadable.","type":"string"},"tool_response_session_risk_warning":{"description":"ToolResponseSessionRiskWarning controls whether the prose ` + "`" + `warning` + "`" + ` field\nis included in the ` + "`" + `session_risk` + "`" + ` object returned by ` + "`" + `retrieve_tools` + "`" + `.\nThe structured fields (level, lethal_trifecta, has_open_world_tools, etc.)\nare always included. Default: false (quiet for LLM clients) — see issue #406.\nMost tools lack annotations, so the MCP-spec defaults treat them as fully\npermissive across all three risk axes, which makes the prose warning fire\non almost every call and wastes tokens.","type":"boolean"},"tools_limit":{"type":"integer"},"toon_min_savings_pct":{"description":"ToonMinSavingsPct is the minimum byte-savings percentage (validated\n1-90; 0/unset → 15) the complete TOON emission (marker + hint + body)\nmust achieve over the exact passthrough emission for adaptive mode to\nencode a block. Byte savings approximate token savings for the tabular\npayload class; the spec-083 profiler reports true token deltas.\nGlobal-only (no per-server override, FR-001).","type":"integer"},"toon_output":{"description":"ToonOutput selects the TOON encoding mode for call_tool_* result text\nblocks (spec 084): \"off\" (default — responses byte-identical to\npre-feature behavior), \"adaptive\" (encode only tabular-uniform payloads\nthat beat compact JSON by ToonMinSavingsPct), or \"always\"\n(benchmark/debug only — encodes every JSON-parseable block and can\nINCREASE token cost). Per-server override: ServerConfig.ToonOutput.\nResolved by ResolveToonOutput; hot-reloadable.","type":"string"},"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"},"trusted_hosts":{"description":"TrustedHosts lists non-loopback Host header values accepted on loopback\nlisteners (GH #898). DNS-rebinding protection rejects requests whose Host\nheader is not a loopback address when mcpproxy listens on loopback; a\nreverse proxy (nginx → 127.0.0.1) forwarding the public domain in Host\ntrips it. Entries are hostnames, case-insensitive; an entry without a\nport matches any port, with a port it must match exactly; a leading dot\n(\".example.com\") is a subdomain wildcard. The single entry \"*\" disables\nHost and Origin validation entirely. The same list also validates the\nOrigin header when present (MCP spec DNS-rebinding defense). Empty\n(default) keeps full protection. Env override: MCPPROXY_TRUSTED_HOSTS\n(comma-separated).","items":{"type":"string"},"type":"array","uniqueItems":false},"update_check":{"$ref":"#/components/schemas/config.UpdateCheckConfig"}},"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.DeepScanConfig":{"description":"DeepScan is the opt-in \"deep scan\" layer (Spec 077 US3). It subsumes the\ndeprecated top-level scanner_fetch_package_source / scanner_disable_no_new_privileges\nkeys (migrated on load) and gates the heavy Docker-based scanners + source\nextraction. Disabled by default (FR-006): only the deterministic in-process\nbaseline scanner runs. A deep-scan failure NEVER changes the baseline verdict\n(FR-007/FR-008).","properties":{"disable_no_new_privileges":{"description":"DisableNoNewPrivileges, when true, omits the ` + "`" + `--security-opt\nno-new-privileges` + "`" + ` flag from scanner container runs (snap-docker/AppArmor\nescape hatch). Absorbs the deprecated top-level\nscanner_disable_no_new_privileges. Default false.","type":"boolean"},"enabled":{"description":"Enabled is the master opt-in for the heavy layer (FR-006). Default false.","type":"boolean"},"fetch_package_source":{"description":"FetchPackageSource controls whether the scanner fetches the PUBLISHED\nsource of package-runner servers (npx/uvx) — without executing it — when\nno local source is available. Absorbs the deprecated top-level\nscanner_fetch_package_source. Default (nil) is ENABLED within deep scan.","type":"boolean"},"scanners":{"description":"Scanners optionally restricts which deep scanners may run under the\numbrella (by scanner id). Empty ⇒ all enabled deep scanners are eligible.","items":{"type":"string"},"type":"array","uniqueItems":false}},"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"},"enable_cache_volume":{"description":"Mount shared cache volumes for faster restarts (default: true)","type":"boolean"},"enabled":{"description":"Global enable/disable for Docker isolation (legacy; superseded by Mode)","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"},"mode":{"description":"Isolation mode: \"docker\" | \"sandbox\" | \"none\" (MCP-34.2). Unset per-server inherits the global mode; unset globally falls back to the legacy \"enabled\" flag (true ⇒ docker, false ⇒ none)","type":"string","x-enum-varnames":["IsolationModeDocker","IsolationModeSandbox","IsolationModeNone"]},"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; legacy, superseded by Mode)","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"},"mode":{"$ref":"#/components/schemas/config.IsolationMode"},"network_mode":{"description":"Custom network mode for this server","type":"string"},"working_dir":{"description":"Custom working directory in container","type":"string"}},"type":"object"},"config.IsolationMode":{"description":"Isolation mode: \"docker\" | \"sandbox\" | \"none\" (MCP-34.2). Unset per-server inherits the global mode; unset globally falls back to the legacy \"enabled\" flag (true ⇒ docker, false ⇒ none)","type":"string","x-enum-varnames":["IsolationModeDocker","IsolationModeSandbox","IsolationModeNone"]},"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.MetricsExporterConfig":{"description":"Metrics gates the Prometheus /metrics scrape endpoint (MCP-32). Disabled\nby default — operators opt in for k8s/enterprise deployments.","properties":{"enabled":{"description":"Enabled exposes /metrics on the existing HTTP listener when true.","type":"boolean"}},"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.ObservabilityConfig":{"description":"Observability settings (Spec 069): usage aggregate cache/persistence cadence.","properties":{"metrics":{"$ref":"#/components/schemas/config.MetricsExporterConfig"},"tracing":{"$ref":"#/components/schemas/config.TracingExporterConfig"},"usage_cache_ttl":{"description":"UsageCacheTTL bounds the freshness of the usage endpoint's read cache for\nwide windows (FR-005). Default 5s.","type":"string"},"usage_persist_interval":{"description":"UsagePersistInterval is how often the actor-owned usage aggregate snapshot\nis flushed to storage. Default 30s.","type":"string"}},"type":"object"},"config.OutputSanitisationConfig":{"description":"Output sanitisation settings (Spec 054 Track B)","properties":{"max_redactions":{"description":"cap on redactions per response; default 100","type":"integer"},"response_action":{"description":"\"spotlight\" | \"redact\" | \"block\"; default \"spotlight\"","type":"string"},"spotlight_untrusted":{"description":"wrap untrusted output in spotlight markers; default true","type":"boolean"},"strip_classes":{"description":"classes to strip: ansi/c0c1/bidi/zero_width","items":{"type":"string"},"type":"array","uniqueItems":false},"strip_control_chars":{"description":"strip control-character classes; default false","type":"boolean"}},"type":"object"},"config.OutputValidationConfig":{"description":"Output-schema validation settings (Spec 056)","properties":{"max_bytes":{"description":"structured payload byte cap; default 5\u003c\u003c20","type":"integer"},"max_depth":{"description":"nesting depth cap; default 64","type":"integer"},"missing_structured_content":{"description":"\"allow\" | \"block\"; default \"allow\"","type":"string"},"mode":{"description":"\"off\" | \"warn\" | \"strict\"; default \"warn\"","type":"string"}},"type":"object"},"config.ProfileConfig":{"properties":{"name":{"description":"URL slug, validated","type":"string"},"servers":{"description":"references to mcpServers[].name","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"},"provenance":{"description":"Provenance is the trust tag for this registry (MCP-866):\nRegistryProvenanceOfficial for built-in defaults, RegistryProvenanceCustom\nfor user-added registries. It is authoritatively (re)computed by the\nregistries merge from whether the ID is a shipped default — a user cannot\nclaim \"official\" by writing it into their config.","type":"string"},"requires_key":{"description":"RequiresKey marks a registry that needs an API key to be queried. When\ntrue and no key is configured, the registry is skipped/marked unavailable\nrather than failing the whole search (FR-008).","type":"boolean"},"servers_url":{"type":"string"},"tags":{"items":{"type":"string"},"type":"array","uniqueItems":false},"url":{"type":"string"}},"type":"object"},"config.SecurityConfig":{"description":"Security scanner settings (Spec 039)","properties":{"auto_baseline_scan":{"description":"AutoBaselineScan is the kill-switch for the AUTOMATIC, informational\nPass-1 baseline scan: the free in-process TPA scan mcpproxy runs for every\nnewly admitted server (any trust mode) and, once per installation, over\npre-existing servers that have never been scanned.\n\nInformational ONLY: the resulting verdict populates the security badge and\nthe scan summary, and NEVER gates quarantine or approval. The\ntrust_mode:\"scan\" admission gate is a separate path and is unaffected by\nthis flag.\n\nDefault (nil) is ENABLED. Set to false to suppress every automatic scan\n(manual scans keep working). Env override: MCPPROXY_AUTO_BASELINE_SCAN,\nwhich wins over this field on every path.","type":"boolean"},"deep_scan":{"$ref":"#/components/schemas/config.DeepScanConfig"},"integrity_check_interval":{"type":"string"},"integrity_check_on_restart":{"type":"boolean"},"runtime_read_only":{"type":"boolean"},"runtime_tmpfs_size":{"type":"string"},"scan_timeout_default":{"type":"string"},"scanner_disable_no_new_privileges":{"description":"Deprecated (Spec 077 US3): migrated on load into DeepScan.DisableNoNewPrivileges\n(see migrateDeepScanConfig). Retained only so existing configs that still carry\nthe top-level key parse; consumers MUST read the effective value via\nSecurityConfig.IsDisableNoNewPrivileges. Cleared after migration.\n\nScannerDisableNoNewPrivileges, when true, omits the\n` + "`" + `--security-opt no-new-privileges` + "`" + ` flag from scanner container runs.\n\nBackground: snap-installed Docker on Ubuntu confines dockerd under the\n` + "`" + `snap.docker.dockerd` + "`" + ` AppArmor profile. When runc tries to transition\nthe container into the inner ` + "`" + `docker-default` + "`" + ` profile to exec the\nentrypoint, AppArmor refuses the transition because NO_NEW_PRIVS\nforbids privilege/profile changes on exec — the result is EPERM\n(\"operation not permitted\") and every scanner fails immediately.\n\nSet this to true ONLY on hosts hitting that incompatibility. Scanner\ncontainers still run with read-only rootfs, tmpfs /tmp, no-network by\ndefault, and read-only source mounts, so the marginal isolation loss\nis small. The preferred fix remains replacing snap docker with a\ndistro-packaged docker.","type":"boolean"},"scanner_fetch_package_source":{"description":"Deprecated (Spec 077 US3): migrated on load into DeepScan.FetchPackageSource\n(see migrateDeepScanConfig). Retained only so existing configs that still carry\nthe top-level key parse; consumers MUST read the effective value via\nSecurityConfig.EffectiveFetchPackageSource. Cleared after migration.\n\nScannerFetchPackageSource controls whether the scanner fetches the\nPUBLISHED source of package-runner servers (npx/uvx) — without executing\nit — when no local source is available (no Docker container, no local\npackage cache, no working_dir). This is the primary quarantine/scan\ntarget: a quarantined-on-add server is never run locally, so without this\nthe scan degrades to tool-definitions-only (no real source-level\nanalysis). See MCP-2206.\n\nFetching uses ` + "`" + `npm pack --ignore-scripts` + "`" + ` (npm) and ` + "`" + `uv pip download` + "`" + ` /\n` + "`" + `pip download` + "`" + ` with ` + "`" + `--only-binary=:all:` + "`" + ` (Python), which only download +\nunpack archives and NEVER run install, build, or setup.py — a scanner must\nnot execute the untrusted code it is scanning. The Python\n` + "`" + `--only-binary=:all:` + "`" + ` flag is required because downloading an sdist would\ninvoke its build backend (setup.py); packages with no wheel fall back to\ntool-definitions-only instead. Extraction is hardened against path\ntraversal and decompression bombs.\n\nDefault (nil) is ENABLED. Set to false on air-gapped deployments to\nforbid the scanner's network egress; such servers then fall back to the\ntool-definitions-only scan with no regression.","type":"boolean"},"scanner_registry_url":{"type":"string"},"tpa_bundle_path":{"description":"TPABundlePath is the filesystem path to the tpa-db scanner-bundle.json\nthe offline TPA scanner runs (spec 086 FR-019: the signature-DB location\nMUST be configuration-driven, not hardcoded). Empty (the default) runs the\ncorpus embedded in this build.\n\nEnv override: MCPPROXY_TPA_BUNDLE_PATH. Hot-reloadable — the path is\nre-read on every config.reloaded event via\nscanner.Service.ApplySecurityConfig, so a corpus refresh needs no restart.\nA configured bundle that fails to read/parse/version-check/compile is\nREFUSED and the previously active corpus stays live (fail-closed, never\nfail-empty); the reason is logged and surfaced in the security overview's\nsignature_bundle.load_error.","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},"auto_approve_tool_changes":{"description":"AutoApproveToolChanges is the per-server intent to auto-approve tool\nchanges/additions (disabling per-server rug-pull protection). Supersedes\nskip_quarantine. MCP-2930 only ACCEPTS, persists, and migrates this flag — it\nis NOT yet consulted at runtime; auto-approval is still governed by\nSkipQuarantine until the trust-baseline behavior change (MCP-2931) migrates the\nruntime consumers onto it.\nTri-state pointer (mirrors QuarantineEnabled): nil = unset (inherit/migrate\nfrom legacy skip_quarantine), explicit true/false = honored as-is so an\nexplicit auto_approve_tool_changes:false overrides a legacy skip_quarantine:true.\nRead via IsAutoApproveToolChanges().","type":"boolean"},"command":{"type":"string"},"created":{"type":"string"},"disabled_tools":{"description":"Denylist: these tools are hidden; mutually exclusive with enabled_tools","items":{"type":"string"},"type":"array","uniqueItems":false},"enabled":{"type":"boolean"},"enabled_tools":{"description":"Allowlist: only these tools are exposed; mutually exclusive with disabled_tools","items":{"type":"string"},"type":"array","uniqueItems":false},"env":{"additionalProperties":{"type":"string"},"type":"object"},"expose_prompts":{"description":"ExposePrompts overrides whether this server's advertised MCP prompts are\naggregated into mcpproxy's prompts/list. nil (default) inherits the\ndefault-aggregate behavior (included if the server advertises\nCapabilities.Prompts); false excludes it regardless of capability.","type":"boolean"},"headers":{"additionalProperties":{"type":"string"},"description":"For HTTP servers","type":"object"},"health_check_interval":{"description":"Per-server discovery \u0026 health-check overrides (spec 074). Same *Duration\ntri-state as the global keys: nil = inherit the global value (or default),\npointer to 0s = disabled for this server, positive = that interval.\nHealthCheckInterval is fully wired into the per-server health loop;\nToolDiscoveryInterval is accepted/validated and round-trips for\nforward-compat, but the periodic index sweep is governed by the global\ncadence in this iteration (see spec 074 plan §C).","type":"string"},"init_timeout":{"description":"InitTimeout overrides the global init_timeout for this server's MCP\n` + "`" + `initialize` + "`" + ` handshake deadline (MCP-3322 / GH #760). *Duration tri-state:\nnil = inherit the global value (or 30s default), positive = that deadline.\nResolved by Config.ResolveInitTimeout; validated to {0} ∪ [1s, 30m]. Raise\nthis for upstreams that do legitimate first-run warmup (e.g. caching many\nchannels/users) before responding to ` + "`" + `initialize` + "`" + `.","type":"string"},"isolation":{"$ref":"#/components/schemas/config.IsolationConfig"},"launcher_wait_timeout":{"description":"LauncherWaitTimeout caps how long mcpproxy will wait for a locally-launched\nHTTP/SSE upstream's URL to become reachable after Spawn(). Only consulted\nwhen the server is configured with both Command and an HTTP/SSE URL — i.e.,\nmcpproxy starts the process AND connects via network. Stdio servers ignore\nthis field. Zero or unset → 30s default.","type":"string"},"max_concurrent_requests":{"description":"Per-server concurrency overrides — scope (c) of FR-020 (spec 093, #955).\nTri-state per setting, exactly like HealthCheckInterval: absent = inherit\nthe per-server default set (server_concurrency_defaults), explicit 0 =\ndisable that setting for this server (0 max = no per-server limiter at\nall; 0 queue_size = no pending capacity, shed immediately at the cap),\npositive = override. The global aggregate limiter is never inherited from\nhere — it applies on top, so effective concurrency is min(per-server,\nglobal). Resolved by Config.ResolveServerConcurrency.","type":"integer"},"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"},"queue_size":{"type":"integer"},"queue_timeout":{"type":"string"},"reconnect_on_use":{"description":"Attempt reconnection when a tool call targets a disconnected server","type":"boolean"},"shared":{"description":"Server edition: shared with all users","type":"boolean"},"skip_quarantine":{"description":"SkipQuarantine is DEPRECATED (MCP-2930): use AutoApproveToolChanges instead.\nKept for back-compat parsing; on config load a legacy skip_quarantine:true is\nmigrated to auto_approve_tool_changes:true only when the new field is unset\n(see normalizeServerQuarantineFlags).","type":"boolean"},"source_registry_id":{"description":"SourceRegistryID records which registry this server was added from (empty\nfor manually-configured servers). MCP-866: surfaced in the approval /\nquarantine view so a reviewer can see a server's origin.","type":"string"},"source_registry_provenance":{"description":"SourceRegistryProvenance records the source registry's provenance at add\ntime (RegistryProvenanceOfficial / RegistryProvenanceCustom). It is purely\ninformational (MCP-1072) — surfaced so a reviewer can see a server's origin\n— and no longer gates quarantine or skip_quarantine.","type":"string"},"tool_discovery_interval":{"type":"string"},"toon_output":{"description":"ToonOutput overrides the global toon_output mode for this server's\ntools (spec 084, FR-001). Plain string, not a pointer: \"\"/absent =\ninherit the global value; \"off\"|\"adaptive\"|\"always\" = override (\"off\"\nis the explicit force-off). Resolved by Config.ResolveToonOutput.","type":"string"},"trust_mode":{"description":"TrustMode is the per-server trust tier: auto|scan|manual. Supersedes\nauto_approve_tool_changes (spec 086). An empty value is derived from the\nlegacy fields at load via normalizeServerQuarantineFlags; the single\nresolution point is EffectiveTrustMode(), which treats an empty or\nunrecognized value as manual (secure by default). Read via\nEffectiveTrustMode(), never the raw string.","type":"string"},"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"},"anonymous_id_created_at":{"description":"Spec 042 (Tier 2) additions — all default-zero, all backwards-compatible.","type":"string"},"enabled":{"description":"Default: true (opt-out)","type":"boolean"},"endpoint":{"description":"Override for testing","type":"string"},"last_reported_version":{"description":"Upgrade funnel","type":"string"},"last_startup_outcome":{"description":"success|port_conflict|db_locked|...","type":"string"},"notice_shown":{"description":"First-run notice flag","type":"boolean"}},"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"},"config.TracingExporterConfig":{"description":"Tracing gates the OpenTelemetry OTLP trace exporter (MCP-32). Disabled by\ndefault.","properties":{"enabled":{"description":"Enabled turns on OTLP trace export for tool calls and upstream hops.","type":"boolean"},"endpoint":{"description":"Endpoint is the collector address as host:port (no scheme), e.g.\n\"localhost:4318\" for http or \"localhost:4317\" for grpc.","type":"string"},"protocol":{"description":"Protocol selects the OTLP transport: \"http\" or \"grpc\".","type":"string"},"sample_rate":{"description":"SampleRate is the head-based trace sampling ratio in [0,1]. Default 0.1.","type":"number"}},"type":"object"},"config.UpdateCheckConfig":{"description":"Update-check settings (Spec 079 FR-012): config-file control of the\nbackground upgrade-awareness checker (internal/updatecheck). nil =\nenabled on the stable channel (existing default behavior). The existing\nenvironment switches keep working and WIN over these keys (FR-014):\nMCPPROXY_DISABLE_AUTO_UPDATE=true force-disables even when\nenabled=true, and MCPPROXY_ALLOW_PRERELEASE_UPDATES=true force-selects\nthe rc channel even when channel=stable.","properties":{"channel":{"description":"Channel selects which releases are offered as updates: \"stable\"\n(default; prereleases never offered) or \"rc\" (prereleases included).\nEmpty resolves to stable. Validated in ValidateDetailed.\n\nNOTE: for a RELEASED build the running binary's own version is\nauthoritative and overrides this field — a stable build is never\noffered an RC (even with channel=rc), and an RC build always tracks the\nrc channel. This field only takes effect on dev/unstamped builds. See\ninternal/updatecheck.Checker.IncludePrereleases.","type":"string"},"enabled":{"description":"Enabled gates all update checking. Tri-state: nil/absent = enabled\n(default true, matching pre-079 behavior). When false, no network\ncheck is performed and no upgrade nudge appears on any surface\n(FR-015) — /api/v1/info omits the update object entirely.","type":"boolean"}},"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"},"connect.ConnectResult":{"description":"The full result; its action mirrors the top-level one","properties":{"action":{"description":"\"created\", \"updated\", \"already_exists\", \"removed\", \"not_found\"","type":"string"},"backup_path":{"type":"string"},"client":{"type":"string"},"config_path":{"type":"string"},"message":{"type":"string"},"server_name":{"type":"string"},"success":{"type":"boolean"}},"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"},"parent_id":{"description":"Correlation id of the parent call (the code_execution whose sandbox issued this sub-call)","type":"string"},"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 transport session ID (regenerated on every reconnect)","type":"string"},"source":{"$ref":"#/components/schemas/contracts.ActivitySource"},"status":{"description":"Result status: \"success\", \"error\", \"blocked\", \"rejected\"","type":"string"},"timestamp":{"description":"When activity occurred","type":"string"},"tool_name":{"description":"Name of tool called","type":"string"},"type":{"$ref":"#/components/schemas/contracts.ActivityType"},"work_session_id":{"description":"Spec 082: one client, one project, across reconnects","type":"string"}},"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"},"rejected_count":{"description":"RejectedCount is the number of calls shed by a concurrency limiter before\nthey reached an upstream (spec 093). Counted separately from errors: it is\nproxy backpressure, not an upstream fault, and it is the signal an\noperator right-sizes max_concurrent_requests against.","type":"integer"},"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.AddFromRegistryRequest":{"properties":{"enabled":{"description":"defaults to true when nil","type":"boolean"},"env":{"additionalProperties":{"type":"string"},"description":"overrides + required-input values","type":"object"},"name":{"description":"optional name override","type":"string"}},"type":"object"},"contracts.AddRegistrySourceRequest":{"properties":{"id":{"description":"derived from the host when empty","type":"string"},"name":{"description":"defaults to the id","type":"string"},"protocol":{"description":"defaults to modelcontextprotocol/registry","type":"string"},"url":{"description":"required https registry URL","type":"string"}},"type":"object"},"contracts.ConfigApplyResult":{"properties":{"applied_immediately":{"type":"boolean"},"changed_fields":{"items":{"type":"string"},"type":"array","uniqueItems":false},"requires_restart":{"type":"boolean"},"restart_reason":{"type":"string"},"success":{"type":"boolean"},"validation_errors":{"items":{"$ref":"#/components/schemas/contracts.ValidationError"},"type":"array","uniqueItems":false}},"type":"object"},"contracts.DCRStatus":{"properties":{"attempted":{"type":"boolean"},"error":{"type":"string"},"status_code":{"type":"integer"},"success":{"type":"boolean"}},"type":"object"},"contracts.DeepScanDescriptor":{"description":"DeepScan reports the opt-in \"deep scan\" layer status (Spec 077 US3),\nSEPARATELY from the baseline verdict above. Always emitted on a computed\nsummary — when deep scan is off (the default) it reports enabled=false\nplus any enabled-but-skipped Docker scanners. It never influences Status.","properties":{"available":{"type":"boolean"},"enabled":{"type":"boolean"},"ran":{"type":"boolean"},"scanners_failed":{"items":{"$ref":"#/components/schemas/contracts.DeepScanScannerFailure"},"type":"array","uniqueItems":false},"skipped_scanners":{"description":"SkippedScanners lists Docker scanners the user enabled that are skipped\nbecause security.deep_scan.enabled is false (informational).","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"contracts.DeepScanScannerFailure":{"properties":{"id":{"type":"string"},"reason":{"type":"string"}},"type":"object"},"contracts.DeprecatedConfigWarning":{"properties":{"field":{"type":"string"},"message":{"type":"string"},"replacement":{"type":"string"}},"type":"object"},"contracts.Diagnostic":{"description":"Spec 044 — structured diagnostic error and stable error code. Both\nare populated when the server is in a failed state and the error\nhas been classified by internal/diagnostics. Healthy servers omit\nthese fields.","properties":{"cause":{"type":"string"},"code":{"type":"string"},"detected_at":{"type":"string"},"docs_url":{"type":"string"},"fix_steps":{"items":{"$ref":"#/components/schemas/contracts.DiagnosticFixStep"},"type":"array","uniqueItems":false},"severity":{"type":"string"},"user_message":{"type":"string"}},"type":"object"},"contracts.DiagnosticFixStep":{"properties":{"command":{"type":"string"},"destructive":{"type":"boolean"},"fixer_key":{"type":"string"},"label":{"type":"string"},"type":{"type":"string"},"url":{"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.EditRegistrySourceRequest":{"properties":{"name":{"description":"new display name","type":"string"},"servers_url":{"description":"explicit servers-collection URL","type":"string"},"url":{"description":"new base/servers https URL","type":"string"}},"type":"object"},"contracts.ErrorResponse":{"properties":{"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"},"contracts.FindingCounts":{"properties":{"dangerous":{"description":"Tool poisoning, active prompt injection","type":"integer"},"info":{"description":"Low-severity CVEs, informational","type":"integer"},"total":{"type":"integer"},"warning":{"description":"Rug pull, supply chain CVEs with exploits","type":"integer"}},"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.GlobalToolsResponse":{"properties":{"failed_servers":{"items":{"type":"string"},"type":"array","uniqueItems":false},"partial":{"type":"boolean"},"stats":{"$ref":"#/components/schemas/contracts.GlobalToolsStats"},"tools":{"items":{"$ref":"#/components/schemas/contracts.Tool"},"type":"array","uniqueItems":false}},"type":"object"},"contracts.GlobalToolsStats":{"properties":{"disabled":{"type":"integer"},"enabled":{"type":"integer"},"pending_approval":{"type":"integer"},"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"},"launched_by":{"description":"LaunchedBy is the durable launch provenance of the running core (Spec\n092 FR-001a): \"tray\" when a tray spawned it, \"installer\" when the macOS\nPKG postinstall did, \"\" when user-launched or unknown. Always present\n(possibly empty) so a tray can distinguish \"old core, not mine\" from\n\"old core I may supersede\".","type":"string"},"listen_addr":{"description":"Listen address (e.g., \"127.0.0.1:8080\")","type":"string"},"pid":{"description":"PID is the operating-system process id of the running core (Spec 092\nFR-002). A tray that merely ATTACHED to a core holds no Process handle\nfor it, so without this there is no mechanism at all to stop a stale\ncore — the consent action would have nothing to act on and could only\nprint instructions. Paired with LaunchedBy it is what lets a newer tray\nsupersede a core an older tray started.","type":"integer"},"update":{"$ref":"#/components/schemas/contracts.UpdateInfo"},"update_policy":{"$ref":"#/components/schemas/contracts.UpdatePolicy"},"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"},"extra_args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"image":{"type":"string"},"memory_limit":{"type":"string"},"network_mode":{"type":"string"},"timeout":{"type":"string"},"working_dir":{"type":"string"}},"type":"object"},"contracts.IsolationDefaults":{"description":"IsolationDefaults exposes the resolved baseline values that\nwould apply when no per-server override is set. Populated on\nlist/get responses; never consumed on PATCH requests.","properties":{"extra_args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"image":{"type":"string"},"network_mode":{"type":"string"},"runtime_type":{"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"},"work_session_id":{"type":"string"},"workspace_name":{"description":"Workspace / work session (Spec 082). WorkspaceName is the project's\nbasename — the full local path is never exposed. WorkSessionID groups the\nreconnects that make up one stretch of user work.","type":"string"}},"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.PreflightPolicy":{"properties":{"exclude_destructive":{"type":"boolean"},"exclude_open_world":{"type":"boolean"},"read_only_only":{"type":"boolean"}},"type":"object"},"contracts.PreflightReason":{"type":"string","x-enum-varnames":["PreflightReasonServerInitializing","PreflightReasonServerUnhealthy","PreflightReasonServerDisabled","PreflightReasonServerQuarantined","PreflightReasonToolPendingApproval","PreflightReasonToolChanged","PreflightReasonToolBlockedByUser","PreflightReasonOAuthRequired","PreflightReasonHashMismatch","PreflightReasonServerNotInScope","PreflightReasonToolDeniedByConfig","PreflightReasonMissingAnnotation","PreflightReasonPolicyFiltered","PreflightReasonNotFound","PreflightReasonServerNotConfigured"]},"contracts.PreflightRequest":{"properties":{"policy":{"$ref":"#/components/schemas/contracts.PreflightPolicy"},"profile":{"description":"Profile evaluates under a named profile's server scope. Unknown: 400.","type":"string"},"tools":{"description":"Tools is 1..100 entries BEFORE dedup; duplicates are collapsed, and\nduplicate ids carrying different pins are a validation error.","items":{"$ref":"#/components/schemas/contracts.PreflightToolRef"},"type":"array","uniqueItems":false},"wait_ms":{"description":"WaitMS polls local state for up to this many milliseconds (cap 10000)\nwhile every failure is retryable-class.","type":"integer"}},"type":"object"},"contracts.PreflightResponse":{"properties":{"checked_at":{"type":"string"},"tools":{"description":"Tools are ordered by first occurrence of each unique id in the request.","items":{"$ref":"#/components/schemas/contracts.PreflightToolResult"},"type":"array","uniqueItems":false},"verdict":{"$ref":"#/components/schemas/contracts.PreflightVerdict"},"waited_ms":{"description":"WaitedMS is present when wait_ms was requested (0 when the wait\nsemaphore was exhausted and the request resolved immediately).","type":"integer"}},"type":"object"},"contracts.PreflightStatus":{"type":"string","x-enum-varnames":["PreflightStatusReady","PreflightStatusUnavailable"]},"contracts.PreflightToolRef":{"properties":{"id":{"description":"ID is a canonical \"\u003cserver\u003e:\u003ctool\u003e\" id. A malformed id is answered with a\nper-ID not_found carrying a format hint, never a request-level error.","type":"string"},"pin_hash":{"description":"PinHash is \"sha256/v{N}:{hex}\" — the schema version is embedded so a\nproxy-side hash-algorithm bump is distinguishable from upstream drift.","type":"string"}},"type":"object"},"contracts.PreflightToolResult":{"properties":{"action":{"type":"string"},"detail":{"type":"string"},"did_you_mean":{"description":"DidYouMean carries up to 3 nearest caller-visible ids on not_found. It\nnever crosses a scope boundary and never names a quarantined server's\ntools.","items":{"type":"string"},"type":"array","uniqueItems":false},"hash":{"description":"Hash is the tool's current pin (\"sha256/v{N}:{hex}\") — operator tier,\nready results only. Never disclosed to an agent token.","type":"string"},"id":{"type":"string"},"reason":{"$ref":"#/components/schemas/contracts.PreflightReason"},"remediation":{"type":"string"},"retryable":{"type":"boolean"},"status":{"$ref":"#/components/schemas/contracts.PreflightStatus"}},"type":"object"},"contracts.PreflightVerdict":{"type":"string","x-enum-varnames":["PreflightVerdictReady","PreflightVerdictDegradedRetryable","PreflightVerdictBlocked","PreflightVerdictUnknownIDs"]},"contracts.QuarantineStats":{"description":"Tool quarantine metrics for this server","properties":{"blocked_count":{"description":"Number of disabled (blocked) tools","type":"integer"},"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.RefreshRegistryResponse":{"properties":{"cleared":{"description":"number of cached entries dropped","type":"integer"},"registry_id":{"type":"string"}},"type":"object"},"contracts.Registry":{"properties":{"count":{"description":"number or string","type":"string"},"description":{"type":"string"},"id":{"type":"string"},"name":{"type":"string"},"protocol":{"type":"string"},"provenance":{"description":"Provenance is the trust tag (MCP-866): \"official/trusted\" for built-in\ndefaults, \"custom/unverified\" for user-added registries.","type":"string"},"servers_url":{"type":"string"},"tags":{"items":{"type":"string"},"type":"array","uniqueItems":false},"trusted":{"description":"Trusted indicates whether this is an official, shipped-by-default\nregistry. Trust is derived from membership in the default set, never\nfrom self-assertion in config.","type":"boolean"},"url":{"type":"string"}},"type":"object"},"contracts.RegistryCacheInfo":{"properties":{"age_seconds":{"type":"number"},"stale":{"type":"boolean"}},"type":"object"},"contracts.RegistryUnavailable":{"properties":{"reason":{"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":{"cache":{"$ref":"#/components/schemas/contracts.RegistryCacheInfo"},"query":{"type":"string"},"registry_id":{"type":"string"},"servers":{"items":{"$ref":"#/components/schemas/contracts.RepositoryServer"},"type":"array","uniqueItems":false},"tag":{"type":"string"},"total":{"type":"integer"},"unavailable":{"$ref":"#/components/schemas/contracts.RegistryUnavailable"}},"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.SecurityScanSummary":{"description":"Latest security scan results summary","properties":{"deep_scan":{"$ref":"#/components/schemas/contracts.DeepScanDescriptor"},"finding_counts":{"$ref":"#/components/schemas/contracts.FindingCounts"},"last_scan_at":{"type":"string"},"risk_score":{"description":"0-100","type":"integer"},"scanners_failed":{"type":"integer"},"scanners_run":{"description":"Scanner coverage for the primary (baseline) scan pass — informational only.\nSpec 077 US3 (FR-008/FR-014): Status is derived SOLELY from the\ndeterministic baseline findings; a failed Docker deep scanner no longer\ndowngrades a clean verdict. That failure is surfaced via DeepScan instead.","type":"integer"},"scanners_total":{"type":"integer"},"status":{"description":"\"clean\", \"warnings\", \"dangerous\", \"failed\", \"not_scanned\", \"scanning\"","type":"string"}},"type":"object"},"contracts.Server":{"properties":{"args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"authenticated":{"description":"OAuth authentication status","type":"boolean"},"auto_approve_tool_changes":{"description":"AutoApproveToolChanges mirrors config.ServerConfig.AutoApproveToolChanges\n(MCP-2930): the per-server intent to auto-approve new/changed tools past\nthe trust baseline. Tri-state *bool — nil means \"never set\" (omitted from\nthe payload), so the Web UI toggle (MCP-2932) can distinguish unset from\nan explicit false. Read-only on the GET path; PATCH/POST accept it via\nAddServerRequest.","type":"boolean"},"command":{"type":"string"},"connected":{"type":"boolean"},"connected_at":{"type":"string"},"connecting":{"type":"boolean"},"created":{"type":"string"},"diagnostic":{"$ref":"#/components/schemas/contracts.Diagnostic"},"enabled":{"type":"boolean"},"env":{"additionalProperties":{"type":"string"},"type":"object"},"error_code":{"type":"string"},"expose_prompts":{"description":"ExposePrompts mirrors config.ServerConfig.ExposePrompts (F9): the per-server\nprompt-aggregation override. Tri-state *bool — nil/omitted means \"inherit\ndefault aggregation\". Surfaced on GET so a caller that PATCHed the override\ncan read it back; PATCH/POST accept it via AddServerRequest.","type":"boolean"},"headers":{"additionalProperties":{"type":"string"},"type":"object"},"health":{"$ref":"#/components/schemas/contracts.HealthStatus"},"id":{"type":"string"},"init_timeout":{"description":"InitTimeout mirrors config.ServerConfig.InitTimeout (MCP-3322 / GH #760):\nthe per-server MCP ` + "`" + `initialize` + "`" + ` handshake deadline override. Serialized as\na duration string (e.g. \"120s\"); nil/omitted means \"inherit the global\ndefault\". Surfaced on the GET path so clients can read back a configured\noverride; PATCH/POST accept it via AddServerRequest.","type":"string"},"isolation":{"$ref":"#/components/schemas/contracts.IsolationConfig"},"isolation_defaults":{"$ref":"#/components/schemas/contracts.IsolationDefaults"},"last_error":{"type":"string"},"last_reconnect_at":{"type":"string"},"last_retry_time":{"type":"string"},"max_concurrent_requests":{"description":"Spec 093 (GH #955) — per-server concurrency overrides, scope (c) of\nFR-020. Each setting is tri-state: nil (omitted) means \"inherit\nserver_concurrency_defaults\", 0 disables that setting for this server,\npositive overrides it. Surfaced on the GET path so a caller can read back\nwhat it set; PATCH/POST accept them via AddServerRequest. The effective\nconcurrency for a server is additionally bounded by the global aggregate\nlimiter, which is NOT an inheritance source for these fields.","type":"integer"},"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"},"queue_size":{"type":"integer"},"queue_timeout":{"type":"string"},"reconnect_count":{"type":"integer"},"reconnect_on_use":{"description":"Attempt reconnection when a tool call targets this disconnected server","type":"boolean"},"retry_count":{"type":"integer"},"security_scan":{"$ref":"#/components/schemas/contracts.SecurityScanSummary"},"should_retry":{"type":"boolean"},"source_registry_id":{"description":"MCP-901 — registry provenance of an upstream that was added from a\nregistry. SourceRegistryID names the source registry (empty for\nmanually-configured servers); SourceRegistryProvenance is the trust tag\nrecorded at add time (\"official/trusted\" or \"custom/unverified\"). Both\nare projected from config.ServerConfig so the approval/quarantine view\ncan render an \"added from \u003cregistry\u003e · unverified\" origin badge. Optional\nand omitted when empty — clients that pre-date this treat them as absent.","type":"string"},"source_registry_provenance":{"type":"string"},"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"},"trust_mode":{"description":"TrustMode mirrors config.ServerConfig.TrustMode (spec 086): the per-server\ntrust tier (\"auto\"/\"scan\"/\"manual\"). Surfaced on the GET path so clients can\nread back the persisted mode; PATCH/POST accept it via AddServerRequest.\nOmitted when empty (server predates the field / relies on legacy flags).","type":"string"},"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"},"approval_status":{"type":"string"},"config_denied":{"description":"ConfigDenied is true when the tool is denied by the server's static\nenabled_tools / disabled_tools config. The user cannot override this toggle.","type":"boolean"},"description":{"type":"string"},"disabled":{"description":"Disabled mirrors ToolApprovalRecord.Disabled so per-tool enable state is\navailable without a second round-trip to the approvals endpoint. Absent\nin the JSON when false (default) to keep responses compact.","type":"boolean"},"hash":{"description":"Hash is the tool's current stored hash rendered in the preflight pin\nformat \"sha256/v{N}:{hex}\" (Spec 098 FR-011), where N is the approval\nrecord's HashSchemaVersion. It is the authoring surface for\n` + "`" + `POST /api/v1/preflight` + "`" + ` pins and ` + "`" + `mcpproxy tools preflight --pin` + "`" + `:\ncopy the value straight into a pin.\n\nDisclosure is OPERATOR TIER ONLY — same rule as the preflight per-tool\nresult. The field is omitted for agent-token callers and for tools with\nno stored hash (no approval record yet, or a record written before\nhashes existed).","type":"string"},"held_reason":{"description":"HeldReason, HeldVerdict and HeldSignals mirror the same-named fields on\nstorage.ToolApprovalRecord: the offline-scan evidence that made\ntrust_mode: scan hold this tool for review (spec 086 FR-018). HeldSignals\nnames the matched deterministic check ids, e.g.\n\"tpa.TPA-2026-0001.hidden_instruction\", so a reviewer can see WHY the tool\nis held. All three are omitted for tools that are not held by the scan gate\n(including every record written before the field existed).","type":"string"},"held_signals":{"items":{"type":"string"},"type":"array","uniqueItems":false},"held_verdict":{"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"},"install_channel":{"description":"Detected install channel (homebrew, dmg, deb, rpm, docker, go-install, windows-installer, tarball, unknown) — Spec 079 FR-008","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"},"nudges_suppressed":{"description":"UI surfaces must stay quiet (CI / non-interactive context); machine-readable fields still report the facts — Spec 079 FR-019","type":"boolean"},"release_url":{"description":"URL to the release page","type":"string"},"update_command":{"description":"One-line update command for the channel; only set when an update is available and the channel has one — Spec 079 FR-009","type":"string"}},"type":"object"},"contracts.UpdatePolicy":{"description":"UpdatePolicy is the effective, hot-reloadable update policy (Spec 092\nFR-015). Always present: the ` + "`" + `update` + "`" + ` object above is omitted both when\nupdate checking is disabled AND when no check has produced a result\nyet, so its absence cannot tell a client whether it is allowed to run\nits own (e.g. Sparkle feed) check. This field states the answer.","properties":{"channel":{"description":"Channel is the tracked release channel: \"stable\" or \"rc\".","type":"string"},"enabled":{"description":"Enabled is the effective automatic-check kill switch: update_check.enabled\nwith MCPPROXY_DISABLE_AUTO_UPDATE=true winning over it. A user-initiated\n\"Check for Updates\" stays available regardless.","type":"boolean"},"nudges_suppressed":{"description":"NudgesSuppressed asks UI surfaces to stay quiet (CI / non-interactive)\nwhile machine-readable fields keep reporting the facts.","type":"boolean"}},"type":"object"},"contracts.UpstreamError":{"properties":{"error_message":{"type":"string"},"server_name":{"type":"string"},"timestamp":{"type":"string"}},"type":"object"},"contracts.UsageAggregateResponse":{"properties":{"freshness_ms":{"description":"age of the underlying snapshot in ms","type":"integer"},"generated_at":{"type":"string"},"other":{"$ref":"#/components/schemas/contracts.UsageOtherBucket"},"timeline":{"items":{"$ref":"#/components/schemas/contracts.UsageTimeBucket"},"type":"array","uniqueItems":false},"token_source":{"description":"\"bytes\" (size-based proxy, FR-006)","type":"string"},"tokens_saved":{"description":"echoed from ServerTokenMetrics (FR-007)","type":"integer"},"tokens_saved_percentage":{"type":"number"},"tools":{"items":{"$ref":"#/components/schemas/contracts.UsageToolStat"},"type":"array","uniqueItems":false},"window":{"type":"string"}},"type":"object"},"contracts.UsageOtherBucket":{"description":"present only when the list was truncated to top-N","properties":{"calls":{"type":"integer"},"tools_folded":{"type":"integer"},"total_resp_bytes":{"type":"integer"}},"type":"object"},"contracts.UsageTimeBucket":{"properties":{"calls":{"type":"integer"},"errors":{"type":"integer"},"start":{"type":"string"},"total_resp_bytes":{"type":"integer"}},"type":"object"},"contracts.UsageToolStat":{"properties":{"avg_req_bytes":{"description":"null when no sized request calls","type":"integer"},"avg_resp_bytes":{"description":"null when sized_calls == 0 (only legacy 0-byte calls)","type":"integer"},"blocked":{"type":"integer"},"calls":{"type":"integer"},"error_rate":{"type":"number"},"errors":{"type":"integer"},"last_used":{"type":"string"},"p50_ms":{"type":"integer"},"p95_ms":{"type":"integer"},"rejected":{"description":"spec 093: shed by a concurrency limit; never executed, so excluded from calls/latency","type":"integer"},"server":{"type":"string"},"sized_calls":{"description":"calls with known response size (basis for avg_resp_bytes)","type":"integer"},"tool":{"type":"string"},"total_req_bytes":{"type":"integer"},"total_resp_bytes":{"type":"integer"}},"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},"auto_approve_tool_changes":{"description":"AutoApproveToolChanges is the per-server intent to auto-approve\nnew/changed tools past the trust baseline (MCP-2930). Tri-state *bool:\na nil pointer means \"leave unchanged\" on PATCH; a present value\n(including false) is applied. Mirrors config.ServerConfig's *bool\nsemantics — do NOT collapse to a plain bool, or an omitted field would\nsilently reset a previously-set value.","type":"boolean"},"command":{"type":"string"},"enabled":{"type":"boolean"},"env":{"additionalProperties":{"type":"string"},"type":"object"},"expose_prompts":{"description":"ExposePrompts is the per-server override for prompt aggregation (F9):\nwhether this server's advertised MCP prompts are merged into mcpproxy's\nprompts/list. Tri-state *bool mirroring config.ServerConfig.ExposePrompts —\na nil pointer means \"leave unchanged\" on PATCH (and \"inherit the default\naggregate behavior\" on create); a present value (including false) is applied.","type":"boolean"},"headers":{"additionalProperties":{"type":"string"},"type":"object"},"init_timeout":{"description":"InitTimeout is the per-server MCP ` + "`" + `initialize` + "`" + ` handshake deadline override\n(MCP-3322 / GH #760), serialized as a duration string (e.g. \"120s\"). A nil\npointer means \"leave unchanged\" on PATCH; a present value is applied.\nMirrors config.ServerConfig.InitTimeout's *Duration tri-state.","type":"string"},"isolation":{"$ref":"#/components/schemas/httpapi.IsolationRequest"},"max_concurrent_requests":{"description":"MaxConcurrentRequests / QueueSize / QueueTimeout are the per-server\nconcurrency overrides (spec 093 / GH #955, FR-020 scope (c)). Each is\ntri-state: a nil pointer means \"leave unchanged\" on PATCH and \"inherit\nserver_concurrency_defaults\" on create; an explicit 0 disables that\nsetting for this server; a positive value overrides it. Do NOT collapse\nthem to plain values — an omitted field would then silently reset a\nconfigured limit.","type":"integer"},"name":{"type":"string"},"protocol":{"type":"string"},"quarantined":{"type":"boolean"},"queue_size":{"type":"integer"},"queue_timeout":{"type":"string"},"reconnect_on_use":{"type":"boolean"},"trust_mode":{"description":"TrustMode is the per-server trust tier (spec 086): \"auto\", \"scan\", or\n\"manual\". Empty means \"leave unchanged\" on PATCH (and inherit the migrated\ndefault on create). A non-empty value is applied to ServerConfig.TrustMode\nand resolved by EffectiveTrustMode (an unrecognized value fails closed to\nmanual). This is the REST seam for changing the trust tier via\nPOST/PATCH /api/v1/servers.","type":"string"},"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.ConnectConflictResponse":{"properties":{"action":{"description":"already_exists | precondition_failed","type":"string"},"data":{"$ref":"#/components/schemas/connect.ConnectResult"},"error":{"description":"Human-readable message","type":"string"},"success":{"description":"Always false","type":"boolean"}},"type":"object"},"httpapi.ConnectRequest":{"properties":{"force":{"description":"Overwrite existing entry","type":"boolean"},"precondition_token":{"description":"PreconditionToken is the opaque token from the preview this write was\nconfirmed against (Spec 091 FR-005). When present, the core rechecks it\nat write time and responds 409 with action \"precondition_failed\" —\nwriting nothing — if the config or the entry MCPProxy would write has\ndrifted since; the caller then re-previews instead of retrying. Absent\nmeans exactly the pre-091 behavior. A replace-classified flow sends this\nTOGETHER with force=true: the token, not the absence of force, is the\noverwrite safety.","type":"string"},"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"},"rename":{"additionalProperties":{"type":"string"},"description":"Rename maps a server name → new name. Applied after parsing so the\ncaller can disambiguate cross-source name collisions (Spec 046 v2 —\ne.g. \"mcpproxy\" → \"mcpproxy_claude_code\"). Keys are matched against\neither the raw source name (OriginalName) or the sanitized name shown\nin the preview (Server.Name); these differ for names that need\nsanitizing (e.g. \"Figma Desktop\" → \"Figma_Desktop\"). Keys not present\nin the imported set are ignored.","type":"object"},"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"},"httpapi.IsolationRequest":{"description":"Isolation carries per-server Docker isolation overrides (image,\nnetwork_mode, extra_args, working_dir, enabled). A nil pointer\nmeans \"do not touch isolation config\"; an empty-but-present\nobject on PATCH intentionally clears the overrides.","properties":{"enabled":{"type":"boolean"},"extra_args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"image":{"type":"string"},"network_mode":{"type":"string"},"working_dir":{"type":"string"}},"type":"object"},"httpapi.OnboardingMarkRequest":{"properties":{"connect_step_status":{"description":"ConnectStepStatus is one of: \"\", \"completed\", \"skipped\". Empty\npreserves the existing value. The stored enum is wider (Spec 080\nFR-001): a \"skipped\" request for a previously untouched connect step\nis upgraded server-side to \"completed_external\" when the install\nshows positive evidence of an external connection (Spec 080 FR-002).\n\"completed_external\" is NOT accepted from clients — it must never be\npersisted without that server-verified evidence (edge case: \"never\nguess completed_external without positive evidence\").","type":"string"},"engaged":{"description":"Engaged marks the wizard as engaged (completed or explicitly skipped).\nOnce true, the wizard does not auto-show again.","type":"boolean"},"mark_shown":{"description":"MarkShown records the wizard's first display time if not already set.","type":"boolean"},"server_step_status":{"description":"ServerStepStatus is one of: \"\", \"completed\", \"skipped\". Empty\npreserves the existing value.","type":"string"}},"type":"object"},"httpapi.SetActiveProfileRequest":{"properties":{"active_profile":{"type":"string"},"profile":{"type":"string"}},"type":"object"},"httpapi.UndoConnectRequest":{"properties":{"backup_name":{"description":"BackupName is the bare filename (filepath.Base) of the backup returned as\nbackup_path by the preceding connect — a name, never a path. Undo resolves\nthe full path server-side by joining it with the client's own config\ndirectory, so a client-supplied value can never contribute a directory\ncomponent (traversal is impossible by construction). Empty means the\nconnect created the file (no prior file existed), so undo removes it.","type":"string"},"server_name":{"description":"Defaults to \"mcpproxy\"","type":"string"}},"type":"object"},"httpapi.UpdateFailureRequest":{"properties":{"stage":{"description":"Stage is the failure stage of the update session.","enum":["appcast","download","install","other"],"type":"string"}},"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"},"forward_proxy_env":{"description":"ForwardProxyEnv opts in to forwarding the ambient HTTP(S)/ALL/NO/FTP proxy\nenvironment variables to spawned upstream servers (MCP-2769). It is OFF by\ndefault and deliberately kept out of the AllowedSystemVars default list:\nproxy URLs frequently carry credentials (http://user:pass@proxy), so\nforwarding them to every stdio upstream is a credential-leak risk. When\nenabled, values are forwarded with their userinfo (credentials) redacted.","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","preflight","prompt_get"],"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 transport session ID","in":"query","name":"session_id","schema":{"type":"string"}},{"description":"Filter by work session (one client, one project, across reconnects)","in":"query","name":"work_session_id","schema":{"type":"string"}},{"description":"Filter by status","in":"query","name":"status","schema":{"enum":["success","error","blocked","rejected"],"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":"Filter by parent call id — returns the sub-calls one code_execution issued","in":"query","name":"parent_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"}},{"description":"Omit arguments, response and metadata except a contextual whitelist (intent.reason, intent.operation_type, decision, reason, client_name, client_version) (default: false). For clients that render summary fields only; has_sensitive_data is still derived before metadata is dropped.","in":"query","name":"exclude_payloads","schema":{"type":"boolean"}}],"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 transport session ID","in":"query","name":"session_id","schema":{"type":"string"}},{"description":"Filter by work session (one client, one project, across reconnects)","in":"query","name":"work_session_id","schema":{"type":"string"}},{"description":"Filter by status","in":"query","name":"status","schema":{"type":"string"}},{"description":"Filter by HTTP request ID for log correlation (Spec 021)","in":"query","name":"request_id","schema":{"type":"string"}},{"description":"Filter by parent call id — exports the sub-calls one code_execution issued","in":"query","name":"parent_id","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/usage":{"get":{"description":"Returns the actor-owned usage aggregate (per-tool rollup + timeline + tokens-saved headline) for the Web UI usage graphs (Spec 069). Served from an in-memory snapshot — never a per-request full-log scan. Per-tool metrics are lifetime-cumulative; ` + "`" + `window` + "`" + ` scopes the timeline and filters the tool list to tools active within the span.","parameters":[{"description":"Time window for timeline + tool-list membership","in":"query","name":"window","schema":{"enum":["24h","7d","all"],"type":"string"}},{"description":"Filter to one server","in":"query","name":"server","schema":{"type":"string"}},{"description":"Filter to one tool","in":"query","name":"tool","schema":{"type":"string"}},{"description":"Filter to tools with activity of this status","in":"query","name":"status","schema":{"enum":["success","error","blocked","rejected"],"type":"string"}},{"description":"Top-N tools by sort key; remainder folded into 'other' (default 20)","in":"query","name":"top","schema":{"type":"integer"}},{"description":"Ranking key for the per-tool list","in":"query","name":"sort","schema":{"enum":["calls","resp_bytes","error_rate","p95"],"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"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"Get usage statistics aggregate","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/code/scripts":{"get":{"description":"List the stored scripts available to the code_execution tool. Scripts are ` + "`" + `\u003cname\u003e.js` + "`" + ` / ` + "`" + `\u003cname\u003e.ts` + "`" + ` files in the ` + "`" + `scripts/` + "`" + ` directory next to the active configuration file. Entries are advisory: ` + "`" + `ok` + "`" + ` scripts are invocable, ` + "`" + `ambiguous` + "`" + ` names have both extensions, and ` + "`" + `invalid` + "`" + ` ones report why (empty, oversized, unreadable, non-regular). Read-only — there is no write surface for stored scripts.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Stored scripts and the directory they were read from"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"List stored code-execution scripts","tags":["code"]}},"/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"]},"patch":{"description":"Deep-merges only the fields present in the request body onto the live in-memory configuration and routes the result through the existing apply pipeline (validation, change detection, disk persistence, hot-reload). Fields the client omits — including masked secrets such as ` + "`" + `api_key` + "`" + ` and secret request headers — are preserved verbatim. Nested objects are merged recursively; arrays and scalars replace wholesale.","requestBody":{"content":{"application/json":{"schema":{"type":"object"}}},"description":"Partial configuration with only the fields to change","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ConfigApplyResult"}}},"description":"Configuration patch applied (inspect validation_errors for rejected values)"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid JSON payload or empty patch"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate configuration)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to read or apply configuration"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Partially update 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"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate configuration)"},"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/docker-isolation":{"patch":{"description":"Convenience endpoint to flip ` + "`" + `docker_isolation.enabled` + "`" + ` without resending the full config. Persists to disk via the existing config writer — the file watcher then hot-reloads the change. Returns the new state and whether a restart is required for existing connections to pick it up.","requestBody":{"content":{"application/json":{"schema":{"properties":{"enabled":{"type":"boolean"}},"type":"object"}}},"description":"New isolation state","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ConfigApplyResult"}}},"description":"Isolation toggle applied"},"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"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate configuration)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to apply configuration"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Toggle global Docker isolation","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, claude-desktop, cursor, windsurf, vscode, codex, gemini, opencode)","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"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Permission denied (macOS App-Data block)"},"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"]},"get":{"description":"Resolves one client's status by reading its config file on demand.\nThis is the only Connect endpoint that opens a client config file, so\non macOS it is the sole place an App-Data privacy prompt may legitimately\nappear (scoped to this user action). Resolves access_state to\naccessible|absent|denied|malformed and populates remediation when denied.","parameters":[{"description":"Client ID (claude-code, claude-desktop, cursor, windsurf, vscode, codex, gemini, opencode)","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"ClientStatus"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unknown client"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get a single client's connection status (on-demand)","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.\nOptionally accepts precondition_token from a preview (Spec 091): when supplied,\nthe core rechecks the raw pre-write state and the entry it would write, and\nrefuses a drifted write with 409 before taking any backup. The 409 body's\naction discriminates the two conflict kinds: \"precondition_failed\" (stale\npreview — re-preview, do not retry) vs \"already_exists\" (entry present — pass\nforce=true). force=true never rescues a stale token.","parameters":[{"description":"Client ID (claude-code, claude-desktop, cursor, windsurf, vscode, codex, gemini, opencode)","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ConnectRequest"}}},"description":"Optional connection parameters (server_name, force, precondition_token)"},"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"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Permission denied (macOS App-Data block)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unknown client"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ConnectConflictResponse"}}},"description":"Conflict: action=already_exists (use force=true) or action=precondition_failed (preview is stale; re-preview)"},"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/connect/{client}/preview":{"get":{"description":"Returns the exact entry a subsequent connect would add to the client's\nconfig — target path, server key, entry name, and entry contents — WITHOUT\nmodifying the file or creating a backup (Spec 078 US1). The embedded API key\nis masked in the payload; contains_api_key flags that a credential is written.\nentry_exists distinguishes a create from an overwrite of a same-named entry.\nReads the config on demand to classify create-vs-overwrite, so on macOS this\nmay raise an App-Data privacy prompt; a denial returns 403 + remediation.\nSpec 091 adds three fields: existing_entry_summary (present only when\nentry_exists — a sanitized, non-secret projection of the entry being replaced:\nits name, type, endpoint with query/userinfo stripped, command, and header and\nenv NAMES, never values); precondition_token (always present — an opaque keyed\ndigest of the raw pre-write state and the pending entry, echoed back on POST\nconnect to detect drift); and connect_refusal (present when the write would\nrefuse regardless of intent, e.g. a non-create-capable client with no config —\ntreat its presence as \"Connect unavailable\").","parameters":[{"description":"Client ID (claude-code, claude-desktop, cursor, windsurf, vscode, codex, gemini, opencode)","in":"path","name":"client","required":true,"schema":{"type":"string"}},{"description":"Entry name to preview (defaults to mcpproxy); mirror the value passed to POST connect","in":"query","name":"server_name","schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"ConnectPreview"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Permission denied (macOS App-Data block)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unknown client"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Preview the change a connect would make (no write)","tags":["connect"]}},"/api/v1/connect/{client}/undo":{"post":{"description":"Reverts the connect that produced the named backup (Spec 078 US3):\nrestores the client config byte-for-byte from that backup, or — when\nbackup_name is empty because the connect created the file — deletes the\ncreated file. backup_name is the bare filename of the backup the connect\nreturned (never a path); undo resolves the full path server-side inside\nthe client's own config directory, so a client value cannot escape it.\nRefuses with 409 when the config changed since the connect (undo never\nclobbers later edits; use DELETE /connect/{client} for a surgical entry\nremoval instead). Takes its own safety backup first; its path is returned\nas backup_path in the result.","parameters":[{"description":"Client ID (claude-code, claude-desktop, cursor, windsurf, vscode, codex, gemini, opencode)","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.UndoConnectRequest"}}},"description":"Undo parameters (server_name, backup_name = the bare filename of the backup the preceding connect returned)"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"ConnectResult (action restored|deleted)"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (e.g. backup_name is a path, or not a backup of this client's config)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Permission denied (macOS App-Data block)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unknown client or backup no longer exists"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Config changed since connect; undo refused"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Undo a connect, restoring the pre-connect config","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\nThe launched_by field reports durable launch provenance (\"tray\", \"installer\", or \"\" for user-launched/unknown)","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/onboarding/mark":{"post":{"description":"Updates wizard engagement and per-step status. Once engaged is\ntrue, the wizard does not auto-show again, even if state regresses.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.OnboardingMarkRequest"}}},"description":"Mark request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Updated OnboardingStateResponse"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Mark onboarding wizard state (Spec 046)","tags":["onboarding"]}},"/api/v1/onboarding/state":{"get":{"description":"Returns the wizard engagement record alongside live predicates\n(whether any client is connected, whether any server is configured),\nplus a derived ShouldShowWizard flag the frontend can rely on.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"OnboardingStateResponse"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get onboarding wizard state and predicates (Spec 046)","tags":["onboarding"]}},"/api/v1/preflight":{"post":{"description":"Deterministic, side-effect-free availability check for a caller-supplied list of tool IDs (Spec 098). Performs zero upstream calls and mutates no runtime state. HTTP status reports whether the CHECK executed: a fully blocked set is still 200, with the availability verdict in the body. Every executed preflight writes an activity record before the response is returned.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.PreflightRequest"}}},"description":"Tool IDs (1-100 before dedup), optional profile, annotation policy filters and wait budget","required":true},"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":"Preflight verdict and per-tool results"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Validation error (malformed, oversized, doubled or unknown-field body; empty or oversized tool list; conflicting duplicate pins; unknown profile; wait_ms out of range)"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Missing or invalid credentials"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Runtime unavailable, evaluator infrastructure read failure, or the activity record could not be persisted"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"Preflight required tools","tags":["tools"]}},"/api/v1/profiles":{"get":{"description":"List all configured profiles with their effective servers and indexed tool count (Profiles v2). A profile scopes tool discovery and calls to a named subset of upstream servers.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Profile list"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Configuration unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"List configured profiles","tags":["profiles"]}},"/api/v1/profiles/active":{"get":{"description":"Get the server-level default active profile used by UI surfaces (Web UI / tray). Empty string means \"all servers\". Note: within a live MCP session, the set_profile tool selection takes precedence over this default.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Active profile"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get the default active profile","tags":["profiles"]},"put":{"description":"Set the server-level default active profile for UI surfaces. The slug must match a configured profile; pass an empty string to clear. This does not affect live MCP sessions, which use the set_profile tool.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.SetActiveProfileRequest"}}},"description":"Profile slug to activate (empty clears)","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Active profile updated"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid request body"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unknown profile"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Set the default active profile","tags":["profiles"]}},"/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"]},"post":{"description":"Adds a generic modelcontextprotocol/registry v0.1 https endpoint as a custom registry (MCP-866). The source is always tagged custom/unverified, so every server discovered through it lands quarantined and can never skip quarantine.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.AddRegistrySourceRequest"}}},"description":"Registry source (https url + optional protocol/id/name)","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Registry source added"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"invalid_registry_url"},"403":{"content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/contracts.ErrorResponse"},{"$ref":"#/components/schemas/contracts.ErrorResponse"}]}}},"description":"Forbidden (agent tokens cannot mutate registries)"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_shadows_builtin | duplicate_registry"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Add a user-supplied registry source","tags":["registries"]}},"/api/v1/registries/{id}":{"delete":{"description":"Removes a custom/unverified registry previously added via add-source (MCP-1057). Built-in registries are refused with registry_shadows_builtin; an unknown id yields registry_not_found. The change is persisted copy-on-write.","parameters":[{"description":"Registry ID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Registry source removed"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Registry ID is required"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registries_locked"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_not_found"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_shadows_builtin"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Remove a user-added custom registry source","tags":["registries"]},"put":{"description":"Updates a custom registry previously added via add-source (MCP-1072): name, url, servers-url. Empty fields are left unchanged. Built-in registries are refused with registry_shadows_builtin; an unknown id yields registry_not_found; a non-https url yields invalid_registry_url. The change is persisted copy-on-write.","parameters":[{"description":"Registry ID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.EditRegistrySourceRequest"}}},"description":"Fields to update (name/url/servers_url; empty = unchanged)","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Registry source updated"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Registry ID is required | invalid_registry_url"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registries_locked"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_not_found"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_shadows_builtin"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Edit a user-added custom registry source","tags":["registries"]}},"/api/v1/registries/{id}/refresh":{"post":{"description":"Invalidates the cached server lists for a registry so the next search re-fetches fresh data from the source (spec 070 FR-007). Returns how many cache entries were dropped.","parameters":[{"description":"Registry ID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.RefreshRegistryResponse"}}},"description":"Registry cache refreshed"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Registry ID is required"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to refresh registry cache"}},"summary":"Refresh a registry's cached server list","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/registries/{id}/servers/{serverId}/add":{"post":{"description":"Resolves a registry server reference server-side, re-derives a validated config, and persists it quarantined (spec 070 keystone). The client never sends a config blob — command/args/url and the quarantine flag are derived from the registry entry, not the request.","parameters":[{"description":"Registry ID","in":"path","name":"id","required":true,"schema":{"type":"string"}},{"description":"Server ID within the registry","in":"path","name":"serverId","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.AddFromRegistryRequest"}}},"description":"Optional overrides (name, env, enabled)"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Server added (quarantined)"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"no_install_info | missing_required_input | duplicate_name"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot add servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_not_found | server_not_found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Add an upstream server from a registry reference","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"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"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"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"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"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"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"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"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}/config-to-secret":{"post":{"description":"Atomically reads the real value from the server config, stores it in the OS keyring, and rewrites the config field to ` + "`" + `${keyring:\u003cname\u003e}` + "`" + `. Unblocks the UI's Convert-to-secret affordance for values the API redacts on the read path.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":{},"type":"object"}}},"description":"Secret stored, config updated with reference"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad scope/key/secret_name, or value is already a reference / empty"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server or key not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Secret resolver or config update failed"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Convert a header / env value to a keyring secret","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"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"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)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot discover tools)"},"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"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"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.)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"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)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"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}/refresh":{"post":{"description":"Re-discover and re-index a specific upstream MCP server's tools without changing any security state. Alias of discover-tools, named for the upstream_servers 'refresh' operation; use it to make just-approved tools searchable immediately.","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 refresh triggered 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 (agent tokens cannot refresh)"},"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 refresh tools"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Refresh a server's tools","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"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"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}/tools/block":{"post":{"description":"Atomically approves AND disables the given tools (or all pending/changed tools when block_all=true) for a server. The approve and disable land in a single write per tool, so a tool is never left in the approved+enabled state. The \"blocked\" field counts tools actually blocked.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Block result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Block (approve+disable) tools for a server","tags":["servers"]}},"/api/v1/servers/{id}/tools/disable_all":{"post":{"description":"Bulk-toggles every known tool of a server. The \"changed\" field","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Operation result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Enable or disable all tools for a server","tags":["servers"]}},"/api/v1/servers/{id}/tools/enable_all":{"post":{"description":"Bulk-toggles every known tool of a server. The \"changed\" field","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Operation result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Enable or disable all 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)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"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"}},{"description":"Filter by session status","in":"query","name":"status","schema":{"enum":["active","closed"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetSessionsResponse"}}},"description":"Sessions retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid status filter"},"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/telemetry/payload":{"get":{"description":"Render the exact JSON heartbeat payload that mcpproxy would next send to the telemetry endpoint, without making a network call. Counters in the payload reflect the current in-memory state. Spec 042.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Telemetry heartbeat payload"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Telemetry service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Preview next telemetry heartbeat payload","tags":["telemetry"]}},"/api/v1/telemetry/update-failure":{"post":{"description":"Records one terminal update-session failure, identified only by its\nstage (appcast, download, install, other). The body carries no error\ntext, URL, or version — the stage is the only value transmitted.\nReturns 204 both when the occurrence was durably persisted and when\ntelemetry is inactive at event time (config opt-out, environment\nopt-out, CI, or dev build), in which case nothing is recorded.\nCallers cannot and need not distinguish the two.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.UpdateFailureRequest"}}},"description":"Update failure stage","required":true},"responses":{"204":{"description":"Accepted (recorded, or a deliberate no-op while telemetry is inactive)"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Malformed body, unknown field, trailing value, or stage outside the closed set"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Persistence failure"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Record a desktop auto-update failure occurrence (Spec 095)","tags":["telemetry"]}},"/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"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Shed by a concurrency limit (Retry-After header carries the wait hint)"},"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":{"get":{"description":"Consolidated, read-only listing of all tools from every configured server (including disabled servers and disabled/config-denied tools), enriched with approval state and 30-day usage. Backs the global Tools page and the CLI global ` + "`" + `tools list` + "`" + ` (spec 050, issue #437).","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GlobalToolsResponse"}}},"description":"All tools across all servers"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Could not enumerate servers"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"List every tool across all servers","tags":["tools"]}},"/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)"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Shed by a concurrency limit (Retry-After header carries the wait hint)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error or tool execution failure"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Call a tool","tags":["tools"]}},"/healthz":{"get":{"description":"Get comprehensive health status including all component health (Kubernetes-compatible liveness probe)","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/observability.HealthResponse"}}},"description":"Service is healthy"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/observability.HealthResponse"}}},"description":"Service is unhealthy"}},"summary":"Get health status","tags":["health"]}},"/readyz":{"get":{"description":"Get readiness status including all component readiness checks (Kubernetes-compatible readiness probe)","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/observability.ReadinessResponse"}}},"description":"Service is ready"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/observability.ReadinessResponse"}}},"description":"Service is not ready"}},"summary":"Get readiness status","tags":["health"]}}}, diff --git a/oas/swagger.yaml b/oas/swagger.yaml index 0c3c68faf..66e14d09a 100644 --- a/oas/swagger.yaml +++ b/oas/swagger.yaml @@ -760,6 +760,22 @@ components: config.SecurityConfig: description: Security scanner settings (Spec 039) properties: + auto_baseline_scan: + description: |- + AutoBaselineScan is the kill-switch for the AUTOMATIC, informational + Pass-1 baseline scan: the free in-process TPA scan mcpproxy runs for every + newly admitted server (any trust mode) and, once per installation, over + pre-existing servers that have never been scanned. + + Informational ONLY: the resulting verdict populates the security badge and + the scan summary, and NEVER gates quarantine or approval. The + trust_mode:"scan" admission gate is a separate path and is unaffected by + this flag. + + Default (nil) is ENABLED. Set to false to suppress every automatic scan + (manual scans keep working). Env override: MCPPROXY_AUTO_BASELINE_SCAN, + which wins over this field on every path. + type: boolean deep_scan: $ref: '#/components/schemas/config.DeepScanConfig' integrity_check_interval: From 41081c64b9dccee2dfff4db65724a348d3506982 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Sun, 23 Aug 2026 21:37:58 +0300 Subject: [PATCH 2/9] fix(security): close informational-scan gating window + sweep/env fail-closed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cross-model review (opencode, gpt-5.6-sol) round 1 findings on the informational Pass-1 scan paths. Four genuine defects, four rejected. 1. Informational scan could cause a gating state change (HIGH). scanModeAdmissionOwns keyed on sc.Quarantined, but quarantine is MUTABLE while a scan is in flight and maybeAutoApproveScanSettled re-reads it at settle time. A trust_mode:"scan" server that was unquarantined when the informational path claimed it, and that the operator quarantined before the clean verdict landed, would be silently unquarantined by the settle handler — exactly the "informational scans never gate" invariant this feature promises. The predicate now keys only on (trust mode, prior approval baseline): a scan-mode server without an approval baseline is precisely the set the settle handler can act on, so the informational path leaves it alone in every quarantine state. Scan-mode servers that already have a baseline stay eligible (the settle handler bails on them). 2. A failed admission scan was never retryable. maybeStartInformationalScans records a server in infoScanKnown before the scan is attempted; releaseInformationalScan dropped only the infoScanQueued claim, so the server stayed permanently "not new" and no later servers.changed could pick it up — and once the one-shot sweep marker is burned, nothing would ever scan it. Release now forgets the name in both maps. 3. A storage read failure burned the one-shot sweep marker. The sweep read its inventory through listStoredServers, which collapses a read error into an empty slice — the sweep's "nothing to do" signal, which persists the marker. A transient BBolt error would therefore permanently mark a sweep that never examined a single server. The sweep now calls ListUpstreamServers directly and fails closed, leaving the marker unset. 4. An invalid MCPPROXY_AUTO_BASELINE_SCAN value disabled the feature. The loader's env pass used a bare non-empty check, so a typo ("yes") wrote AutoBaselineScan=false over a config that had explicitly enabled it; the accessor then ignores the unrecognized env value and reads that overwritten false. The loader now uses the same true/1/false/0 vocabulary as IsAutoBaselineScanEnabled and ignores anything else. Rejected as not defects: the settle-timeout serialization release and the partial-sweep marker rule are documented deliberate tradeoffs (an unbounded wait would let one hung scan block the queue forever; requiring zero failures would re-sweep on every start for a permanently broken server); the "unbounded goroutines" claim is bounded by the number of distinct newly admitted servers, which drain; and the engine's detachment onto context.Background in executeScan is pre-existing behaviour shared by the spec-086 admission scan and every manual scan, not introduced here. Tests: scanModeAdmissionOwns table gains both unquarantined-scan-mode cases; new TestInformationalScan_FailedStartRetriesOnNextServersChanged, TestBaselineSweep_UnreadableStoreNeverBurnsMarker, and loader-vocabulary coverage in internal/config. --- internal/config/auto_baseline_scan_test.go | 41 ++++++++++++ internal/config/loader.go | 17 ++++- internal/server/scan_informational.go | 38 +++++++++-- internal/server/scan_informational_test.go | 76 +++++++++++++++++++++- 4 files changed, 162 insertions(+), 10 deletions(-) diff --git a/internal/config/auto_baseline_scan_test.go b/internal/config/auto_baseline_scan_test.go index 8e76ba42b..6d549d54d 100644 --- a/internal/config/auto_baseline_scan_test.go +++ b/internal/config/auto_baseline_scan_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestIsAutoBaselineScanEnabled(t *testing.T) { @@ -46,3 +47,43 @@ func TestIsAutoBaselineScanEnabled(t *testing.T) { assert.True(t, (&SecurityConfig{}).IsAutoBaselineScanEnabled()) }) } + +// The loader's env pass must use the SAME vocabulary as the accessor. A bare +// non-empty check there would materialize AutoBaselineScan=false for a typo like +// "yes", and because the accessor then ignores the unrecognized env value it +// would read that overwritten false — silently disabling automatic scanning for +// a config that had explicitly enabled it. +func TestAutoBaselineScanEnvOverride_LoaderVocabulary(t *testing.T) { + boolPtr := func(b bool) *bool { return &b } + + t.Run("unrecognized value leaves the configured field alone", func(t *testing.T) { + t.Setenv(EnvAutoBaselineScan, "yes") + cfg := &Config{Security: &SecurityConfig{AutoBaselineScan: boolPtr(true)}} + applyTLSEnvOverrides(cfg) + require.NotNil(t, cfg.Security.AutoBaselineScan) + assert.True(t, *cfg.Security.AutoBaselineScan) + assert.True(t, cfg.Security.IsAutoBaselineScanEnabled()) + }) + + t.Run("unrecognized value does not materialize a security block", func(t *testing.T) { + t.Setenv(EnvAutoBaselineScan, "maybe") + cfg := &Config{} + applyTLSEnvOverrides(cfg) + assert.Nil(t, cfg.Security) + }) + + t.Run("recognized values still override and materialize the block", func(t *testing.T) { + t.Setenv(EnvAutoBaselineScan, "false") + cfg := &Config{} + applyTLSEnvOverrides(cfg) + require.NotNil(t, cfg.Security) + require.NotNil(t, cfg.Security.AutoBaselineScan) + assert.False(t, *cfg.Security.AutoBaselineScan) + + t.Setenv(EnvAutoBaselineScan, "1") + cfg = &Config{Security: &SecurityConfig{AutoBaselineScan: boolPtr(false)}} + applyTLSEnvOverrides(cfg) + require.NotNil(t, cfg.Security.AutoBaselineScan) + assert.True(t, *cfg.Security.AutoBaselineScan) + }) +} diff --git a/internal/config/loader.go b/internal/config/loader.go index e8a334605..ba0e4d367 100644 --- a/internal/config/loader.go +++ b/internal/config/loader.go @@ -21,6 +21,7 @@ const ( DefaultDataDir = ".mcpproxy" ConfigFileName = "mcp_config.json" trueValue = "true" + falseValue = "false" ) // LoadFromFile loads configuration from a specific file @@ -689,11 +690,23 @@ func applyTLSEnvOverrides(cfg *Config) { // `security` key can still be opted out (or explicitly back in). // IsAutoBaselineScanEnabled re-reads the same variable, so the env value // also wins on paths that never pass through the loader. - if value := os.Getenv(EnvAutoBaselineScan); value != "" { + // Only the documented vocabulary overrides. An unrecognized value (typo, + // "yes", "maybe") must be IGNORED, matching IsAutoBaselineScanEnabled — a + // bare `value != ""` check would have materialized `false` here and silently + // turned automatic scanning off for a config that had explicitly enabled it, + // because the accessor then reads the overwritten field rather than the env. + switch os.Getenv(EnvAutoBaselineScan) { + case trueValue, "1": + enabled := true + if cfg.Security == nil { + cfg.Security = &SecurityConfig{} + } + cfg.Security.AutoBaselineScan = &enabled + case falseValue, "0": + enabled := false if cfg.Security == nil { cfg.Security = &SecurityConfig{} } - enabled := value == trueValue || value == "1" cfg.Security.AutoBaselineScan = &enabled } diff --git a/internal/server/scan_informational.go b/internal/server/scan_informational.go index 18b723630..ce0470bbd 100644 --- a/internal/server/scan_informational.go +++ b/internal/server/scan_informational.go @@ -58,14 +58,26 @@ func isTerminalScanStatus(status string) bool { } // scanModeAdmissionOwns reports whether the spec-086 trust_mode:"scan" admission -// gate is responsible for this server's baseline scan. Those servers must NOT be -// picked up by the informational path: the gating path already scans them, and -// double-scanning would race the settle-driven auto-approval. +// gate — and the settle-driven auto-approval behind it — is responsible for this +// server. Those servers must NOT be picked up by the informational path. +// +// The predicate deliberately does NOT look at sc.Quarantined, even though the +// gating path itself does. Quarantine is MUTABLE while a scan is in flight, and +// maybeAutoApproveScanSettled re-reads it when the scan settles: a scan-mode +// server that is unquarantined when we claim it, and is quarantined by the +// operator before the clean verdict lands, would be silently unquarantined by +// the settle handler — an informational scan causing a gating state change. +// Keying only on the IMMUTABLE-for-this-purpose pair (trust mode, prior approval +// baseline) closes that window: a scan-mode server without an approval baseline +// is exactly the set the settle handler can act on, so the informational path +// never touches it in any quarantine state. Scan-mode servers that already have +// an approval baseline are safe (the settle handler bails on them) and stay +// eligible for an informational badge. func scanModeAdmissionOwns(sc *config.ServerConfig, hasApprovalBaseline bool) bool { if sc == nil { return false } - return sc.EffectiveTrustMode() == config.TrustModeScan && sc.Quarantined && !hasApprovalBaseline + return sc.EffectiveTrustMode() == config.TrustModeScan && !hasApprovalBaseline } // informationalScansEnabled resolves the security.auto_baseline_scan kill switch @@ -202,10 +214,17 @@ func (s *Server) claimInformationalScan(ctx context.Context, sc *config.ServerCo // releaseInformationalScan drops a claim so a later servers.changed can retry a // scan that failed to start. +// +// It must forget the server in BOTH maps. maybeStartInformationalScans records a +// name in infoScanKnown at the moment it decides the server is new, before the +// scan is attempted; dropping only the infoScanQueued claim would leave the +// server permanently "not new", so no later servers.changed could ever retry it +// and (once the one-shot sweep marker is burned) nothing would scan it again. func (s *Server) releaseInformationalScan(name string) { s.infoScanMu.Lock() defer s.infoScanMu.Unlock() delete(s.infoScanQueued, name) + delete(s.infoScanKnown, name) } // startInformationalScan claims and runs one informational scan in the @@ -318,7 +337,16 @@ func (s *Server) runBaselineSweep(ctx context.Context) { return } - servers := s.listStoredServers() + // Read the inventory DIRECTLY rather than through listStoredServers, which + // collapses "storage read failed" into an empty slice. An empty slice is the + // sweep's "nothing to do" signal and burns the one-shot marker — so a + // transient storage error would permanently mark a sweep that never looked + // at a single server. Fail closed: skip this start, retry on the next one. + servers, err := sm.ListUpstreamServers() + if err != nil { + s.logger.Warn("baseline sweep: could not list servers, skipping (marker left unset)", zap.Error(err)) + return + } scanned := 0 findings := 0 failed := 0 diff --git a/internal/server/scan_informational_test.go b/internal/server/scan_informational_test.go index ece9cc3e6..017415d57 100644 --- a/internal/server/scan_informational_test.go +++ b/internal/server/scan_informational_test.go @@ -140,17 +140,82 @@ func TestInformationalScan_ScanModeAdmissionPathUnchanged(t *testing.T) { assert.Equal(t, []string{"gated"}, fake.startedScans()) }) - t.Run("scan-mode server that is NOT quarantined is informational", func(t *testing.T) { + // A scan-mode server with NO approval baseline is off-limits to the + // informational path even while it is unquarantined. Quarantine is mutable + // during the scan and maybeAutoApproveScanSettled re-reads it, so scanning + // here would let an operator quarantine that lands mid-scan be silently + // reverted by the clean settle — an informational scan causing a gating + // state change. + t.Run("scan-mode without approval baseline is never informational, quarantined or not", func(t *testing.T) { fake := newFakeSecurityScanner() fake.scanResult["srv"] = &scanner.ScanSummary{Status: "clean"} s := newInformationalTestServer(t, fake, nil, enabledServer("srv", config.TrustModeScan)) + s.maybeStartInformationalScans(context.Background()) + s.runBaselineSweep(context.Background()) + time.Sleep(50 * time.Millisecond) + + assert.Empty(t, fake.startedScans(), "the settle handler could still act on this server") + assert.Empty(t, fake.approvedServers()) + }) + + // Once a server HAS an approval baseline the settle handler bails on it + // unconditionally, so it is safe to give it an informational badge. + t.Run("scan-mode WITH approval baseline is informational", func(t *testing.T) { + fake := newFakeSecurityScanner() + fake.scanResult["srv"] = &scanner.ScanSummary{Status: "clean"} + fake.hasBaseline["srv"] = true + s := newInformationalTestServer(t, fake, nil, enabledServer("srv", config.TrustModeScan)) + s.maybeStartInformationalScans(context.Background()) waitForStartedScans(t, fake, []string{"srv"}) assert.Empty(t, fake.approvedServers()) }) } +// A new server whose scan fails to START must stay retryable: the admission path +// records it as "known" before attempting the scan, so releasing the claim has +// to un-know it too — otherwise no later servers.changed could ever pick it up +// and (once the one-shot sweep marker is burned) nothing would scan it again. +func TestInformationalScan_FailedStartRetriesOnNextServersChanged(t *testing.T) { + fake := newFakeSecurityScanner() + fake.startScanErr = errors.New("server unreachable") + s := newInformationalTestServer(t, fake, nil, enabledServer("srv", config.TrustModeManual)) + + s.maybeStartInformationalScans(context.Background()) + require.Eventually(t, func() bool { + s.infoScanMu.Lock() + defer s.infoScanMu.Unlock() + return !s.infoScanQueued["srv"] && !s.infoScanKnown["srv"] + }, 2*time.Second, 5*time.Millisecond) + + fake.mu.Lock() + fake.startScanErr = nil + fake.mu.Unlock() + + // The next servers.changed sees it as new again and retries — no sweep needed. + s.maybeStartInformationalScans(context.Background()) + waitForStartedScans(t, fake, []string{"srv"}) +} + +// An unreadable store must never be mistaken for "nothing to sweep". Both +// storage reads in runBaselineSweep (the marker, then the inventory) fail closed +// on the same invariant: no scans start and the one-shot marker is left unset so +// the next start retries. A closed BBolt handle fails the marker read first, so +// this exercises that guard; the inventory read is guarded identically, which +// matters because listStoredServers collapses a read error into an empty slice — +// the sweep therefore calls ListUpstreamServers directly. +func TestBaselineSweep_UnreadableStoreNeverBurnsMarker(t *testing.T) { + fake := newFakeSecurityScanner() + s := newInformationalTestServer(t, fake, nil, enabledServer("a", config.TrustModeManual)) + + require.NoError(t, s.runtime.StorageManager().Close()) + + s.runBaselineSweep(context.Background()) + + assert.Empty(t, fake.startedScans(), "an unreadable store must not scan anything") +} + // (d) Disabled servers are skipped by both paths — a scan would have to start // the server to export its tool definitions. func TestInformationalScan_DisabledServersSkipped(t *testing.T) { @@ -263,7 +328,7 @@ func TestInformationalScan_FailedStartIsRetryable(t *testing.T) { fake.startScanErr = nil fake.mu.Unlock() - // The server is no longer "new", so the sweep is what retries it. + // The sweep is the other retry route (it never consulted the known-set). s.runBaselineSweep(context.Background()) assert.Equal(t, []string{"srv"}, fake.startedScans()) } @@ -307,7 +372,12 @@ func TestScanModeAdmissionOwns(t *testing.T) { {"nil", nil, false, false}, {"scan+quarantined+no baseline", &config.ServerConfig{TrustMode: "scan", Quarantined: true}, false, true}, {"scan+quarantined+baseline (re-quarantine)", &config.ServerConfig{TrustMode: "scan", Quarantined: true}, true, false}, - {"scan+not quarantined", &config.ServerConfig{TrustMode: "scan"}, false, false}, + // Quarantine is mutable while a scan is in flight and the settle handler + // re-reads it, so an unquarantined scan-mode server with no approval + // baseline is STILL the gating path's — otherwise an operator quarantine + // landing mid-scan would let the clean settle unquarantine it again. + {"scan+not quarantined+no baseline (settle could still act)", &config.ServerConfig{TrustMode: "scan"}, false, true}, + {"scan+not quarantined+baseline (settle bails)", &config.ServerConfig{TrustMode: "scan"}, true, false}, {"manual+quarantined", &config.ServerConfig{TrustMode: "manual", Quarantined: true}, false, false}, {"empty trust mode (manual) + quarantined", &config.ServerConfig{Quarantined: true}, false, false}, } From a380dcde05ed8d6089fdb75b99adef8b5214e83d Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Sun, 23 Aug 2026 21:41:45 +0300 Subject: [PATCH 3/9] fix(security): cap informational-scan retries per process MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the previous commit's retry fix. Making releaseInformationalScan clear infoScanKnown restored the intended retry, but left it unbounded: StartScan returns an error for a server it cannot connect to ("no source files available and server is disconnected"), and that failure path spends up to ~60s inside StartScan (EnsureConnected plus a 30s connection wait, twice) while holding infoScanRunMu. For a permanently broken upstream every servers.changed would re-enter it, stalling the informational queue and respawning the process endlessly. Retries now stop after maxInformationalScanAttempts (3) per server per process; past the cap the server keeps its "known" mark and is retired to manual scanning. A server that was merely still connecting — the case the retry exists for — is unaffected. --- internal/server/scan_admission_test.go | 11 ++++++++ internal/server/scan_informational.go | 24 ++++++++++++++++++ internal/server/scan_informational_test.go | 29 ++++++++++++++++++++++ internal/server/server.go | 2 ++ 4 files changed, 66 insertions(+) diff --git a/internal/server/scan_admission_test.go b/internal/server/scan_admission_test.go index fc2938edf..1d5b72fed 100644 --- a/internal/server/scan_admission_test.go +++ b/internal/server/scan_admission_test.go @@ -34,6 +34,9 @@ type fakeSecurityScanner struct { approveCalls []string startScanCalls []string + // startScanTries records EVERY StartScan entry, including the ones that + // return startScanErr, so retry-capping can be asserted. + startScanTries []string } func newFakeSecurityScanner() *fakeSecurityScanner { @@ -63,6 +66,7 @@ func (f *fakeSecurityScanner) ApproveServer(_ context.Context, serverName string func (f *fakeSecurityScanner) StartScan(_ context.Context, serverName string, _ bool, _ []string, _ string) (*scanner.ScanJob, error) { f.mu.Lock() defer f.mu.Unlock() + f.startScanTries = append(f.startScanTries, serverName) if f.startScanErr != nil { return nil, f.startScanErr } @@ -96,6 +100,13 @@ func (f *fakeSecurityScanner) startedScans() []string { return append([]string(nil), f.startScanCalls...) } +// startScanAttempts counts every StartScan entry, failures included. +func (f *fakeSecurityScanner) startScanAttempts() []string { + f.mu.Lock() + defer f.mu.Unlock() + return append([]string(nil), f.startScanTries...) +} + // newAdmissionTestServer builds a Server whose runtime config carries the given // servers and whose securityScanner is the supplied fake. The event loop is NOT // started — tests drive the admission methods directly. diff --git a/internal/server/scan_informational.go b/internal/server/scan_informational.go index ce0470bbd..59114c475 100644 --- a/internal/server/scan_informational.go +++ b/internal/server/scan_informational.go @@ -44,6 +44,16 @@ const ( // cannot export its tool definitions and fails outright, which would burn // the one-shot marker on an empty result. baselineSweepStartDelay = 45 * time.Second + // maxInformationalScanAttempts caps how many times one server's informational + // scan may be retried per process. StartScan fails outright for a server it + // cannot connect to ("no source files available and server is disconnected"), + // and that failure path costs up to ~60s inside StartScan (EnsureConnected + + // a 30s connection wait, twice) while holding infoScanRunMu. Retrying is + // worth it for a server that was merely still connecting; retrying forever on + // every servers.changed for a permanently broken one would stall the queue + // and respawn the upstream process endlessly. After the cap the server keeps + // its "known" mark and is left to a manual scan. + maxInformationalScanAttempts = 3 ) // isTerminalScanStatus reports whether a scan summary status means the scan has @@ -220,10 +230,24 @@ func (s *Server) claimInformationalScan(ctx context.Context, sc *config.ServerCo // scan is attempted; dropping only the infoScanQueued claim would leave the // server permanently "not new", so no later servers.changed could ever retry it // and (once the one-shot sweep marker is burned) nothing would scan it again. +// +// Bounded by maxInformationalScanAttempts: past the cap the "known" mark stays, +// which retires the server from the automatic path instead of retrying a broken +// upstream on every servers.changed. func (s *Server) releaseInformationalScan(name string) { s.infoScanMu.Lock() defer s.infoScanMu.Unlock() delete(s.infoScanQueued, name) + if s.infoScanAttempts == nil { + s.infoScanAttempts = make(map[string]int) + } + s.infoScanAttempts[name]++ + if s.infoScanAttempts[name] >= maxInformationalScanAttempts { + s.logger.Debug("informational baseline scan retired after repeated start failures", + zap.String("server", name), + zap.Int("attempts", s.infoScanAttempts[name])) + return + } delete(s.infoScanKnown, name) } diff --git a/internal/server/scan_informational_test.go b/internal/server/scan_informational_test.go index 017415d57..16f23a6b3 100644 --- a/internal/server/scan_informational_test.go +++ b/internal/server/scan_informational_test.go @@ -198,6 +198,35 @@ func TestInformationalScan_FailedStartRetriesOnNextServersChanged(t *testing.T) waitForStartedScans(t, fake, []string{"srv"}) } +// The retry above must be BOUNDED. StartScan fails outright for a server it +// cannot connect to, and that path costs ~60s inside StartScan while holding the +// serialization mutex — retrying it on every servers.changed for a permanently +// broken upstream would stall the queue and respawn the process endlessly. +func TestInformationalScan_RetriesAreCapped(t *testing.T) { + fake := newFakeSecurityScanner() + fake.startScanErr = errors.New("server is disconnected") + s := newInformationalTestServer(t, fake, nil, enabledServer("srv", config.TrustModeManual)) + + for i := 0; i < maxInformationalScanAttempts+2; i++ { + s.maybeStartInformationalScans(context.Background()) + require.Eventually(t, func() bool { + s.infoScanMu.Lock() + defer s.infoScanMu.Unlock() + return !s.infoScanQueued["srv"] + }, 2*time.Second, 5*time.Millisecond) + } + + // Every attempt reached StartScan (each returns the error), but no more than + // the cap, and the server is retired rather than re-queued forever. + assert.Len(t, fake.startScanAttempts(), maxInformationalScanAttempts, + "retries must stop at the cap") + + s.infoScanMu.Lock() + retired := s.infoScanKnown["srv"] + s.infoScanMu.Unlock() + assert.True(t, retired, "a retired server must stay marked known") +} + // An unreadable store must never be mistaken for "nothing to sweep". Both // storage reads in runBaselineSweep (the marker, then the inventory) fail closed // on the same invariant: no scans start and the one-shot marker is left unset so diff --git a/internal/server/server.go b/internal/server/server.go index 09af448dd..7a2c4ea78 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -116,6 +116,7 @@ type Server struct { infoScanMu sync.Mutex infoScanKnown map[string]bool infoScanQueued map[string]bool + infoScanAttempts map[string]int infoScanRunMu sync.Mutex infoScanSettleTimeout time.Duration infoScanSweepDelay time.Duration @@ -228,6 +229,7 @@ func NewServerWithConfigPath(cfg *config.Config, configPath string, logger *zap. infoScanKnown: make(map[string]bool), infoScanQueued: make(map[string]bool), + infoScanAttempts: make(map[string]int), infoScanSettleTimeout: informationalScanSettleTimeout, infoScanSweepDelay: baselineSweepStartDelay, } From f0709b70cbea0f3c537ab90ae1150a6b01e77d7e Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Sun, 23 Aug 2026 21:46:18 +0300 Subject: [PATCH 4/9] fix(security): honour the auto_baseline_scan kill switch at execution time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cross-model review round 2 (opencode) confirmed the round-1 fixes and found one remaining real gap: a queued informational scan never re-read the kill switch. docs/configuration.md documents security.auto_baseline_scan as hot-reloadable and "read live at each decision point", but the flag was only consulted where a scan was QUEUED. A queued scan can sit behind infoScanRunMu for minutes while earlier scans settle, and the one-shot sweep waits out a 45s startup delay before doing anything — so an operator who disabled automatic scanning in either window still watched the queue drain into their upstreams. The flag is now re-read at the last decision point before StartScan, and again after the sweep's startup delay. Three consequences handled explicitly: - A kill-switch skip is not a failed attempt: it releases the claim WITHOUT consuming one of the server's bounded retries, so toggling the flag off and on cannot retire servers that never failed a scan. - The sweep abandons rather than counting the remaining servers as failures. Counting them would still burn the one-shot marker whenever an earlier server had already scanned, permanently retiring servers the sweep never examined. - The marker is left unset in both abandon paths, so a re-enabled sweep resumes on the next start. Rejected from this round: the claim that a mutable trust_mode lets an informational scan become an auto-approving admission scan. The scenario (operator PATCHes a quarantined server from manual to scan mid-scan, clean settle unquarantines it) reaches no state that is not already reachable on main: maybeStartAdmissionScans runs on every servers.changed — which connection-state changes emit continuously — and would scan that same never-scanned, scan-mode, quarantined server and auto-approve it on the same clean verdict. The informational path adds no new capability there, and the transition is what flipping a quarantined server to trust_mode:"scan" means. --- internal/server/scan_informational.go | 51 +++++++++++++++ internal/server/scan_informational_test.go | 75 ++++++++++++++++++++++ 2 files changed, 126 insertions(+) diff --git a/internal/server/scan_informational.go b/internal/server/scan_informational.go index 59114c475..473ed68d2 100644 --- a/internal/server/scan_informational.go +++ b/internal/server/scan_informational.go @@ -2,6 +2,7 @@ package server import ( "context" + "errors" "time" "go.uber.org/zap" @@ -56,6 +57,12 @@ const ( maxInformationalScanAttempts = 3 ) +// errInformationalScansDisabled is returned when the security.auto_baseline_scan +// kill switch was turned off after a scan was queued but before it ran. It is a +// clean skip, not a failure: the caller releases the claim so the server is +// picked up again if the flag comes back. +var errInformationalScansDisabled = errors.New("informational baseline scans are disabled") + // isTerminalScanStatus reports whether a scan summary status means the scan has // finished (successfully or not). "scanning" and "not_scanned" are transient. func isTerminalScanStatus(status string) bool { @@ -234,6 +241,16 @@ func (s *Server) claimInformationalScan(ctx context.Context, sc *config.ServerCo // Bounded by maxInformationalScanAttempts: past the cap the "known" mark stays, // which retires the server from the automatic path instead of retrying a broken // upstream on every servers.changed. +// unclaimInformationalScan reverses a claim WITHOUT counting a failed attempt. +// Used when the scan was skipped for a reason that says nothing about the server +// (the kill switch flipped while it was queued). +func (s *Server) unclaimInformationalScan(name string) { + s.infoScanMu.Lock() + defer s.infoScanMu.Unlock() + delete(s.infoScanQueued, name) + delete(s.infoScanKnown, name) +} + func (s *Server) releaseInformationalScan(name string) { s.infoScanMu.Lock() defer s.infoScanMu.Unlock() @@ -269,6 +286,13 @@ func (s *Server) startInformationalScan(ctx context.Context, sc *config.ServerCo s.logger.Debug("informational baseline scan did not run", zap.String("server", name), zap.Error(err)) + // A kill-switch skip is not a failed attempt: it must not consume one + // of the server's bounded retries, or toggling the flag off and on + // would silently retire servers that never actually failed a scan. + if errors.Is(err, errInformationalScansDisabled) { + s.unclaimInformationalScan(name) + return + } s.releaseInformationalScan(name) } }() @@ -285,6 +309,16 @@ func (s *Server) runInformationalScan(ctx context.Context, name string) (int, er if err := ctx.Err(); err != nil { return 0, err } + // Re-read the kill switch HERE, not only where the scan was queued. A queued + // scan can sit behind infoScanRunMu for minutes while earlier scans settle, + // and the sweep waits out its startup delay first — so an operator who sets + // security.auto_baseline_scan:false in that window would otherwise still see + // the automatic scans they just switched off fire one by one. The documented + // contract is that the flag is read live at each decision point; this is the + // last decision point before a scan actually starts. + if !s.informationalScansEnabled() { + return 0, errInformationalScansDisabled + } if _, err := s.securityScanner.StartScan(ctx, name, false, nil, ""); err != nil { return 0, err } @@ -347,6 +381,13 @@ func (s *Server) runBaselineSweep(ctx context.Context) { case <-timer.C: } } + // Re-read the kill switch after the startup delay: the operator has had 45 + // seconds to disable automatic scanning, and the sweep must honour that + // rather than acting on the value it read before waiting. + if !s.informationalScansEnabled() { + s.logger.Debug("baseline sweep: disabled during startup delay, skipping (marker left unset)") + return + } state, err := sm.LoadBaselineSweepState() if err != nil { // Unknown marker state: do NOT sweep. Re-running the sweep on every @@ -384,6 +425,16 @@ func (s *Server) runBaselineSweep(ctx context.Context) { continue } n, err := s.runInformationalScan(ctx, sc.Name) + if errors.Is(err, errInformationalScansDisabled) { + // The operator disabled automatic scanning mid-sweep. Abandon the + // sweep WITHOUT marking it done — treating the remaining servers as + // "failed" would still burn the marker whenever an earlier server had + // already scanned, and they were never actually examined. + s.unclaimInformationalScan(sc.Name) + s.logger.Info("baseline sweep abandoned: automatic scanning was disabled; will resume if re-enabled", + zap.Int("servers_scanned", scanned)) + return + } if err != nil { failed++ s.releaseInformationalScan(sc.Name) diff --git a/internal/server/scan_informational_test.go b/internal/server/scan_informational_test.go index 16f23a6b3..4a8dc8186 100644 --- a/internal/server/scan_informational_test.go +++ b/internal/server/scan_informational_test.go @@ -391,6 +391,81 @@ func TestInformationalScan_KillSwitch(t *testing.T) { waitForStartedScans(t, fake, []string{"gated"}) } +// The kill switch is documented as read live "at each decision point". A queued +// scan can sit behind infoScanRunMu for minutes, so the LAST decision point — +// immediately before StartScan — has to re-read it too, or an operator who +// disables automatic scanning still watches the queue drain into their upstreams. +func TestInformationalScan_KillSwitchCheckedAtExecutionTime(t *testing.T) { + fake := newFakeSecurityScanner() + s := newInformationalTestServer(t, fake, nil, enabledServer("srv", config.TrustModeManual)) + + // Flip the switch after the scan would have been queued. The env override is + // resolved inside IsAutoBaselineScanEnabled, so it takes effect immediately. + t.Setenv(config.EnvAutoBaselineScan, "false") + + n, err := s.runInformationalScan(context.Background(), "srv") + require.ErrorIs(t, err, errInformationalScansDisabled) + assert.Zero(t, n) + assert.Empty(t, fake.startScanAttempts(), "a disabled scan must never reach StartScan") +} + +// A kill-switch skip says nothing about the server, so it must not consume one +// of its bounded retries — otherwise toggling the flag off and on would retire +// servers that never failed a scan. +func TestInformationalScan_KillSwitchSkipDoesNotConsumeRetries(t *testing.T) { + fake := newFakeSecurityScanner() + s := newInformationalTestServer(t, fake, nil, enabledServer("srv", config.TrustModeManual)) + + t.Setenv(config.EnvAutoBaselineScan, "false") + for i := 0; i < maxInformationalScanAttempts+2; i++ { + s.maybeStartInformationalScans(context.Background()) + require.Eventually(t, func() bool { + s.infoScanMu.Lock() + defer s.infoScanMu.Unlock() + return !s.infoScanQueued["srv"] && !s.infoScanKnown["srv"] + }, 2*time.Second, 5*time.Millisecond) + } + + s.infoScanMu.Lock() + attempts := s.infoScanAttempts["srv"] + s.infoScanMu.Unlock() + assert.Zero(t, attempts, "kill-switch skips are not failed attempts") + + // Re-enabling must still scan it. + t.Setenv(config.EnvAutoBaselineScan, "true") + fake.scanResult["srv"] = &scanner.ScanSummary{Status: "clean"} + s.maybeStartInformationalScans(context.Background()) + waitForStartedScans(t, fake, []string{"srv"}) +} + +// Disabling automatic scanning DURING the sweep's startup delay must stop it, +// and must leave the one-shot marker unset so it resumes if re-enabled. +func TestBaselineSweep_KillSwitchDuringStartupDelay(t *testing.T) { + fake := newFakeSecurityScanner() + s := newInformationalTestServer(t, fake, nil, enabledServer("a", config.TrustModeManual)) + s.infoScanSweepDelay = 300 * time.Millisecond + + done := make(chan struct{}) + go func() { + defer close(done) + s.runBaselineSweep(context.Background()) + }() + + time.Sleep(50 * time.Millisecond) + t.Setenv(config.EnvAutoBaselineScan, "false") + + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("sweep did not return") + } + + assert.Empty(t, fake.startScanAttempts(), "the sweep must re-read the flag after its delay") + state, err := s.runtime.StorageManager().LoadBaselineSweepState() + require.NoError(t, err) + assert.Nil(t, state, "an abandoned sweep must not burn the one-shot marker") +} + func TestScanModeAdmissionOwns(t *testing.T) { tests := []struct { name string From e991bbf586946269f136fdd2db48bc6d3462e1fa Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Sun, 23 Aug 2026 21:54:53 +0300 Subject: [PATCH 5/9] fix(security): keep the informational path out of scan mode entirely MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cross-model review round 3 (opencode) found the remaining hole in the "informational scans never gate" invariant, and it is a TOCTOU the previous predicate could not close. scanModeAdmissionOwns still allowed one class through: a trust_mode:"scan" server that HAS an approval baseline, on the reasoning that maybeAutoApproveScanSettled bails unconditionally on such servers. But that gate is re-read at SETTLE time and the baseline is deletable in between — POST /api/v1/security/servers/{name}/reject calls Service.RejectServer, which deletes the integrity baseline. So: a re-quarantined scan-mode server is claimed for an informational scan, the operator rejects it mid-scan, the baseline disappears, and the clean settle then auto-approves and unquarantines the server that was just rejected. Every input the settle handler gates on (quarantine, approval baseline) is mutable while a scan is in flight, so no static snapshot of them is safe to scan on. The rule is now the blunt, provable one: the informational path never touches a trust_mode:"scan" server, in any quarantine state, with or without a baseline. Those servers still get a verdict from the gating path's own admission scan or from a manual scan. This also subsumes the round-2 mutable- trust_mode concern. Also from round 3: context cancellation no longer consumes one of a server's bounded retries. A shutdown draining the queue would otherwise count an attempt per parked scan even though none ran, retiring servers on a Server object that is later restarted in-process. Cancellation now takes the same uncounted unclaim path as a kill-switch skip. Docs updated to state the scan-mode exclusion and why. --- docs/configuration.md | 2 +- docs/features/security-quarantine.md | 7 +- internal/server/scan_informational.go | 54 +++++++++------ internal/server/scan_informational_test.go | 79 +++++++++++----------- 4 files changed, 79 insertions(+), 63 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 72dadd2a5..8160c1e5e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -654,7 +654,7 @@ block** — off by default, best-effort, and unable to change the baseline verdi | Field | Type | Default | Description | |-------|------|---------|-------------| | `tpa_bundle_path` | string | `""` (embedded) | Filesystem path to the tpa-db `scanner-bundle.json` the offline TPA scanner runs. Empty uses the corpus embedded in the build. Env override: `MCPPROXY_TPA_BUNDLE_PATH`, which wins over this field on every path (loader, hot-reload, `/api/v1/config/apply`). Re-read on config hot-reload and honoured in every transport, stdio included. A bundle that fails to read/parse/version-check/compile — or that contributes zero runnable rules — is refused and the previously active corpus stays live; the reason is surfaced as `signature_bundle.load_error` in `GET /api/v1/security/overview` and in `mcpproxy security overview`. | -| `auto_baseline_scan` | boolean | `true` | Kill switch for the **automatic informational baseline scan**. When on (the default), every newly added server — in any trust mode — gets one free in-process Pass-1 TPA scan, and once per installation a background sweep scans pre-existing enabled servers that have never been scanned (marker persisted in BBolt, so it runs exactly once and never delays startup). The result only populates the security badge / scan summary: it **never** quarantines, approves, or otherwise gates a server, and the `trust_mode: "scan"` admission gate is a separate path this flag does not affect. Disabled servers are skipped. Set to `false` to suppress all automatic scans; manual scans keep working. Hot-reloadable — the flag is read live at each decision point. Env override: `MCPPROXY_AUTO_BASELINE_SCAN` (`true`/`1`/`false`/`0`), which wins over this field on every path. | +| `auto_baseline_scan` | boolean | `true` | Kill switch for the **automatic informational baseline scan**. When on (the default), every newly added server gets one free in-process Pass-1 TPA scan, and once per installation a background sweep scans pre-existing enabled servers that have never been scanned (marker persisted in BBolt, so it runs exactly once and never delays startup). The result only populates the security badge / scan summary: it **never** quarantines, approves, or otherwise gates a server. Disabled servers are skipped, and so are `trust_mode: "scan"` servers — that mode's own admission gate scans them and auto-approves on a clean verdict, so the informational path stays out of it entirely rather than risk feeding that gate; this flag does not affect that separate path. Set to `false` to suppress all automatic scans; manual scans keep working. Hot-reloadable — the flag is read live at each decision point. Env override: `MCPPROXY_AUTO_BASELINE_SCAN` (`true`/`1`/`false`/`0`), which wins over this field on every path. | | `deep_scan.enabled` | boolean | `false` | Master opt-in for the heavy layer. When `false`, no Docker scanner runs and no source extraction is attempted — only the in-process baseline scanner executes. | | `deep_scan.fetch_package_source` | boolean | `true` (when deep scan is on) | Whether the scanner fetches (never executes) the published source of `npx`/`uvx` package-runner servers when no local source is available. Set `false` for air-gapped deployments. | | `deep_scan.disable_no_new_privileges` | boolean | `false` | Omits `--security-opt no-new-privileges` from scanner container runs (snap-docker/AppArmor escape hatch). | diff --git a/docs/features/security-quarantine.md b/docs/features/security-quarantine.md index 7ea6c5844..4410d20bb 100644 --- a/docs/features/security-quarantine.md +++ b/docs/features/security-quarantine.md @@ -313,9 +313,10 @@ fails closed to `manual`. Independently of `trust_mode`, MCPProxy runs the free in-process Pass-1 TPA scan so every server ends up with a security verdict instead of an empty badge: -- **On admission** — a newly added, enabled server gets one baseline scan in any - trust mode. Servers the `scan`-mode admission gate already owns are skipped so - nothing is scanned twice. +- **On admission** — a newly added, enabled server gets one baseline scan. Servers + with `trust_mode: "scan"` are skipped entirely: that mode's own admission gate + scans them, and routing an informational verdict into its settle-driven + auto-approval could change quarantine state. - **Once per installation** — a background sweep at startup scans enabled servers that have never been scanned (for installs that predate this behaviour). It is serialized, never delays startup, is cancelled on shutdown, and a persisted diff --git a/internal/server/scan_informational.go b/internal/server/scan_informational.go index 473ed68d2..51a97b9a3 100644 --- a/internal/server/scan_informational.go +++ b/internal/server/scan_informational.go @@ -78,23 +78,31 @@ func isTerminalScanStatus(status string) bool { // gate — and the settle-driven auto-approval behind it — is responsible for this // server. Those servers must NOT be picked up by the informational path. // -// The predicate deliberately does NOT look at sc.Quarantined, even though the -// gating path itself does. Quarantine is MUTABLE while a scan is in flight, and -// maybeAutoApproveScanSettled re-reads it when the scan settles: a scan-mode -// server that is unquarantined when we claim it, and is quarantined by the -// operator before the clean verdict lands, would be silently unquarantined by -// the settle handler — an informational scan causing a gating state change. -// Keying only on the IMMUTABLE-for-this-purpose pair (trust mode, prior approval -// baseline) closes that window: a scan-mode server without an approval baseline -// is exactly the set the settle handler can act on, so the informational path -// never touches it in any quarantine state. Scan-mode servers that already have -// an approval baseline are safe (the settle handler bails on them) and stay -// eligible for an informational badge. -func scanModeAdmissionOwns(sc *config.ServerConfig, hasApprovalBaseline bool) bool { +// The rule is deliberately the blunt one: EVERY trust_mode:"scan" server belongs +// to the gating path, whatever its quarantine state or approval history. +// +// Narrower predicates were tried and are unsafe, because every input the settle +// handler gates on is MUTABLE while a scan is in flight, and +// maybeAutoApproveScanSettled re-reads all of them when the verdict lands: +// +// - Quarantine: a scan-mode server unquarantined at claim time that the +// operator quarantines mid-scan gets silently unquarantined by the clean +// settle. +// - Approval baseline: a scan-mode server WITH a baseline (which the settle +// handler would normally bail on) loses it if the operator rejects the +// server mid-scan — POST .../reject calls RejectServer, which deletes the +// integrity baseline — and the clean settle then auto-approves the very +// server that was just rejected. +// +// Since the informational path's whole contract is that it can never cause a +// gating state change, it simply never scans a server the settle handler could +// act on. Scan-mode servers still get their verdict from the gating path's own +// admission scan, or from a manual scan. +func scanModeAdmissionOwns(sc *config.ServerConfig) bool { if sc == nil { return false } - return sc.EffectiveTrustMode() == config.TrustModeScan && !hasApprovalBaseline + return sc.EffectiveTrustMode() == config.TrustModeScan } // informationalScansEnabled resolves the security.auto_baseline_scan kill switch @@ -212,8 +220,9 @@ func (s *Server) claimInformationalScan(ctx context.Context, sc *config.ServerCo if summary := s.securityScanner.GetScanSummary(ctx, sc.Name); summary != nil { return false } - // Leave the gating admission path's servers alone (no double scan). - if scanModeAdmissionOwns(sc, s.securityScanner.HasApprovalBaseline(sc.Name)) { + // Leave the gating admission path's servers alone: no double scan, and no + // way for an informational verdict to reach the settle-driven auto-approval. + if scanModeAdmissionOwns(sc) { return false } @@ -286,10 +295,15 @@ func (s *Server) startInformationalScan(ctx context.Context, sc *config.ServerCo s.logger.Debug("informational baseline scan did not run", zap.String("server", name), zap.Error(err)) - // A kill-switch skip is not a failed attempt: it must not consume one - // of the server's bounded retries, or toggling the flag off and on - // would silently retire servers that never actually failed a scan. - if errors.Is(err, errInformationalScansDisabled) { + // A skip that says nothing about the SERVER is not a failed attempt + // and must not consume one of its bounded retries. Two such skips: + // the kill switch flipping while the scan sat in the queue, and the + // server context being cancelled (shutdown) before StartScan ran. If + // either counted, toggling the flag — or a shutdown draining a queue + // on a Server object that is later restarted in-process — would + // silently retire servers that never actually failed a scan. + if errors.Is(err, errInformationalScansDisabled) || errors.Is(err, context.Canceled) || + errors.Is(err, context.DeadlineExceeded) { s.unclaimInformationalScan(name) return } diff --git a/internal/server/scan_informational_test.go b/internal/server/scan_informational_test.go index 4a8dc8186..c78c28c71 100644 --- a/internal/server/scan_informational_test.go +++ b/internal/server/scan_informational_test.go @@ -140,37 +140,38 @@ func TestInformationalScan_ScanModeAdmissionPathUnchanged(t *testing.T) { assert.Equal(t, []string{"gated"}, fake.startedScans()) }) - // A scan-mode server with NO approval baseline is off-limits to the - // informational path even while it is unquarantined. Quarantine is mutable - // during the scan and maybeAutoApproveScanSettled re-reads it, so scanning - // here would let an operator quarantine that lands mid-scan be silently - // reverted by the clean settle — an informational scan causing a gating - // state change. - t.Run("scan-mode without approval baseline is never informational, quarantined or not", func(t *testing.T) { - fake := newFakeSecurityScanner() - fake.scanResult["srv"] = &scanner.ScanSummary{Status: "clean"} - s := newInformationalTestServer(t, fake, nil, enabledServer("srv", config.TrustModeScan)) - - s.maybeStartInformationalScans(context.Background()) - s.runBaselineSweep(context.Background()) - time.Sleep(50 * time.Millisecond) - - assert.Empty(t, fake.startedScans(), "the settle handler could still act on this server") - assert.Empty(t, fake.approvedServers()) - }) - - // Once a server HAS an approval baseline the settle handler bails on it - // unconditionally, so it is safe to give it an informational badge. - t.Run("scan-mode WITH approval baseline is informational", func(t *testing.T) { - fake := newFakeSecurityScanner() - fake.scanResult["srv"] = &scanner.ScanSummary{Status: "clean"} - fake.hasBaseline["srv"] = true - s := newInformationalTestServer(t, fake, nil, enabledServer("srv", config.TrustModeScan)) - - s.maybeStartInformationalScans(context.Background()) - waitForStartedScans(t, fake, []string{"srv"}) - assert.Empty(t, fake.approvedServers()) - }) + // EVERY scan-mode server belongs to the gating path, in every quarantine + // state and with or without an approval baseline. Both of the settle + // handler's gates are mutable while a scan is in flight and it re-reads them + // at settle time: an operator quarantine landing mid-scan, or a POST + // .../reject (RejectServer deletes the integrity baseline) landing mid-scan, + // would each let a clean informational verdict unquarantine the server. + for _, tc := range []struct { + name string + quarantined bool + hasBaseline bool + }{ + {"unquarantined, no baseline", false, false}, + {"unquarantined, has baseline", false, true}, + {"quarantined, has baseline (re-quarantine)", true, true}, + } { + t.Run("scan-mode is never informational: "+tc.name, func(t *testing.T) { + fake := newFakeSecurityScanner() + fake.scanResult["srv"] = &scanner.ScanSummary{Status: "clean"} + fake.hasBaseline["srv"] = tc.hasBaseline + srv := enabledServer("srv", config.TrustModeScan) + srv.Quarantined = tc.quarantined + s := newInformationalTestServer(t, fake, nil, srv) + + s.maybeStartInformationalScans(context.Background()) + s.runBaselineSweep(context.Background()) + time.Sleep(50 * time.Millisecond) + + assert.Empty(t, fake.startScanAttempts(), + "an informational verdict must never be able to reach the settle handler") + assert.Empty(t, fake.approvedServers()) + }) + } } // A new server whose scan fails to START must stay retryable: the admission path @@ -474,20 +475,20 @@ func TestScanModeAdmissionOwns(t *testing.T) { want bool }{ {"nil", nil, false, false}, + // Every quarantine/baseline combination of a scan-mode server belongs to + // the gating path: both of the settle handler's gates are mutable while a + // scan is in flight, so no static snapshot of them is safe to scan on. {"scan+quarantined+no baseline", &config.ServerConfig{TrustMode: "scan", Quarantined: true}, false, true}, - {"scan+quarantined+baseline (re-quarantine)", &config.ServerConfig{TrustMode: "scan", Quarantined: true}, true, false}, - // Quarantine is mutable while a scan is in flight and the settle handler - // re-reads it, so an unquarantined scan-mode server with no approval - // baseline is STILL the gating path's — otherwise an operator quarantine - // landing mid-scan would let the clean settle unquarantine it again. - {"scan+not quarantined+no baseline (settle could still act)", &config.ServerConfig{TrustMode: "scan"}, false, true}, - {"scan+not quarantined+baseline (settle bails)", &config.ServerConfig{TrustMode: "scan"}, true, false}, + {"scan+quarantined+baseline (re-quarantine, reject can delete it)", &config.ServerConfig{TrustMode: "scan", Quarantined: true}, true, true}, + {"scan+not quarantined+no baseline", &config.ServerConfig{TrustMode: "scan"}, false, true}, + {"scan+not quarantined+baseline", &config.ServerConfig{TrustMode: "scan"}, true, true}, {"manual+quarantined", &config.ServerConfig{TrustMode: "manual", Quarantined: true}, false, false}, + {"auto+quarantined", &config.ServerConfig{TrustMode: "auto", Quarantined: true}, false, false}, {"empty trust mode (manual) + quarantined", &config.ServerConfig{Quarantined: true}, false, false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, tt.want, scanModeAdmissionOwns(tt.sc, tt.hasBaseline)) + assert.Equal(t, tt.want, scanModeAdmissionOwns(tt.sc)) }) } } From 617b4c0d0699baa730d0de91dc1321ab9500181d Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Sun, 23 Aug 2026 22:08:37 +0300 Subject: [PATCH 6/9] fix(security): don't charge a retry for a cancelled sweep scan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cross-model review round 4 (opencode). The previous commit stopped context cancellation from consuming a bounded retry in the admission goroutine but left the sweep loop on the old path, so a shutdown draining the sweep still charged an attempt per server for scans that never ran. Both call sites now route cancellation to the uncounted unclaim. Rejected from this round: - "Trust mode is checked only at claim time, so a manual server flipped to scan+quarantined mid-scan can still reach maybeAutoApproveScanSettled." This was raised and rejected in round 2 and re-raised here. It reaches no state that is not already reachable on main: maybeStartAdmissionScans runs on every servers.changed — which connection-state changes emit continuously — and independently scans and auto-approves exactly that server (scan mode, quarantined, never scanned, no baseline) on the same clean verdict. The informational path adds no capability, and unquarantine-on-clean is the documented meaning of putting a quarantined server in trust_mode:"scan". - "A sweep that skips servers already claimed by the admission path burns the marker with scanned==0 && failed==0." Changing this would be worse than the problem: the admission path queues its scans before the sweep's 45s delay elapses, so counting in-flight claims as unfinished work would leave the marker unset on every start and the one-shot sweep would run forever — precisely what the marker exists to prevent. Servers in that state are covered by the admission path's own bounded retries. --- internal/server/scan_informational.go | 8 ++++++ internal/server/scan_informational_test.go | 29 ++++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/internal/server/scan_informational.go b/internal/server/scan_informational.go index 51a97b9a3..5fbe06058 100644 --- a/internal/server/scan_informational.go +++ b/internal/server/scan_informational.go @@ -449,6 +449,14 @@ func (s *Server) runBaselineSweep(ctx context.Context) { zap.Int("servers_scanned", scanned)) return } + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + // Shutdown, not a scan failure. Same rule as the admission path: an + // uncounted unclaim, so draining the sweep on shutdown cannot spend a + // server's bounded retries on scans that never ran. The loop's own + // ctx.Err() checks handle abandoning without burning the marker. + s.unclaimInformationalScan(sc.Name) + continue + } if err != nil { failed++ s.releaseInformationalScan(sc.Name) diff --git a/internal/server/scan_informational_test.go b/internal/server/scan_informational_test.go index c78c28c71..83c5584b3 100644 --- a/internal/server/scan_informational_test.go +++ b/internal/server/scan_informational_test.go @@ -326,6 +326,35 @@ func TestBaselineSweep_CancelledDoesNotPersistMarker(t *testing.T) { assert.Nil(t, state) } +// Cancellation is shutdown, not a scan failure. runInformationalScan must report +// it as a context error so BOTH callers route it to the uncounted unclaim rather +// than spending one of the server's bounded retries on a scan that never ran. +func TestInformationalScan_CancellationIsNotAFailedAttempt(t *testing.T) { + fake := newFakeSecurityScanner() + s := newInformationalTestServer(t, fake, nil, enabledServer("srv", config.TrustModeManual)) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + n, err := s.runInformationalScan(ctx, "srv") + require.ErrorIs(t, err, context.Canceled) + assert.Zero(t, n) + assert.Empty(t, fake.startScanAttempts(), "a cancelled scan must never reach StartScan") + + // The two release paths must differ exactly in whether they charge an attempt. + s.unclaimInformationalScan("srv") + s.infoScanMu.Lock() + afterUnclaim := s.infoScanAttempts["srv"] + s.infoScanMu.Unlock() + assert.Zero(t, afterUnclaim, "unclaim must not charge an attempt") + + s.releaseInformationalScan("srv") + s.infoScanMu.Lock() + afterRelease := s.infoScanAttempts["srv"] + s.infoScanMu.Unlock() + assert.Equal(t, 1, afterRelease, "release charges exactly one attempt") +} + // A sweep where every candidate failed (e.g. servers still connecting) must not // burn the one-shot marker — the next start has to retry. func TestBaselineSweep_AllScansFailedKeepsMarkerUnset(t *testing.T) { From a17ebadbe659ccdbc69875a752c873e04382a61a Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Sun, 23 Aug 2026 23:02:32 +0300 Subject: [PATCH 7/9] fix(security): read the settle handler's server config from storage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes the E2E Tests (ubuntu-latest) job, which was red on this branch from the first push. All nine failures were one data race, reported identically every time: Write test goroutine mutating a config.ServerConfig in place Read (*Server).findServerConfig <- (*Server).maybeAutoApproveScanSettled <- (*Server).listenForRoutingModeRefresh findServerConfig ranged runtime.Config().Servers, whose comment claimed the snapshot was "immutable — safe to call from event-loop goroutines". It is not: Config() hands back a shared, lock-free snapshot whose ServerConfig structs other goroutines mutate in place. maybeStartAdmissionScans, twenty lines above, already documents exactly this hazard and reads from storage to avoid it; findServerConfig did not. The race is pre-existing code, but this branch is what makes it fire: before these informational scan paths, a stock configuration never started a scan, so nothing ever emitted the settle event that reaches findServerConfig. Once scans actually run, the settle handler runs with them. findServerConfig now reads through StorageManager.ListUpstreamServers, which the storage manager mutex serializes against SaveUpstreamServer and which returns fresh copies. It has exactly one caller, maybeAutoApproveScanSettled, so nothing else is affected — this is the one place this branch touches the spec-086 gating path, and it changes the read seam, not the gating logic. The failure is not reproducible locally (it needs the ubuntu runner's timing); CI is the verification. --- internal/server/server.go | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/internal/server/server.go b/internal/server/server.go index 7a2c4ea78..4e9c4a27c 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -702,15 +702,30 @@ func (s *Server) maybeStartAdmissionScan(ctx context.Context, sc *config.ServerC }() } -// findServerConfig returns the live ServerConfig for serverName from the current -// config snapshot, or nil if absent. Read-only lookup over the immutable -// snapshot — safe to call from event-loop goroutines. +// findServerConfig returns the live ServerConfig for serverName, or nil if +// absent. +// +// Reads from STORAGE, not runtime.Config().Servers. The snapshot Config() hands +// back is shared and lock-free, and its ServerConfig structs are mutated in +// place by other goroutines — so ranging it from this background event-loop +// goroutine is a genuine data race (the same hazard maybeStartAdmissionScans +// documents, and one the race detector reports against this function's only +// caller, maybeAutoApproveScanSettled, once anything actually settles a scan). +// ListUpstreamServers is serialized against SaveUpstreamServer by the storage +// manager mutex and returns fresh copies. func (s *Server) findServerConfig(serverName string) *config.ServerConfig { - cfg := s.runtime.Config() - if cfg == nil { + sm := s.runtime.StorageManager() + if sm == nil { return nil } - for _, sc := range cfg.Servers { + servers, err := sm.ListUpstreamServers() + if err != nil { + s.logger.Debug("findServerConfig: failed to list servers", + zap.String("server", serverName), + zap.Error(err)) + return nil + } + for _, sc := range servers { if sc != nil && sc.Name == serverName { return sc } From 6031120143cb09d40addb58589655cf5bdb85732 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Mon, 24 Aug 2026 06:42:13 +0300 Subject: [PATCH 8/9] fix(security): don't strand a server that was admitted disabled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A disabled server was skipped for scanning (correct: the scan would have to start it to export tool definitions) but still recorded in infoScanKnown by seedKnownServers and by the servers.changed handler. That mark is permanent, so enabling the server minutes later looked to the admission path like a server it had already seen, and the one-shot, marker-gated sweep never came back for it. The server's security badge read "not scanned" for the life of the installation — the exact hole this feature exists to close. Skip disabled servers in BOTH recording sites instead: they are not the sweep's job either (the sweep skips them too), so leaving them unrecorded costs one map lookup per servers.changed and makes the enable act as the admission. Regression test covers seed + admission + enable, and asserts the enable still yields exactly one scan and no approval. --- internal/server/scan_informational.go | 22 +++++++++++++++- internal/server/scan_informational_test.go | 30 ++++++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/internal/server/scan_informational.go b/internal/server/scan_informational.go index 5fbe06058..f1bd47dd8 100644 --- a/internal/server/scan_informational.go +++ b/internal/server/scan_informational.go @@ -137,6 +137,12 @@ func (s *Server) informationalScanContext() context.Context { // was already configured. Seeding from the startup config (rather than from the // first servers.changed) is what makes "the very first server a fresh install // adds" count as new. +// +// DISABLED servers are deliberately NOT seeded — see the note on +// maybeStartInformationalScans. They are not the sweep's job either (the sweep +// skips them), so recording them here would strand them: enabling one later +// would look like a server that had "already been seen" and it would never be +// scanned by either path. func (s *Server) seedKnownServers(servers []*config.ServerConfig) { s.infoScanMu.Lock() defer s.infoScanMu.Unlock() @@ -144,7 +150,7 @@ func (s *Server) seedKnownServers(servers []*config.ServerConfig) { s.infoScanKnown = make(map[string]bool, len(servers)) } for _, sc := range servers { - if sc != nil && sc.Name != "" { + if sc != nil && sc.Name != "" && sc.Enabled { s.infoScanKnown[sc.Name] = true } } @@ -172,6 +178,15 @@ func (s *Server) listStoredServers() []*config.ServerConfig { // and gets one informational baseline scan, regardless of trust mode. Servers // that were already configured at startup are the baseline sweep's job and are // only recorded here. +// +// A DISABLED server is neither scanned nor recorded as "known". Recording it +// would be a permanent strand: claimInformationalScan refuses to scan a disabled +// server (the scan would have to start it to export tool definitions), so a +// server admitted disabled and enabled minutes later would look like one that +// had already been seen — the admission path would skip it as not-new and the +// sweep, being one-shot and marker-gated, would never come back for it. Its +// badge would read "not scanned" forever. Leaving it unrecorded costs one map +// lookup per servers.changed and lets the enable act as the admission. func (s *Server) maybeStartInformationalScans(ctx context.Context) { if !s.informationalScansEnabled() { return @@ -190,6 +205,11 @@ func (s *Server) maybeStartInformationalScans(ctx context.Context) { if sc == nil || sc.Name == "" { continue } + // Not recorded, not scanned: a disabled server stays "unseen" so that + // enabling it later is what admits it. See the note above. + if !sc.Enabled { + continue + } if s.infoScanKnown[sc.Name] { continue } diff --git a/internal/server/scan_informational_test.go b/internal/server/scan_informational_test.go index 83c5584b3..644b619b1 100644 --- a/internal/server/scan_informational_test.go +++ b/internal/server/scan_informational_test.go @@ -265,6 +265,36 @@ func TestInformationalScan_DisabledServersSkipped(t *testing.T) { assert.Equal(t, 0, state.ServersScanned) } +// A disabled server must not be STRANDED by being skipped: because a skipped +// server is neither scanned nor recorded as "known", enabling it later is what +// admits it. Before this was fixed the skip still marked the server known, so +// the admission path treated the enable as "already seen" and the one-shot, +// marker-gated sweep never came back — its badge read "not scanned" forever. +func TestInformationalScan_DisabledServerScannedOnceEnabled(t *testing.T) { + fake := newFakeSecurityScanner() + fake.scanResult["srv"] = &scanner.ScanSummary{Status: "clean"} + sc := &config.ServerConfig{Name: "srv", TrustMode: string(config.TrustModeManual), Enabled: false} + s := newInformationalTestServer(t, fake, nil, sc) + // Present at process start AND disabled: the seed must not claim it either. + s.seedKnownServers([]*config.ServerConfig{sc}) + + s.maybeStartInformationalScans(context.Background()) + time.Sleep(50 * time.Millisecond) + require.Empty(t, fake.startedScans(), "a disabled server is never scanned") + + // The operator enables it; servers.changed fires again. + sc.Enabled = true + require.NoError(t, s.runtime.StorageManager().SaveUpstreamServer(sc)) + s.maybeStartInformationalScans(context.Background()) + waitForStartedScans(t, fake, []string{"srv"}) + + // Still exactly one scan, and still informational. + s.maybeStartInformationalScans(context.Background()) + time.Sleep(50 * time.Millisecond) + assert.Equal(t, []string{"srv"}, fake.startedScans()) + assert.Empty(t, fake.approvedServers()) +} + // (c) The sweep runs once; the persisted marker prevents any re-run, even for a // fresh process whose in-memory dedupe maps are empty. func TestBaselineSweep_RunsOnceThenMarkerBlocksRerun(t *testing.T) { From e12fcd1be6438bc0464ebfc016a452585f3d66b4 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Mon, 24 Aug 2026 07:08:49 +0300 Subject: [PATCH 9/9] fix(security): close the scanner-publish race, sweep stranding, and a temp-dir leak MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three confirmed defects from the cross-model review of the informational baseline scan paths. 1. securityScanner was published unsynchronized. NewServerWithConfigPath starts the event-listener goroutine, but the scanner service is only assigned much later in startCustomHTTPServer. That goroutine reads the field on every servers.changed / scan-settled event, so publish and read are concurrent — a genuine data race on an interface value, which can observe a torn or nil service and silently drop an admission scan. Route every access through securityScannerSvc()/setSecurityScanner() behind a dedicated RWMutex (not s.mu, so it cannot join a lock cycle with the lifecycle lock), and capture the service once per call site. 2. A partially failing baseline sweep burned its one-shot marker. The rule "scanned > 0 || failed == 0" marked the sweep done as soon as one server succeeded, so a server that was merely still connecting was stranded: the marker outlives the process, and the in-process retry route dies with it. Require failed == 0. Retrying is self-limiting — the next sweep's only candidates are servers that still have no scan summary. 3. Service.StartScan leaked its prepared temp source directory whenever engine.StartScan rejected the scan. The engine only invokes the callback (which owns the cleanup) for a scan it accepted, so all three rejection paths — concurrent scan in progress, scanner resolution failure, no scanners installed — orphaned the directory. The automatic paths retry and collide with manual scans, which turns a rare leak into a recurring one. Release the cleanup on the error return. Also documents two findings that are NOT fixed here, in the code at the places a future reader will look: the settle handler can still be reached if a server's trust_mode is flipped to "scan" mid-scan (closing it needs scan provenance threaded through the runtime event payload and into spec-086's gating path, whose semantics this change leaves untouched), and findServerConfig's storage read can be stale inside ApplyConfig's emit-before-sync window. --- internal/security/scanner/service.go | 10 +++ internal/server/scan_admission_test.go | 7 ++ internal/server/scan_informational.go | 64 ++++++++++++++---- internal/server/scan_informational_test.go | 33 +++++++++ internal/server/server.go | 78 +++++++++++++++++----- 5 files changed, 163 insertions(+), 29 deletions(-) diff --git a/internal/security/scanner/service.go b/internal/security/scanner/service.go index 96f176b03..e8adf1f0e 100644 --- a/internal/security/scanner/service.go +++ b/internal/security/scanner/service.go @@ -1148,6 +1148,16 @@ func (s *Service) StartScan(ctx context.Context, serverName string, dryRun bool, } job, err := s.engine.StartScan(ctx, req, callback) if err != nil { + // The callback owns resolvedCleanup, but the engine only ever invokes + // the callback for a scan it ACCEPTED. Every rejection path here — + // "scan already in progress", scanner resolution failure, no scanners + // installed — returns before OnScanStarted, so the temp source + // directory prepared above would be orphaned on disk. Release it on the + // way out; the automatic baseline paths retry, and the concurrent-scan + // rejection is exactly what they hit when they race a manual scan. + if resolvedCleanup != nil { + resolvedCleanup() + } return nil, err } diff --git a/internal/server/scan_admission_test.go b/internal/server/scan_admission_test.go index 1d5b72fed..4250f3637 100644 --- a/internal/server/scan_admission_test.go +++ b/internal/server/scan_admission_test.go @@ -31,6 +31,10 @@ type fakeSecurityScanner struct { // Absent ⇒ the scan leaves the summary nil. scanResult map[string]*scanner.ScanSummary startScanErr error + // startScanErrByServer fails StartScan for specific servers only, so a + // PARTIALLY failing sweep can be exercised. Takes precedence over + // startScanErr for the servers it names. + startScanErrByServer map[string]error approveCalls []string startScanCalls []string @@ -67,6 +71,9 @@ func (f *fakeSecurityScanner) StartScan(_ context.Context, serverName string, _ f.mu.Lock() defer f.mu.Unlock() f.startScanTries = append(f.startScanTries, serverName) + if err, ok := f.startScanErrByServer[serverName]; ok { + return nil, err + } if f.startScanErr != nil { return nil, f.startScanErr } diff --git a/internal/server/scan_informational.go b/internal/server/scan_informational.go index f1bd47dd8..2e779befe 100644 --- a/internal/server/scan_informational.go +++ b/internal/server/scan_informational.go @@ -98,6 +98,30 @@ func isTerminalScanStatus(status string) bool { // gating state change, it simply never scans a server the settle handler could // act on. Scan-mode servers still get their verdict from the gating path's own // admission scan, or from a manual scan. +// +// KNOWN RESIDUAL WINDOW (cross-model review, PR #1031). The predicate is +// evaluated when the scan is CLAIMED, but the settle handler re-reads +// everything — including the trust mode itself — when the verdict lands. So a +// server informationally scanned as manual/auto that the operator switches to +// trust_mode:"scan" AND quarantines while the scan is in flight can still reach +// maybeAutoApproveScanSettled and be auto-approved by that clean verdict. No +// predicate here can close it: the decision belongs to the settle handler, and +// this path has no say once StartScan has been issued. +// +// Not closed in this change because the only real fix is scan PROVENANCE — the +// settle handler acting solely on scans the gating path itself started — which +// means threading a scan id through the runtime event payload +// (publishScanSettled carries server_name/status/findings only) and into +// spec-086's gating path, whose semantics this change deliberately leaves +// untouched. The window is narrow and the outcome is policy-consistent (a +// scan-mode + quarantined + no-baseline + clean server is exactly what spec 086 +// auto-approves), and ApproveServer(force=false) still re-gates independently. +// +// KNOWN COVERAGE GAP, same review: a trust_mode:"scan" server that is NOT +// quarantined (e.g. quarantine_enabled:false globally) is scanned by NEITHER +// path — this one skips all scan-mode servers, and the gating path only handles +// quarantined ones. Narrowing this predicate to match is what the two bullets +// above rule out, so closing that gap also needs provenance. func scanModeAdmissionOwns(sc *config.ServerConfig) bool { if sc == nil { return false @@ -108,7 +132,7 @@ func scanModeAdmissionOwns(sc *config.ServerConfig) bool { // informationalScansEnabled resolves the security.auto_baseline_scan kill switch // (default ON) against the live config, and requires a scanner service. func (s *Server) informationalScansEnabled() bool { - if s.securityScanner == nil { + if s.securityScannerSvc() == nil { return false } var sec *config.SecurityConfig @@ -227,7 +251,8 @@ func (s *Server) maybeStartInformationalScans(ctx context.Context) { // qualifies, claims it so no other path scans it again this process. Returns // false (without claiming) when the server must be skipped. func (s *Server) claimInformationalScan(ctx context.Context, sc *config.ServerConfig) bool { - if sc == nil || sc.Name == "" || s.securityScanner == nil { + scanSvc := s.securityScannerSvc() + if sc == nil || sc.Name == "" || scanSvc == nil { return false } // Disabled servers are never scanned: the scan would have to start the @@ -237,7 +262,7 @@ func (s *Server) claimInformationalScan(ctx context.Context, sc *config.ServerCo } // Already scanned (or a scan is in flight): GetScanSummary returns nil only // when no scan job exists at all — the one "never scanned" signal. - if summary := s.securityScanner.GetScanSummary(ctx, sc.Name); summary != nil { + if summary := scanSvc.GetScanSummary(ctx, sc.Name); summary != nil { return false } // Leave the gating admission path's servers alone: no double scan, and no @@ -353,17 +378,21 @@ func (s *Server) runInformationalScan(ctx context.Context, name string) (int, er if !s.informationalScansEnabled() { return 0, errInformationalScansDisabled } - if _, err := s.securityScanner.StartScan(ctx, name, false, nil, ""); err != nil { + scanSvc := s.securityScannerSvc() + if scanSvc == nil { + return 0, errInformationalScansDisabled + } + if _, err := scanSvc.StartScan(ctx, name, false, nil, ""); err != nil { return 0, err } - return s.waitForInformationalScan(ctx, name), nil + return s.waitForInformationalScan(ctx, scanSvc, name), nil } // waitForInformationalScan blocks until the server's scan summary reaches a // terminal status (or the timeout / shutdown fires) and returns its finding // count. A timeout is not an error: the scan keeps running in the background, // the wait only exists to serialize the queue. -func (s *Server) waitForInformationalScan(ctx context.Context, name string) int { +func (s *Server) waitForInformationalScan(ctx context.Context, scanSvc securityScannerService, name string) int { timeout := s.infoScanSettleTimeout if timeout <= 0 { return 0 @@ -374,7 +403,7 @@ func (s *Server) waitForInformationalScan(ctx context.Context, name string) int defer ticker.Stop() for { - if summary := s.securityScanner.GetScanSummary(ctx, name); summary != nil && isTerminalScanStatus(summary.Status) { + if summary := scanSvc.GetScanSummary(ctx, name); summary != nil && isTerminalScanStatus(summary.Status) { if summary.FindingCounts != nil { return summary.FindingCounts.Total } @@ -494,11 +523,22 @@ func (s *Server) runBaselineSweep(ctx context.Context) { return } - // Burn the one-shot marker only when the sweep actually achieved something: - // scanned at least one server, or had nothing to scan at all. A sweep where - // every candidate failed (servers still connecting, unreachable) is left - // unmarked so the next start retries it. - if scanned > 0 || failed == 0 { + // Burn the one-shot marker only when the sweep actually FINISHED its job — + // no candidate failed. "Nothing to scan at all" (failed == 0, scanned == 0) + // still counts as finished. + // + // The weaker rule "scanned > 0 || failed == 0" stranded the failures: in a + // mixed sweep where A scanned and B was still connecting, the marker was + // burned on A's success and B never got a baseline scan on any later start. + // (Within THIS process B is still retried — releaseInformationalScan clears + // its known-mark for the next servers.changed — but that dies with the + // process, and the marker is what outlives it.) + // + // Retrying is cheap and self-limiting: the next sweep's only candidates are + // servers that still have no scan summary, so everything already scanned is + // skipped by claimInformationalScan. A permanently unscannable server costs + // one failed StartScan per start, in the background, off the startup path. + if failed == 0 { if err := sm.SaveBaselineSweepState(&storage.BaselineSweepState{ Version: httpapi.GetBuildVersion(), CompletedAt: time.Now(), diff --git a/internal/server/scan_informational_test.go b/internal/server/scan_informational_test.go index 644b619b1..2308d2a09 100644 --- a/internal/server/scan_informational_test.go +++ b/internal/server/scan_informational_test.go @@ -399,6 +399,39 @@ func TestBaselineSweep_AllScansFailedKeepsMarkerUnset(t *testing.T) { assert.Nil(t, state, "a sweep that scanned nothing must stay retryable") } +// A PARTIALLY failing sweep must also stay retryable. Burning the marker as +// soon as one server scanned stranded the rest: the marker outlives the process, +// so a server that was merely still connecting would never be swept again. +func TestBaselineSweep_PartialFailureKeepsMarkerUnset(t *testing.T) { + fake := newFakeSecurityScanner() + fake.startScanErrByServer = map[string]error{"b": errors.New("server is disconnected")} + fake.scanResult["a"] = &scanner.ScanSummary{Status: "clean"} + s := newInformationalTestServer(t, fake, nil, + enabledServer("a", config.TrustModeManual), + enabledServer("b", config.TrustModeManual)) + + s.runBaselineSweep(context.Background()) + require.Equal(t, []string{"a"}, fake.startedScans(), "a scanned, b failed to start") + + state, err := s.runtime.StorageManager().LoadBaselineSweepState() + require.NoError(t, err) + assert.Nil(t, state, "a sweep with any failed candidate must stay retryable") + + // Once b's transient failure clears, the retried sweep finishes and marks. + fake.mu.Lock() + fake.startScanErrByServer = nil + fake.mu.Unlock() + fake.scanResult["b"] = &scanner.ScanSummary{Status: "clean"} + + s.runBaselineSweep(context.Background()) + assert.ElementsMatch(t, []string{"a", "b"}, fake.startedScans(), + "the retry scans only the server that still has no summary") + + state, err = s.runtime.StorageManager().LoadBaselineSweepState() + require.NoError(t, err) + require.NotNil(t, state, "a sweep with no failures marks itself done") +} + // A scan that fails to start releases its claim so a later servers.changed can // retry it. func TestInformationalScan_FailedStartIsRetryable(t *testing.T) { diff --git a/internal/server/server.go b/internal/server/server.go index 4e9c4a27c..4fd7310a5 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -68,6 +68,23 @@ type securityScannerService interface { DeepScanEnabled() bool } +// securityScannerSvc returns the published scanner service, or nil while the +// HTTP startup path has not wired one yet. Every read of s.securityScanner from +// outside the constructor must go through here — see the field comment. +func (s *Server) securityScannerSvc() securityScannerService { + s.securityScannerMu.RLock() + defer s.securityScannerMu.RUnlock() + return s.securityScanner +} + +// setSecurityScanner publishes the scanner service to the event-listener +// goroutine and the HTTP handlers. +func (s *Server) setSecurityScanner(svc securityScannerService) { + s.securityScannerMu.Lock() + defer s.securityScannerMu.Unlock() + s.securityScanner = svc +} + // Server wraps the MCP proxy server with all its dependencies type Server struct { logger *zap.Logger @@ -98,7 +115,16 @@ type Server struct { startTime time.Time // Spec 039: Security scanner service (for scan summaries in server list) - securityScanner securityScannerService + // securityScanner is published LATE — startCustomHTTPServer constructs the + // scanner service long after NewServerWithConfigPath has already started the + // event-listener goroutine. That goroutine reads this field on every + // servers.changed / scan-settled event, so the publish and the reads are + // concurrent and must be synchronized: guard both with securityScannerMu and + // reach the field only through securityScannerSvc()/setSecurityScanner(). + // A dedicated mutex, not s.mu, so it can never participate in a lock cycle + // with the broader server lifecycle lock. + securityScannerMu sync.RWMutex + securityScanner securityScannerService // Spec 086 stage 3 (FR-011): tracks scan-mode servers for which a one-shot // admission baseline scan has already been triggered this process, so the @@ -572,7 +598,8 @@ func shouldAutoApproveScanSettled(mode config.TrustMode, quarantined bool, verdi // if there is no scan report, so a stale or buggy verdict still cannot // unquarantine a dangerous or unscanned server. func (s *Server) maybeAutoApproveScanSettled(ctx context.Context, serverName string) { - if serverName == "" || s.securityScanner == nil { + scanSvc := s.securityScannerSvc() + if serverName == "" || scanSvc == nil { return } sc := s.findServerConfig(serverName) @@ -591,13 +618,13 @@ func (s *Server) maybeAutoApproveScanSettled(ctx context.Context, serverName str // unquarantined at least once, so this quarantine is a deliberate operator // re-quarantine (or a rug-pull re-quarantine), NOT the initial admission — // never silently override that by auto-approving on a later clean settle. - if s.securityScanner.HasApprovalBaseline(serverName) { + if scanSvc.HasApprovalBaseline(serverName) { s.logger.Debug("scan-mode server has a prior approval baseline; not auto-approving on settle (respect operator re-quarantine)", zap.String("server", serverName)) return } verdict := "" - if summary := s.securityScanner.GetScanSummary(ctx, serverName); summary != nil { + if summary := scanSvc.GetScanSummary(ctx, serverName); summary != nil { verdict = summary.Status } if !shouldAutoApproveScanSettled(mode, sc.Quarantined, verdict) { @@ -606,7 +633,7 @@ func (s *Server) maybeAutoApproveScanSettled(ctx context.Context, serverName str zap.String("verdict", verdict)) return } - if err := s.securityScanner.ApproveServer(ctx, serverName, false, "scan-auto"); err != nil { + if err := scanSvc.ApproveServer(ctx, serverName, false, "scan-auto"); err != nil { // ApproveServer's own hard-tier/missing-report gate can reject; that is the // intended fail-closed outcome, not a fatal error. Log and leave quarantined. s.logger.Warn("auto-approve of scan-mode server after green scan was rejected", @@ -627,7 +654,7 @@ func (s *Server) maybeAutoApproveScanSettled(ctx context.Context, serverName str // plus the "already scanned" verdict guard keep the servers.changed stream from // restarting an in-flight or completed scan. func (s *Server) maybeStartAdmissionScans(ctx context.Context) { - if s.securityScanner == nil { + if s.securityScannerSvc() == nil { return } // Read servers from storage (RLock-guarded, returns fresh ServerConfig copies) @@ -661,7 +688,8 @@ func (s *Server) maybeStartAdmissionScans(ctx context.Context) { // goroutine so the event loop is never blocked; a launch failure clears the // kicked flag so a later servers.changed can retry. func (s *Server) maybeStartAdmissionScan(ctx context.Context, sc *config.ServerConfig) { - if sc == nil || s.securityScanner == nil { + scanSvc := s.securityScannerSvc() + if sc == nil || scanSvc == nil { return } if sc.EffectiveTrustMode() != config.TrustModeScan || !sc.Quarantined { @@ -670,13 +698,13 @@ func (s *Server) maybeStartAdmissionScan(ctx context.Context, sc *config.ServerC // Already scanned (scanning/clean/failed/…): the admission scan already ran // or a manual scan is in flight. GetScanSummary returns nil only when no // scan job exists yet — the sole "never scanned" signal. - if summary := s.securityScanner.GetScanSummary(ctx, sc.Name); summary != nil { + if summary := scanSvc.GetScanSummary(ctx, sc.Name); summary != nil { return } // A prior approval baseline means this is a re-quarantine of a server that // was already admitted once, not a first-time admission — do not re-scan or // auto-approve it (aligns with the settle handler's admission-window gate). - if s.securityScanner.HasApprovalBaseline(sc.Name) { + if scanSvc.HasApprovalBaseline(sc.Name) { return } name := sc.Name @@ -691,7 +719,7 @@ func (s *Server) maybeStartAdmissionScan(ctx context.Context, sc *config.ServerC s.logger.Info("triggering admission baseline scan for scan-mode server (spec 086 FR-011)", zap.String("server", name)) go func() { - if _, err := s.securityScanner.StartScan(ctx, name, false, nil, ""); err != nil { + if _, err := scanSvc.StartScan(ctx, name, false, nil, ""); err != nil { s.logger.Warn("admission baseline scan failed to start; will retry on next servers.changed", zap.String("server", name), zap.Error(err)) @@ -713,6 +741,21 @@ func (s *Server) maybeStartAdmissionScan(ctx context.Context, sc *config.ServerC // caller, maybeAutoApproveScanSettled, once anything actually settles a scan). // ListUpstreamServers is serialized against SaveUpstreamServer by the storage // manager mutex and returns fresh copies. +// +// TRADE-OFF (cross-model review, PR #1031). Storage is not a strictly better +// source: Runtime.ApplyConfig publishes the new config and emits +// config.reloaded / servers.changed BEFORE the goroutine it spawns reaches +// LoadConfiguredServers, so for that window storage still holds the PREVIOUS +// records. A scan settling inside it resolves the old policy — e.g. a server +// the operator just moved off trust_mode:"scan" can still be seen as scan-mode +// and quarantined here, and auto-approved on a clean verdict. +// +// Storage is still the right read: the alternative races (the config snapshot's +// ServerConfig structs are mutated in place, which the race detector reports +// against this function), and a sub-second staleness window on a fail-closed +// path is a smaller defect than undefined behaviour. Closing it properly means +// making ApplyConfig synchronize storage before it emits — a change to the +// config-apply pipeline, not to this reader. func (s *Server) findServerConfig(serverName string) *config.ServerConfig { sm := s.runtime.StorageManager() if sm == nil { @@ -756,18 +799,19 @@ func (s *Server) reapplyScannerSecurityConfig() { // there is no scanner Service at all — the scan gate still runs (spec 086 // FR-019 hot-reload). configureTPABundle(cfg, s.logger) - if s.securityScanner == nil { + scanSvc := s.securityScannerSvc() + if scanSvc == nil { return } if cfg == nil { return } - s.securityScanner.ApplySecurityConfig(cfg.Security) + scanSvc.ApplySecurityConfig(cfg.Security) if cfg.DockerIsolation != nil { - s.securityScanner.SetIsolationMode(string(cfg.DockerIsolation.ResolvedMode())) + scanSvc.SetIsolationMode(string(cfg.DockerIsolation.ResolvedMode())) } s.logger.Debug("Re-applied security scanner config on hot-reload", - zap.Bool("deep_scan_enabled", s.securityScanner.DeepScanEnabled())) + zap.Bool("deep_scan_enabled", scanSvc.DeepScanEnabled())) } // Start starts the MCP proxy server @@ -1287,8 +1331,8 @@ func (s *Server) GetAllServers() ([]map[string]interface{}, error) { } // Spec 039: Add security scan summary if available - if s.securityScanner != nil { - scanSummary := s.securityScanner.GetScanSummary(context.Background(), serverStatus.Name) + if scanSvc := s.securityScannerSvc(); scanSvc != nil { + scanSummary := scanSvc.GetScanSummary(context.Background(), serverStatus.Name) if scanSummary != nil { serverMap["security_scan"] = scanSummary } @@ -2543,7 +2587,7 @@ func (s *Server) startCustomHTTPServer(ctx context.Context, streamableServer *se if mgmtSvc, ok := s.runtime.GetManagementService().(management.Service); ok && mgmtSvc != nil { mgmtSvc.SetScanSummaryEnricher(&scanSummaryEnricherAdapter{scanner: secService}) } - s.securityScanner = secService + s.setSecurityScanner(secService) // One-shot post-upgrade baseline sweep: scan enabled servers that have // never been scanned so their badges stop reading "not scanned" on an // install that predates automatic scanning. Backgrounded (never delays