diff --git a/docs/configuration.md b/docs/configuration.md index 8fc5bec94..8160c1e5e 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 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 309021335..4410d20bb 100644 --- a/docs/features/security-quarantine.md +++ b/docs/features/security-quarantine.md @@ -308,6 +308,25 @@ 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. 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 + 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..6d549d54d --- /dev/null +++ b/internal/config/auto_baseline_scan_test.go @@ -0,0 +1,89 @@ +package config + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +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()) + }) +} + +// 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/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..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 @@ -684,6 +685,31 @@ 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. + // 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{} + } + 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/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 40f902151..4250f3637 100644 --- a/internal/server/scan_admission_test.go +++ b/internal/server/scan_admission_test.go @@ -26,15 +26,28 @@ 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 + // 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 + // startScanTries records EVERY StartScan entry, including the ones that + // return startScanErr, so retry-capping can be asserted. + startScanTries []string } func newFakeSecurityScanner() *fakeSecurityScanner { return &fakeSecurityScanner{ summaries: map[string]*scanner.ScanSummary{}, hasBaseline: map[string]bool{}, + scanResult: map[string]*scanner.ScanSummary{}, } } @@ -57,7 +70,18 @@ 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 err, ok := f.startScanErrByServer[serverName]; ok { + return nil, err + } + 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 } @@ -83,6 +107,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 new file mode 100644 index 000000000..2e779befe --- /dev/null +++ b/internal/server/scan_informational.go @@ -0,0 +1,557 @@ +package server + +import ( + "context" + "errors" + "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 + // 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 +) + +// 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 { + switch status { + case "clean", "warnings", "dangerous", "failed": + return true + default: + return false + } +} + +// scanModeAdmissionOwns reports whether the spec-086 trust_mode:"scan" admission +// 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 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. +// +// 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 + } + return sc.EffectiveTrustMode() == config.TrustModeScan +} + +// 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.securityScannerSvc() == 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. +// +// 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() + if s.infoScanKnown == nil { + s.infoScanKnown = make(map[string]bool, len(servers)) + } + for _, sc := range servers { + if sc != nil && sc.Name != "" && sc.Enabled { + 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. +// +// 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 + } + 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 + } + // 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 + } + 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 { + scanSvc := s.securityScannerSvc() + if sc == nil || sc.Name == "" || scanSvc == 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 := scanSvc.GetScanSummary(ctx, sc.Name); summary != nil { + return false + } + // 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 + } + + 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. +// +// 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. +// +// 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() + 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) +} + +// 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)) + // 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 + } + 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 + } + // 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 + } + 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, 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, scanSvc securityScannerService, 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 := scanSvc.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: + } + } + // 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 + // 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 + } + + // 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 + 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 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 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) + 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 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(), + 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..2308d2a09 --- /dev/null +++ b/internal/server/scan_informational_test.go @@ -0,0 +1,595 @@ +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()) + }) + + // 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 +// 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"}) +} + +// 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 +// 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) { + 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) +} + +// 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) { + 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) +} + +// 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) { + 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 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) { + 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 sweep is the other retry route (it never consulted the known-set). + 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"}) +} + +// 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 + sc *config.ServerConfig + hasBaseline bool + 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, 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)) + }) + } +} + +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..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 @@ -107,6 +133,20 @@ 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 + infoScanAttempts map[string]int + infoScanRunMu sync.Mutex + infoScanSettleTimeout time.Duration + infoScanSweepDelay time.Duration + // Spec 024: Shutdown info for lifecycle events shutdownReason string shutdownSignal string @@ -212,7 +252,19 @@ 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), + infoScanAttempts: make(map[string]int), + 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 +535,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 @@ -541,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) @@ -560,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) { @@ -575,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", @@ -596,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) @@ -630,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 { @@ -639,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 @@ -660,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)) @@ -671,15 +730,45 @@ 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. +// +// 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 { - 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 } @@ -710,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 @@ -1241,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 } @@ -2497,7 +2587,14 @@ 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 + // 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: