From b7f90ffe5bf615d8c3bbb66ef2c5e61a22f2a074 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Fri, 7 Aug 2026 20:43:52 +0300 Subject: [PATCH 01/37] spec(092): one-click auto-updater (macOS) + channel-aware mcpproxy update CLI Related #957 Spec for Option A from the decision research: Phase 0 stale-version supersede (the actual #957 bug fix), Phase 1 finish the half-plumbed Sparkle 2 integration, Phase 2 channel-aware CLI self-update. Includes the multi-agent decision report under docs/research/. --- .../auto-updater-issue-957-2026-08-07.html | 347 ++++++++++++++++++ .../checklists/requirements.md | 35 ++ specs/092-auto-updater/spec.md | 178 +++++++++ 3 files changed, 560 insertions(+) create mode 100644 docs/research/auto-updater-issue-957-2026-08-07.html create mode 100644 specs/092-auto-updater/checklists/requirements.md create mode 100644 specs/092-auto-updater/spec.md diff --git a/docs/research/auto-updater-issue-957-2026-08-07.html b/docs/research/auto-updater-issue-957-2026-08-07.html new file mode 100644 index 00000000..7b695a07 --- /dev/null +++ b/docs/research/auto-updater-issue-957-2026-08-07.html @@ -0,0 +1,347 @@ + + +Auto-updater for MCPProxy — decision report (#957) + +
+

MCPProxy · Engineering decision report

+

One-click auto-updater for macOS + mcpproxy update CLI

+

+ Issue #957 — old version app still running after upgrade + · 2026-08-07 · 21-agent research workflow, 14 load-bearing claims adversarially verified against the code +

+ +
+

Recommendation

+

Option A — finish the Sparkle 2 integration that is already half-shipped, with the stale-process fix (Option D) folded in as Phase 0.

+

Sparkle 2.9.3 is already declared in Package.swift, dynamically linked into the tray binary, and bundled + signed into Contents/Frameworks — it has just never been imported. The Info.plist already carries SUFeedURL=https://mcpproxy.app/appcast.xml (nonexistent) and a placeholder EdDSA key. Spec 037 FR-014/FR-015 already wrote the design. The remaining work is mostly deterministic CI plumbing, not novel security-critical runtime code.

+

Critically, Sparkle alone does not fix #957 — its relaunch callbacks don't cover install-on-quit or manual DMG drag-installs (Sparkle Discussion #2572). The actual bug fix is a version-mismatch supersede check in the tray, which ships first and works for every upgrade path.

+
+ +

01What exists today (verified)

+

Three parallel update-awareness paths, zero install automation:

+ + +

02Root cause of #957

+

Nothing in any upgrade path stops or restarts the running processes. The user drags the new MCPProxy.app over the old one (or runs the PKG); macOS replaces the bundle on disk, but the old tray and its old core keep running from deleted inodes. The PKG postinstall launches with open -a — self-documented as “activates rather than duplicating” — so it foregrounds the stale old-version instance. There is zero bundle-replacement or version-mismatch detection in the Swift tray.

+

The tray’s lifecycle actively works against upgrades: on start it attaches to any core answering ~/.mcpproxy/mcpproxy.sock with no version comparison, and the core enforces single-instance via the bbolt flock on config.db (loser exits code 3) and TCP port conflict (exit 2) — so a new-version core can’t even start while the old one runs. postinstall.sh:27,62-75 · CoreProcessManager.swift:248-319,462-475 — CONFIRMED (one nuance: a busy Unix socket alone doesn’t stop the core; the DB flock and port do)

+ +

03Options

+
+ + + + + + + +
OptionDelivers one-click UXEffortRisk
A · Finish Sparkle 2Yes — full download → verify → swap → relaunchLMedium
B · Custom Swift updaterYesXLHigh
C · Go-core-driven self-updateYesXLHighest
D · Fix #957 only, no downloaderNo — browser download staysS/MLow
+ +
+
+

Option A — Finish the Sparkle 2 integration + channel-gated CLI

+
RECOMMENDEDEffort LRisk Medium
+
+

Wire SPUStandardUpdaterController programmatically in UpdateService.swift with the “gentle reminders” pattern for menu-bar apps: the menu item “Update 0.54.1 — ready to restart?” triggers one click → download, EdDSA + Apple-signature verify, bundle swap, relaunch. Add EdDSA keys, CI-generated appcast, and a notarized + stapled .app zip enclosure. Fix #957 belt-and-suspenders: delegate hooks stop the Go core before the swap, and an unconditional startup version-mismatch check supersedes any stale core. Separately: mcpproxy update that self-replaces only on tarball/unknown channels and prints the package-manager command elsewhere.

+
+

Pros

    +
  • Sparkle handles the genuinely hard macOS parts free: quarantine release (Ventura 13.1+ Gatekeeper regression that kills naive custom updaters), App Translocation detection, privileged copy, atomic swap, EdDSA pinning, delta updates.
  • +
  • Least new code: framework already linked/bundled/signed; menu slot, update state, and core lifecycle ownership all exist.
  • +
  • Spec 037 FR-014/FR-015 already specifies exactly this flow — design work is done.
  • +
  • Ecosystem-standard for menu-bar apps (iTerm2 et al.); maintained through 2026; no sandbox/XPC work needed.
  • +
  • The startup version-mismatch check fixes #957 for all upgrade paths, not just the one-click path.
  • +
  • CLI reuses existing checksums.txt + cosign bundle + SLSA provenance — zero new CI for CLI verification.
  • +
+

Cons

    +
  • The bulk of the cost is CI/release plumbing: EdDSA key secret, appcast generation and stable hosting, a new stapled .app zip asset.
  • +
  • Appcast URL becomes forever-infrastructure (Info.plist already points at the nonexistent mcpproxy.app/appcast.xml).
  • +
  • Sparkle silently no-ops when the app runs translocated/read-only — must surface via delegate.
  • +
  • Two update brains (Sparkle feed + internal/updatecheck) must not double-nudge; CI=true suppression must carry over.
  • +
  • import Sparkle under plain swift build (no Xcode) needs a quick prototype — it already links, but the import path is unproven.
  • +
+
+
+ +
+

Option B — Custom Swift updater (no Sparkle)

+
Effort XLRisk High
+

Download the notarized artifact, verify sha256 against cosign-verified checksums.txt + codesign --verify --deep + spctl -a, strip quarantine, atomic-rename-swap, relaunch via a detached helper. One signing system (cosign) instead of two.

+
+

Pros

    +
  • No appcast, EdDSA key, or hosted infra — GitHub Releases + cosign is the whole supply chain.
  • +
  • Could drop the 5 MB dead-weight Sparkle.framework from the bundle.
  • +
  • Uniform verification across tray and CLI; full control over core-shutdown choreography.
  • +
+

Cons

    +
  • Re-implements what Sparkle hardened over 20 years: quarantine release, translocation, privileged copy, per-inode signature caching (Killed: 9), rollback.
  • +
  • The relaunch helper (survive deletion of the bundle it was spawned from) is subtle and macOS-version-sensitive.
  • +
  • Most novel security-critical Swift code to maintain; a bug bricks installs. Still needs the same notarized-artifact CI work as A.
  • +
+
+
+ +
+

Option C — Go-core-driven self-update (core swaps the bundle)

+
Effort XLRisk Highest
+

All update logic in Go (go-selfupdate + minio/selfupdate, cosign-verified); the tray menu calls a new POST /api/v1/update/apply; the core swaps /Applications/MCPProxy.app and asks the tray to relaunch.

+
+

Pros

    +
  • Single implementation for CLI, tray, and later Windows/Linux; better testing story in Go.
  • +
  • Mature Go libraries for the binary-replace primitive; no Sparkle/appcast/EdDSA.
  • +
+

Cons

    +
  • Architecturally inverted: the core replaces its own parent bundle while both processes run from it — the exact deleted-inode tangle behind #957, with more moving parts.
  • +
  • Go selfupdate libraries are single-binary oriented; whole-bundle swap with nested signatures is entirely custom (bundle seal breaks → Killed: 9 per Apple DTS).
  • +
  • Reverses the working tray-owns-core lifecycle; a failed swap can leave no surviving process to show an error.
  • +
+
+
+ +
+

Option D — Minimal: fix #957 only, keep browser-download UX

+
Effort S/MRisk Low
+

Stale-process supersede in the tray + postinstall.sh fix + tarball-only CLI self-update. The “Update available” menu item keeps opening the browser.

+
+

Pros

    +
  • Smallest, fastest change; directly closes the reported bug for every upgrade path.
  • +
  • No new CI, keys, or infrastructure; no security-critical download/swap code.
  • +
  • Every piece is a prerequisite of Option A anyway — zero throwaway work.
  • +
+

Cons

    +
  • Does not deliver the requested one-click UX; users still download and drag DMGs.
  • +
  • Sparkle stays linked, bundled, signed, and dead in every release.
  • +
+
+
+ +

04Implementation plan (recommended path)

+
    +
  1. +

    Fix #957 — ships independently, in the very next release

    +
      +
    • Stale-core supersede (CoreProcessManager.swift): compare the attached/managed core’s reported version against the bundled core’s version. Tray-managed + older → terminate (existing SIGTERM→SIGKILL) and respawn from Contents/Resources/bin. Externally-attached → don’t kill; show “Old core vX still running — restart into vY” (open decision below). Run at attach time and on every version report.
    • +
    • Stale-tray detection: on didBecomeActive + low-frequency timer, stat the on-disk bundle version vs the running version; on mismatch show “MCPProxy was updated to vY — Relaunch” (stops core, detached open -n). This closes the drag-install deleted-inode case directly.
    • +
    • packaging/macos/postinstall.sh: quit the running instance by bundle id (osascript → wait → pkill fallback) before launching the fresh bundle, instead of open -a foregrounding the stale one.
    • +
    • XCTest for the supersede state machine (fixture-driven, per 090 pattern); manual QA via mcpproxy-ui-test on a real old-DMG → new-DMG upgrade.
    • +
    +
  2. +
  3. +

    Sparkle one-click updater — the requested UX

    +
      +
    • Keys: generate_keys once → public key into Info.plist SUPublicEDKey; private key → GitHub secret SPARKLE_ED_PRIVATE_KEY. Lock the SUFeedURL now (forever URL — open decision).
    • +
    • Swift: import Sparkle; programmatic SPUStandardUpdaterController; prototype the import under plain swift build first (fallback: vendor the framework from the release tarball). Gentle-reminders user-driver delegate feeds the published update state instead of Sparkle’s window; honor CI=true and MCPPROXY_DISABLE_AUTO_UPDATE. Updater delegate stops the managed core before the swap (prototype shouldPostponeRelaunchForUpdate vs synchronous stop). Surface translocation/read-only failures. Phase 0’s startup check stays permanently as the suspenders.
    • +
    • Menu: repurpose the existing “Update available: vX” slot (MCPProxyApp.swift:1132-1136) into “Update 0.54.1 — ready to restart?” → checkForUpdates(). Menu-repaint plumbing already exists.
    • +
    • CI (release.yml): notarize + staple the .app itself; ditto -c -k --sequesterRsrc --keepParent zip (symlink-preserving or the signature breaks) as a release asset in checksums.txt; new appcast job runs generate_appcast --ed-key-file … --download-url-prefix … (delta updates free); map v*-rc.* to a Sparkle beta channel per docs/prerelease-builds.md; keep nested-first codesign order.
    • +
    • Homebrew cask: set auto_updates true so brew doesn’t fight Sparkle.
    • +
    +
  4. +
  5. +

    mcpproxy update CLI — the uv/deno pattern

    +
      +
    • New cmd/mcpproxy/update_cmd.go branching on the existing channel detection: homebrew/deb/rpm/go-install → print the existing one-liner, never self-replace. docker/windows-installer → guidance. dmg → “Use the tray: Check for Updates” (later: trigger via socket). tarball/unknown-writable → real self-update: resolve via internal/updatecheck/github.go, verify cosign bundle offline (identity pinned to the release workflow + GitHub OIDC issuer), sha256-match, apply via minio/selfupdate (write-temp + rename, never in-place — macOS per-inode kill-9). Refuse downgrades without --force; never auto-sudo; never touch anything inside MCPProxy.app.
    • +
    • Table-driven unit tests per channel branch (mock GitHub, CI="" pinning); e2e smoke against a faked release server.
    • +
    +
  6. +
  7. +

    Cleanup & rollout

    +
      +
    • Have the tray refresh/remove the legacy staged core at ~/Library/Application Support/mcpproxy/bin/ (stale copies shadow for legacy-tray users).
    • +
    • docs/features/auto-update.md (channel matrix), configuration.md env vars, Spec 037 FR-014/015 marked implemented.
    • +
    • First Sparkle-capable release N publishes the appcast; one-click activates for N→N+1. Test end-to-end with a genuine notarized older build, not a dev build.
    • +
    +
  8. +
+ +

05Open decisions (maintainer input needed)

+
    +
  1. Appcast hosting: mcpproxy.app/appcast.xml via the website repo (matches the URL already baked into shipped Info.plists — strong argument) vs GitHub Pages vs a mutable release asset. The URL is forever.
  2. +
  3. Externally-attached cores (brew-services / CLI-started): may the tray kill/restart one during a one-click update, should the core grow a shutdown/restart socket endpoint, or is one-click limited to tray-managed cores (the sketch’s default)?
  4. +
  5. Graceful drain: finish in-flight MCP requests before shutdown during update (Spec 037’s own open question), and with what timeout — or is SIGTERM→SIGKILL acceptable?
  6. +
  7. Enclosure arch: per-arch zips (CI builds per-arch today) vs the universal binary build-macos-tray.sh already supports (simpler feed, larger download).
  8. +
  9. CLI verification dep: sigstore-go (heavy dep tree vs the “avoid new dependencies” rule) vs adding a minisign signature in CI (tiny dep, native minio/selfupdate support) vs shelling out to user-installed cosign.
  10. +
  11. Bare DMG notarization: start notarizing/stapling the drag-and-drop DMG too (Gatekeeper quality issue independent of Sparkle), or drop it for installer-DMG + app-zip only?
  12. +
  13. Windows scope: defer entirely to the MSI channel, or support tarball-on-Windows via the rename-to-.old trick?
  14. +
  15. Prerelease mapping: --prerelease and the Sparkle beta channel both track next/v*-rc.*, with docs/prerelease-builds.md staying the single source of truth?
  16. +
  17. Sparkle XPC services: strip from the bundle (unsandboxed app, saves size) or leave stock for easier upgrades?
  18. +
+ +

06Verification appendix

+

14 load-bearing codebase claims were adversarially re-verified by independent agents instructed to refute them: 13 CONFIRMED, 1 PARTIAL. The PARTIAL: single-instance enforcement is via the config.db flock (exit 3) and TCP port conflict (exit 2) — a busy Unix socket alone does not stop the core (it logs a warning and continues TCP-only); the net effect claimed (new core can’t run alongside old) still holds. External-research claims (Sparkle behavior, Gatekeeper regression, library maturity) rest on primary sources: Sparkle docs/discussions #2572, Apple dev-forums thread 730314, library repos.

+
+ + + + + + + + + + + + + + + + + +
Claim (abridged)Verdict
Channel detection: build-time -X marker then path heuristics; release matrix builds intentionally unstampedCONFIRMED
Checker cadence 24h, backoff 8×, env kill-switch wins over configCONFIRMED
DMG channel has no programmatic update path in the coreCONFIRMED
Sparkle declared + linked + bundled + signed, never importedCONFIRMED
UpdateService’s checkWithSparkle() is a stub falling through to GitHub APICONFIRMED
Info.plist SUFeedURL/SUPublicEDKey placeholders; no appcast anywhereCONFIRMED
Menu already has “Check for Updates” + “Update available: vX” slots wired to repaintCONFIRMED
Bundle layout: tray at Contents/MacOS, core at Contents/Resources/bin, Sparkle at Contents/Frameworks, hardened runtimeCONFIRMED
Core-binary resolution order; only the legacy Go tray stages to Application Support (size+mtime freshness only)CONFIRMED
Tray spawns core directly (no launchd daemon); login item relaunches trayCONFIRMED
Attach-first lifecycle with no version comparison — stale cores survive upgradesCONFIRMED
Single-instance enforcement is socket+flock+portPARTIAL
#957 root cause: nothing stops old processes; postinstall open -a foregrounds the stale instanceCONFIRMED
Tray quit kills managed core only; external cores just disconnect and keep runningCONFIRMED
+ +

Method: multi-agent workflow (2 codebase investigators, 3 external researchers, 14 adversarial verifiers, 2 synthesists · 1.2M tokens). Companion report: request queueing / concurrency limits for issue #955.

+
diff --git a/specs/092-auto-updater/checklists/requirements.md b/specs/092-auto-updater/checklists/requirements.md new file mode 100644 index 00000000..09bbf405 --- /dev/null +++ b/specs/092-auto-updater/checklists/requirements.md @@ -0,0 +1,35 @@ +# Specification Quality Checklist: One-Click Auto-Updater (macOS) + Channel-Aware `mcpproxy update` CLI + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-08-07 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- "Sparkle", "appcast", "EdDSA", and "cosign" appear in the Input quote and Assumptions (as the recorded Option-A decision from the research report) but the requirements themselves are stated capability-first (feed-level signature, OS code-signature validity, signed checksum manifest). This is deliberate: the decision report is the authoritative HOW; the spec stays WHAT/WHY. +- Open decisions that need maintainer input before planning are listed in the decision report (feed URL hosting, externally-attached core policy, per-arch vs universal enclosure, CLI verification dependency); the spec's Assumptions record the defaults chosen so planning is not blocked. diff --git a/specs/092-auto-updater/spec.md b/specs/092-auto-updater/spec.md new file mode 100644 index 00000000..8184b2d2 --- /dev/null +++ b/specs/092-auto-updater/spec.md @@ -0,0 +1,178 @@ +# Feature Specification: One-Click Auto-Updater (macOS) + Channel-Aware `mcpproxy update` CLI + +**Feature Branch**: `092-auto-updater` +**Created**: 2026-08-07 +**Status**: Draft +**Input**: User description: "One-click auto-updater for macOS + channel-aware mcpproxy update CLI (fixes #957). Phase 0 fixes the stale-process bug for every upgrade path (version-mismatch supersede in tray, bundle-replacement detection, postinstall quit-before-launch). Phase 1 finishes the half-plumbed Sparkle 2 integration (one-click download, verify, bundle swap, relaunch; EdDSA keys, CI appcast, notarized stapled .app zip enclosure). Phase 2 adds mcpproxy update CLI branching on existing install-channel detection (self-update only on tarball/unknown-writable channels, cosign-verified; package-manager guidance elsewhere; DMG delegates to tray). Decision report: docs/research/auto-updater-issue-957-2026-08-07.html" + +> Related: issue #957 ("old version App still after upgrade"). Decision analysis with option comparison and verification appendix: `docs/research/auto-updater-issue-957-2026-08-07.html` (Option A chosen). + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Upgrading never leaves the old version running (Priority: P1) + +A macOS user upgrades MCPProxy by any means — dragging a new DMG into Applications, running the PKG installer, or any future automated path. After the upgrade, the running menu-bar app and the background core service are the new version (or the user is offered a one-click relaunch into it). The old version never silently keeps serving. + +**Why this priority**: This is the reported bug in #957. Every other story builds on top of it; it must hold even for users who never adopt the one-click updater. It ships independently in the next release. + +**Independent Test**: Install an older release, start it, install a newer release over it (drag-install and PKG separately), and verify the running app/core end up on the new version without manually killing processes. + +**Acceptance Scenarios**: + +1. **Given** an old-version tray and its managed core are running, **When** the user drag-installs a newer app bundle over the old one, **Then** the tray detects the on-disk version change and offers "MCPProxy was updated to vY — Relaunch", and accepting stops the old core and relaunches into the new version. +2. **Given** an old-version core is running (started by the tray), **When** a newer tray starts and attaches to it, **Then** the tray detects that the running core is older than its bundled core, stops it, and respawns the bundled (new) core automatically. +3. **Given** an old-version core is running that the tray did not start (externally managed, e.g. started from a terminal), **When** a newer tray attaches, **Then** the tray does NOT kill it but surfaces a clear "Old core vX still running — restart into vY" action in the menu. +4. **Given** the old app is running, **When** the user runs the PKG installer for a newer version, **Then** installation completes with the old instance quit first and the new version launched — not the stale instance brought to the foreground. +5. **Given** versions already match, **When** the tray performs its checks, **Then** no restart prompt or process churn occurs. + +--- + +### User Story 2 - One-click update from the menu bar (Priority: P2) + +A macOS user (DMG/PKG install) sees "Update 0.54.1 — ready to restart?" in the MCPProxy menu when a new version is available. One click downloads the update, verifies its authenticity, replaces the installed app, and relaunches — tray and core come back on the new version. No browser, no DMG dragging, no installer steps. + +**Why this priority**: The requested UX and the reason users stay up to date. Depends on release-infrastructure changes (update feed, verified update archive), so it ships after the P1 bug fix. + +**Independent Test**: Install a notarized older release that includes the updater, publish a newer release, click the menu item, and observe download → verify → swap → relaunch complete without any manual step; app and core report the new version. + +**Acceptance Scenarios**: + +1. **Given** a newer version exists in the update feed, **When** the user opens the menu, **Then** an "Update X.Y.Z — ready to restart?" item is visible (gentle nudge — no interrupting popups). +2. **Given** the user clicks the update item, **When** the update runs, **Then** the download is verified for both feed authenticity and OS code-signature validity before anything is replaced, the managed core is stopped gracefully before the swap, and the app relaunches on the new version with the core restarted. +3. **Given** the update completed, **When** the user checks versions (menu, `mcpproxy --version`, API), **Then** app and core both report the new version, and existing configuration, tokens, and quarantine state are untouched. +4. **Given** the app is running from a read-only or translocated location (e.g. launched from the DMG itself), **When** an update is attempted, **Then** the user gets a clear explanation and a fallback (e.g. move to Applications / download link) instead of a silent no-op. +5. **Given** verification of the downloaded update fails (tampered or corrupt), **When** the update runs, **Then** nothing is replaced, the running version keeps working, and the user sees an actionable error. +6. **Given** the user is on the release-candidate channel (per `docs/prerelease-builds.md`), **When** updates are offered, **Then** RC builds are offered only to RC-channel users; stable users see stable releases only. +7. **Given** automatic update checks are disabled (existing kill switch env/config), **When** the tray runs, **Then** no update nudges appear and no feed checks occur. + +--- + +### User Story 3 - `mcpproxy update` does the right thing per install channel (Priority: P3) + +A CLI user runs `mcpproxy update`. The command knows how mcpproxy was installed (Homebrew, deb/rpm, Docker, DMG, tarball, go-install …) and either performs a safe verified self-update (standalone-binary channels), prints the exact package-manager command to run, or points at the tray updater — never corrupting a package manager's bookkeeping and never touching the installed app bundle. + +**Why this priority**: Completes the story for headless/CLI users; independent of the macOS GUI work and lower risk. Mirrors the behavior of best-in-class CLIs (uv, deno). + +**Independent Test**: Run `mcpproxy update` on each channel fixture and verify: self-replace happens only on tarball/unknown-writable installs, with integrity verification; all other channels get correct guidance and a zero-side-effect exit. + +**Acceptance Scenarios**: + +1. **Given** a Homebrew/deb/rpm/go-install install, **When** the user runs `mcpproxy update`, **Then** the command prints the appropriate upgrade command (e.g. `brew upgrade mcpproxy`) and exits without modifying anything. +2. **Given** a Docker or Windows-installer install, **When** the user runs `mcpproxy update`, **Then** channel-appropriate guidance is printed and nothing is modified. +3. **Given** a macOS DMG install, **When** the user runs `mcpproxy update`, **Then** the command directs the user to the tray's updater (and never modifies the app bundle or any staged copy of it). +4. **Given** a tarball install in a user-writable location, **When** the user runs `mcpproxy update`, **Then** the new binary is downloaded, its integrity verified against the release's signed checksums, and swapped in atomically; the old binary is recoverable until success is confirmed. +5. **Given** the target binary location is not writable (e.g. root-owned), **When** the user runs `mcpproxy update`, **Then** the command fails with an explicit message naming the path and owner and suggesting options — it never escalates privileges itself. +6. **Given** the available version is the same or older than the running one, **When** the user runs `mcpproxy update`, **Then** the command reports "already up to date" and refuses to downgrade unless `--force` is passed. +7. **Given** any channel, **When** the user runs `mcpproxy update --check` (or `mcpproxy update` with no newer version), **Then** the command reports current/latest versions and the detected channel without side effects. + +--- + +### Edge Cases + +- Update clicked while the core is mid-request: managed core must be stopped gracefully (bounded wait) before the swap; in-flight tool calls fail visibly rather than hanging forever. +- Old core cannot be superseded because a different user/session owns it: surface the situation instead of fighting over the single-instance locks (config.db flock, TCP port). +- Network failure mid-download: no partial state; retry is safe; the running version is unaffected. +- Update feed unreachable: menu behaves as "no update available"; existing daily GitHub check remains the fallback surface; the two mechanisms must not double-nudge. +- Both per-arch artifacts exist: the correct architecture is selected on Apple Silicon and Intel. +- User declines the relaunch prompt: they can keep working on the old version; the prompt remains available, not nagging (respects existing nudge-suppression rules, e.g. CI environments). +- Legacy staged core copy (`~/Library/Application Support/mcpproxy/bin/mcpproxy` from the old Go tray) is stale: it must not shadow the new bundled core after an update. +- `mcpproxy update` run inside the app bundle context (binary resolved from `MCPProxy.app/Contents/...`): treated as DMG channel; never self-replaces in place. + +## Requirements *(mandatory)* + +### Functional Requirements + +**Stale-version supersede (P1 — fixes #957)** + +- **FR-001**: The tray MUST compare the running core's version with its bundled core's version at attach time and whenever a version report is received, and, for tray-managed cores that are older, stop and respawn the bundled core automatically. +- **FR-002**: For externally-attached cores that are older than the bundled core, the tray MUST surface a non-destructive restart action instead of killing the process. +- **FR-003**: The tray MUST detect that the on-disk app bundle version differs from the running app's version (drag-install upgrade) and offer a one-click relaunch that stops the managed core, launches the new bundle, and exits the old instance. +- **FR-004**: The macOS package installer's post-install step MUST quit any running MCPProxy instance (politely, with a bounded wait and forced fallback) before launching the newly installed version. +- **FR-005**: Supersede checks MUST be idempotent and silent when versions already match (no restart loops, no spurious prompts). A downgrade (running > bundled) MUST NOT trigger automatic supersede. + +**One-click updater (P2)** + +- **FR-010**: The menu MUST present available updates as a gentle, non-interrupting menu item of the form "Update X.Y.Z — ready to restart?"; activating it MUST complete download, verification, installed-app replacement, and relaunch without further user steps. +- **FR-011**: Updates MUST be verified with two independent mechanisms before installation: feed-level cryptographic signature (pinned public key shipped in the app) and OS code-signature validity of the replacement app. A failure of either MUST abort with no changes and an actionable error. +- **FR-012**: The updater MUST stop the tray-managed core gracefully before the app bundle is replaced, and the relaunched app MUST start the new core. The P1 supersede check MUST remain active permanently as the safety net for update paths the updater does not control (install-on-quit, manual installs). +- **FR-013**: The update feed MUST be generated automatically by the release pipeline for every stable release, hosted at a stable HTTPS URL, and offer per-architecture (or universal) artifacts that pass macOS Gatekeeper (notarized and stapled) and preserve the app's code-signature integrity (symlink-preserving archive). +- **FR-014**: Release-candidate builds MUST be offered only to users on the RC channel, consistent with the existing prerelease opt-in mechanism; stable users MUST never be offered RCs. +- **FR-015**: The updater MUST honor the existing update-check kill switches (config setting and environment variable) and the existing nudge-suppression rules (e.g. CI); when disabled, no feed checks or nudges occur. +- **FR-016**: When the app cannot be updated in place (translocated, read-only volume, insufficient permissions), the updater MUST tell the user why and offer a fallback path rather than failing silently. +- **FR-017**: The existing daily update check and the new feed-based updater MUST NOT produce duplicate nudges for the same version. +- **FR-018**: The Homebrew cask MUST be marked as self-updating so the package manager does not fight the built-in updater. + +**Channel-aware CLI (P3)** + +- **FR-020**: `mcpproxy update` MUST branch on the already-detected install channel: package-manager channels (Homebrew, deb, rpm, go-install) get the exact upgrade command printed; Docker and Windows-installer get guidance; DMG gets a pointer to the tray updater; tarball/unknown-with-writable-target get a real self-update. +- **FR-021**: CLI self-update MUST verify artifact integrity against the release's signed checksum manifest (signature verified offline against the project's release identity) before replacement, and MUST replace the binary atomically (write-new + rename, never in-place) with rollback on failure. +- **FR-022**: CLI self-update MUST refuse to install a version equal to or older than the running one unless `--force` is given, MUST never escalate privileges automatically, and MUST never modify files inside the installed app bundle or its staged copies. +- **FR-023**: `mcpproxy update` MUST support a check-only mode reporting current version, latest version, and detected channel, with no side effects, honoring the existing prerelease-channel selection. +- **FR-024**: After the one-click updater ships, the DMG channel's guidance text (status/doctor/Web UI surfaces) MUST direct users to the tray updater instead of "download the latest DMG". + +**Cleanup** + +- **FR-030**: The tray MUST ensure a stale legacy staged core copy cannot shadow the bundled core after an upgrade (refresh or remove it). + +### Key Entities + +- **Install channel**: the detected provenance of the running binary (dmg, homebrew, deb, rpm, docker, go-install, windows-installer, tarball, unknown); determines which update behavior applies. +- **Update feed (appcast)**: the machine-readable list of published versions, per-architecture artifacts, and their signatures that the in-app updater consumes; generated by the release pipeline; stable URL is permanent infrastructure. +- **Update artifact (enclosure)**: a notarized, stapled, symlink-preserving archive of the app bundle attached to each release, listed in the signed checksum manifest. +- **Version pair (running vs. available)**: running core version, running app version, on-disk bundle version, bundled core version — the comparisons that drive supersede and update decisions. +- **Core ownership**: whether the core process is tray-managed (safe to restart automatically) or externally attached (restart requires user consent). + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: After upgrading by any supported path (drag-install, PKG, one-click), the running app and core report the new version within 60 seconds — with at most one user click — in 100% of QA upgrade scenarios. +- **SC-002**: Zero scenarios remain in which an older core keeps serving requests indefinitely after an upgrade without a visible prompt (the #957 report class is eliminated). +- **SC-003**: A user on a DMG install can go from "update available" to "running the new version" via a single menu click, with no browser, Finder, or installer interaction. +- **SC-004**: A tampered or corrupt update artifact is never installed (verification failure aborts with no change) in 100% of tamper-test cases. +- **SC-005**: `mcpproxy update` never modifies a package-manager-owned install; on self-managed installs it updates atomically or leaves the previous binary working — no scenario ends with a broken binary. +- **SC-006**: Update checks and nudges respect the existing kill switches and CI suppression in 100% of cases; stable-channel users are never offered RC builds. +- **SC-007**: Support burden: new-release adoption requires no manual process-killing instructions in issues/support (no recurrence of #957-style reports for releases shipped after Phase 0). + +## Assumptions + +- The chosen approach is Option A from the decision report: complete the existing in-app update framework integration rather than building a custom downloader/swapper, with the P1 supersede fix shipping first and remaining permanently as the safety net. +- The update feed URL will be decided by the maintainer before Phase 1 ships (the app bundles already reference `https://mcpproxy.app/appcast.xml`, which argues for honoring that URL); until decided, Phase 1 CI work can proceed against a placeholder. +- One-click update is limited to tray-managed cores in v1; externally-attached cores get the non-destructive prompt (open decision #2 in the report). Graceful drain of in-flight requests beyond the existing bounded stop is out of scope for v1. +- Windows and Linux GUI one-click update are out of scope; Windows/Linux users are served by the existing installer/package channels and the P3 CLI where applicable. +- Signing-key material (feed private key) lives in CI secrets; key rotation procedures are documented but rotation tooling is out of scope. +- The release pipeline's existing signed checksum manifest (checksums.txt + cosign bundle) is the trust root for CLI self-update; no new registry or update server is introduced. + +## Commit Message Conventions *(mandatory)* + +When committing changes for this feature, follow these guidelines: + +### Issue References +- ✅ **Use**: `Related #957` - Links the commit to the issue without auto-closing +- ❌ **Do NOT use**: `Fixes #957`, `Closes #957`, `Resolves #957` - These auto-close issues on merge + +**Rationale**: Issues should only be closed manually after verification and testing in production, not automatically on merge. + +### Co-Authorship +- ❌ **Do NOT include**: `Co-Authored-By: Claude ` +- ❌ **Do NOT include**: "🤖 Generated with [Claude Code](https://claude.com/claude-code)" + +**Rationale**: Commit authorship should reflect the human contributors, not the AI tools used. + +### Example Commit Message +``` +feat(tray): supersede stale core after upgrade + +Related #957 + +Detect version mismatch between running core and bundled core on attach +and restart tray-managed cores into the bundled version. + +## Changes +- Version comparison at attach time and on version reports +- Non-destructive restart prompt for externally-attached cores + +## Testing +- Fixture-driven XCTest for the supersede state machine +- Manual old-DMG -> new-DMG upgrade QA +``` From 8bf3b21a8f89ff356a2d53cdea4ec9b80daaba3c Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Fri, 7 Aug 2026 20:53:01 +0300 Subject: [PATCH 02/37] =?UTF-8?q?spec(092):=20codex=20round=201=20?= =?UTF-8?q?=E2=80=94=20durable=20core=20ownership,=20unknown-channel=20gui?= =?UTF-8?q?dance-only,=20enclosure-signature=20wording,=20RC=20pipeline=20?= =?UTF-8?q?parity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Related #957 - FR-001a: launch provenance must survive tray restarts (today every pre-existing core reads as external) - FR-002: external-core restart = explicit user consent or instructions - FR-020: self-update only on positively identified tarball channel; unknown stays guidance-only (detector never returns tarball today — positive marker is a prerequisite) - FR-011: enclosure-level signature + bundle-identity codesign policy - FR-014: prerelease pipeline must gain feed/manifest parity for RC channel - FR-015: explicit hot-reloadable update policy contract governing all tray-side checks - FR-017: single-source-of-truth menu state machine (feed owns one-click; legacy check is browser-guidance only) - FR-021/022: defined confirmation point, backup/rollback, symlink/permission semantics; downgrade needs explicit version + --force - FR-006: SemVer 2.0 precedence (rc.10 > rc.2) - SC-001 scoped by ownership/acceptance; FR-030 premise corrected --- specs/092-auto-updater/spec.md | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/specs/092-auto-updater/spec.md b/specs/092-auto-updater/spec.md index 8184b2d2..fd57ebbe 100644 --- a/specs/092-auto-updater/spec.md +++ b/specs/092-auto-updater/spec.md @@ -20,8 +20,8 @@ A macOS user upgrades MCPProxy by any means — dragging a new DMG into Applicat **Acceptance Scenarios**: 1. **Given** an old-version tray and its managed core are running, **When** the user drag-installs a newer app bundle over the old one, **Then** the tray detects the on-disk version change and offers "MCPProxy was updated to vY — Relaunch", and accepting stops the old core and relaunches into the new version. -2. **Given** an old-version core is running (started by the tray), **When** a newer tray starts and attaches to it, **Then** the tray detects that the running core is older than its bundled core, stops it, and respawns the bundled (new) core automatically. -3. **Given** an old-version core is running that the tray did not start (externally managed, e.g. started from a terminal), **When** a newer tray attaches, **Then** the tray does NOT kill it but surfaces a clear "Old core vX still running — restart into vY" action in the menu. +2. **Given** an old-version core is running that identifies itself as tray-launched (durable launch provenance — regardless of whether the tray instance that started it still exists), **When** a newer tray starts and attaches to it, **Then** the tray detects that the running core is older than its bundled core, stops it, and respawns the bundled (new) core automatically. +3. **Given** an old-version core is running that identifies as user/externally launched (e.g. started from a terminal), **When** a newer tray attaches, **Then** the tray does NOT kill it automatically but surfaces a clear "Old core vX still running — restart into vY" action; activating it is explicit user consent to stop that core, and if no safe stop mechanism is available the tray presents instructions instead. 4. **Given** the old app is running, **When** the user runs the PKG installer for a newer version, **Then** installation completes with the old instance quit first and the new version launched — not the stale instance brought to the foreground. 5. **Given** versions already match, **When** the tray performs its checks, **Then** no restart prompt or process churn occurs. @@ -84,35 +84,37 @@ A CLI user runs `mcpproxy update`. The command knows how mcpproxy was installed **Stale-version supersede (P1 — fixes #957)** -- **FR-001**: The tray MUST compare the running core's version with its bundled core's version at attach time and whenever a version report is received, and, for tray-managed cores that are older, stop and respawn the bundled core automatically. -- **FR-002**: For externally-attached cores that are older than the bundled core, the tray MUST surface a non-destructive restart action instead of killing the process. +- **FR-001**: The tray MUST compare the running core's version with its bundled core's version at attach time and whenever a version report is received, and, for cores with tray launch provenance that are older, stop and respawn the bundled core automatically. +- **FR-001a**: Core launch provenance ("launched by a tray" vs. "launched by the user/other") MUST be durable and queryable across tray restarts — today ownership is only in-memory in the launching tray, and every pre-existing core is classified as external, which would defeat FR-001 for the exact tray-upgrade scenario this feature targets. The core already receives a launched-by marker at spawn; it MUST report it so any newer tray can recognize (and supersede) a core an older tray started. +- **FR-002**: For user/externally launched cores that are older than the bundled core, the tray MUST surface a restart action that requires explicit user activation (consent) rather than acting automatically. When no safe stop mechanism exists for that core, the action MUST present instructions instead of failing silently; the tray MUST NOT kill a user-launched process without that explicit activation. - **FR-003**: The tray MUST detect that the on-disk app bundle version differs from the running app's version (drag-install upgrade) and offer a one-click relaunch that stops the managed core, launches the new bundle, and exits the old instance. - **FR-004**: The macOS package installer's post-install step MUST quit any running MCPProxy instance (politely, with a bounded wait and forced fallback) before launching the newly installed version. - **FR-005**: Supersede checks MUST be idempotent and silent when versions already match (no restart loops, no spurious prompts). A downgrade (running > bundled) MUST NOT trigger automatic supersede. +- **FR-006**: All version comparisons driving supersede and update decisions MUST follow SemVer 2.0 precedence, including numeric prerelease identifiers (rc.10 > rc.2 — the existing tray comparison sorts these lexicographically and MUST NOT be reused as-is), tolerate a leading "v" and build metadata, and treat malformed or missing versions as "no supersede" with a logged reason. **One-click updater (P2)** - **FR-010**: The menu MUST present available updates as a gentle, non-interrupting menu item of the form "Update X.Y.Z — ready to restart?"; activating it MUST complete download, verification, installed-app replacement, and relaunch without further user steps. -- **FR-011**: Updates MUST be verified with two independent mechanisms before installation: feed-level cryptographic signature (pinned public key shipped in the app) and OS code-signature validity of the replacement app. A failure of either MUST abort with no changes and an actionable error. +- **FR-011**: Updates MUST be verified with two independent mechanisms before installation: a cryptographic signature on each downloaded update artifact (enclosure-level, verified against a public key pinned in the shipped app — the feed XML itself is not what carries the signature) and OS code-signature validation of the replacement app that checks it matches the expected bundle identity/signing identity, not merely "some valid signature". A failure of either MUST abort with no changes and an actionable error. - **FR-012**: The updater MUST stop the tray-managed core gracefully before the app bundle is replaced, and the relaunched app MUST start the new core. The P1 supersede check MUST remain active permanently as the safety net for update paths the updater does not control (install-on-quit, manual installs). - **FR-013**: The update feed MUST be generated automatically by the release pipeline for every stable release, hosted at a stable HTTPS URL, and offer per-architecture (or universal) artifacts that pass macOS Gatekeeper (notarized and stapled) and preserve the app's code-signature integrity (symlink-preserving archive). -- **FR-014**: Release-candidate builds MUST be offered only to users on the RC channel, consistent with the existing prerelease opt-in mechanism; stable users MUST never be offered RCs. -- **FR-015**: The updater MUST honor the existing update-check kill switches (config setting and environment variable) and the existing nudge-suppression rules (e.g. CI); when disabled, no feed checks or nudges occur. +- **FR-014**: Release-candidate builds MUST be offered only to users on the RC channel, consistent with the existing prerelease opt-in mechanism; stable users MUST never be offered RCs. Because RC builds are produced by a separate prerelease pipeline that today publishes neither update-feed entries nor signed checksum manifests, that pipeline MUST gain equivalent artifact/feed/manifest generation (channel-tagged entries in the feed, RC artifacts covered by signed checksums) — RC support is in scope for the release-infrastructure work, not an afterthought on the stable pipeline. +- **FR-015**: The updater MUST honor the existing update-check kill switches (config setting and environment variable) and the existing nudge-suppression rules (e.g. CI). The effective policy (updates enabled/disabled, selected channel, nudges suppressed) MUST be an explicit, hot-reloadable contract visible to the tray — not inferred from missing data — and MUST govern every tray-side check, including any independent periodic check the tray performs today. User-initiated "Check for Updates" remains available even when automatic checks are disabled. - **FR-016**: When the app cannot be updated in place (translocated, read-only volume, insufficient permissions), the updater MUST tell the user why and offer a fallback path rather than failing silently. -- **FR-017**: The existing daily update check and the new feed-based updater MUST NOT produce duplicate nudges for the same version. +- **FR-017**: Exactly one source of truth MUST own the update menu item at any time: when the feed-based updater is available, it owns the one-click item and the legacy release check MUST NOT surface a competing nudge for the same or lower version; when only the legacy check has a result (feed unreachable, or it advertises a version absent from the feed), the item MUST present as browser-download guidance, never as a one-click action it cannot perform. Equal versions from both sources deduplicate to a single item. - **FR-018**: The Homebrew cask MUST be marked as self-updating so the package manager does not fight the built-in updater. **Channel-aware CLI (P3)** -- **FR-020**: `mcpproxy update` MUST branch on the already-detected install channel: package-manager channels (Homebrew, deb, rpm, go-install) get the exact upgrade command printed; Docker and Windows-installer get guidance; DMG gets a pointer to the tray updater; tarball/unknown-with-writable-target get a real self-update. -- **FR-021**: CLI self-update MUST verify artifact integrity against the release's signed checksum manifest (signature verified offline against the project's release identity) before replacement, and MUST replace the binary atomically (write-new + rename, never in-place) with rollback on failure. -- **FR-022**: CLI self-update MUST refuse to install a version equal to or older than the running one unless `--force` is given, MUST never escalate privileges automatically, and MUST never modify files inside the installed app bundle or its staged copies. +- **FR-020**: `mcpproxy update` MUST branch on the already-detected install channel: package-manager channels (Homebrew, deb, rpm, go-install) get the exact upgrade command printed; Docker and Windows-installer get guidance; DMG gets a pointer to the tray updater. Self-update MUST require a **positively identified self-managed install** (tarball channel). The `unknown` channel MUST remain guidance-only — writability of the target does not establish ownership (ambiguous installs include AUR, MacPorts/Nix-like layouts, and manually installed packages), and a positive tarball marker MUST exist for self-update to ever activate (today the detector defines the tarball channel but never returns it, so positive identification — e.g. build-time channel stamping of tarball artifacts — is a prerequisite, not an assumption). An explicit `--self` style override MAY allow a user to assert self-managed ownership on `unknown`, with a clear warning. +- **FR-021**: CLI self-update MUST verify artifact integrity against the release's signed checksum manifest (signature verified offline against the project's release identity) before replacement, and MUST replace the binary atomically (write-new-in-target-directory + rename, never in-place). The previous binary MUST be retained until success is confirmed — success meaning the new binary executes and reports the expected version — and restored on failure; the spec's atomicity promise includes preserving file permissions/mode, following (not replacing) a symlinked target's destination, and defined behavior when a running core still executes from the old binary (it keeps running; the swap affects the next start). +- **FR-022**: CLI self-update MUST refuse to install a version equal to or older than the running one unless the user passes both an explicit target version option and `--force` (a bare "update to latest" has no downgrade to force), MUST never escalate privileges automatically, and MUST never modify files inside the installed app bundle or its staged copies. - **FR-023**: `mcpproxy update` MUST support a check-only mode reporting current version, latest version, and detected channel, with no side effects, honoring the existing prerelease-channel selection. - **FR-024**: After the one-click updater ships, the DMG channel's guidance text (status/doctor/Web UI surfaces) MUST direct users to the tray updater instead of "download the latest DMG". **Cleanup** -- **FR-030**: The tray MUST ensure a stale legacy staged core copy cannot shadow the bundled core after an upgrade (refresh or remove it). +- **FR-030**: The plan MUST identify which execution paths (if any) still resolve the legacy staged core copy ahead of the bundled core — the current tray prefers the bundled core, so the staged copy only matters for legacy-tray or non-bundle flows — and neutralize only those paths (refresh or remove the staged copy with clear ownership rules), never deleting a binary the user may manage themselves without that analysis. ### Key Entities @@ -126,7 +128,7 @@ A CLI user runs `mcpproxy update`. The command knows how mcpproxy was installed ### Measurable Outcomes -- **SC-001**: After upgrading by any supported path (drag-install, PKG, one-click), the running app and core report the new version within 60 seconds — with at most one user click — in 100% of QA upgrade scenarios. +- **SC-001**: After upgrading by any supported path (drag-install, PKG, one-click), for tray-launched cores and where the user accepts the offered relaunch: the running app and core report the new version within 60 seconds — with at most one user click — in 100% of QA upgrade scenarios. Where the user declines, the offer remains available without nagging; for externally launched cores, the consent action (or instructions) is present in 100% of scenarios. - **SC-002**: Zero scenarios remain in which an older core keeps serving requests indefinitely after an upgrade without a visible prompt (the #957 report class is eliminated). - **SC-003**: A user on a DMG install can go from "update available" to "running the new version" via a single menu click, with no browser, Finder, or installer interaction. - **SC-004**: A tampered or corrupt update artifact is never installed (verification failure aborts with no change) in 100% of tamper-test cases. From a91c8442a887cfd038c879a5a465d472e75c8e02 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Fri, 7 Aug 2026 20:55:11 +0300 Subject: [PATCH 03/37] =?UTF-8?q?spec(092):=20codex=20round=202=20?= =?UTF-8?q?=E2=80=94=20align=20P3=20test/scenario=20with=20FR-020/FR-022?= =?UTF-8?q?=20(unknown=20guidance-only;=20downgrade=20needs=20explicit=20v?= =?UTF-8?q?ersion=20+=20--force)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Related #957 --- specs/092-auto-updater/spec.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/specs/092-auto-updater/spec.md b/specs/092-auto-updater/spec.md index fd57ebbe..c17a6902 100644 --- a/specs/092-auto-updater/spec.md +++ b/specs/092-auto-updater/spec.md @@ -6,6 +6,8 @@ **Input**: User description: "One-click auto-updater for macOS + channel-aware mcpproxy update CLI (fixes #957). Phase 0 fixes the stale-process bug for every upgrade path (version-mismatch supersede in tray, bundle-replacement detection, postinstall quit-before-launch). Phase 1 finishes the half-plumbed Sparkle 2 integration (one-click download, verify, bundle swap, relaunch; EdDSA keys, CI appcast, notarized stapled .app zip enclosure). Phase 2 adds mcpproxy update CLI branching on existing install-channel detection (self-update only on tarball/unknown-writable channels, cosign-verified; package-manager guidance elsewhere; DMG delegates to tray). Decision report: docs/research/auto-updater-issue-957-2026-08-07.html" > Related: issue #957 ("old version App still after upgrade"). Decision analysis with option comparison and verification appendix: `docs/research/auto-updater-issue-957-2026-08-07.html` (Option A chosen). +> +> Note: the Input above is the verbatim original description. Where it says "self-update only on tarball/unknown-writable channels", FR-020 supersedes it: self-update requires a positively identified tarball install; `unknown` is guidance-only absent an explicit user override. ## User Scenarios & Testing *(mandatory)* @@ -53,7 +55,7 @@ A CLI user runs `mcpproxy update`. The command knows how mcpproxy was installed **Why this priority**: Completes the story for headless/CLI users; independent of the macOS GUI work and lower risk. Mirrors the behavior of best-in-class CLIs (uv, deno). -**Independent Test**: Run `mcpproxy update` on each channel fixture and verify: self-replace happens only on tarball/unknown-writable installs, with integrity verification; all other channels get correct guidance and a zero-side-effect exit. +**Independent Test**: Run `mcpproxy update` on each channel fixture and verify: self-replace happens only on positively identified tarball installs (or on `unknown` with the explicit self-managed override), with integrity verification; all other channels — including plain `unknown` — get correct guidance and a zero-side-effect exit. **Acceptance Scenarios**: @@ -62,7 +64,7 @@ A CLI user runs `mcpproxy update`. The command knows how mcpproxy was installed 3. **Given** a macOS DMG install, **When** the user runs `mcpproxy update`, **Then** the command directs the user to the tray's updater (and never modifies the app bundle or any staged copy of it). 4. **Given** a tarball install in a user-writable location, **When** the user runs `mcpproxy update`, **Then** the new binary is downloaded, its integrity verified against the release's signed checksums, and swapped in atomically; the old binary is recoverable until success is confirmed. 5. **Given** the target binary location is not writable (e.g. root-owned), **When** the user runs `mcpproxy update`, **Then** the command fails with an explicit message naming the path and owner and suggesting options — it never escalates privileges itself. -6. **Given** the available version is the same or older than the running one, **When** the user runs `mcpproxy update`, **Then** the command reports "already up to date" and refuses to downgrade unless `--force` is passed. +6. **Given** the available version is the same or older than the running one, **When** the user runs `mcpproxy update`, **Then** the command reports "already up to date"; a downgrade requires BOTH an explicit target-version option AND `--force` (per FR-022 — a bare "update to latest" has nothing to force). 7. **Given** any channel, **When** the user runs `mcpproxy update --check` (or `mcpproxy update` with no newer version), **Then** the command reports current/latest versions and the detected channel without side effects. --- From e186f5736e8bebbdc63fdcc793f7f5ff650debf3 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Sat, 8 Aug 2026 05:51:54 +0300 Subject: [PATCH 04/37] feat(core): report durable launch provenance in /api/v1/info MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Related #957 The tray already stamps MCPPROXY_LAUNCHED_BY=tray on every core it spawns (CoreProcessManager.swift, cmd/mcpproxy-tray), and the macOS PKG postinstall stamps installer — but the core never reported it back, so ownership lived only in the launching tray's memory. A newer tray attaching to a core started by an older tray classified it as external and could not supersede it: exactly the #957 upgrade scenario (FR-001a). ## Changes - internal/launch: capture MCPPROXY_LAUNCHED_BY once at process start and normalize it to "tray" / "installer" / "" (strict — an unrecognized marker never authorizes killing a process) - GET /api/v1/info always carries launched_by (contracts.InfoResponse, oas, generated frontend contracts, docs/api/rest-api.md) - mcpproxy status renders "Launched by:" when the core asserted a marker and stays quiet otherwise; JSON keeps it omitempty ## Testing - go test -race ./internal/launch/... ./internal/httpapi/... ./cmd/mcpproxy/... - golangci-lint v2 clean on the touched packages --- cmd/generate-types/main.go | 3 + cmd/mcpproxy/status_cmd.go | 15 +++++ cmd/mcpproxy/status_launched_by_test.go | 82 +++++++++++++++++++++++ docs/api/rest-api.md | 2 + frontend/src/types/contracts.ts | 3 + internal/contracts/types.go | 6 ++ internal/httpapi/info_launched_by_test.go | 66 ++++++++++++++++++ internal/httpapi/server.go | 15 +++++ internal/launch/provenance.go | 73 ++++++++++++++++++++ internal/launch/provenance_test.go | 71 ++++++++++++++++++++ oas/docs.go | 4 +- oas/swagger.yaml | 9 +++ 12 files changed, 347 insertions(+), 2 deletions(-) create mode 100644 cmd/mcpproxy/status_launched_by_test.go create mode 100644 internal/httpapi/info_launched_by_test.go create mode 100644 internal/launch/provenance.go create mode 100644 internal/launch/provenance_test.go diff --git a/cmd/generate-types/main.go b/cmd/generate-types/main.go index 8c78cd3c..65633fd0 100644 --- a/cmd/generate-types/main.go +++ b/cmd/generate-types/main.go @@ -446,6 +446,9 @@ export interface InfoResponse { socket: string; }; update?: UpdateInfo; + // Spec 092 FR-001a: durable launch provenance of the running core — + // "tray", "installer", or "" (user-launched / unknown). + launched_by: string; } `) diff --git a/cmd/mcpproxy/status_cmd.go b/cmd/mcpproxy/status_cmd.go index 07ea457d..75be2917 100644 --- a/cmd/mcpproxy/status_cmd.go +++ b/cmd/mcpproxy/status_cmd.go @@ -34,6 +34,7 @@ type StatusInfo struct { SocketPath string `json:"socket_path,omitempty"` ConfigPath string `json:"config_path,omitempty"` Version string `json:"version,omitempty"` + LaunchedBy string `json:"launched_by,omitempty"` // Spec 092 FR-001a; empty = user-launched/unknown or older daemon Update *StatusUpdateInfo `json:"update,omitempty"` ServerEditionInfo *ServerEditionStatusInfo `json:"server_edition,omitempty"` } @@ -224,6 +225,12 @@ func collectStatusFromDaemon(cfg *config.Config, client *cliclient.Client, socke if url, ok := infoData["web_ui_url"].(string); ok { info.WebUIURL = url } + // Spec 092 FR-001a: durable launch provenance of the running core. + // Older daemons omit the field entirely — rendered as absent, not as + // "user-launched", because we cannot tell the two apart. + if v, ok := infoData["launched_by"].(string); ok { + info.LaunchedBy = v + } info.Update = extractStatusUpdate(infoData) } @@ -482,6 +489,14 @@ func printStatusTable(info *StatusInfo) { fmt.Printf(" %-12s %s%s\n", "Version:", info.Version, statusVersionSuffix(info.Update)) } + // Spec 092 FR-001a. Only rendered when the core asserted a marker: an + // empty value means user-launched/unknown (or a pre-092 daemon), and + // printing "unknown" for the ordinary `mcpproxy serve` case would be + // noise on every status call. + if info.LaunchedBy != "" { + fmt.Printf(" %-12s %s\n", "Launched by:", info.LaunchedBy) + } + fmt.Printf(" %-12s %s\n", "Listen:", info.ListenAddr) if info.Uptime != "" { diff --git a/cmd/mcpproxy/status_launched_by_test.go b/cmd/mcpproxy/status_launched_by_test.go new file mode 100644 index 00000000..d5725301 --- /dev/null +++ b/cmd/mcpproxy/status_launched_by_test.go @@ -0,0 +1,82 @@ +package main + +import ( + "encoding/json" + "strings" + "testing" +) + +// Spec 092 FR-001a: `mcpproxy status` surfaces the running core's launch +// provenance so an operator (and support) can tell a tray-owned core from one +// they started themselves. +func TestStatusTableShowsLaunchedBy(t *testing.T) { + tests := []struct { + name string + launchedBy string + wantLine bool + }{ + {"tray-launched", "tray", true}, + {"installer-launched", "installer", true}, + // Empty means user-launched/unknown or a pre-092 daemon: printing + // "unknown" on every `mcpproxy serve` status call would be noise. + {"user-launched stays quiet", "", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + info := &StatusInfo{ + State: "Running", + Edition: "personal", + ListenAddr: "127.0.0.1:8080", + APIKey: "a1b2****a1b2", + WebUIURL: "http://127.0.0.1:8080/ui/", + Version: "v0.55.0", + LaunchedBy: tt.launchedBy, + } + + output := captureStdout(t, func() { printStatusTable(info) }) + + hasLine := strings.Contains(output, "Launched by:") + if hasLine != tt.wantLine { + t.Fatalf("Launched by line present = %v, want %v; output:\n%s", hasLine, tt.wantLine, output) + } + if tt.wantLine && !strings.Contains(output, tt.launchedBy) { + t.Errorf("expected provenance %q in output:\n%s", tt.launchedBy, output) + } + }) + } +} + +func TestStatusJSONLaunchedBy(t *testing.T) { + t.Run("present when the core asserted a marker", func(t *testing.T) { + info := &StatusInfo{State: "Running", LaunchedBy: "tray"} + out := captureStdout(t, func() { + if err := printStatusJSON(info); err != nil { + t.Fatalf("printStatusJSON: %v", err) + } + }) + var parsed map[string]interface{} + if err := json.Unmarshal([]byte(out), &parsed); err != nil { + t.Fatalf("invalid JSON: %v\n%s", err, out) + } + if parsed["launched_by"] != "tray" { + t.Errorf("launched_by = %v, want tray", parsed["launched_by"]) + } + }) + + t.Run("omitted for user-launched / older daemons", func(t *testing.T) { + info := &StatusInfo{State: "Running"} + out := captureStdout(t, func() { + if err := printStatusJSON(info); err != nil { + t.Fatalf("printStatusJSON: %v", err) + } + }) + var parsed map[string]interface{} + if err := json.Unmarshal([]byte(out), &parsed); err != nil { + t.Fatalf("invalid JSON: %v\n%s", err, out) + } + if _, ok := parsed["launched_by"]; ok { + t.Errorf("launched_by should be omitted when empty, got:\n%s", out) + } + }) +} diff --git a/docs/api/rest-api.md b/docs/api/rest-api.md index f229691f..beab93d5 100644 --- a/docs/api/rest-api.md +++ b/docs/api/rest-api.md @@ -892,6 +892,7 @@ Get application info, version, and update availability. "http": "127.0.0.1:8080", "socket": "/Users/user/.mcpproxy/mcpproxy.sock" }, + "launched_by": "tray", "update": { "available": true, "latest_version": "v1.3.0", @@ -914,6 +915,7 @@ Get application info, version, and update availability. | `listen_addr` | string | Server listen address | | `endpoints.http` | string | HTTP API endpoint address | | `endpoints.socket` | string | Unix socket path (empty if disabled) | +| `launched_by` | string | Durable launch provenance of the running core (Spec 092 FR-001a): `tray` when a tray spawned it, `installer` when the macOS PKG postinstall did, `""` when user-launched or unknown. Always present. A tray uses this to decide whether it may stop and respawn a stale core it did not itself start — an empty value means consent is required. | | `update` | object | Update information (may be null if not checked yet; omitted entirely when update checking is disabled via `update_check.enabled: false` or `MCPPROXY_DISABLE_AUTO_UPDATE=true`) | | `update.available` | boolean | Whether a newer version is available | | `update.latest_version` | string | Latest version available on GitHub | diff --git a/frontend/src/types/contracts.ts b/frontend/src/types/contracts.ts index 28a69fcb..686d4e7f 100644 --- a/frontend/src/types/contracts.ts +++ b/frontend/src/types/contracts.ts @@ -380,4 +380,7 @@ export interface InfoResponse { socket: string; }; update?: UpdateInfo; + // Spec 092 FR-001a: durable launch provenance of the running core — + // "tray", "installer", or "" (user-launched / unknown). + launched_by: string; } diff --git a/internal/contracts/types.go b/internal/contracts/types.go index 4f8eb7cb..1279b3e9 100644 --- a/internal/contracts/types.go +++ b/internal/contracts/types.go @@ -1166,4 +1166,10 @@ type InfoResponse struct { ListenAddr string `json:"listen_addr"` // Listen address (e.g., "127.0.0.1:8080") Endpoints InfoEndpoints `json:"endpoints"` // Available API endpoints Update *UpdateInfo `json:"update,omitempty"` // Update information (if available) + // LaunchedBy is the durable launch provenance of the running core (Spec + // 092 FR-001a): "tray" when a tray spawned it, "installer" when the macOS + // PKG postinstall did, "" when user-launched or unknown. Always present + // (possibly empty) so a tray can distinguish "old core, not mine" from + // "old core I may supersede". + LaunchedBy string `json:"launched_by"` } diff --git a/internal/httpapi/info_launched_by_test.go b/internal/httpapi/info_launched_by_test.go new file mode 100644 index 00000000..96b1fa08 --- /dev/null +++ b/internal/httpapi/info_launched_by_test.go @@ -0,0 +1,66 @@ +package httpapi + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap/zaptest" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/contracts" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/launch" +) + +// Spec 092 FR-001a: /api/v1/info reports durable launch provenance so a newer +// tray can tell "a tray started this core" (safe to supersede) from +// "user-launched" (consent required) — including for cores started by a tray +// instance that no longer exists. +func TestInfoEndpointReportsLaunchedBy(t *testing.T) { + tests := []struct { + name string + provided string + wantField string + }{ + {"tray-launched core", launch.ByTray, "tray"}, + {"installer-launched core", launch.ByInstaller, "installer"}, + {"user-launched core reports empty, not a guess", launch.ByUnknown, ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + prev := launchedByFn + launchedByFn = func() string { return tt.provided } + t.Cleanup(func() { launchedByFn = prev }) + + logger := zaptest.NewLogger(t).Sugar() + server := NewServer(&MockServerController{}, logger, nil) + + req := httptest.NewRequest("GET", "/api/v1/info", http.NoBody) + w := httptest.NewRecorder() + server.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + + var response contracts.APIResponse + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) + data, ok := response.Data.(map[string]interface{}) + require.True(t, ok, "response data should be a map") + + // The key is always present (even when empty) so consumers can + // distinguish a 092-aware core from an older one that omits it. + require.Contains(t, data, "launched_by", "info response must always carry launched_by") + assert.Equal(t, tt.wantField, data["launched_by"]) + }) + } +} + +// The default wiring reads the process-wide capture from internal/launch; a +// core started without the marker must report "" rather than inventing a +// provenance. +func TestInfoEndpointLaunchedByDefaultsToProcessCapture(t *testing.T) { + assert.Equal(t, launch.LaunchedBy(), launchedByFn(), + "launchedByFn must default to the internal/launch process capture") +} diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 5a5986af..36befaea 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -23,6 +23,7 @@ import ( "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" "github.com/smart-mcp-proxy/mcpproxy-go/internal/connect" "github.com/smart-mcp-proxy/mcpproxy-go/internal/contracts" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/launch" "github.com/smart-mcp-proxy/mcpproxy-go/internal/logs" "github.com/smart-mcp-proxy/mcpproxy-go/internal/management" "github.com/smart-mcp-proxy/mcpproxy-go/internal/oauth" @@ -1105,6 +1106,7 @@ func (s *Server) handleGetRouting(w http.ResponseWriter, _ *http.Request) { // @Description Get essential server metadata including version, web UI URL, endpoint addresses, and update availability // @Description This endpoint is designed for tray-core communication and version checking // @Description Use refresh=true query parameter to force an immediate update check against GitHub +// @Description The launched_by field reports durable launch provenance ("tray", "installer", or "" for user-launched/unknown) // @Tags status // @Produce json // @Param refresh query boolean false "Force immediate update check against GitHub" @@ -1147,6 +1149,13 @@ func (s *Server) handleGetInfo(w http.ResponseWriter, r *http.Request) { "http": listenAddr, "socket": getSocketPath(), // Returns socket path if enabled, empty otherwise }, + // Spec 092 FR-001a: durable launch provenance. A tray that attaches to + // an already-running core (possibly started by an *earlier* tray that + // no longer exists) reads this to decide whether it owns the process + // and may supersede it, instead of relying on in-memory ownership that + // dies with the launching tray. "" means user/unknown → consent + // required (FR-002). + "launched_by": launchedByFn(), } if versionInfo != nil { response["update"] = versionInfo.ToAPIResponse() @@ -1195,6 +1204,12 @@ func (s *Server) buildWebUIURLWithAPIKey(listenAddr string, r *http.Request) str // buildVersion is set during build using -ldflags var buildVersion = "development" +// launchedByFn resolves this process's launch provenance for /api/v1/info +// (Spec 092 FR-001a). Indirected through a variable so handler tests can +// exercise every provenance value without mutating the process-wide capture +// in internal/launch. +var launchedByFn = launch.LaunchedBy + // editionValue identifies the MCPProxy edition (personal or server). var editionValue = "personal" diff --git a/internal/launch/provenance.go b/internal/launch/provenance.go new file mode 100644 index 00000000..c194b911 --- /dev/null +++ b/internal/launch/provenance.go @@ -0,0 +1,73 @@ +// Package launch exposes the core process's durable launch provenance: which +// component started this mcpproxy core (Spec 092 FR-001a). +// +// Why a dedicated package rather than reusing internal/telemetry's +// LaunchSource: telemetry classifies for analytics and falls back to PPID/TTY +// heuristics (login_item, cli, unknown). FR-001a needs the *asserted* marker +// only — "a tray spawned me" / "the installer spawned me" — because the tray +// uses it to decide whether it may stop and respawn a core it did not itself +// start. A heuristic guess must never authorize killing someone else's +// process, so anything that is not an explicit marker reports "" (user or +// unknown provenance) and the tray must ask for consent instead (FR-002). +package launch + +import ( + "os" + "strings" + "sync" +) + +// EnvLaunchedBy is the environment variable both trays and the macOS +// installer stamp on the core they spawn: +// - native/macos/.../CoreProcessManager.swift → tray +// - cmd/mcpproxy-tray/main.go → tray +// - packaging/macos/postinstall.sh → installer +const EnvLaunchedBy = "MCPPROXY_LAUNCHED_BY" + +// Canonical provenance values. Anything else — including an unset, +// misspelled, or attacker-supplied value — normalizes to ByUnknown. +const ( + // ByTray: a tray process spawned this core and owns its lifecycle, so a + // newer tray may supersede it without asking (FR-001). + ByTray = "tray" + + // ByInstaller: the macOS PKG postinstall launched the app that spawned + // this core. + ByInstaller = "installer" + + // ByUnknown: user-launched (terminal, launchd unit, brew services, …) or + // unknowable. Serialized as the empty string so API consumers can treat + // "absent" and "not tray-owned" identically. + ByUnknown = "" +) + +// Classify normalizes a raw MCPPROXY_LAUNCHED_BY value. It is deliberately +// strict: only the two canonical markers are honored (case-insensitively, +// with surrounding whitespace trimmed, because `open --env` and shell +// wrappers are easy to get slightly wrong), everything else is ByUnknown. +func Classify(raw string) string { + switch strings.ToLower(strings.TrimSpace(raw)) { + case ByTray: + return ByTray + case ByInstaller: + return ByInstaller + default: + return ByUnknown + } +} + +var ( + captureOnce sync.Once + captured string +) + +// LaunchedBy returns this process's launch provenance, captured from the +// environment exactly once. Capturing once matters: the value must describe +// how the process was *started*, and later os.Setenv calls (config reload, +// child-process env shaping) must not be able to rewrite history. +func LaunchedBy() string { + captureOnce.Do(func() { + captured = Classify(os.Getenv(EnvLaunchedBy)) + }) + return captured +} diff --git a/internal/launch/provenance_test.go b/internal/launch/provenance_test.go new file mode 100644 index 00000000..42a8ee3b --- /dev/null +++ b/internal/launch/provenance_test.go @@ -0,0 +1,71 @@ +package launch + +import ( + "os" + "sync" + "testing" +) + +func TestClassify(t *testing.T) { + tests := []struct { + name string + raw string + want string + }{ + {"tray marker", "tray", ByTray}, + {"installer marker", "installer", ByInstaller}, + {"unset", "", ByUnknown}, + {"whitespace padded tray", " tray\n", ByTray}, + {"uppercase tray", "TRAY", ByTray}, + {"mixed case installer", "Installer", ByInstaller}, + {"unknown marker never guesses", "launchd", ByUnknown}, + {"near-miss is not honored", "tray-app", ByUnknown}, + {"whitespace only", " ", ByUnknown}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := Classify(tt.raw); got != tt.want { + t.Errorf("Classify(%q) = %q, want %q", tt.raw, got, tt.want) + } + }) + } +} + +func TestLaunchedBy_CapturesEnvOnce(t *testing.T) { + // Reset the process-wide capture so this test owns it; restore afterwards + // so a later test in the same binary still sees a clean once. + t.Cleanup(func() { + captureOnce = sync.Once{} + captured = "" + }) + captureOnce = sync.Once{} + captured = "" + + t.Setenv(EnvLaunchedBy, "tray") + if got := LaunchedBy(); got != ByTray { + t.Fatalf("LaunchedBy() = %q, want %q", got, ByTray) + } + + // A later mutation of the environment must not rewrite provenance: the + // value describes how the process started, not what the env says now. + if err := os.Setenv(EnvLaunchedBy, "installer"); err != nil { + t.Fatalf("Setenv: %v", err) + } + if got := LaunchedBy(); got != ByTray { + t.Errorf("LaunchedBy() after env mutation = %q, want the captured %q", got, ByTray) + } +} + +func TestLaunchedBy_UnsetIsUnknown(t *testing.T) { + t.Cleanup(func() { + captureOnce = sync.Once{} + captured = "" + }) + captureOnce = sync.Once{} + captured = "" + + t.Setenv(EnvLaunchedBy, "") + if got := LaunchedBy(); got != ByUnknown { + t.Errorf("LaunchedBy() = %q, want %q for an unset marker", got, ByUnknown) + } +} diff --git a/oas/docs.go b/oas/docs.go index 73701fc2..f4c77c32 100644 --- a/oas/docs.go +++ b/oas/docs.go @@ -6,10 +6,10 @@ import "github.com/swaggo/swag/v2" const docTemplate = `{ "schemes": {{ marshal .Schemes }}, - "components": {"schemas":{"config.Config":{"properties":{"activity_cleanup_interval_min":{"description":"Background cleanup interval in minutes (default: 60)","type":"integer"},"activity_max_records":{"description":"Max records before pruning (default: 100000)","type":"integer"},"activity_max_response_size":{"description":"Response truncation limit in bytes (default: 65536)","type":"integer"},"activity_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"},"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_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"},"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_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"},"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"},"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"},"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"},"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"},"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.","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"},"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\"","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"},"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"},"listen_addr":{"description":"Listen address (e.g., \"127.0.0.1:8080\")","type":"string"},"update":{"$ref":"#/components/schemas/contracts.UpdateInfo"},"version":{"description":"Current MCPProxy version","type":"string"},"web_ui_url":{"description":"URL to access the web control panel","type":"string"}},"type":"object"},"contracts.IsolationConfig":{"properties":{"cpu_limit":{"type":"string"},"enabled":{"type":"boolean"},"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.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"},"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"},"name":{"type":"string"},"oauth":{"$ref":"#/components/schemas/contracts.OAuthConfig"},"oauth_status":{"description":"OAuth status: \"authenticated\", \"expired\", \"error\", \"none\"","type":"string"},"protocol":{"type":"string"},"quarantine":{"$ref":"#/components/schemas/contracts.QuarantineStats"},"quarantined":{"type":"boolean"},"reconnect_count":{"type":"integer"},"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"},"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.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"},"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"},"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"},"name":{"type":"string"},"protocol":{"type":"string"},"quarantined":{"type":"boolean"},"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"},"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.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"},"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_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"},"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_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"},"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"},"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"},"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"},"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"},"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.","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"},"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\"","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"},"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"},"update":{"$ref":"#/components/schemas/contracts.UpdateInfo"},"version":{"description":"Current MCPProxy version","type":"string"},"web_ui_url":{"description":"URL to access the web control panel","type":"string"}},"type":"object"},"contracts.IsolationConfig":{"properties":{"cpu_limit":{"type":"string"},"enabled":{"type":"boolean"},"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.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"},"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"},"name":{"type":"string"},"oauth":{"$ref":"#/components/schemas/contracts.OAuthConfig"},"oauth_status":{"description":"OAuth status: \"authenticated\", \"expired\", \"error\", \"none\"","type":"string"},"protocol":{"type":"string"},"quarantine":{"$ref":"#/components/schemas/contracts.QuarantineStats"},"quarantined":{"type":"boolean"},"reconnect_count":{"type":"integer"},"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"},"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.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"},"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"},"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"},"name":{"type":"string"},"protocol":{"type":"string"},"quarantined":{"type":"boolean"},"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"},"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"],"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"],"type":"string"}},{"description":"Filter by intent operation type (Spec 018)","in":"query","name":"intent_type","schema":{"enum":["read","write","destructive"],"type":"string"}},{"description":"Filter by HTTP request ID for log correlation (Spec 021)","in":"query","name":"request_id","schema":{"type":"string"}},{"description":"Include successful call_tool_* internal tool calls (default: false, excluded to avoid duplicates)","in":"query","name":"include_call_tool","schema":{"type":"boolean"}},{"description":"Filter by sensitive data detection (true=has detections, false=no detections)","in":"query","name":"sensitive_data","schema":{"type":"boolean"}},{"description":"Filter by specific detection type (e.g., 'aws_access_key', 'credit_card')","in":"query","name":"detection_type","schema":{"type":"string"}},{"description":"Filter by severity level","in":"query","name":"severity","schema":{"enum":["critical","high","medium","low"],"type":"string"}},{"description":"Filter by agent token name (Spec 028)","in":"query","name":"agent","schema":{"type":"string"}},{"description":"Filter by auth type (Spec 028)","in":"query","name":"auth_type","schema":{"enum":["admin","agent"],"type":"string"}},{"description":"Filter activities after this time (RFC3339)","in":"query","name":"start_time","schema":{"type":"string"}},{"description":"Filter activities before this time (RFC3339)","in":"query","name":"end_time","schema":{"type":"string"}},{"description":"Maximum records to return (1-100, default 50)","in":"query","name":"limit","schema":{"type":"integer"}},{"description":"Pagination offset (default 0)","in":"query","name":"offset","schema":{"type":"integer"}},{"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 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"],"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/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","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/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/tool-calls":{"get":{"description":"Retrieves paginated tool call history across all upstream servers or filtered by session ID. Includes execution timestamps, arguments, results, and error information for debugging and auditing.","parameters":[{"description":"Maximum number of records to return (1-100, default 50)","in":"query","name":"limit","schema":{"type":"integer"}},{"description":"Number of records to skip for pagination (default 0)","in":"query","name":"offset","schema":{"type":"integer"}},{"description":"Filter tool calls by MCP session ID","in":"query","name":"session_id","schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetToolCallsResponse"}}},"description":"Tool calls retrieved successfully"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to get tool calls"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get tool call history","tags":["tool-calls"]}},"/api/v1/tool-calls/{id}":{"get":{"description":"Retrieves detailed information about a specific tool call execution including full request arguments, response data, execution time, and any errors encountered.","parameters":[{"description":"Tool call ID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetToolCallDetailResponse"}}},"description":"Tool call details retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Tool call ID required"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Tool call not found"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get tool call details by ID","tags":["tool-calls"]}},"/api/v1/tool-calls/{id}/replay":{"post":{"description":"Re-executes a previous tool call with optional modified arguments. Useful for debugging and testing tool behavior with different inputs. Creates a new tool call record linked to the original.","parameters":[{"description":"Original tool call ID to replay","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ReplayToolCallRequest"}}},"description":"Optional modified arguments for replay"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ReplayToolCallResponse"}}},"description":"Tool call replayed successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Tool call ID required or invalid JSON payload"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to replay tool call"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Replay a tool call","tags":["tool-calls"]}},"/api/v1/tools":{"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)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error or tool execution failure"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Call a tool","tags":["tools"]}},"/healthz":{"get":{"description":"Get comprehensive health status including all component health (Kubernetes-compatible liveness probe)","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/observability.HealthResponse"}}},"description":"Service is healthy"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/observability.HealthResponse"}}},"description":"Service is unhealthy"}},"summary":"Get health status","tags":["health"]}},"/readyz":{"get":{"description":"Get readiness status including all component readiness checks (Kubernetes-compatible readiness probe)","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/observability.ReadinessResponse"}}},"description":"Service is ready"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/observability.ReadinessResponse"}}},"description":"Service is not ready"}},"summary":"Get readiness status","tags":["health"]}}}, + "paths": {"/api/v1/activity":{"get":{"description":"Returns paginated list of activity records with optional filtering","parameters":[{"description":"Filter by activity type(s), comma-separated for multiple (Spec 024)","in":"query","name":"type","schema":{"enum":["tool_call","policy_decision","quarantine_change","server_change","system_start","system_stop","internal_tool_call","config_change"],"type":"string"}},{"description":"Filter by server name","in":"query","name":"server","schema":{"type":"string"}},{"description":"Filter by tool name","in":"query","name":"tool","schema":{"type":"string"}},{"description":"Filter by MCP 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"],"type":"string"}},{"description":"Filter by intent operation type (Spec 018)","in":"query","name":"intent_type","schema":{"enum":["read","write","destructive"],"type":"string"}},{"description":"Filter by HTTP request ID for log correlation (Spec 021)","in":"query","name":"request_id","schema":{"type":"string"}},{"description":"Include successful call_tool_* internal tool calls (default: false, excluded to avoid duplicates)","in":"query","name":"include_call_tool","schema":{"type":"boolean"}},{"description":"Filter by sensitive data detection (true=has detections, false=no detections)","in":"query","name":"sensitive_data","schema":{"type":"boolean"}},{"description":"Filter by specific detection type (e.g., 'aws_access_key', 'credit_card')","in":"query","name":"detection_type","schema":{"type":"string"}},{"description":"Filter by severity level","in":"query","name":"severity","schema":{"enum":["critical","high","medium","low"],"type":"string"}},{"description":"Filter by agent token name (Spec 028)","in":"query","name":"agent","schema":{"type":"string"}},{"description":"Filter by auth type (Spec 028)","in":"query","name":"auth_type","schema":{"enum":["admin","agent"],"type":"string"}},{"description":"Filter activities after this time (RFC3339)","in":"query","name":"start_time","schema":{"type":"string"}},{"description":"Filter activities before this time (RFC3339)","in":"query","name":"end_time","schema":{"type":"string"}},{"description":"Maximum records to return (1-100, default 50)","in":"query","name":"limit","schema":{"type":"integer"}},{"description":"Pagination offset (default 0)","in":"query","name":"offset","schema":{"type":"integer"}},{"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 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"],"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/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/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/tool-calls":{"get":{"description":"Retrieves paginated tool call history across all upstream servers or filtered by session ID. Includes execution timestamps, arguments, results, and error information for debugging and auditing.","parameters":[{"description":"Maximum number of records to return (1-100, default 50)","in":"query","name":"limit","schema":{"type":"integer"}},{"description":"Number of records to skip for pagination (default 0)","in":"query","name":"offset","schema":{"type":"integer"}},{"description":"Filter tool calls by MCP session ID","in":"query","name":"session_id","schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetToolCallsResponse"}}},"description":"Tool calls retrieved successfully"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to get tool calls"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get tool call history","tags":["tool-calls"]}},"/api/v1/tool-calls/{id}":{"get":{"description":"Retrieves detailed information about a specific tool call execution including full request arguments, response data, execution time, and any errors encountered.","parameters":[{"description":"Tool call ID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetToolCallDetailResponse"}}},"description":"Tool call details retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Tool call ID required"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Tool call not found"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get tool call details by ID","tags":["tool-calls"]}},"/api/v1/tool-calls/{id}/replay":{"post":{"description":"Re-executes a previous tool call with optional modified arguments. Useful for debugging and testing tool behavior with different inputs. Creates a new tool call record linked to the original.","parameters":[{"description":"Original tool call ID to replay","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ReplayToolCallRequest"}}},"description":"Optional modified arguments for replay"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ReplayToolCallResponse"}}},"description":"Tool call replayed successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Tool call ID required or invalid JSON payload"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to replay tool call"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Replay a tool call","tags":["tool-calls"]}},"/api/v1/tools":{"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)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error or tool execution failure"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Call a tool","tags":["tools"]}},"/healthz":{"get":{"description":"Get comprehensive health status including all component health (Kubernetes-compatible liveness probe)","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/observability.HealthResponse"}}},"description":"Service is healthy"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/observability.HealthResponse"}}},"description":"Service is unhealthy"}},"summary":"Get health status","tags":["health"]}},"/readyz":{"get":{"description":"Get readiness status including all component readiness checks (Kubernetes-compatible readiness probe)","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/observability.ReadinessResponse"}}},"description":"Service is ready"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/observability.ReadinessResponse"}}},"description":"Service is not ready"}},"summary":"Get readiness status","tags":["health"]}}}, "openapi": "3.1.0" }` diff --git a/oas/swagger.yaml b/oas/swagger.yaml index c0300059..c6632dc3 100644 --- a/oas/swagger.yaml +++ b/oas/swagger.yaml @@ -1665,6 +1665,14 @@ components: properties: endpoints: $ref: '#/components/schemas/contracts.InfoEndpoints' + launched_by: + description: |- + LaunchedBy is the durable launch provenance of the running core (Spec + 092 FR-001a): "tray" when a tray spawned it, "installer" when the macOS + PKG postinstall did, "" when user-launched or unknown. Always present + (possibly empty) so a tray can distinguish "old core, not mine" from + "old core I may supersede". + type: string listen_addr: description: Listen address (e.g., "127.0.0.1:8080") type: string @@ -4253,6 +4261,7 @@ paths: Get essential server metadata including version, web UI URL, endpoint addresses, and update availability This endpoint is designed for tray-core communication and version checking Use refresh=true query parameter to force an immediate update check against GitHub + The launched_by field reports durable launch provenance ("tray", "installer", or "" for user-launched/unknown) parameters: - description: Force immediate update check against GitHub in: query From 4b466bd10e63f00289fad3a16753683bfbf3007d Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Sat, 8 Aug 2026 05:57:29 +0300 Subject: [PATCH 05/37] feat(updatecheck): make the tarball install channel positively detectable Related #957 ChannelTarball existed since Spec 079 but detect() could never return it: the release matrix stamped no build marker and no heuristic produces "tarball". A positively identified tarball install is the precondition for `mcpproxy update` to ever self-replace a binary (FR-020), so without it the CLI self-update path is unreachable by construction. ## Changes - release.yml builds a SEPARATE core binary for the .tar.gz/.zip archive, stamped with updatecheck.buildChannel=tarball, signed alongside the others on macOS, and verified post-build via `go version -m`. The shared matrix binary stays unstamped so the DMG/PKG bundle and the .deb/.rpm packages keep detecting their own channel. - The tarball marker is deliberately WEAK in detect(): Homebrew installs the very same .tar.gz into its Cellar, so positive heuristics (brew prefix, container, package-manager path, .app bundle) override it and it only applies where detection would otherwise answer "unknown". Every other marker keeps absolute precedence. - UpdateCommand(tarball) / PrereleaseUpdateCommand(tarball) => "mcpproxy update" (FR-024 for the tarball channel only; DMG guidance is unchanged until the tray updater ships). - prerelease.yml documents why RC archives stay unstamped for now: self-update needs checksums.txt + cosign bundle, which that pipeline does not publish yet (FR-014). - docs/features/version-updates.md: weak-marker rule and the updated command matrix. ## Testing - go test -race ./internal/updatecheck/... (incl. a precedence table proving a stamped binary under a brew prefix / in a container / in an .app bundle / under dpkg ownership never resolves to tarball) - golangci-lint v2 clean; both workflows re-parsed as YAML --- .github/workflows/prerelease.yml | 8 + .github/workflows/release.yml | 111 ++++++++++-- docs/features/version-updates.md | 34 +++- internal/updatecheck/channel.go | 45 ++++- internal/updatecheck/channel_tarball_test.go | 177 +++++++++++++++++++ internal/updatecheck/guidance.go | 20 ++- internal/updatecheck/guidance_test.go | 23 ++- 7 files changed, 379 insertions(+), 39 deletions(-) create mode 100644 internal/updatecheck/channel_tarball_test.go diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml index 19b7c119..f1eb12c0 100644 --- a/.github/workflows/prerelease.yml +++ b/.github/workflows/prerelease.yml @@ -217,6 +217,14 @@ jobs: fi # Create clean core binary for archive + # + # NOTE (Spec 092 FR-014/FR-020): unlike release.yml, the RC archive is + # deliberately NOT stamped with updatecheck.buildChannel=tarball yet. + # A tarball-stamped binary makes `mcpproxy update` self-update, which + # requires the release to carry checksums.txt + its cosign bundle — + # artifacts this prerelease pipeline does not publish today. Stamp this + # build in the same change that adds signed checksum manifests here, so + # RC tarball users never get a self-update path that cannot verify. go build -ldflags "${LDFLAGS}" -o ${CLEAN_BINARY} ./cmd/mcpproxy # Build tray binary for macOS diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f35c8670..fc215c48 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -392,12 +392,13 @@ jobs: CGO_LDFLAGS: "-mmacosx-version-min=13.0" run: | VERSION=${GITHUB_REF#refs/tags/} - # NOTE (Spec 079): the updatecheck.buildChannel install-channel marker is - # intentionally NOT stamped here — this one matrix binary feeds the tarball, - # Homebrew, and DMG artifacts, so any single channel value would be wrong for - # some consumers. Those installs rely on runtime heuristics - # (internal/updatecheck/channel.go); only single-channel pipelines stamp it - # (Dockerfile -> docker, scripts/build-windows-installer.ps1 -> windows-installer). + # NOTE (Spec 079/092): the shared matrix binary stays UNSTAMPED. It feeds + # the DMG/PKG app bundle and the .deb/.rpm packages, which must keep + # detecting their own channel via runtime heuristics + # (internal/updatecheck/channel.go). Single-channel pipelines stamp their + # own marker: Dockerfile -> docker, + # scripts/build-windows-installer.ps1 -> windows-installer, and the + # archive build below -> tarball. LDFLAGS="-s -w -X main.version=${VERSION} -X github.com/smart-mcp-proxy/mcpproxy-go/internal/httpapi.buildVersion=${VERSION}" # Determine clean binary name and build flags @@ -412,9 +413,28 @@ jobs: CLEAN_BINARY="mcpproxy" fi - # Create clean core binary for archive + # Create clean core binary (app bundle, .deb/.rpm, installers) go build ${BUILD_TAGS} -ldflags "${LDFLAGS}" -o ${CLEAN_BINARY} ./cmd/mcpproxy + # Spec 092 FR-020: build a SEPARATE core binary for the .tar.gz/.zip + # archive, stamped with the tarball install-channel marker. Only this + # copy is stamped — a positively identified tarball install is the one + # channel `mcpproxy update` is allowed to self-replace, and the DMG / + # deb / rpm consumers of ${CLEAN_BINARY} must never inherit that. + # + # Homebrew is the one consumer that still shares this artifact (the tap + # formula downloads the very same .tar.gz). That is safe because the + # tarball marker is deliberately WEAK: detect() runs its positive + # heuristics first, so a binary living under a Homebrew prefix/Cellar + # resolves to homebrew regardless of the stamp. See + # internal/updatecheck/channel.go and its channel_tarball_test.go. + TARBALL_STAGE="tarball-stage" + rm -rf "${TARBALL_STAGE}" + mkdir -p "${TARBALL_STAGE}" + go build ${BUILD_TAGS} \ + -ldflags "${LDFLAGS} -X github.com/smart-mcp-proxy/mcpproxy-go/internal/updatecheck.buildChannel=tarball" \ + -o "${TARBALL_STAGE}/${CLEAN_BINARY}" ./cmd/mcpproxy + # Build tray binary for platforms with GUI support (macOS and Windows, personal only) if [ "$EDITION" != "server" ] && { [ "${{ matrix.goos }}" = "darwin" ] || [ "${{ matrix.goos }}" = "windows" ]; }; then echo "Building mcpproxy-tray for ${{ matrix.goos }}..." @@ -526,6 +546,45 @@ jobs: exit 1 fi + # Sign the tarball-stamped core binary (Spec 092 FR-020). It is a + # separate build product from ${CLEAN_BINARY}, so it needs its own + # signature — an unsigned binary in the .tar.gz would be a Gatekeeper + # regression for tarball users. + echo "Signing tarball-stamped core binary: ${TARBALL_STAGE}/${CLEAN_BINARY}" + TARBALL_SIGN_SUCCESS=false + for attempt in 1 2 3; do + echo "Tarball core signing attempt $attempt/3..." + if $TIMEOUT_CMD 300 codesign --force \ + --options runtime \ + --entitlements scripts/entitlements.plist \ + --sign "${CERT_IDENTITY}" \ + --timestamp \ + "${TARBALL_STAGE}/${CLEAN_BINARY}"; then + + TARBALL_SIGN_SUCCESS=true + echo "✅ Tarball core signing succeeded on attempt $attempt" + break + else + echo "❌ Tarball core signing attempt $attempt failed or timed out" + if [ $attempt -lt 3 ]; then + echo "Retrying in 10 seconds..." + sleep 10 + fi + fi + done + + if [ "$TARBALL_SIGN_SUCCESS" != "true" ]; then + echo "❌ All tarball core signing attempts failed" + exit 1 + fi + + if codesign -vvv --deep --strict "${TARBALL_STAGE}/${CLEAN_BINARY}"; then + echo "✅ Tarball core strict verification PASSED" + else + echo "❌ Tarball core strict verification FAILED" + exit 1 + fi + # Sign tray binary echo "Signing tray binary: mcpproxy-tray" TRAY_SIGN_SUCCESS=false @@ -623,37 +682,57 @@ jobs: ARCHIVE_BASE="mcpproxy-${VERSION#v}-${{ matrix.goos }}-${{ matrix.goarch }}" LATEST_ARCHIVE_BASE="mcpproxy-latest-${{ matrix.goos }}-${{ matrix.goarch }}" - # Determine files to include in archive + # Archives ship the tarball-stamped core from ${TARBALL_STAGE} (Spec 092 + # FR-020); everything downstream (DMG/PKG, .deb/.rpm, installers) keeps + # using the unstamped ${CLEAN_BINARY} in the working directory. + # Archive members must stay at the archive root, so the tray binary is + # copied into the stage directory and archives are created from there. FILES_TO_ARCHIVE="${CLEAN_BINARY}" # Add tray binary if it exists (Windows and macOS) if [ "${{ matrix.goos }}" = "windows" ] && [ -f "mcpproxy-tray.exe" ]; then + cp mcpproxy-tray.exe "${TARBALL_STAGE}/" FILES_TO_ARCHIVE="${FILES_TO_ARCHIVE} mcpproxy-tray.exe" echo "Including mcpproxy-tray.exe in archive" elif [ "${{ matrix.goos }}" = "darwin" ] && [ -f "mcpproxy-tray" ]; then + cp mcpproxy-tray "${TARBALL_STAGE}/" FILES_TO_ARCHIVE="${FILES_TO_ARCHIVE} mcpproxy-tray" echo "Including mcpproxy-tray in archive" fi + ARCHIVE_OUT="$(pwd)" + if [ "${{ matrix.archive_format }}" = "zip" ]; then # Create ZIP archive (Windows) # Use PowerShell Compress-Archive on Windows since zip command isn't available if [ "${{ matrix.goos }}" = "windows" ]; then - # Convert space-separated list to comma-separated for PowerShell - PS_FILES=$(echo ${FILES_TO_ARCHIVE} | sed 's/ /,/g') + # Convert space-separated list to comma-separated for PowerShell. + # Compress-Archive stores each file at the archive root regardless + # of the source directory, so the staged paths are safe here. + PS_FILES=$(echo ${FILES_TO_ARCHIVE} | sed "s#[^ ]*#${TARBALL_STAGE}/&#g" | sed 's/ /,/g') powershell -Command "Compress-Archive -Path ${PS_FILES} -DestinationPath '${ARCHIVE_BASE}.zip'" powershell -Command "Compress-Archive -Path ${PS_FILES} -DestinationPath '${LATEST_ARCHIVE_BASE}.zip'" else - # Create versioned archive - zip "${ARCHIVE_BASE}.zip" ${FILES_TO_ARCHIVE} - # Create latest archive - zip "${LATEST_ARCHIVE_BASE}.zip" ${FILES_TO_ARCHIVE} + (cd "${TARBALL_STAGE}" && zip "${ARCHIVE_OUT}/${ARCHIVE_BASE}.zip" ${FILES_TO_ARCHIVE}) + (cd "${TARBALL_STAGE}" && zip "${ARCHIVE_OUT}/${LATEST_ARCHIVE_BASE}.zip" ${FILES_TO_ARCHIVE}) fi else # Create versioned archive - tar -czf "${ARCHIVE_BASE}.tar.gz" ${FILES_TO_ARCHIVE} + tar -czf "${ARCHIVE_BASE}.tar.gz" -C "${TARBALL_STAGE}" ${FILES_TO_ARCHIVE} # Create latest archive - tar -czf "${LATEST_ARCHIVE_BASE}.tar.gz" ${FILES_TO_ARCHIVE} + tar -czf "${LATEST_ARCHIVE_BASE}.tar.gz" -C "${TARBALL_STAGE}" ${FILES_TO_ARCHIVE} + fi + + # Fail loudly if the stamp did not land. A silently unstamped archive + # would leave every tarball install on the `unknown` channel and + # `mcpproxy update` permanently guidance-only — the exact failure + # FR-020 exists to prevent. `go version -m` reads the recorded build + # settings, so this works for cross-compiled targets too. + if go version -m "${TARBALL_STAGE}/${CLEAN_BINARY}" | grep -q "updatecheck.buildChannel=tarball"; then + echo "✅ Archive core carries the tarball install-channel marker" + else + echo "❌ Archive core is missing the tarball install-channel marker" + exit 1 fi - name: Build Linux .deb and .rpm packages diff --git a/docs/features/version-updates.md b/docs/features/version-updates.md index ba7fb87b..f9f2002f 100644 --- a/docs/features/version-updates.md +++ b/docs/features/version-updates.md @@ -171,10 +171,10 @@ guided command in `mcpproxy status`, `mcpproxy doctor`, and the Web UI banner). Detection prefers a **build-time channel marker** stamped into -single-channel artifacts at packaging time (the Docker image and the Windows -installer). When no marker is present — the release archives feed the -tarball, Homebrew, and DMG channels from one binary — runtime heuristics run -in decreasing confidence order: +single-channel artifacts at packaging time (the Docker image, the Windows +installer, and — since Spec 092 — the release `.tar.gz`/`.zip` archives). +When no marker is present, runtime heuristics run in decreasing confidence +order: 1. **Homebrew**: the (symlink-resolved) executable path lives under a Homebrew prefix (`/opt/homebrew/`, a `Cellar/` path, or @@ -208,6 +208,22 @@ in decreasing confidence order: Ambiguity always resolves to `unknown`: MCPProxy never guesses a channel, because a wrong update command is worse than a generic instruction. +### Why the `tarball` marker is weak + +The `tarball` marker is the one exception to "the marker wins". Homebrew +installs the *same* release `.tar.gz` into its Cellar, so a stamped binary can +legitimately end up in a Homebrew (or, after a future packaging change, some +other) install. The detector therefore runs heuristics 1–5 first and only +applies a `tarball` marker when none of them matched — that is, when the +binary is an official release archive extracted somewhere no package manager +owns. Every other marker (docker, windows-installer, …) still short-circuits +detection. + +This is what makes `tarball` a *positive* identification: `mcpproxy update` +self-replaces the binary only on this channel (Spec 092 FR-020). A plain +`unknown` install is never self-updated, no matter how writable it is — +writability is not ownership. + ### Update Commands per Channel | Channel | `update_command` | Guidance shown instead | @@ -219,7 +235,8 @@ because a wrong update command is worse than a generic instruction. | `dmg` | — | Download the latest DMG (release page is deep-linked) | | `windows-installer` | — | Download the latest Windows installer | | `docker` | — | Pull or rebuild the newer image for your deployment | -| `tarball` / `unknown` | — | Download the latest release from the releases page | +| `tarball` | `mcpproxy update` | — | +| `unknown` | — | Download the latest release from the releases page | Every surface always deep-links the release notes for the latest version, whether or not a command is available. @@ -229,9 +246,10 @@ whether or not a command is available. published only to the GitHub pre-release channel, so the package-manager commands above would not deliver them (`brew`/`apt`/`dnf` serve stable artifacts, and Go's `@latest` resolves to the newest stable). When the offered -version is a prerelease, only `go-install` gets a command — pinned to the -exact version (`…/cmd/mcpproxy@v0.48.0-rc.1`) — and every other channel falls -back to the release-page guidance. +version is a prerelease, only `go-install` (pinned to the exact version, +`…/cmd/mcpproxy@v0.48.0-rc.1`) and `tarball` (`mcpproxy update`, which resolves +releases through the same prerelease selection) keep a command — every other +channel falls back to the release-page guidance. ## Updating MCPProxy diff --git a/internal/updatecheck/channel.go b/internal/updatecheck/channel.go index a3925bf4..12b4beb4 100644 --- a/internal/updatecheck/channel.go +++ b/internal/updatecheck/channel.go @@ -23,10 +23,19 @@ import ( // would build fine and stamp nothing (this exact bug shipped as a P1 with the // httpapi.buildVersion stamp in the v0.47.0 rc builds). // -// The release.yml/prerelease.yml matrix builds intentionally do NOT stamp it: -// one binary there feeds the tarball, Homebrew, and DMG artifacts, so any -// single value would be wrong for some consumers; those installs rely on the -// runtime heuristics below. +// Marker strength (Spec 092 FR-020). Every marker except "tarball" is +// absolute: docker and windows-installer identify a single-purpose artifact +// that cannot be redistributed through another channel. "tarball" is +// deliberately WEAK — the release pipeline's .tar.gz/.zip archive is also the +// substrate Homebrew extracts into its Cellar — so a positive runtime +// heuristic (Homebrew prefix, container, package-manager path, .app bundle) +// overrides it and it only applies where the detector would otherwise answer +// "unknown". See detect(). +// +// Before Spec 092 the release matrix stamped nothing at all, which left +// ChannelTarball defined but unreachable; FR-020 requires a positively +// identified tarball install before `mcpproxy update` may ever self-replace a +// binary, so release.yml now stamps the archive build (and only that build). var buildChannel = "" // Install channel identifiers (Spec 079 FR-008 / key entity "Install channel"). @@ -127,12 +136,22 @@ func DetectChannel(ldflagsVersion string) string { } func (d *channelDetector) detect() string { - // (0) Build-time marker wins over every heuristic (FR-008). + // (0) Build-time marker wins over every heuristic (FR-008) — with one + // deliberate exception. An unrecognized marker never guesses. if d.marker != "" { - if knownChannels[d.marker] { + if !knownChannels[d.marker] { + return ChannelUnknown + } + // Spec 092 FR-020: the tarball marker is a weak "this is an official + // release archive" signal, not proof of how the binary was installed. + // Homebrew installs the very same .tar.gz into its Cellar, and a + // future pipeline change could reuse the archive binary for another + // package. Fall through so any positive heuristic below wins; the + // marker is re-applied at (6) only when nothing else matched, which + // is precisely "extracted archive, not owned by a package manager". + if d.marker != ChannelTarball { return d.marker } - return ChannelUnknown } path := d.resolvedExecPath() @@ -212,7 +231,17 @@ func (d *channelDetector) detect() string { return ChannelGoInstall } - // (6) No reliable signal — generic guidance only. + // (6) Nothing else matched. A tarball-stamped binary here is an official + // release archive extracted to a location no package manager owns — the + // positive identification FR-020 requires before `mcpproxy update` may + // self-replace it. Note the linux /usr/bin/mcpproxy branch above is + // terminal on purpose: a stamped binary sitting in a package manager's + // directory degrades to unknown (guidance only) rather than reaching here. + if d.marker == ChannelTarball { + return ChannelTarball + } + + // (7) No reliable signal — generic guidance only. return ChannelUnknown } diff --git a/internal/updatecheck/channel_tarball_test.go b/internal/updatecheck/channel_tarball_test.go new file mode 100644 index 00000000..4073b961 --- /dev/null +++ b/internal/updatecheck/channel_tarball_test.go @@ -0,0 +1,177 @@ +package updatecheck + +import ( + "os" + "runtime/debug" + "testing" +) + +// Spec 092 FR-020: ChannelTarball must be reachable — before this change the +// constant existed but detect() could never return it, so `mcpproxy update` +// had no positively identified self-managed install to act on. +func TestDetectChannel_TarballMarkerIsReachable(t *testing.T) { + d := testDetector() + d.marker = ChannelTarball + d.execPath = func() (string, error) { return "/home/user/.local/bin/mcpproxy", nil } + + if got := d.detect(); got != ChannelTarball { + t.Fatalf("detect() = %q, want %q", got, ChannelTarball) + } +} + +// The tarball marker is WEAK by design: the release .tar.gz is also what +// Homebrew extracts into its Cellar, so the marker must never mask a positive +// runtime signal. Every case here would be a wrong `mcpproxy update` +// self-replace of a package-manager-owned binary if the marker short-circuited +// (FR-020, SC-005). +func TestDetectChannel_TarballMarkerLosesToPositiveHeuristics(t *testing.T) { + tests := []struct { + name string + mutate func(d *channelDetector) + want string + rawWant string // documents why the case matters + }{ + { + name: "homebrew cellar extraction of the same tarball", + mutate: func(d *channelDetector) { + d.goos = "darwin" + d.execPath = func() (string, error) { + return "/opt/homebrew/Cellar/mcpproxy/0.55.0/bin/mcpproxy", nil + } + }, + want: ChannelHomebrew, + rawWant: "brew owns the install; self-update would break its bookkeeping", + }, + { + name: "container runtime", + mutate: func(d *channelDetector) { + d.statFile = func(p string) error { + if p == "/.dockerenv" { + return nil + } + return os.ErrNotExist + } + }, + want: ChannelDocker, + rawWant: "image layers are immutable; the user rebuilds", + }, + { + name: "macOS app bundle core", + mutate: func(d *channelDetector) { + d.goos = "darwin" + d.execPath = func() (string, error) { + return "/Applications/MCPProxy.app/Contents/Resources/bin/mcpproxy", nil + } + }, + want: ChannelDMG, + rawWant: "FR-022 forbids touching anything inside the app bundle", + }, + { + name: "staged core copy of a DMG install", + mutate: func(d *channelDetector) { + d.goos = "darwin" + d.execPath = func() (string, error) { + return "/Users/u/Library/Application Support/mcpproxy/bin/mcpproxy", nil + } + }, + want: ChannelDMG, + rawWant: "staged copies belong to the tray, not to the CLI", + }, + { + name: "apt-owned /usr/bin install", + mutate: func(d *channelDetector) { + d.goos = "linux" + d.execPath = func() (string, error) { return "/usr/bin/mcpproxy", nil } + d.statFile = func(p string) error { + switch p { + case "/var/lib/dpkg/info/mcpproxy.list", "/etc/apt/sources.list.d/mcpproxy.list": + return nil + } + return os.ErrNotExist + } + }, + want: ChannelDeb, + rawWant: "dpkg owns the file", + }, + { + name: "package-manager directory with no ownership evidence stays unknown", + mutate: func(d *channelDetector) { + d.goos = "linux" + d.execPath = func() (string, error) { return "/usr/bin/mcpproxy", nil } + }, + want: ChannelUnknown, + rawWant: "a standalone .deb from a GitHub release lands here; guidance only", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + d := testDetector() + d.marker = ChannelTarball + tt.mutate(d) + if got := d.detect(); got != tt.want { + t.Errorf("detect() = %q, want %q (%s)", got, tt.want, tt.rawWant) + } + }) + } +} + +// Non-tarball markers keep the original absolute precedence (FR-008): they +// identify single-purpose artifacts that are never redistributed through +// another channel, so no heuristic may override them. +func TestDetectChannel_NonTarballMarkersStayAbsolute(t *testing.T) { + for _, marker := range []string{ChannelDocker, ChannelWindowsInstaller, ChannelHomebrew, ChannelDMG, ChannelDeb, ChannelRPM, ChannelGoInstall} { + t.Run(marker, func(t *testing.T) { + d := testDetector() + d.marker = marker + // Poison every heuristic at once. + d.goos = "darwin" + d.execPath = func() (string, error) { + return "/opt/homebrew/Cellar/mcpproxy/0.55.0/bin/mcpproxy", nil + } + d.statFile = func(string) error { return nil } + if got := d.detect(); got != marker { + t.Errorf("detect() = %q, want %q", got, marker) + } + }) + } +} + +// An unstamped binary must still be unknown, never tarball: FR-020 requires a +// positive marker before self-update can activate, and "writable location" +// alone is not ownership. +func TestDetectChannel_UnstampedNeverBecomesTarball(t *testing.T) { + d := testDetector() + d.execPath = func() (string, error) { return "/home/user/.local/bin/mcpproxy", nil } + if got := d.detect(); got != ChannelUnknown { + t.Errorf("detect() = %q, want %q for an unstamped binary", got, ChannelUnknown) + } +} + +// An unrecognized marker must not fall through to the heuristics either — it +// is a packaging bug, and guessing a channel from paths could hand a wrong +// upgrade command (or a self-update) to a packaging system we do not know. +func TestDetectChannel_UnknownMarkerStillTerminal(t *testing.T) { + d := testDetector() + d.marker = "flatpak" + d.goos = "darwin" + d.execPath = func() (string, error) { return "/opt/homebrew/bin/mcpproxy", nil } + if got := d.detect(); got != ChannelUnknown { + t.Errorf("detect() = %q, want %q", got, ChannelUnknown) + } +} + +// go-install detection must not be confused by the tarball marker: a stamped +// release binary carries a semver ldflags version, so isGoInstall() is false +// and the marker applies. +func TestDetectChannel_TarballMarkerWithBuildInfo(t *testing.T) { + d := testDetector() + d.marker = ChannelTarball + d.ldflagsVersion = "v0.55.0" + d.readBuildInfo = func() (*debug.BuildInfo, bool) { + return &debug.BuildInfo{Main: debug.Module{Version: "v0.55.0"}}, true + } + if got := d.detect(); got != ChannelTarball { + t.Errorf("detect() = %q, want %q", got, ChannelTarball) + } +} diff --git a/internal/updatecheck/guidance.go b/internal/updatecheck/guidance.go index a727528e..702765ef 100644 --- a/internal/updatecheck/guidance.go +++ b/internal/updatecheck/guidance.go @@ -6,9 +6,9 @@ import "fmt" // install channel, or "" when no command can be safely offered (Spec 079 // FR-009: never emit a channel-specific command that could be wrong). // -// Only package-manager/toolchain channels get a command; dmg, -// windows-installer, tarball, docker, and unknown installs get guidance text -// via GuidanceLine instead. +// Only package-manager/toolchain channels and the self-managed tarball +// channel get a command; dmg, windows-installer, docker, and unknown installs +// get guidance text via GuidanceLine instead. func UpdateCommand(channel string) string { switch channel { case ChannelHomebrew: @@ -19,6 +19,13 @@ func UpdateCommand(channel string) string { return "sudo dnf upgrade mcpproxy" case ChannelGoInstall: return "go install github.com/smart-mcp-proxy/mcpproxy-go/cmd/mcpproxy@latest" + case ChannelTarball: + // Spec 092 FR-020: a positively identified tarball install is the one + // channel mcpproxy owns end-to-end, so the command is our own + // verified self-update rather than a re-download-and-extract dance. + // Only the (weak) tarball build marker can produce this channel, so + // this command is never offered to a package-manager-owned binary. + return "mcpproxy update" default: return "" } @@ -36,6 +43,13 @@ func PrereleaseUpdateCommand(channel, version string) string { if channel == ChannelGoInstall && version != "" { return "go install github.com/smart-mcp-proxy/mcpproxy-go/cmd/mcpproxy@" + ensureVPrefix(version) } + // Spec 092: `mcpproxy update` resolves releases through the same + // prerelease-channel selection that produced this offer (update_check. + // channel / MCPPROXY_ALLOW_PRERELEASE_UPDATES), so it delivers the + // advertised rc — unlike brew/apt/dnf, which only serve stable. + if channel == ChannelTarball { + return "mcpproxy update" + } return "" } diff --git a/internal/updatecheck/guidance_test.go b/internal/updatecheck/guidance_test.go index 0c17151e..845835ef 100644 --- a/internal/updatecheck/guidance_test.go +++ b/internal/updatecheck/guidance_test.go @@ -14,6 +14,10 @@ func TestUpdateCommand_ExactPerChannel(t *testing.T) { {ChannelDeb, "sudo apt update && sudo apt install --only-upgrade mcpproxy"}, {ChannelRPM, "sudo dnf upgrade mcpproxy"}, {ChannelGoInstall, "go install github.com/smart-mcp-proxy/mcpproxy-go/cmd/mcpproxy@latest"}, + // Spec 092 FR-020/FR-024: a positively identified tarball install is + // self-managed, so its "command" is mcpproxy's own verified + // self-update. + {ChannelTarball, "mcpproxy update"}, } for _, tt := range tests { t.Run(tt.channel, func(t *testing.T) { @@ -25,9 +29,11 @@ func TestUpdateCommand_ExactPerChannel(t *testing.T) { } func TestUpdateCommand_NoCommandChannels(t *testing.T) { - // dmg / windows-installer / tarball / docker / unknown must never emit a - // command that could be wrong for the user's setup (FR-009). - for _, channel := range []string{ChannelDMG, ChannelWindowsInstaller, ChannelTarball, ChannelDocker, ChannelUnknown, ""} { + // dmg / windows-installer / docker / unknown must never emit a command + // that could be wrong for the user's setup (FR-009). unknown stays + // command-free even though it is writable: writability is not ownership + // (Spec 092 FR-020). + for _, channel := range []string{ChannelDMG, ChannelWindowsInstaller, ChannelDocker, ChannelUnknown, ""} { if got := UpdateCommand(channel); got != "" { t.Errorf("UpdateCommand(%q) = %q, want empty", channel, got) } @@ -60,12 +66,21 @@ func TestPrereleaseUpdateCommand(t *testing.T) { }) t.Run("package-manager channels never get a prerelease command", func(t *testing.T) { - for _, channel := range []string{ChannelHomebrew, ChannelDeb, ChannelRPM, ChannelDMG, ChannelWindowsInstaller, ChannelTarball, ChannelDocker, ChannelUnknown, ""} { + for _, channel := range []string{ChannelHomebrew, ChannelDeb, ChannelRPM, ChannelDMG, ChannelWindowsInstaller, ChannelDocker, ChannelUnknown, ""} { if got := PrereleaseUpdateCommand(channel, "v0.48.0-rc.1"); got != "" { t.Errorf("PrereleaseUpdateCommand(%q) = %q, want empty", channel, got) } } }) + + t.Run("tarball self-update honors the prerelease channel", func(t *testing.T) { + // `mcpproxy update` resolves the release through the same + // prerelease selection that produced the offer, so unlike brew/apt/dnf + // it actually delivers the advertised rc (Spec 092). + if got := PrereleaseUpdateCommand(ChannelTarball, "v0.48.0-rc.1"); got != "mcpproxy update" { + t.Errorf("PrereleaseUpdateCommand(tarball) = %q, want %q", got, "mcpproxy update") + } + }) } func TestGuidanceLine_PerChannel(t *testing.T) { From 00e2b5c499397e4500838a286c80758fdadf1f58 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Sat, 8 Aug 2026 06:11:15 +0300 Subject: [PATCH 06/37] feat(cli): add channel-aware `mcpproxy update` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Related #957 `mcpproxy update` branches on the already-detected install channel (Spec 092 US3/FR-020..FR-023): package-manager installs get the exact upgrade command, Docker/Windows-installer get guidance, a macOS app bundle is delegated to the tray, and only a positively identified tarball install (or `unknown` plus an explicit `--self` assertion) is ever self-replaced. ## Changes - cmd/mcpproxy/update_cmd.go: the command, its decision table and the -o json/yaml report. --check reports current/latest/channel with no side effects and is also the output when nothing is newer. A development build is reported as such instead of "already up to date". - cmd/mcpproxy/update_apply.go: checksum manifest parsing, sha256 verification, archive extraction, and the atomic swap — stage inside the target directory, preserve mode, rename target -> .old, rename new -> target, run ` --version` and only then drop the backup; restore on any failure. Symlinked launchers have their destination replaced, never the symlink. - Refusals: app-bundle and staged-copy paths (checked independently of GOOS), non-writable targets (error names path + owner, never suggests sudo), and a downgrade without both --version and --force. - internal/updatecheck: GetReleaseByTag + an overridable API base so an exact tag can be requested and tests can run against httptest. ## Signature verification — deliberate compromise (flagged) FR-021 asks for offline signature verification. The sha256 check against the release's checksums.txt is unconditional, and the cosign bundle over checksums.txt is verified with the identity pinned to this repo's release workflow — but via a locally installed cosign binary, not in-process. sigstore-go v1.3.0 would add 72 linked modules (+16 MB to a hello-world binary) and force a go.mod Go-directive bump; the decision report lists that dependency as an open maintainer decision. Missing cosign therefore ABORTS the update by default; --allow-unverified-signature is the explicit, loudly warned opt-out. ## Testing - go test -race ./cmd/mcpproxy/... ./internal/updatecheck/... — 130 test cases across the channel decision table, the downgrade/force matrix, bundle-path refusal, non-writable targets, and a real download->verify->swap against an httptest release server (happy path, tampered artifact, verify-failure restore, missing/failing signature, unlisted artifact) - golangci-lint v2 (.github/.golangci.yml) on ./... — 0 issues - go build -tags server ./cmd/mcpproxy + go test -tags server ./internal/serveredition/... -race --- CLAUDE.md | 2 +- cmd/mcpproxy/main.go | 1 + cmd/mcpproxy/update_apply.go | 273 ++++++++++ cmd/mcpproxy/update_apply_test.go | 276 ++++++++++ cmd/mcpproxy/update_cmd.go | 711 +++++++++++++++++++++++++ cmd/mcpproxy/update_cmd_test.go | 529 ++++++++++++++++++ cmd/mcpproxy/update_owner_unix.go | 34 ++ cmd/mcpproxy/update_owner_windows.go | 20 + cmd/mcpproxy/update_selfupdate_test.go | 480 +++++++++++++++++ docs/features/version-updates.md | 42 ++ internal/updatecheck/github.go | 54 +- 11 files changed, 2418 insertions(+), 4 deletions(-) create mode 100644 cmd/mcpproxy/update_apply.go create mode 100644 cmd/mcpproxy/update_apply_test.go create mode 100644 cmd/mcpproxy/update_cmd.go create mode 100644 cmd/mcpproxy/update_cmd_test.go create mode 100644 cmd/mcpproxy/update_owner_unix.go create mode 100644 cmd/mcpproxy/update_owner_windows.go create mode 100644 cmd/mcpproxy/update_selfupdate_test.go diff --git a/CLAUDE.md b/CLAUDE.md index 12157195..456f47ff 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -86,7 +86,7 @@ go test -tags server ./internal/serveredition/... -race # server edition ./mcpproxy-tray # tray (auto-starts core) ``` -**CLI management** — `mcpproxy upstream|tools|activity|token|telemetry|feedback|doctor …`. Output: `-o json|yaml`, `MCPPROXY_OUTPUT=json`, `--help-json` (machine-readable for agents). References: [docs/cli-management-commands.md](docs/cli-management-commands.md) · [docs/cli/activity-commands.md](docs/cli/activity-commands.md) · [docs/features/agent-tokens.md](docs/features/agent-tokens.md) · [docs/cli-output-formatting.md](docs/cli-output-formatting.md). +**CLI management** — `mcpproxy upstream|tools|activity|token|telemetry|feedback|doctor|update …` (`update` is channel-aware: guidance for package-manager installs, verified self-update only on tarball). Output: `-o json|yaml`, `MCPPROXY_OUTPUT=json`, `--help-json` (machine-readable for agents). References: [docs/cli-management-commands.md](docs/cli-management-commands.md) · [docs/cli/activity-commands.md](docs/cli/activity-commands.md) · [docs/features/agent-tokens.md](docs/features/agent-tokens.md) · [docs/cli-output-formatting.md](docs/cli-output-formatting.md). **Verifying Web-UI changes** (Playwright sweep + HTML report) — required when touching `frontend/src/`: [docs/development/web-ui-verification.md](docs/development/web-ui-verification.md). diff --git a/cmd/mcpproxy/main.go b/cmd/mcpproxy/main.go index 17759f2e..34d597bd 100644 --- a/cmd/mcpproxy/main.go +++ b/cmd/mcpproxy/main.go @@ -212,6 +212,7 @@ func main() { rootCmd.AddCommand(connectCmd) rootCmd.AddCommand(disconnectCmd) rootCmd.AddCommand(GetVersionCommand()) + rootCmd.AddCommand(GetUpdateCommand()) // Server-edition-only commands (e.g. `credential`). No-op in personal edition. registerServerEditionCommands(rootCmd) diff --git a/cmd/mcpproxy/update_apply.go b/cmd/mcpproxy/update_apply.go new file mode 100644 index 00000000..2fa4ed58 --- /dev/null +++ b/cmd/mcpproxy/update_apply.go @@ -0,0 +1,273 @@ +package main + +import ( + "archive/tar" + "archive/zip" + "compress/gzip" + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "time" +) + +// update_apply.go holds the mechanical half of `mcpproxy update`'s self-update +// (Spec 092 FR-021): integrity verification, archive extraction, and the +// atomic binary swap. It is deliberately free of cobra and of network code so +// every rule below is unit-testable against real files in a t.TempDir(). + +const ( + // maxArchiveMemberBytes bounds a single extracted archive member. The core + // binary is ~60-90 MB; the cap exists so a malicious archive cannot fill + // the disk before the checksum comparison would have rejected it. + maxArchiveMemberBytes = 512 << 20 + + // verifyExecTimeout bounds the post-swap ` --version` probe + // (FR-021: success means the new binary actually runs). + verifyExecTimeout = 30 * time.Second +) + +// parseChecksums parses a sha256sum-format manifest (" " or +// " *") into name -> lowercase hex digest. Unparseable lines are +// skipped rather than failing the whole file: the manifest is generated by CI +// and a future extra line must not break verification of the entry we need. +func parseChecksums(r io.Reader) (map[string]string, error) { + data, err := io.ReadAll(io.LimitReader(r, 4<<20)) + if err != nil { + return nil, fmt.Errorf("read checksums: %w", err) + } + out := map[string]string{} + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + fields := strings.Fields(line) + if len(fields) < 2 { + continue + } + digest := strings.ToLower(fields[0]) + if len(digest) != 64 { + continue + } + if _, err := hex.DecodeString(digest); err != nil { + continue + } + name := strings.TrimPrefix(fields[len(fields)-1], "*") + name = strings.TrimPrefix(name, "./") + out[name] = digest + } + if len(out) == 0 { + return nil, errors.New("checksums manifest contains no usable entries") + } + return out, nil +} + +// verifyFileSHA256 hard-fails unless path hashes to wantHex. +func verifyFileSHA256(path, wantHex string) error { + f, err := os.Open(path) // #nosec G304 -- path is a file this process just downloaded into its own temp dir + if err != nil { + return fmt.Errorf("open downloaded artifact: %w", err) + } + defer f.Close() + + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return fmt.Errorf("hash downloaded artifact: %w", err) + } + got := hex.EncodeToString(h.Sum(nil)) + if !strings.EqualFold(got, wantHex) { + return fmt.Errorf("checksum mismatch for %s: manifest says %s, download is %s (nothing was installed)", + filepath.Base(path), wantHex, got) + } + return nil +} + +// extractBinary pulls the single archive member named memberName (matched on +// base name, so a future archive that nests files still works) into destPath, +// which is created with mode 0o700 — the caller re-applies the real mode when +// swapping it into place. +func extractBinary(archivePath, memberName, destPath string) error { + switch { + case strings.HasSuffix(archivePath, ".zip"): + return extractFromZip(archivePath, memberName, destPath) + case strings.HasSuffix(archivePath, ".tar.gz"), strings.HasSuffix(archivePath, ".tgz"): + return extractFromTarGz(archivePath, memberName, destPath) + default: + return fmt.Errorf("unsupported archive format: %s", filepath.Base(archivePath)) + } +} + +func extractFromTarGz(archivePath, memberName, destPath string) error { + f, err := os.Open(archivePath) // #nosec G304 -- self-downloaded temp file + if err != nil { + return fmt.Errorf("open archive: %w", err) + } + defer f.Close() + + gz, err := gzip.NewReader(f) + if err != nil { + return fmt.Errorf("open gzip stream: %w", err) + } + defer gz.Close() + + tr := tar.NewReader(gz) + for { + hdr, err := tr.Next() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return fmt.Errorf("read archive: %w", err) + } + if hdr.Typeflag != tar.TypeReg || filepath.Base(hdr.Name) != memberName { + continue + } + return writeMember(tr, destPath) + } + return fmt.Errorf("archive does not contain %q", memberName) +} + +func extractFromZip(archivePath, memberName, destPath string) error { + zr, err := zip.OpenReader(archivePath) + if err != nil { + return fmt.Errorf("open archive: %w", err) + } + defer zr.Close() + + for _, entry := range zr.File { + if entry.FileInfo().IsDir() || filepath.Base(entry.Name) != memberName { + continue + } + rc, err := entry.Open() + if err != nil { + return fmt.Errorf("open archive member: %w", err) + } + defer rc.Close() + return writeMember(rc, destPath) + } + return fmt.Errorf("archive does not contain %q", memberName) +} + +// writeMember copies at most maxArchiveMemberBytes from r into destPath. +func writeMember(r io.Reader, destPath string) error { + out, err := os.OpenFile(destPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC|os.O_EXCL, 0o700) // #nosec G304 -- destPath is our own temp path + if err != nil { + return fmt.Errorf("create staged binary: %w", err) + } + written, err := io.Copy(out, io.LimitReader(r, maxArchiveMemberBytes+1)) + if err != nil { + out.Close() + return fmt.Errorf("write staged binary: %w", err) + } + if written > maxArchiveMemberBytes { + out.Close() + return fmt.Errorf("archive member exceeds the %d-byte limit", int64(maxArchiveMemberBytes)) + } + if err := out.Sync(); err != nil { + out.Close() + return fmt.Errorf("flush staged binary: %w", err) + } + return out.Close() +} + +// ensureTargetWritable reports why the binary cannot be replaced, naming the +// path and its owner. It never suggests (let alone performs) privilege +// escalation: a root-owned install belongs to whoever installed it (FR-022). +func ensureTargetWritable(target string) error { + dir := filepath.Dir(target) + + probe, err := os.CreateTemp(dir, ".mcpproxy-update-probe-*") + if err != nil { + return fmt.Errorf( + "cannot update %s: its directory %s is not writable by the current user (%s).\n"+ + "mcpproxy never escalates privileges. Either reinstall through whatever owns that path, "+ + "or install a copy somewhere you own (e.g. ~/.local/bin) and update that one", + target, dir, describeOwner(dir)) + } + name := probe.Name() + probe.Close() + if err := os.Remove(name); err != nil { + return fmt.Errorf("cannot clean up write probe %s: %w", name, err) + } + return nil +} + +// applyNewBinary swaps staged into target atomically (FR-021): +// rename target -> target.old, rename staged -> target, then prove the new +// binary runs. The previous binary is restored on any failure and only removed +// once verification passed. Callers must pass an already-resolved (symlink +// free) target so a symlinked launcher keeps pointing at the file we replace. +func applyNewBinary(target, staged string, verify func(path string) error) (err error) { + mode := os.FileMode(0o755) + if fi, statErr := os.Stat(target); statErr == nil { + mode = fi.Mode().Perm() + } + if chmodErr := os.Chmod(staged, mode); chmodErr != nil { + return fmt.Errorf("preserve file mode %o: %w", mode, chmodErr) + } + + backup := target + ".old" + // A leftover .old from an interrupted run must not block the rename. + _ = os.Remove(backup) + + if renameErr := os.Rename(target, backup); renameErr != nil { + return fmt.Errorf("move current binary aside: %w", renameErr) + } + + restore := func() { + _ = os.Remove(target) + if restoreErr := os.Rename(backup, target); restoreErr != nil { + // Nothing left to try; make the situation explicit so the user can + // recover by hand instead of discovering a missing binary later. + fmt.Fprintf(os.Stderr, + "CRITICAL: failed to restore the previous binary. It is at %s — move it back to %s manually: %v\n", + backup, target, restoreErr) + } + } + + if renameErr := os.Rename(staged, target); renameErr != nil { + restore() + return fmt.Errorf("install new binary: %w", renameErr) + } + + if verify != nil { + if verifyErr := verify(target); verifyErr != nil { + restore() + return fmt.Errorf("new binary failed verification, previous version restored: %w", verifyErr) + } + } + + // Only now is the old binary expendable (FR-021). + if rmErr := os.Remove(backup); rmErr != nil { + // Non-fatal: the update succeeded, there is just a stale .old file. + fmt.Fprintf(os.Stderr, "note: could not remove %s: %v\n", backup, rmErr) + } + return nil +} + +// verifyInstalledVersion runs ` --version` and requires the output to +// mention wantVersion. FR-021: "success" is the new binary executing and +// reporting the expected version, not merely a rename returning nil. +func verifyInstalledVersion(path, wantVersion string) error { + ctx, cancel := context.WithTimeout(context.Background(), verifyExecTimeout) + defer cancel() + + cmd := exec.CommandContext(ctx, path, "--version") // #nosec G204 -- path is the binary we just installed at a caller-resolved location + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("running %s --version failed: %w (output: %s)", + filepath.Base(path), err, strings.TrimSpace(string(out))) + } + got := strings.TrimSpace(string(out)) + if !strings.Contains(got, strings.TrimPrefix(wantVersion, "v")) { + return fmt.Errorf("installed binary reports %q, expected version %s", got, wantVersion) + } + return nil +} diff --git a/cmd/mcpproxy/update_apply_test.go b/cmd/mcpproxy/update_apply_test.go new file mode 100644 index 00000000..4bc3eda3 --- /dev/null +++ b/cmd/mcpproxy/update_apply_test.go @@ -0,0 +1,276 @@ +package main + +import ( + "archive/zip" + "bytes" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestParseChecksums(t *testing.T) { + const digestA = "1111111111111111111111111111111111111111111111111111111111111111" + const digestB = "2222222222222222222222222222222222222222222222222222222222222222" + + manifest := strings.Join([]string{ + "# a comment line", + digestA + " mcpproxy-0.55.0-darwin-arm64.tar.gz", + digestB + " *mcpproxy-0.55.0-windows-amd64.zip", // binary-mode marker + "garbage line without a digest", + "deadbeef too-short-digest.txt", + "", + }, "\n") + + got, err := parseChecksums(strings.NewReader(manifest)) + if err != nil { + t.Fatalf("parseChecksums: %v", err) + } + if got["mcpproxy-0.55.0-darwin-arm64.tar.gz"] != digestA { + t.Errorf("darwin entry = %q, want %q", got["mcpproxy-0.55.0-darwin-arm64.tar.gz"], digestA) + } + if got["mcpproxy-0.55.0-windows-amd64.zip"] != digestB { + t.Errorf("windows entry = %q (the '*' binary-mode marker must be stripped)", got["mcpproxy-0.55.0-windows-amd64.zip"]) + } + if _, ok := got["too-short-digest.txt"]; ok { + t.Errorf("a malformed digest must be skipped, not accepted") + } + if len(got) != 2 { + t.Errorf("parsed %d entries, want 2: %v", len(got), got) + } +} + +func TestParseChecksums_EmptyManifestIsAnError(t *testing.T) { + // An empty or unparseable manifest must never read as "nothing to verify". + if _, err := parseChecksums(strings.NewReader("# only comments\n")); err == nil { + t.Fatal("expected an error for a manifest with no usable entries") + } +} + +func TestVerifyFileSHA256(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "artifact") + if err := os.WriteFile(path, []byte("hello"), 0o600); err != nil { + t.Fatalf("write: %v", err) + } + // sha256("hello") + const want = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" + + if err := verifyFileSHA256(path, want); err != nil { + t.Errorf("matching digest should verify: %v", err) + } + if err := verifyFileSHA256(path, strings.ToUpper(want)); err != nil { + t.Errorf("digest comparison should be case-insensitive: %v", err) + } + err := verifyFileSHA256(path, "0000000000000000000000000000000000000000000000000000000000000000") + if err == nil || !strings.Contains(err.Error(), "checksum mismatch") { + t.Errorf("error = %v, want a checksum mismatch", err) + } +} + +func TestExtractBinary_Zip(t *testing.T) { + dir := t.TempDir() + archivePath := filepath.Join(dir, "release.zip") + + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + w, err := zw.Create("mcpproxy.exe") + if err != nil { + t.Fatalf("zip create: %v", err) + } + if _, err := w.Write([]byte("binary-bytes")); err != nil { + t.Fatalf("zip write: %v", err) + } + if err := zw.Close(); err != nil { + t.Fatalf("zip close: %v", err) + } + if err := os.WriteFile(archivePath, buf.Bytes(), 0o600); err != nil { + t.Fatalf("write archive: %v", err) + } + + dest := filepath.Join(dir, "staged") + if err := extractBinary(archivePath, "mcpproxy.exe", dest); err != nil { + t.Fatalf("extractBinary: %v", err) + } + got, err := os.ReadFile(dest) + if err != nil { + t.Fatalf("read staged: %v", err) + } + if string(got) != "binary-bytes" { + t.Errorf("staged content = %q", string(got)) + } +} + +func TestExtractBinary_MissingMember(t *testing.T) { + dir := t.TempDir() + archivePath := filepath.Join(dir, "release.tar.gz") + if err := os.WriteFile(archivePath, makeTarGz(t, "something-else", "x"), 0o600); err != nil { + t.Fatalf("write archive: %v", err) + } + + err := extractBinary(archivePath, "mcpproxy", filepath.Join(dir, "staged")) + if err == nil || !strings.Contains(err.Error(), "does not contain") { + t.Fatalf("error = %v, want a missing-member error", err) + } +} + +func TestExtractBinary_UnsupportedFormat(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "release.7z") + if err := os.WriteFile(path, []byte("x"), 0o600); err != nil { + t.Fatalf("write: %v", err) + } + if err := extractBinary(path, "mcpproxy", filepath.Join(dir, "staged")); err == nil { + t.Fatal("expected an error for an unsupported archive format") + } +} + +// applyNewBinary must preserve the target's mode and remove the backup only +// after verification succeeds (FR-021). +func TestApplyNewBinary_PreservesModeAndClearsBackup(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "mcpproxy") + staged := filepath.Join(dir, ".mcpproxy.new") + + if err := os.WriteFile(target, []byte("old"), 0o750); err != nil { + t.Fatalf("write target: %v", err) + } + if err := os.Chmod(target, 0o750); err != nil { + t.Fatalf("chmod: %v", err) + } + if err := os.WriteFile(staged, []byte("new"), 0o600); err != nil { + t.Fatalf("write staged: %v", err) + } + + verified := "" + err := applyNewBinary(target, staged, func(path string) error { + verified = path + // The new binary must already be in place when verification runs. + content, readErr := os.ReadFile(path) + if readErr != nil { + return readErr + } + if string(content) != "new" { + t.Errorf("verification saw %q, want the new binary", string(content)) + } + return nil + }) + if err != nil { + t.Fatalf("applyNewBinary: %v", err) + } + if verified != target { + t.Errorf("verify was called with %q, want %q", verified, target) + } + + fi, err := os.Stat(target) + if err != nil { + t.Fatalf("stat: %v", err) + } + if fi.Mode().Perm() != 0o750 { + t.Errorf("mode = %o, want 0750", fi.Mode().Perm()) + } + if _, err := os.Stat(target + ".old"); !os.IsNotExist(err) { + t.Errorf("backup must be removed after successful verification") + } +} + +func TestApplyNewBinary_RestoresOnVerifyFailure(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "mcpproxy") + staged := filepath.Join(dir, ".mcpproxy.new") + + if err := os.WriteFile(target, []byte("old"), 0o755); err != nil { + t.Fatalf("write target: %v", err) + } + if err := os.WriteFile(staged, []byte("new"), 0o600); err != nil { + t.Fatalf("write staged: %v", err) + } + + err := applyNewBinary(target, staged, func(string) error { return os.ErrInvalid }) + if err == nil || !strings.Contains(err.Error(), "previous version restored") { + t.Fatalf("error = %v, want a restore error", err) + } + + got, readErr := os.ReadFile(target) + if readErr != nil { + t.Fatalf("target must exist after restore: %v", readErr) + } + if string(got) != "old" { + t.Errorf("target content = %q, want the restored old binary", string(got)) + } + if _, err := os.Stat(target + ".old"); !os.IsNotExist(err) { + t.Errorf("backup must not linger after a restore") + } +} + +// A stale .old left by an interrupted run must not block the next attempt. +func TestApplyNewBinary_OverwritesStaleBackup(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "mcpproxy") + staged := filepath.Join(dir, ".mcpproxy.new") + + for path, content := range map[string]string{ + target: "old", + staged: "new", + target + ".old": "stale", + target + ".other": "unrelated", + } { + if err := os.WriteFile(path, []byte(content), 0o755); err != nil { + t.Fatalf("write %s: %v", path, err) + } + } + + if err := applyNewBinary(target, staged, nil); err != nil { + t.Fatalf("applyNewBinary: %v", err) + } + got, _ := os.ReadFile(target) + if string(got) != "new" { + t.Errorf("target content = %q, want new", string(got)) + } +} + +func TestEnsureTargetWritable(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "mcpproxy") + if err := os.WriteFile(target, []byte("x"), 0o755); err != nil { + t.Fatalf("write: %v", err) + } + + if err := ensureTargetWritable(target); err != nil { + t.Errorf("a writable directory should pass: %v", err) + } + + // The probe must not litter. + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatalf("read dir: %v", err) + } + if len(entries) != 1 { + t.Errorf("probe left files behind: %v", entries) + } +} + +func TestVerifyInstalledVersion(t *testing.T) { + requirePOSIXShell(t) + + dir := t.TempDir() + bin := filepath.Join(dir, "fake") + if err := os.WriteFile(bin, []byte("#!/bin/sh\necho \"MCPProxy v1.2.3 (personal)\"\n"), 0o755); err != nil { + t.Fatalf("write: %v", err) + } + + if err := verifyInstalledVersion(bin, "v1.2.3"); err != nil { + t.Errorf("matching version should verify: %v", err) + } + if err := verifyInstalledVersion(bin, "v9.9.9"); err == nil { + t.Error("a mismatched version must fail verification") + } + + broken := filepath.Join(dir, "broken") + if err := os.WriteFile(broken, []byte("not an executable"), 0o755); err != nil { + t.Fatalf("write: %v", err) + } + if err := verifyInstalledVersion(broken, "v1.2.3"); err == nil { + t.Error("a binary that cannot run must fail verification") + } +} diff --git a/cmd/mcpproxy/update_cmd.go b/cmd/mcpproxy/update_cmd.go new file mode 100644 index 00000000..47286fee --- /dev/null +++ b/cmd/mcpproxy/update_cmd.go @@ -0,0 +1,711 @@ +package main + +import ( + "context" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "time" + + "github.com/spf13/cobra" + "go.uber.org/zap" + "golang.org/x/mod/semver" + + clioutput "github.com/smart-mcp-proxy/mcpproxy-go/internal/cli/output" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/httpapi" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/updatecheck" +) + +// update_cmd.go implements `mcpproxy update` (Spec 092 US3 / FR-020..FR-023): +// one command that knows how this binary was installed and either prints the +// exact package-manager command, points at the tray updater, or performs a +// verified self-replace — never corrupting a package manager's bookkeeping and +// never touching an app bundle. + +// Actions the command can take. They are part of the -o json contract. +const ( + actionUpToDate = "up-to-date" + actionCommand = "command" // a package manager owns the install + actionGuidance = "guidance" // no safe command exists + actionTray = "tray" // macOS app bundle: the tray owns updates + actionSelfUpdate = "self-update" // positively identified self-managed install +) + +// Cosign identity pinning for checksums.txt (FR-021). These mirror the verify +// recipe documented in .github/workflows/release.yml next to the signing step: +// releases are only ever cut from a tag, so the ref is pinned too — a +// workflow_dispatch run on a branch must not be able to sign an artifact this +// command will install. +const ( + cosignIdentityRegexp = `^https://github\.com/smart-mcp-proxy/mcpproxy-go/\.github/workflows/release\.yml@refs/tags/v` + cosignOIDCIssuer = "https://token.actions.githubusercontent.com" +) + +const ( + checksumsAssetName = "checksums.txt" + cosignBundleName = "checksums.txt.cosign.bundle" + + downloadTimeout = 10 * time.Minute + cosignTimeout = 2 * time.Minute +) + +type updateFlags struct { + checkOnly bool + self bool + targetVersion string + force bool + allowUnverified bool +} + +// releaseSource is the release-metadata seam. Tests point it at an httptest +// server; production uses internal/updatecheck's GitHub client. +type releaseSource interface { + Latest(includePrereleases bool) (*updatecheck.GitHubRelease, error) + ByTag(tag string) (*updatecheck.GitHubRelease, error) +} + +type githubReleaseSource struct { + client *updatecheck.GitHubClient +} + +func (g *githubReleaseSource) Latest(includePrereleases bool) (*updatecheck.GitHubRelease, error) { + return g.client.GetRelease(includePrereleases) +} + +func (g *githubReleaseSource) ByTag(tag string) (*updatecheck.GitHubRelease, error) { + return g.client.GetReleaseByTag(tag) +} + +// updateReport is the machine-readable result of the command (-o json/yaml) +// and the source of the human-readable rendering. +type updateReport struct { + CurrentVersion string `json:"current_version" yaml:"current_version"` + LatestVersion string `json:"latest_version,omitempty" yaml:"latest_version,omitempty"` + Channel string `json:"channel" yaml:"channel"` + UpdateAvailable bool `json:"update_available" yaml:"update_available"` + Action string `json:"action" yaml:"action"` + Command string `json:"command,omitempty" yaml:"command,omitempty"` + Guidance string `json:"guidance,omitempty" yaml:"guidance,omitempty"` + ReleaseURL string `json:"release_url,omitempty" yaml:"release_url,omitempty"` + IsPrerelease bool `json:"is_prerelease,omitempty" yaml:"is_prerelease,omitempty"` + Applied bool `json:"applied" yaml:"applied"` + Message string `json:"message,omitempty" yaml:"message,omitempty"` +} + +// updateRunner carries every dependency the command touches so the whole +// decision table is testable without a network, a real install, or cobra. +type updateRunner struct { + out io.Writer + errOut io.Writer + format string + + currentVersion string + channel string + execPath string // symlink-resolved path of the binary to replace + includePrereleases bool + + flags updateFlags + releases releaseSource + + httpClient *http.Client + cosignVerify func(checksumsPath, bundlePath string) error + verifyInstalled func(path, version string) error + cosignAvailable func() bool + + // selfUpdateFn performs the download/verify/swap. Indirected so the + // decision-table tests can assert "this branch would (not) have installed + // something" without staging a real release. + selfUpdateFn func(*updatecheck.GitHubRelease) error +} + +// GetUpdateCommand returns the `update` subcommand. +func GetUpdateCommand() *cobra.Command { + flags := updateFlags{} + + cmd := &cobra.Command{ + Use: "update", + Short: "Update MCPProxy, or show how to update it for your install channel", + Long: `Check for a newer MCPProxy release and update it the way your install expects. + +MCPProxy detects how it was installed and acts accordingly: + homebrew / deb / rpm / go-install prints the exact upgrade command + docker / windows-installer prints channel-appropriate guidance + dmg (macOS app bundle) points at the tray updater; never touches the bundle + tarball (official release archive) downloads, verifies and swaps the binary in place + unknown guidance only, unless you assert ownership with --self + +Self-update verifies the release's signed checksum manifest before replacing +anything, keeps the previous binary until the new one runs, and never +escalates privileges. + +Examples: + mcpproxy update --check # report current/latest/channel, change nothing + mcpproxy update # do the right thing for this install + mcpproxy update --self # assert a self-managed install on an unknown channel + mcpproxy update --version v0.54.0 --force # deliberate downgrade`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + runner, err := newUpdateRunner(cmd.OutOrStdout(), cmd.ErrOrStderr(), flags) + if err != nil { + return err + } + return runner.run() + }, + } + + cmd.Flags().BoolVar(&flags.checkOnly, "check", false, "Report current version, latest version and install channel without changing anything") + cmd.Flags().BoolVar(&flags.self, "self", false, "Assert that you manage this binary yourself (allows self-update on the unknown channel)") + cmd.Flags().StringVar(&flags.targetVersion, "version", "", "Install this exact release instead of the latest (required for a downgrade)") + cmd.Flags().BoolVar(&flags.force, "force", false, "Allow installing a version that is not newer (requires --version)") + cmd.Flags().BoolVar(&flags.allowUnverified, "allow-unverified-signature", false, + "Proceed when the release signature cannot be verified (checksum verification still applies)") + + return cmd +} + +// newUpdateRunner wires the production dependencies. +func newUpdateRunner(out, errOut io.Writer, flags updateFlags) (*updateRunner, error) { + version := httpapi.GetBuildVersion() + channel := updatecheck.DetectChannel(version) + + execPath, err := resolvedExecutablePath() + if err != nil { + return nil, fmt.Errorf("cannot determine the running binary's path: %w", err) + } + + client := updatecheck.NewGitHubClient(zap.NewNop()) + + return &updateRunner{ + out: out, + errOut: errOut, + format: clioutput.ResolveFormat(globalOutputFormat, globalJSONOutput), + currentVersion: version, + channel: channel, + execPath: execPath, + includePrereleases: prereleasePreference(), + flags: flags, + releases: &githubReleaseSource{client: client}, + httpClient: &http.Client{Timeout: downloadTimeout}, + cosignVerify: verifyWithCosignBinary, + verifyInstalled: verifyInstalledVersion, + cosignAvailable: cosignOnPath, + }, nil +} + +// prereleasePreference mirrors the checker's precedence (Spec 079 FR-014): +// MCPPROXY_ALLOW_PRERELEASE_UPDATES wins over update_check.channel, so +// `mcpproxy update` offers exactly what the daemon's nudge offered (FR-023). +func prereleasePreference() bool { + if os.Getenv(updatecheck.EnvAllowPrereleaseUpdates) == "true" { + return true + } + cfg, err := loadCLIConfig(configFile) + if err != nil || cfg == nil { + return false + } + return cfg.UpdateCheck.IncludePrereleases() +} + +// resolvedExecutablePath returns the symlink-resolved path of the running +// binary. Resolving matters for FR-021: a symlinked launcher +// (~/.local/bin/mcpproxy -> ~/opt/mcpproxy/mcpproxy) must have its destination +// replaced, not the symlink itself. +func resolvedExecutablePath() (string, error) { + raw, err := os.Executable() + if err != nil { + return "", err + } + if resolved, err := filepath.EvalSymlinks(raw); err == nil && resolved != "" { + return resolved, nil + } + return raw, nil +} + +// insideAppBundle reports whether path lives in (or is a staged copy of) the +// macOS app bundle. FR-022 forbids modifying either, and the CLI must reach +// that conclusion from the path alone — the DMG heuristics in +// internal/updatecheck only run on darwin, while this guard must hold +// everywhere (a bundle copied onto another OS is still not ours to rewrite). +func insideAppBundle(path string) bool { + if path == "" { + return false + } + normalized := filepath.ToSlash(path) + return strings.Contains(normalized, "MCPProxy.app/") || + strings.HasSuffix(normalized, "/Library/Application Support/mcpproxy/bin/mcpproxy") +} + +// effectiveChannel applies the app-bundle override on top of the detected +// channel. +func (r *updateRunner) effectiveChannel() string { + if insideAppBundle(r.execPath) { + return updatecheck.ChannelDMG + } + return r.channel +} + +// decideAction maps a channel to what the command will do (FR-020). +func decideAction(channel string, self bool) string { + switch channel { + case updatecheck.ChannelHomebrew, updatecheck.ChannelDeb, updatecheck.ChannelRPM, updatecheck.ChannelGoInstall: + return actionCommand + case updatecheck.ChannelDocker, updatecheck.ChannelWindowsInstaller: + return actionGuidance + case updatecheck.ChannelDMG: + return actionTray + case updatecheck.ChannelTarball: + return actionSelfUpdate + case updatecheck.ChannelUnknown: + // FR-020: writability is not ownership. Only an explicit user + // assertion turns unknown into a self-managed install. + if self { + return actionSelfUpdate + } + return actionGuidance + default: + return actionGuidance + } +} + +func (r *updateRunner) run() error { + channel := r.effectiveChannel() + + release, err := r.resolveRelease() + if err != nil { + return err + } + + latest := release.TagName + newer := isNewer(r.currentVersion, latest) + + report := &updateReport{ + CurrentVersion: normalizeVersion(r.currentVersion), + LatestVersion: normalizeVersion(latest), + Channel: channel, + UpdateAvailable: newer, + ReleaseURL: release.HTMLURL, + IsPrerelease: release.Prerelease, + } + + action := decideAction(channel, r.flags.self) + r.annotateAction(report, action, release) + + // A development build has no place on the release timeline: comparing it + // would either offer every release as an "update" or silently claim it is + // current. Say so instead (FR-006: malformed versions are a logged + // no-decision, not a guess). + devBuild := !semver.IsValid(normalizeVersion(r.currentVersion)) + if devBuild { + report.Message = fmt.Sprintf( + "Running a development build (%s), which cannot be compared to released versions. "+ + "Install a specific release with --version --force.", report.CurrentVersion) + } + + // --check never acts; it reports what would happen (FR-023). + if r.flags.checkOnly { + if !newer && !devBuild { + report.Message = "Already up to date." + } + return r.emit(report) + } + + if devBuild && r.flags.targetVersion == "" { + return r.emit(report) + } + + // Nothing newer and no explicit target: same output as --check (FR-023). + if !newer && r.flags.targetVersion == "" { + report.Action = actionUpToDate + report.Message = "Already up to date." + return r.emit(report) + } + + // An explicit target that is not newer is a downgrade/reinstall and needs + // --force as well (FR-022). + if !newer && !r.flags.force { + return fmt.Errorf("refusing to install %s: it is not newer than the running %s. "+ + "Pass --force together with --version to install it deliberately", + normalizeVersion(latest), normalizeVersion(r.currentVersion)) + } + + switch action { + case actionSelfUpdate: + apply := r.selfUpdateFn + if apply == nil { + apply = r.selfUpdate + } + if err := apply(release); err != nil { + return err + } + report.Applied = true + report.Message = fmt.Sprintf("Updated %s to %s. Restart any running mcpproxy process to pick it up.", + r.execPath, normalizeVersion(latest)) + return r.emit(report) + default: + // Every other channel is guidance-only and exits 0 having changed + // nothing (FR-020, SC-005). + return r.emit(report) + } +} + +// annotateAction fills in the action-specific command/guidance text. +func (r *updateRunner) annotateAction(report *updateReport, action string, release *updatecheck.GitHubRelease) { + report.Action = action + switch action { + case actionCommand: + // A prerelease is only ever published to the GitHub prerelease + // channel, so the generic package-manager command would install a + // different (stable) version than the one advertised — mirror the + // checker's rule and let it degrade to guidance instead (FR-009). + if release.Prerelease { + report.Command = updatecheck.PrereleaseUpdateCommand(report.Channel, release.TagName) + } else { + report.Command = updatecheck.UpdateCommand(report.Channel) + } + if report.Command == "" { + // A prerelease on a package-manager channel has no command that + // would actually deliver it; fall back to guidance. + report.Action = actionGuidance + report.Guidance = updatecheck.GuidanceLine(report.Channel, release.HTMLURL) + } + case actionGuidance: + report.Guidance = updatecheck.GuidanceLine(report.Channel, release.HTMLURL) + if report.Channel == updatecheck.ChannelUnknown && !r.flags.self { + report.Guidance += ". If you manage this binary yourself (an extracted release archive), " + + "re-run with --self to let mcpproxy replace it" + } + case actionTray: + report.Guidance = "MCPProxy is installed as a macOS app bundle — use the menu bar app: " + + "MCPProxy menu -> Check for Updates. `mcpproxy update` never modifies an app bundle " + + "or its staged copies" + case actionSelfUpdate: + report.Command = "mcpproxy update" + } +} + +// resolveRelease picks the release to act on: the explicitly requested tag, or +// the newest one on the selected channel. +func (r *updateRunner) resolveRelease() (*updatecheck.GitHubRelease, error) { + if r.flags.targetVersion != "" { + tag := normalizeVersion(r.flags.targetVersion) + release, err := r.releases.ByTag(tag) + if err != nil { + return nil, err + } + return release, nil + } + release, err := r.releases.Latest(r.includePrereleases) + if err != nil { + return nil, fmt.Errorf("could not determine the latest release: %w", err) + } + return release, nil +} + +// selfUpdate downloads, verifies and installs the release (FR-021). +func (r *updateRunner) selfUpdate(release *updatecheck.GitHubRelease) error { + target := r.execPath + if insideAppBundle(target) { + // Defence in depth: decideAction already routes bundles to the tray. + return fmt.Errorf("refusing to modify %s: it is inside a macOS app bundle", target) + } + + // Fail before spending a ~90 MB download on an install we cannot write. + if err := ensureTargetWritable(target); err != nil { + return err + } + + version := normalizeVersion(release.TagName) + assetName := archiveAssetName(version, runtime.GOOS, runtime.GOARCH) + + archiveURL, err := assetURL(release, assetName) + if err != nil { + return err + } + checksumsURL, err := assetURL(release, checksumsAssetName) + if err != nil { + return fmt.Errorf("release %s publishes no %s, so its artifacts cannot be verified: %w", + version, checksumsAssetName, err) + } + bundleURL, bundleErr := assetURL(release, cosignBundleName) + + workDir, err := os.MkdirTemp("", "mcpproxy-update-*") + if err != nil { + return fmt.Errorf("create work directory: %w", err) + } + defer os.RemoveAll(workDir) + + checksumsPath := filepath.Join(workDir, checksumsAssetName) + if err := r.download(checksumsURL, checksumsPath); err != nil { + return err + } + + // Signature verification of the checksum manifest (FR-021). + if err := r.verifySignature(workDir, checksumsPath, bundleURL, bundleErr); err != nil { + return err + } + + manifest, err := openChecksums(checksumsPath) + if err != nil { + return err + } + wantDigest, ok := manifest[assetName] + if !ok { + return fmt.Errorf("%s is not listed in %s for release %s; refusing to install an unlisted artifact", + assetName, checksumsAssetName, version) + } + + archivePath := filepath.Join(workDir, assetName) + if err := r.download(archiveURL, archivePath); err != nil { + return err + } + if err := verifyFileSHA256(archivePath, wantDigest); err != nil { + return err + } + + // Stage inside the target directory: a rename across filesystems fails + // (EXDEV), and FR-021 requires the swap itself to be a rename. + stagedPath := filepath.Join(filepath.Dir(target), fmt.Sprintf(".%s.new-%d", filepath.Base(target), os.Getpid())) + _ = os.Remove(stagedPath) + defer os.Remove(stagedPath) + + if err := extractBinary(archivePath, coreBinaryName(), stagedPath); err != nil { + return err + } + + verify := r.verifyInstalled + if verify == nil { + verify = verifyInstalledVersion + } + return applyNewBinary(target, stagedPath, func(path string) error { + return verify(path, version) + }) +} + +// verifySignature verifies the cosign bundle over checksums.txt. +// +// COMPROMISE (see the report's open decision #5): this shells out to a +// user-installed cosign instead of verifying in-process with sigstore-go. +// sigstore-go v1.3.0 pulls 72 additional linked modules (+16 MB to a +// hello-world binary) and requires bumping go.mod's Go directive — a +// dependency decision the maintainer has not made. Until then: the sha256 +// comparison against checksums.txt is unconditional, and signature +// verification is REQUIRED by default — a missing cosign aborts the update +// rather than silently downgrading the trust model. --allow-unverified- +// signature is the explicit, loudly warned opt-out. +func (r *updateRunner) verifySignature(workDir, checksumsPath, bundleURL string, bundleErr error) error { + if r.flags.allowUnverified { + fmt.Fprintf(r.errOut, + "WARNING: --allow-unverified-signature: installing after checksum verification only. "+ + "The release signature was NOT checked, so a compromised %s would not be detected.\n", + checksumsAssetName) + return nil + } + + if bundleErr != nil || bundleURL == "" { + return fmt.Errorf("release publishes no %s, so its checksums cannot be authenticated. "+ + "Re-run with --allow-unverified-signature to accept checksum-only verification", cosignBundleName) + } + + available := r.cosignAvailable + if available == nil { + available = cosignOnPath + } + if !available() { + return fmt.Errorf("cosign is required to verify the release signature but was not found on PATH.\n" + + "Install it (https://docs.sigstore.dev/cosign/installation/) and re-run, or accept " + + "checksum-only verification with --allow-unverified-signature") + } + + bundlePath := filepath.Join(workDir, cosignBundleName) + if err := r.download(bundleURL, bundlePath); err != nil { + return err + } + + verify := r.cosignVerify + if verify == nil { + verify = verifyWithCosignBinary + } + if err := verify(checksumsPath, bundlePath); err != nil { + return fmt.Errorf("release signature verification failed, nothing was installed: %w", err) + } + return nil +} + +func cosignOnPath() bool { + _, err := exec.LookPath("cosign") + return err == nil +} + +// verifyWithCosignBinary runs the same verification the release workflow +// documents next to its signing step, with the identity and issuer pinned. +func verifyWithCosignBinary(checksumsPath, bundlePath string) error { + ctx, cancel := context.WithTimeout(context.Background(), cosignTimeout) + defer cancel() + + cmd := exec.CommandContext(ctx, "cosign", "verify-blob", // #nosec G204 -- fixed argv; only file paths this process created vary + "--bundle", bundlePath, + "--certificate-identity-regexp", cosignIdentityRegexp, + "--certificate-oidc-issuer", cosignOIDCIssuer, + checksumsPath, + ) + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("%w: %s", err, strings.TrimSpace(string(out))) + } + return nil +} + +func openChecksums(path string) (map[string]string, error) { + f, err := os.Open(path) // #nosec G304 -- file this process just downloaded into its own temp dir + if err != nil { + return nil, fmt.Errorf("open %s: %w", checksumsAssetName, err) + } + defer f.Close() + return parseChecksums(f) +} + +// download fetches url into dest. +func (r *updateRunner) download(url, dest string) error { + client := r.httpClient + if client == nil { + client = &http.Client{Timeout: downloadTimeout} + } + resp, err := client.Get(url) // #nosec G107 -- url comes from the GitHub release metadata we just fetched + if err != nil { + return fmt.Errorf("download %s: %w", filepath.Base(dest), err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("download %s: server returned status %d", filepath.Base(dest), resp.StatusCode) + } + + out, err := os.OpenFile(dest, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) // #nosec G304 -- dest is inside our own temp dir + if err != nil { + return fmt.Errorf("create %s: %w", dest, err) + } + if _, err := io.Copy(out, io.LimitReader(resp.Body, maxArchiveMemberBytes+1)); err != nil { + out.Close() + return fmt.Errorf("write %s: %w", dest, err) + } + return out.Close() +} + +// assetURL returns the download URL of the named release asset. +func assetURL(release *updatecheck.GitHubRelease, name string) (string, error) { + for i := range release.Assets { + if release.Assets[i].Name == name { + return release.Assets[i].BrowserDownloadURL, nil + } + } + return "", fmt.Errorf("release %s has no asset named %s (this platform may not be published for that release)", + release.TagName, name) +} + +// archiveAssetName builds the release archive filename the pipeline produces: +// mcpproxy---.. +func archiveAssetName(version, goos, goarch string) string { + ext := ".tar.gz" + if goos == "windows" { + ext = ".zip" + } + return fmt.Sprintf("mcpproxy-%s-%s-%s%s", strings.TrimPrefix(version, "v"), goos, goarch, ext) +} + +// coreBinaryName is the archive member to install. Archives always store the +// canonical name, whatever the user renamed their local copy to. The server +// edition ships as mcpproxy-server, so a server binary must never extract the +// personal-edition member over itself. +func coreBinaryName() string { + name := "mcpproxy" + if Edition == "server" { + name = "mcpproxy-server" + } + if runtime.GOOS == "windows" { + name += ".exe" + } + return name +} + +// isNewer reports whether latest is strictly newer than current under SemVer +// precedence (FR-006: rc.10 > rc.2). A non-semver current version (a dev +// build) is never "older": offering a release against it would be a guess. +func isNewer(current, latest string) bool { + c := normalizeVersion(current) + l := normalizeVersion(latest) + if !semver.IsValid(c) || !semver.IsValid(l) { + return false + } + return semver.Compare(c, l) < 0 +} + +// normalizeVersion adds the "v" prefix semver.Compare needs, but only to +// something that actually looks like a version — "development" must stay +// "development" rather than being rendered as "vdevelopment". +func normalizeVersion(v string) string { + v = strings.TrimSpace(v) + if v == "" || strings.HasPrefix(v, "v") { + return v + } + if v[0] >= '0' && v[0] <= '9' { + return "v" + v + } + return v +} + +// emit renders the report in the requested format. +func (r *updateRunner) emit(report *updateReport) error { + switch strings.ToLower(r.format) { + case "json", "yaml": + formatter, err := clioutput.NewFormatter(r.format) + if err != nil { + return err + } + out, err := formatter.Format(report) + if err != nil { + return err + } + fmt.Fprintln(r.out, out) + return nil + default: + r.printReport(report) + return nil + } +} + +func (r *updateRunner) printReport(report *updateReport) { + fmt.Fprintln(r.out, "MCPProxy update") + fmt.Fprintf(r.out, " %-10s %s\n", "Current:", report.CurrentVersion) + if report.LatestVersion != "" { + label := report.LatestVersion + if report.IsPrerelease { + label += " (prerelease)" + } + fmt.Fprintf(r.out, " %-10s %s\n", "Latest:", label) + } + fmt.Fprintf(r.out, " %-10s %s\n", "Channel:", report.Channel) + if report.ReleaseURL != "" { + fmt.Fprintf(r.out, " %-10s %s\n", "Release:", report.ReleaseURL) + } + + fmt.Fprintln(r.out) + switch { + case report.Applied: + fmt.Fprintln(r.out, report.Message) + case report.Message != "" && !report.UpdateAvailable: + fmt.Fprintln(r.out, report.Message) + case report.Action == actionUpToDate || !report.UpdateAvailable: + fmt.Fprintln(r.out, "Already up to date.") + case report.Action == actionSelfUpdate && r.flags.checkOnly: + fmt.Fprintln(r.out, "Run `mcpproxy update` to download, verify and install it.") + case report.Command != "": + fmt.Fprintf(r.out, "Run: %s\n", report.Command) + case report.Guidance != "": + fmt.Fprintln(r.out, report.Guidance) + } +} diff --git a/cmd/mcpproxy/update_cmd_test.go b/cmd/mcpproxy/update_cmd_test.go new file mode 100644 index 00000000..8ad97f68 --- /dev/null +++ b/cmd/mcpproxy/update_cmd_test.go @@ -0,0 +1,529 @@ +package main + +import ( + "bytes" + "encoding/json" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/updatecheck" +) + +// fakeReleaseSource serves fixture release metadata without touching GitHub. +type fakeReleaseSource struct { + latest *updatecheck.GitHubRelease + byTag map[string]*updatecheck.GitHubRelease + latestErr error + tagErr error + + latestCalls int + tagCalls []string +} + +func (f *fakeReleaseSource) Latest(_ bool) (*updatecheck.GitHubRelease, error) { + f.latestCalls++ + if f.latestErr != nil { + return nil, f.latestErr + } + return f.latest, nil +} + +func (f *fakeReleaseSource) ByTag(tag string) (*updatecheck.GitHubRelease, error) { + f.tagCalls = append(f.tagCalls, tag) + if f.tagErr != nil { + return nil, f.tagErr + } + if rel, ok := f.byTag[tag]; ok { + return rel, nil + } + return nil, errNotFound{tag} +} + +type errNotFound struct{ tag string } + +func (e errNotFound) Error() string { return "release " + e.tag + " not found" } + +func fixtureRelease(tag string, prerelease bool) *updatecheck.GitHubRelease { + return &updatecheck.GitHubRelease{ + TagName: tag, + Prerelease: prerelease, + HTMLURL: "https://github.com/smart-mcp-proxy/mcpproxy-go/releases/tag/" + tag, + } +} + +// newTestRunner builds a runner whose every side effect is either injected or +// confined to t.TempDir(). Self-update would fail loudly if a branch reached +// it unexpectedly, which is exactly what the guidance-only cases must prove. +func newTestRunner(t *testing.T, channel string, flags updateFlags, src releaseSource) (*updateRunner, *bytes.Buffer, *bytes.Buffer) { + t.Helper() + // Pin CI so the behavior does not differ between a laptop and CI. + t.Setenv("CI", "") + + out := &bytes.Buffer{} + errOut := &bytes.Buffer{} + return &updateRunner{ + out: out, + errOut: errOut, + format: "json", + currentVersion: "v0.50.0", + channel: channel, + execPath: filepath.Join(t.TempDir(), "mcpproxy"), + flags: flags, + releases: src, + cosignAvailable: func() bool { return true }, + cosignVerify: func(_, _ string) error { return nil }, + verifyInstalled: func(_, _ string) error { return nil }, + }, out, errOut +} + +func decodeReport(t *testing.T, out *bytes.Buffer) updateReport { + t.Helper() + var report updateReport + if err := json.Unmarshal(out.Bytes(), &report); err != nil { + t.Fatalf("output is not valid JSON: %v\n%s", err, out.String()) + } + return report +} + +// Spec 092 FR-020: every channel gets exactly one correct behavior, and only a +// positively identified tarball install (or an explicit --self assertion on +// unknown) may self-replace the binary. +func TestUpdateCommand_ChannelBranches(t *testing.T) { + tests := []struct { + name string + channel string + self bool + wantAction string + wantCommand string + guidanceHas string + }{ + { + name: "homebrew prints the brew command", + channel: updatecheck.ChannelHomebrew, + wantAction: actionCommand, + wantCommand: "brew upgrade mcpproxy", + }, + { + name: "deb prints the apt command", + channel: updatecheck.ChannelDeb, + wantAction: actionCommand, + wantCommand: "sudo apt update && sudo apt install --only-upgrade mcpproxy", + }, + { + name: "rpm prints the dnf command", + channel: updatecheck.ChannelRPM, + wantAction: actionCommand, + wantCommand: "sudo dnf upgrade mcpproxy", + }, + { + name: "go-install prints the go install command", + channel: updatecheck.ChannelGoInstall, + wantAction: actionCommand, + wantCommand: "go install github.com/smart-mcp-proxy/mcpproxy-go/cmd/mcpproxy@latest", + }, + { + name: "docker gets guidance only", + channel: updatecheck.ChannelDocker, + wantAction: actionGuidance, + guidanceHas: "image", + }, + { + name: "windows installer gets guidance only", + channel: updatecheck.ChannelWindowsInstaller, + wantAction: actionGuidance, + guidanceHas: "Windows installer", + }, + { + name: "dmg is delegated to the tray updater", + channel: updatecheck.ChannelDMG, + wantAction: actionTray, + guidanceHas: "Check for Updates", + }, + { + name: "tarball self-updates", + channel: updatecheck.ChannelTarball, + wantAction: actionSelfUpdate, + wantCommand: "mcpproxy update", + }, + { + name: "unknown stays guidance-only without --self", + channel: updatecheck.ChannelUnknown, + wantAction: actionGuidance, + guidanceHas: "--self", + }, + { + name: "unknown with --self becomes self-managed", + channel: updatecheck.ChannelUnknown, + self: true, + wantAction: actionSelfUpdate, + wantCommand: "mcpproxy update", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + src := &fakeReleaseSource{latest: fixtureRelease("v0.60.0", false)} + // --check keeps the self-update branches side-effect free while + // still exercising the full decision table. + runner, out, _ := newTestRunner(t, tt.channel, updateFlags{checkOnly: true, self: tt.self}, src) + + if err := runner.run(); err != nil { + t.Fatalf("run() error = %v", err) + } + report := decodeReport(t, out) + + if report.Action != tt.wantAction { + t.Errorf("action = %q, want %q", report.Action, tt.wantAction) + } + if tt.wantCommand != "" && report.Command != tt.wantCommand { + t.Errorf("command = %q, want %q", report.Command, tt.wantCommand) + } + if tt.guidanceHas != "" && !strings.Contains(report.Guidance, tt.guidanceHas) { + t.Errorf("guidance %q does not mention %q", report.Guidance, tt.guidanceHas) + } + if !report.UpdateAvailable { + t.Errorf("update_available = false, want true for v0.50.0 -> v0.60.0") + } + if report.Applied { + t.Errorf("--check must never apply anything") + } + }) + } +} + +// Guidance channels must exit 0 having changed nothing even without --check: +// a Homebrew user typing `mcpproxy update` gets the command, not a rewrite of +// a brew-owned binary (SC-005). +func TestUpdateCommand_GuidanceChannelsNeverSelfUpdate(t *testing.T) { + for _, channel := range []string{ + updatecheck.ChannelHomebrew, updatecheck.ChannelDeb, updatecheck.ChannelRPM, + updatecheck.ChannelGoInstall, updatecheck.ChannelDocker, + updatecheck.ChannelWindowsInstaller, updatecheck.ChannelDMG, updatecheck.ChannelUnknown, + } { + t.Run(channel, func(t *testing.T) { + src := &fakeReleaseSource{latest: fixtureRelease("v0.60.0", false)} + runner, out, _ := newTestRunner(t, channel, updateFlags{}, src) + // Any attempt to download would panic on the nil http client. + runner.httpClient = nil + + if err := runner.run(); err != nil { + t.Fatalf("run() error = %v", err) + } + report := decodeReport(t, out) + if report.Applied { + t.Errorf("channel %s must not modify anything", channel) + } + if report.Action == actionSelfUpdate { + t.Errorf("channel %s must not resolve to self-update", channel) + } + }) + } +} + +// FR-022: a bare "update to latest" has no downgrade to force; a downgrade +// needs BOTH --version and --force. +func TestUpdateCommand_DowngradeMatrix(t *testing.T) { + tests := []struct { + name string + targetVersion string + force bool + latest string + wantErr bool + wantErrHas string + wantApplied bool + }{ + { + name: "latest is newer: applies", + latest: "v0.60.0", wantApplied: true, + }, + { + name: "latest equals current: reports up to date, no error", + latest: "v0.50.0", + }, + { + name: "latest is older: reports up to date, no error", + latest: "v0.40.0", + }, + { + name: "explicit older version without --force is refused", + // The user named a version, so silence would be wrong: this is a + // deliberate downgrade attempt and must be rejected loudly. + targetVersion: "v0.40.0", latest: "v0.60.0", + wantErr: true, wantErrHas: "--force", + }, + { + name: "explicit equal version without --force is refused", + targetVersion: "v0.50.0", latest: "v0.60.0", + wantErr: true, wantErrHas: "not newer", + }, + { + name: "explicit older version with --force downgrades", + targetVersion: "v0.40.0", force: true, latest: "v0.60.0", + wantApplied: true, + }, + { + name: "--force alone does not downgrade to latest", + force: true, latest: "v0.40.0", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + src := &fakeReleaseSource{ + latest: fixtureRelease(tt.latest, false), + byTag: map[string]*updatecheck.GitHubRelease{ + "v0.40.0": fixtureRelease("v0.40.0", false), + "v0.50.0": fixtureRelease("v0.50.0", false), + "v0.60.0": fixtureRelease("v0.60.0", false), + }, + } + flags := updateFlags{targetVersion: tt.targetVersion, force: tt.force} + runner, out, _ := newTestRunner(t, updatecheck.ChannelTarball, flags, src) + + applied := false + runner.selfUpdateFn = func(_ *updatecheck.GitHubRelease) error { + applied = true + return nil + } + + err := runner.run() + if tt.wantErr { + if err == nil { + t.Fatalf("expected an error, got none (output: %s)", out.String()) + } + if tt.wantErrHas != "" && !strings.Contains(err.Error(), tt.wantErrHas) { + t.Errorf("error %q does not mention %q", err, tt.wantErrHas) + } + if applied { + t.Errorf("a refused downgrade must not install anything") + } + return + } + if err != nil { + t.Fatalf("run() error = %v", err) + } + if applied != tt.wantApplied { + t.Errorf("applied = %v, want %v (output: %s)", applied, tt.wantApplied, out.String()) + } + }) + } +} + +// FR-022: nothing inside a macOS app bundle (or its staged copy) is ever +// modified, even when the build marker claims a tarball install. +func TestUpdateCommand_RefusesAppBundlePaths(t *testing.T) { + paths := []string{ + "/Applications/MCPProxy.app/Contents/Resources/bin/mcpproxy", + "/Applications/MCPProxy.app/Contents/MacOS/mcpproxy", + "/Users/someone/Library/Application Support/mcpproxy/bin/mcpproxy", + } + + for _, path := range paths { + t.Run(path, func(t *testing.T) { + src := &fakeReleaseSource{latest: fixtureRelease("v0.60.0", false)} + runner, out, _ := newTestRunner(t, updatecheck.ChannelTarball, updateFlags{}, src) + runner.execPath = path + runner.selfUpdateFn = func(_ *updatecheck.GitHubRelease) error { + t.Fatalf("self-update must never run for %s", path) + return nil + } + + if err := runner.run(); err != nil { + t.Fatalf("run() error = %v", err) + } + report := decodeReport(t, out) + if report.Channel != updatecheck.ChannelDMG { + t.Errorf("channel = %q, want %q for an app-bundle path", report.Channel, updatecheck.ChannelDMG) + } + if report.Action != actionTray { + t.Errorf("action = %q, want %q", report.Action, actionTray) + } + }) + } +} + +// Even if the decision table were bypassed, selfUpdate itself refuses bundle +// paths (defence in depth for FR-022). +func TestSelfUpdate_RefusesBundlePathDirectly(t *testing.T) { + src := &fakeReleaseSource{latest: fixtureRelease("v0.60.0", false)} + runner, _, _ := newTestRunner(t, updatecheck.ChannelTarball, updateFlags{}, src) + runner.execPath = "/Applications/MCPProxy.app/Contents/Resources/bin/mcpproxy" + + err := runner.selfUpdate(fixtureRelease("v0.60.0", false)) + if err == nil || !strings.Contains(err.Error(), "app bundle") { + t.Fatalf("selfUpdate error = %v, want a refusal mentioning the app bundle", err) + } +} + +func TestInsideAppBundle(t *testing.T) { + tests := []struct { + path string + want bool + }{ + {"/Applications/MCPProxy.app/Contents/MacOS/mcpproxy", true}, + {"/Applications/MCPProxy.app/Contents/Resources/bin/mcpproxy", true}, + {"/Users/u/Library/Application Support/mcpproxy/bin/mcpproxy", true}, + {"/usr/local/bin/mcpproxy", false}, + {"/home/u/.local/bin/mcpproxy", false}, + // A directory that merely mentions the name is not a bundle. + {"/home/u/MCPProxy.app-notes/mcpproxy", false}, + {"", false}, + } + for _, tt := range tests { + if got := insideAppBundle(tt.path); got != tt.want { + t.Errorf("insideAppBundle(%q) = %v, want %v", tt.path, got, tt.want) + } + } +} + +// A prerelease offered on a package-manager channel has no command that would +// actually deliver it (brew/apt/dnf serve stable), so the command degrades to +// guidance rather than printing something that would not work. +func TestUpdateCommand_PrereleaseOnPackageChannelFallsBackToGuidance(t *testing.T) { + src := &fakeReleaseSource{latest: fixtureRelease("v0.60.0-rc.1", true)} + runner, out, _ := newTestRunner(t, updatecheck.ChannelHomebrew, updateFlags{checkOnly: true}, src) + + if err := runner.run(); err != nil { + t.Fatalf("run() error = %v", err) + } + report := decodeReport(t, out) + if report.Action != actionGuidance { + t.Errorf("action = %q, want %q", report.Action, actionGuidance) + } + if report.Command != "" { + t.Errorf("command = %q, want empty for a prerelease on homebrew", report.Command) + } +} + +// go-install can pin the exact prerelease, so it keeps a command. +func TestUpdateCommand_PrereleaseOnGoInstallPinsVersion(t *testing.T) { + src := &fakeReleaseSource{latest: fixtureRelease("v0.60.0-rc.1", true)} + runner, out, _ := newTestRunner(t, updatecheck.ChannelGoInstall, updateFlags{checkOnly: true}, src) + + if err := runner.run(); err != nil { + t.Fatalf("run() error = %v", err) + } + report := decodeReport(t, out) + if !strings.HasSuffix(report.Command, "@v0.60.0-rc.1") { + t.Errorf("command = %q, want a version-pinned go install", report.Command) + } +} + +// FR-006: SemVer precedence, including numeric prerelease identifiers. +func TestIsNewer(t *testing.T) { + tests := []struct { + current, latest string + want bool + }{ + {"v0.50.0", "v0.51.0", true}, + {"v0.50.0", "v0.50.0", false}, + {"v0.51.0", "v0.50.0", false}, + {"0.50.0", "0.51.0", true}, + // Lexicographic comparison would get this backwards. + {"v0.50.0-rc.2", "v0.50.0-rc.10", true}, + {"v0.50.0-rc.10", "v0.50.0-rc.2", false}, + {"v0.50.0-rc.1", "v0.50.0", true}, + // A dev build is never "older": offering a release would be a guess. + {"development", "v0.51.0", false}, + {"v0.50.0", "not-a-version", false}, + } + for _, tt := range tests { + if got := isNewer(tt.current, tt.latest); got != tt.want { + t.Errorf("isNewer(%q, %q) = %v, want %v", tt.current, tt.latest, got, tt.want) + } + } +} + +func TestNormalizeVersion(t *testing.T) { + tests := map[string]string{ + "v1.2.3": "v1.2.3", + "1.2.3": "v1.2.3", + " 1.2.3 ": "v1.2.3", + "development": "development", // must not become "vdevelopment" + "": "", + } + for in, want := range tests { + if got := normalizeVersion(in); got != want { + t.Errorf("normalizeVersion(%q) = %q, want %q", in, got, want) + } + } +} + +func TestArchiveAssetName(t *testing.T) { + tests := []struct { + version, goos, goarch, want string + }{ + {"v0.55.0", "darwin", "arm64", "mcpproxy-0.55.0-darwin-arm64.tar.gz"}, + {"0.55.0", "linux", "amd64", "mcpproxy-0.55.0-linux-amd64.tar.gz"}, + {"v0.55.0", "windows", "amd64", "mcpproxy-0.55.0-windows-amd64.zip"}, + } + for _, tt := range tests { + if got := archiveAssetName(tt.version, tt.goos, tt.goarch); got != tt.want { + t.Errorf("archiveAssetName(%q,%q,%q) = %q, want %q", tt.version, tt.goos, tt.goarch, got, tt.want) + } + } +} + +func TestCoreBinaryName(t *testing.T) { + want := "mcpproxy" + if Edition == "server" { + want = "mcpproxy-server" + } + if runtime.GOOS == "windows" { + want += ".exe" + } + if got := coreBinaryName(); got != want { + t.Errorf("coreBinaryName() = %q, want %q", got, want) + } +} + +// A development build must not be reported as "already up to date" — that +// reads as "you are current" when in fact no comparison was possible. +func TestUpdateCommand_DevBuildIsExplicit(t *testing.T) { + src := &fakeReleaseSource{latest: fixtureRelease("v0.60.0", false)} + runner, out, _ := newTestRunner(t, updatecheck.ChannelUnknown, updateFlags{}, src) + runner.currentVersion = "development" + runner.selfUpdateFn = func(_ *updatecheck.GitHubRelease) error { + t.Fatal("a dev build must not trigger self-update") + return nil + } + + if err := runner.run(); err != nil { + t.Fatalf("run() error = %v", err) + } + report := decodeReport(t, out) + if report.CurrentVersion != "development" { + t.Errorf("current_version = %q, want %q", report.CurrentVersion, "development") + } + if !strings.Contains(report.Message, "development build") { + t.Errorf("message %q should explain the dev build", report.Message) + } + if report.UpdateAvailable { + t.Errorf("a dev build must not be reported as having an update available") + } +} + +// --version resolves the exact tag rather than the latest release. +func TestUpdateCommand_ExplicitVersionUsesTagLookup(t *testing.T) { + src := &fakeReleaseSource{ + latest: fixtureRelease("v0.60.0", false), + byTag: map[string]*updatecheck.GitHubRelease{"v0.55.0": fixtureRelease("v0.55.0", false)}, + } + runner, out, _ := newTestRunner(t, updatecheck.ChannelTarball, updateFlags{targetVersion: "0.55.0", checkOnly: true}, src) + + if err := runner.run(); err != nil { + t.Fatalf("run() error = %v", err) + } + if src.latestCalls != 0 { + t.Errorf("latest release must not be queried when --version is given") + } + if len(src.tagCalls) != 1 || src.tagCalls[0] != "v0.55.0" { + t.Errorf("tag lookups = %v, want [v0.55.0] (the v prefix is added)", src.tagCalls) + } + report := decodeReport(t, out) + if report.LatestVersion != "v0.55.0" { + t.Errorf("latest_version = %q, want v0.55.0", report.LatestVersion) + } +} diff --git a/cmd/mcpproxy/update_owner_unix.go b/cmd/mcpproxy/update_owner_unix.go new file mode 100644 index 00000000..5436c4e4 --- /dev/null +++ b/cmd/mcpproxy/update_owner_unix.go @@ -0,0 +1,34 @@ +//go:build !windows + +package main + +import ( + "fmt" + "os" + "os/user" + "strconv" + "syscall" +) + +// describeOwner renders "owner: root (uid 0), mode drwxr-xr-x" for a path, so +// a refusal to write names who actually owns it (FR-022). Every lookup +// degrades gracefully: a diagnostic must never be the thing that fails. +func describeOwner(path string) string { + fi, err := os.Stat(path) + if err != nil { + return "owner unknown: " + err.Error() + } + + mode := fi.Mode().String() + st, ok := fi.Sys().(*syscall.Stat_t) + if !ok { + return fmt.Sprintf("mode %s", mode) + } + + uid := strconv.FormatUint(uint64(st.Uid), 10) + name := uid + if u, err := user.LookupId(uid); err == nil && u.Username != "" { + name = u.Username + } + return fmt.Sprintf("owner: %s (uid %s), mode %s", name, uid, mode) +} diff --git a/cmd/mcpproxy/update_owner_windows.go b/cmd/mcpproxy/update_owner_windows.go new file mode 100644 index 00000000..58a58a4d --- /dev/null +++ b/cmd/mcpproxy/update_owner_windows.go @@ -0,0 +1,20 @@ +//go:build windows + +package main + +import ( + "fmt" + "os" +) + +// describeOwner reports what Windows makes cheaply available. Resolving the +// security descriptor's owner SID needs golang.org/x/sys/windows plumbing that +// would only ever feed an error message, so the mode is enough context here +// (FR-022 requires naming the path; the owner is best-effort). +func describeOwner(path string) string { + fi, err := os.Stat(path) + if err != nil { + return "owner unknown: " + err.Error() + } + return fmt.Sprintf("mode %s", fi.Mode().String()) +} diff --git a/cmd/mcpproxy/update_selfupdate_test.go b/cmd/mcpproxy/update_selfupdate_test.go new file mode 100644 index 00000000..3a54b43e --- /dev/null +++ b/cmd/mcpproxy/update_selfupdate_test.go @@ -0,0 +1,480 @@ +package main + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "crypto/sha256" + "encoding/hex" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/updatecheck" +) + +// These tests exercise the real download → verify → swap path (FR-021) +// against an httptest release server and real files. The "binary" shipped in +// the fixture archive is a tiny shell script so `--version` genuinely runs, +// which is what FR-021 defines success as. + +func requirePOSIXShell(t *testing.T) { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("fixture binaries are POSIX shell scripts") + } +} + +// makeTarGz builds a .tar.gz containing one regular file. +func makeTarGz(t *testing.T, memberName, content string) []byte { + t.Helper() + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + tw := tar.NewWriter(gz) + hdr := &tar.Header{ + Name: memberName, + Mode: 0o755, + Size: int64(len(content)), + Typeflag: tar.TypeReg, + } + if err := tw.WriteHeader(hdr); err != nil { + t.Fatalf("tar header: %v", err) + } + if _, err := tw.Write([]byte(content)); err != nil { + t.Fatalf("tar write: %v", err) + } + if err := tw.Close(); err != nil { + t.Fatalf("tar close: %v", err) + } + if err := gz.Close(); err != nil { + t.Fatalf("gzip close: %v", err) + } + return buf.Bytes() +} + +func sha256Hex(b []byte) string { + sum := sha256.Sum256(b) + return hex.EncodeToString(sum[:]) +} + +// releaseFixture serves an archive, its checksums manifest and a cosign +// bundle, and reports which paths were requested. +type releaseFixture struct { + server *httptest.Server + release *updatecheck.GitHubRelease + archive []byte + assetName string + requested map[string]int + corruptFile bool +} + +func newReleaseFixture(t *testing.T, tag, binaryScript string, opts ...func(*releaseFixture)) *releaseFixture { + t.Helper() + + version := strings.TrimPrefix(tag, "v") + assetName := archiveAssetName(version, runtime.GOOS, runtime.GOARCH) + archive := makeTarGz(t, coreBinaryName(), binaryScript) + + f := &releaseFixture{ + archive: archive, + assetName: assetName, + requested: map[string]int{}, + } + for _, opt := range opts { + opt(f) + } + + // The manifest always describes the pristine archive; corruption is + // injected on the wire so the checksum check is the thing under test. + checksums := fmt.Sprintf("%s %s\n%s some-other-artifact.dmg\n", + sha256Hex(archive), assetName, sha256Hex([]byte("unrelated"))) + + mux := http.NewServeMux() + mux.HandleFunc("/"+assetName, func(w http.ResponseWriter, _ *http.Request) { + f.requested[assetName]++ + body := archive + if f.corruptFile { + body = append(append([]byte{}, archive...), 'x') + } + _, _ = w.Write(body) + }) + mux.HandleFunc("/"+checksumsAssetName, func(w http.ResponseWriter, _ *http.Request) { + f.requested[checksumsAssetName]++ + _, _ = w.Write([]byte(checksums)) + }) + mux.HandleFunc("/"+cosignBundleName, func(w http.ResponseWriter, _ *http.Request) { + f.requested[cosignBundleName]++ + _, _ = w.Write([]byte(`{"fixture":"bundle"}`)) + }) + + f.server = httptest.NewServer(mux) + t.Cleanup(f.server.Close) + + f.release = &updatecheck.GitHubRelease{ + TagName: tag, + HTMLURL: "https://example.invalid/releases/" + tag, + Assets: []updatecheck.Asset{ + {Name: assetName, BrowserDownloadURL: f.server.URL + "/" + assetName}, + {Name: checksumsAssetName, BrowserDownloadURL: f.server.URL + "/" + checksumsAssetName}, + {Name: cosignBundleName, BrowserDownloadURL: f.server.URL + "/" + cosignBundleName}, + }, + } + return f +} + +// withoutAsset drops a trust artifact from the published release. +func withoutAsset(name string) func(*updatecheck.GitHubRelease) { + return func(rel *updatecheck.GitHubRelease) { + kept := rel.Assets[:0] + for _, a := range rel.Assets { + if a.Name != name { + kept = append(kept, a) + } + } + rel.Assets = kept + } +} + +const newBinaryScript = "#!/bin/sh\necho \"MCPProxy v9.9.9 (personal) test\"\n" + +// installTarget writes a stand-in "currently installed" binary. +func installTarget(t *testing.T, mode os.FileMode) string { + t.Helper() + dir := t.TempDir() + target := filepath.Join(dir, "mcpproxy") + old := "#!/bin/sh\necho \"MCPProxy v0.50.0 (personal) test\"\n" + if err := os.WriteFile(target, []byte(old), mode); err != nil { + t.Fatalf("write target: %v", err) + } + if err := os.Chmod(target, mode); err != nil { + t.Fatalf("chmod target: %v", err) + } + return target +} + +func selfUpdateRunner(t *testing.T, target string, fixture *releaseFixture, flags updateFlags) (*updateRunner, *bytes.Buffer) { + t.Helper() + t.Setenv("CI", "") + errOut := &bytes.Buffer{} + return &updateRunner{ + out: &bytes.Buffer{}, + errOut: errOut, + format: "json", + currentVersion: "v0.50.0", + channel: updatecheck.ChannelTarball, + execPath: target, + flags: flags, + releases: &fakeReleaseSource{latest: fixture.release}, + httpClient: fixture.server.Client(), + cosignAvailable: func() bool { return true }, + cosignVerify: func(_, _ string) error { return nil }, + verifyInstalled: verifyInstalledVersion, + }, errOut +} + +// The happy path: download, verify, swap, prove the new binary runs, preserve +// mode, and leave no .old or staging file behind (FR-021). +func TestSelfUpdate_HappyPath(t *testing.T) { + requirePOSIXShell(t) + + target := installTarget(t, 0o750) + fixture := newReleaseFixture(t, "v9.9.9", newBinaryScript) + runner, _ := selfUpdateRunner(t, target, fixture, updateFlags{}) + + if err := runner.selfUpdate(fixture.release); err != nil { + t.Fatalf("selfUpdate() error = %v", err) + } + + got, err := os.ReadFile(target) + if err != nil { + t.Fatalf("read target: %v", err) + } + if string(got) != newBinaryScript { + t.Errorf("target was not replaced; content = %q", string(got)) + } + + fi, err := os.Stat(target) + if err != nil { + t.Fatalf("stat target: %v", err) + } + if fi.Mode().Perm() != 0o750 { + t.Errorf("mode = %o, want 0750 (FR-021 preserves permissions)", fi.Mode().Perm()) + } + + if _, err := os.Stat(target + ".old"); !os.IsNotExist(err) { + t.Errorf(".old backup must be removed once the new binary verified") + } + assertNoStagingLeftovers(t, filepath.Dir(target)) + + // All three artifacts must have been fetched: the archive is worthless + // without the manifest, and the manifest is worthless without its + // signature bundle. + for _, name := range []string{fixture.assetName, checksumsAssetName, cosignBundleName} { + if fixture.requested[name] == 0 { + t.Errorf("%s was never downloaded", name) + } + } +} + +// SC-004: a tampered artifact is never installed. +func TestSelfUpdate_ChecksumMismatchInstallsNothing(t *testing.T) { + requirePOSIXShell(t) + + target := installTarget(t, 0o755) + before, _ := os.ReadFile(target) + + fixture := newReleaseFixture(t, "v9.9.9", newBinaryScript, func(f *releaseFixture) { f.corruptFile = true }) + runner, _ := selfUpdateRunner(t, target, fixture, updateFlags{}) + + err := runner.selfUpdate(fixture.release) + if err == nil || !strings.Contains(err.Error(), "checksum mismatch") { + t.Fatalf("error = %v, want a checksum mismatch", err) + } + + after, _ := os.ReadFile(target) + if !bytes.Equal(before, after) { + t.Errorf("the installed binary must be untouched after a checksum failure") + } + assertNoStagingLeftovers(t, filepath.Dir(target)) +} + +// FR-021: if the new binary does not run and report the expected version, the +// previous one is restored. +func TestSelfUpdate_RestoresPreviousBinaryOnVerifyFailure(t *testing.T) { + requirePOSIXShell(t) + + target := installTarget(t, 0o755) + before, _ := os.ReadFile(target) + + // The "new" binary reports a different version than the release claims. + fixture := newReleaseFixture(t, "v9.9.9", "#!/bin/sh\necho \"MCPProxy v1.0.0\"\n") + runner, _ := selfUpdateRunner(t, target, fixture, updateFlags{}) + + err := runner.selfUpdate(fixture.release) + if err == nil || !strings.Contains(err.Error(), "previous version restored") { + t.Fatalf("error = %v, want a restore-on-failure error", err) + } + + after, err := os.ReadFile(target) + if err != nil { + t.Fatalf("the previous binary must still exist: %v", err) + } + if !bytes.Equal(before, after) { + t.Errorf("previous binary was not restored; content = %q", string(after)) + } + if _, err := os.Stat(target + ".old"); !os.IsNotExist(err) { + t.Errorf("the .old backup must be gone after a successful restore") + } +} + +// FR-021: signature verification is required. A release without a cosign +// bundle aborts rather than silently degrading to checksum-only trust. +func TestSelfUpdate_MissingSignatureBundleAborts(t *testing.T) { + requirePOSIXShell(t) + + target := installTarget(t, 0o755) + fixture := newReleaseFixture(t, "v9.9.9", newBinaryScript) + withoutAsset(cosignBundleName)(fixture.release) + + runner, _ := selfUpdateRunner(t, target, fixture, updateFlags{}) + err := runner.selfUpdate(fixture.release) + if err == nil || !strings.Contains(err.Error(), cosignBundleName) { + t.Fatalf("error = %v, want an abort naming the missing bundle", err) + } + if fixture.requested[fixture.assetName] != 0 { + t.Errorf("the archive must not be downloaded once verification is known to be impossible") + } +} + +// …and the opt-out is explicit and loud. +func TestSelfUpdate_AllowUnverifiedSignatureWarnsAndProceeds(t *testing.T) { + requirePOSIXShell(t) + + target := installTarget(t, 0o755) + fixture := newReleaseFixture(t, "v9.9.9", newBinaryScript) + withoutAsset(cosignBundleName)(fixture.release) + + runner, errOut := selfUpdateRunner(t, target, fixture, updateFlags{allowUnverified: true}) + runner.cosignVerify = func(_, _ string) error { + t.Fatal("cosign must not run when the signature check is waived") + return nil + } + + if err := runner.selfUpdate(fixture.release); err != nil { + t.Fatalf("selfUpdate() error = %v", err) + } + if !strings.Contains(errOut.String(), "WARNING") { + t.Errorf("waiving signature verification must warn loudly; stderr = %q", errOut.String()) + } + got, _ := os.ReadFile(target) + if string(got) != newBinaryScript { + t.Errorf("target was not replaced") + } +} + +// A release with no checksums.txt cannot be verified at all. +func TestSelfUpdate_MissingChecksumsAborts(t *testing.T) { + requirePOSIXShell(t) + + target := installTarget(t, 0o755) + fixture := newReleaseFixture(t, "v9.9.9", newBinaryScript) + withoutAsset(checksumsAssetName)(fixture.release) + + runner, _ := selfUpdateRunner(t, target, fixture, updateFlags{}) + err := runner.selfUpdate(fixture.release) + if err == nil || !strings.Contains(err.Error(), checksumsAssetName) { + t.Fatalf("error = %v, want an abort naming checksums.txt", err) + } +} + +// A failing signature verification aborts with nothing installed. +func TestSelfUpdate_SignatureVerificationFailureAborts(t *testing.T) { + requirePOSIXShell(t) + + target := installTarget(t, 0o755) + before, _ := os.ReadFile(target) + fixture := newReleaseFixture(t, "v9.9.9", newBinaryScript) + + runner, _ := selfUpdateRunner(t, target, fixture, updateFlags{}) + runner.cosignVerify = func(_, _ string) error { return fmt.Errorf("certificate identity mismatch") } + + err := runner.selfUpdate(fixture.release) + if err == nil || !strings.Contains(err.Error(), "signature verification failed") { + t.Fatalf("error = %v, want a signature verification failure", err) + } + after, _ := os.ReadFile(target) + if !bytes.Equal(before, after) { + t.Errorf("nothing may be installed when the signature does not verify") + } +} + +// Cosign missing from PATH is a hard stop by default, with an actionable +// message — not a silent downgrade of the trust model. +func TestSelfUpdate_CosignMissingIsActionable(t *testing.T) { + requirePOSIXShell(t) + + target := installTarget(t, 0o755) + fixture := newReleaseFixture(t, "v9.9.9", newBinaryScript) + + runner, _ := selfUpdateRunner(t, target, fixture, updateFlags{}) + runner.cosignAvailable = func() bool { return false } + + err := runner.selfUpdate(fixture.release) + if err == nil { + t.Fatal("expected an error when cosign is unavailable") + } + for _, want := range []string{"cosign", "--allow-unverified-signature"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error %q should mention %q", err, want) + } + } +} + +// FR-022: a non-writable target fails with a message naming the path and its +// owner, and never suggests sudo. +func TestSelfUpdate_NonWritableTarget(t *testing.T) { + requirePOSIXShell(t) + if os.Geteuid() == 0 { + t.Skip("root can write anywhere") + } + + dir := t.TempDir() + target := filepath.Join(dir, "mcpproxy") + if err := os.WriteFile(target, []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatalf("write target: %v", err) + } + if err := os.Chmod(dir, 0o500); err != nil { + t.Fatalf("chmod dir: %v", err) + } + t.Cleanup(func() { _ = os.Chmod(dir, 0o700) }) + + fixture := newReleaseFixture(t, "v9.9.9", newBinaryScript) + runner, _ := selfUpdateRunner(t, target, fixture, updateFlags{}) + + err := runner.selfUpdate(fixture.release) + if err == nil { + t.Fatal("expected a refusal for a non-writable target") + } + msg := err.Error() + if !strings.Contains(msg, target) { + t.Errorf("error must name the target path; got %q", msg) + } + if !strings.Contains(msg, "owner:") { + t.Errorf("error must name the owner; got %q", msg) + } + if strings.Contains(strings.ToLower(msg), "sudo") { + t.Errorf("error must never suggest privilege escalation; got %q", msg) + } + if fixture.requested[fixture.assetName] != 0 { + t.Errorf("nothing should be downloaded when the target cannot be written") + } +} + +// The release may simply not publish an artifact for this platform. +func TestSelfUpdate_MissingPlatformAsset(t *testing.T) { + requirePOSIXShell(t) + + target := installTarget(t, 0o755) + fixture := newReleaseFixture(t, "v9.9.9", newBinaryScript) + withoutAsset(fixture.assetName)(fixture.release) + + runner, _ := selfUpdateRunner(t, target, fixture, updateFlags{}) + err := runner.selfUpdate(fixture.release) + if err == nil || !strings.Contains(err.Error(), fixture.assetName) { + t.Fatalf("error = %v, want a message naming the missing asset", err) + } +} + +// An artifact present on the release but absent from the manifest must not be +// installed: an unlisted file is outside the signed trust boundary. +func TestSelfUpdate_AssetNotListedInManifest(t *testing.T) { + requirePOSIXShell(t) + + target := installTarget(t, 0o755) + fixture := newReleaseFixture(t, "v9.9.9", newBinaryScript) + + // Re-serve a manifest that omits our asset. + mux := http.NewServeMux() + mux.HandleFunc("/"+checksumsAssetName, func(w http.ResponseWriter, _ *http.Request) { + fmt.Fprintf(w, "%s unrelated.tar.gz\n", sha256Hex([]byte("nope"))) + }) + mux.HandleFunc("/"+fixture.assetName, func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write(fixture.archive) + }) + mux.HandleFunc("/"+cosignBundleName, func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("{}")) + }) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + for i := range fixture.release.Assets { + fixture.release.Assets[i].BrowserDownloadURL = srv.URL + "/" + fixture.release.Assets[i].Name + } + + runner, _ := selfUpdateRunner(t, target, fixture, updateFlags{}) + runner.httpClient = srv.Client() + + err := runner.selfUpdate(fixture.release) + if err == nil || !strings.Contains(err.Error(), "not listed") { + t.Fatalf("error = %v, want a refusal for an unlisted artifact", err) + } +} + +// assertNoStagingLeftovers proves the swap cleaned up after itself. +func assertNoStagingLeftovers(t *testing.T, dir string) { + t.Helper() + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatalf("read dir: %v", err) + } + for _, e := range entries { + name := e.Name() + if strings.HasPrefix(name, ".mcpproxy") || strings.HasSuffix(name, ".old") || strings.Contains(name, ".new-") { + t.Errorf("leftover staging file: %s", name) + } + } +} diff --git a/docs/features/version-updates.md b/docs/features/version-updates.md index f9f2002f..9576fe2f 100644 --- a/docs/features/version-updates.md +++ b/docs/features/version-updates.md @@ -253,6 +253,48 @@ channel falls back to the release-page guidance. ## Updating MCPProxy +### `mcpproxy update` (all channels) + +`mcpproxy update` branches on the detected install channel (Spec 092 US3) so +you never have to remember which one you are on: + +| Channel | What the command does | +|---------|----------------------| +| `homebrew` / `deb` / `rpm` / `go-install` | Prints the exact upgrade command and exits. Nothing is modified — the package manager owns the install. | +| `docker` / `windows-installer` | Prints channel-appropriate guidance. | +| `dmg` (macOS app bundle) | Points at the menu bar app (MCPProxy menu → Check for Updates). The app bundle and its staged core copy are never modified. | +| `tarball` | Performs a verified self-update (see below). | +| `unknown` | Guidance only. Pass `--self` to assert that you manage the binary yourself; writability alone is not ownership. | + +```bash +mcpproxy update --check # current / latest / channel, no side effects +mcpproxy update # do the right thing for this install +mcpproxy update -o json # machine-readable report +mcpproxy update --self # assert a self-managed install on `unknown` +mcpproxy update --version v0.54.0 --force # deliberate downgrade (both flags required) +``` + +**Self-update safety rules** (FR-021/FR-022): + +- The release archive's SHA-256 is always checked against the release's + `checksums.txt`, and `checksums.txt` itself is verified against its cosign + signature bundle with the signing identity pinned to this repository's + release workflow. Verification currently shells out to a locally installed + [cosign](https://docs.sigstore.dev/cosign/installation/); if cosign is not on + `PATH` the update **aborts** — `--allow-unverified-signature` is the explicit + (and loudly warned) opt-out that falls back to checksum-only verification. +- The swap is a rename inside the target directory, the file mode is + preserved, and a symlinked launcher has its destination replaced rather than + the symlink. +- The previous binary is kept as `.old` until the new one runs and + reports the expected version; on any failure it is restored. +- A non-writable target fails with a message naming the path and its owner. + MCPProxy never escalates privileges. +- A version equal to or older than the running one requires **both** + `--version ` and `--force`. +- An already-running core keeps executing the old binary; the swap takes + effect the next time it starts. + ### Homebrew (macOS/Linux) ```bash diff --git a/internal/updatecheck/github.go b/internal/updatecheck/github.go index 762519b5..c96d0600 100644 --- a/internal/updatecheck/github.go +++ b/internal/updatecheck/github.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "net/http" + "strings" "time" "go.uber.org/zap" @@ -13,6 +14,9 @@ const ( // GitHubRepo is the repository to check for releases GitHubRepo = "smart-mcp-proxy/mcpproxy-go" + // DefaultAPIBase is the public GitHub REST API root. + DefaultAPIBase = "https://api.github.com" + // httpTimeout is the timeout for GitHub API requests httpTimeout = 10 * time.Second ) @@ -22,6 +26,9 @@ type GitHubClient struct { logger *zap.Logger httpClient *http.Client repo string + // apiBase is the REST API root. Overridable so tests can point the client + // at an httptest server instead of reaching the real GitHub. + apiBase string } // NewGitHubClient creates a new GitHub API client. @@ -31,13 +38,23 @@ func NewGitHubClient(logger *zap.Logger) *GitHubClient { httpClient: &http.Client{ Timeout: httpTimeout, }, - repo: GitHubRepo, + repo: GitHubRepo, + apiBase: DefaultAPIBase, } } +// SetAPIBase overrides the REST API root (tests, self-hosted mirrors). An +// empty value restores the public GitHub API. +func (c *GitHubClient) SetAPIBase(base string) { + if base == "" { + base = DefaultAPIBase + } + c.apiBase = strings.TrimSuffix(base, "/") +} + // GetLatestRelease fetches the latest stable release from GitHub. func (c *GitHubClient) GetLatestRelease() (*GitHubRelease, error) { - url := fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", c.repo) + url := fmt.Sprintf("%s/repos/%s/releases/latest", c.apiBase, c.repo) resp, err := c.httpClient.Get(url) // #nosec G107 -- URL is constructed from known repo constant if err != nil { @@ -64,7 +81,7 @@ func (c *GitHubClient) GetLatestRelease() (*GitHubRelease, error) { // GetLatestReleaseIncludingPrereleases fetches the latest release including prereleases. func (c *GitHubClient) GetLatestReleaseIncludingPrereleases() (*GitHubRelease, error) { - url := fmt.Sprintf("https://api.github.com/repos/%s/releases", c.repo) + url := fmt.Sprintf("%s/repos/%s/releases", c.apiBase, c.repo) resp, err := c.httpClient.Get(url) // #nosec G107 -- URL is constructed from known repo constant if err != nil { @@ -101,3 +118,34 @@ func (c *GitHubClient) GetRelease(includePrereleases bool) (*GitHubRelease, erro } return c.GetLatestRelease() } + +// GetReleaseByTag fetches one specific release by its git tag. `mcpproxy +// update --version vX.Y.Z` needs it: the latest/list endpoints cannot address +// an older (or a specific prerelease) tag, and FR-022 allows a deliberate +// downgrade only when the user names the exact version. +func (c *GitHubClient) GetReleaseByTag(tag string) (*GitHubRelease, error) { + if tag == "" { + return nil, fmt.Errorf("release tag is required") + } + url := fmt.Sprintf("%s/repos/%s/releases/tags/%s", c.apiBase, c.repo, tag) + + resp, err := c.httpClient.Get(url) // #nosec G107 -- base is a constant or test-injected; tag is validated by the caller + if err != nil { + c.logger.Debug("Failed to fetch release by tag", zap.String("tag", tag), zap.Error(err)) + return nil, fmt.Errorf("failed to fetch release %s: %w", tag, err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return nil, fmt.Errorf("release %s not found", tag) + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("GitHub API returned status %d for release %s", resp.StatusCode, tag) + } + + var release GitHubRelease + if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { + return nil, fmt.Errorf("failed to decode release %s: %w", tag, err) + } + return &release, nil +} From a20f7fa329bd3433dd953ab2b634195b0b863da2 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Sat, 8 Aug 2026 06:11:50 +0300 Subject: [PATCH 07/37] fix(ci): make the tarball stamp self-check pipefail-safe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Related #957 The step runs under `bash -eo pipefail`, where `go version -m … | grep -q …` can report the pipeline as failed when grep exits on the first match and go takes SIGPIPE — turning the guard into a flaky release blocker. Capture the build settings into a variable and match with `case` instead. --- .github/workflows/release.yml | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fc215c48..3daaec4c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -728,12 +728,19 @@ jobs: # `mcpproxy update` permanently guidance-only — the exact failure # FR-020 exists to prevent. `go version -m` reads the recorded build # settings, so this works for cross-compiled targets too. - if go version -m "${TARBALL_STAGE}/${CLEAN_BINARY}" | grep -q "updatecheck.buildChannel=tarball"; then - echo "✅ Archive core carries the tarball install-channel marker" - else - echo "❌ Archive core is missing the tarball install-channel marker" - exit 1 - fi + # Capture first, match second: this step runs under `bash -eo pipefail`, + # where `go version -m … | grep -q …` can report the pipeline as failed + # when grep exits on the first match and go takes SIGPIPE. + STAMP_INFO="$(go version -m "${TARBALL_STAGE}/${CLEAN_BINARY}" || true)" + case "$STAMP_INFO" in + *updatecheck.buildChannel=tarball*) + echo "✅ Archive core carries the tarball install-channel marker" + ;; + *) + echo "❌ Archive core is missing the tarball install-channel marker" + exit 1 + ;; + esac - name: Build Linux .deb and .rpm packages if: matrix.goos == 'linux' && matrix.edition != 'server' From fae973cc6d98cce24d87b285979f38420e4be35f Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Sat, 8 Aug 2026 06:18:20 +0300 Subject: [PATCH 08/37] feat(core): report the core pid in /api/v1/info MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Related #957 FR-002 needs a stop mechanism for a core the tray only ATTACHED to: it holds no Process handle for one, and the core exposes no shutdown endpoint, so without a pid the consent action could only ever print instructions. Paired with the launched_by provenance added in e186f5736 this is what lets a newer tray actually supersede a core an older tray started. ## Changes - contracts.InfoResponse.PID + the `pid` key in the handler payload, indirected through `var pidFn = os.Getpid` as the test seam - regenerated frontend/src/types/contracts.ts (generator edited, not the output), oas/swagger.yaml + oas/docs.go via `make swagger` - docs/api/rest-api.md field table and example ## Testing - go test -race ./internal/httpapi/... ./internal/contracts/... — ok - golangci-lint v2 (.github/.golangci.yml) — 0 issues --- cmd/generate-types/main.go | 3 ++ docs/api/rest-api.md | 2 ++ frontend/src/types/contracts.ts | 3 ++ internal/contracts/types.go | 7 +++++ internal/httpapi/info_launched_by_test.go | 35 +++++++++++++++++++++++ internal/httpapi/server.go | 11 +++++++ oas/docs.go | 2 +- oas/swagger.yaml | 9 ++++++ 8 files changed, 71 insertions(+), 1 deletion(-) diff --git a/cmd/generate-types/main.go b/cmd/generate-types/main.go index 65633fd0..5db3e231 100644 --- a/cmd/generate-types/main.go +++ b/cmd/generate-types/main.go @@ -449,6 +449,9 @@ export interface InfoResponse { // Spec 092 FR-001a: durable launch provenance of the running core — // "tray", "installer", or "" (user-launched / unknown). launched_by: string; + // Spec 092 FR-002: OS process id of the running core, so a tray that only + // attached to it still has a mechanism to stop a stale one. + pid: number; } `) diff --git a/docs/api/rest-api.md b/docs/api/rest-api.md index beab93d5..c85aa109 100644 --- a/docs/api/rest-api.md +++ b/docs/api/rest-api.md @@ -893,6 +893,7 @@ Get application info, version, and update availability. "socket": "/Users/user/.mcpproxy/mcpproxy.sock" }, "launched_by": "tray", + "pid": 4711, "update": { "available": true, "latest_version": "v1.3.0", @@ -916,6 +917,7 @@ Get application info, version, and update availability. | `endpoints.http` | string | HTTP API endpoint address | | `endpoints.socket` | string | Unix socket path (empty if disabled) | | `launched_by` | string | Durable launch provenance of the running core (Spec 092 FR-001a): `tray` when a tray spawned it, `installer` when the macOS PKG postinstall did, `""` when user-launched or unknown. Always present. A tray uses this to decide whether it may stop and respawn a stale core it did not itself start — an empty value means consent is required. | +| `pid` | integer | OS process id of the running core (Spec 092 FR-002). A tray that only *attached* to a core holds no process handle for it and the core exposes no shutdown endpoint, so this is the mechanism behind the consent-gated "restart the stale core" action. | | `update` | object | Update information (may be null if not checked yet; omitted entirely when update checking is disabled via `update_check.enabled: false` or `MCPPROXY_DISABLE_AUTO_UPDATE=true`) | | `update.available` | boolean | Whether a newer version is available | | `update.latest_version` | string | Latest version available on GitHub | diff --git a/frontend/src/types/contracts.ts b/frontend/src/types/contracts.ts index 686d4e7f..04e15f7c 100644 --- a/frontend/src/types/contracts.ts +++ b/frontend/src/types/contracts.ts @@ -383,4 +383,7 @@ export interface InfoResponse { // Spec 092 FR-001a: durable launch provenance of the running core — // "tray", "installer", or "" (user-launched / unknown). launched_by: string; + // Spec 092 FR-002: OS process id of the running core, so a tray that only + // attached to it still has a mechanism to stop a stale one. + pid: number; } diff --git a/internal/contracts/types.go b/internal/contracts/types.go index 1279b3e9..1800e43e 100644 --- a/internal/contracts/types.go +++ b/internal/contracts/types.go @@ -1172,4 +1172,11 @@ type InfoResponse struct { // (possibly empty) so a tray can distinguish "old core, not mine" from // "old core I may supersede". LaunchedBy string `json:"launched_by"` + // PID is the operating-system process id of the running core (Spec 092 + // FR-002). A tray that merely ATTACHED to a core holds no Process handle + // for it, so without this there is no mechanism at all to stop a stale + // core — the consent action would have nothing to act on and could only + // print instructions. Paired with LaunchedBy it is what lets a newer tray + // supersede a core an older tray started. + PID int `json:"pid"` } diff --git a/internal/httpapi/info_launched_by_test.go b/internal/httpapi/info_launched_by_test.go index 96b1fa08..558e5a4b 100644 --- a/internal/httpapi/info_launched_by_test.go +++ b/internal/httpapi/info_launched_by_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "os" "testing" "github.com/stretchr/testify/assert" @@ -64,3 +65,37 @@ func TestInfoEndpointLaunchedByDefaultsToProcessCapture(t *testing.T) { assert.Equal(t, launch.LaunchedBy(), launchedByFn(), "launchedByFn must default to the internal/launch process capture") } + +// Spec 092 FR-002: the core reports its own pid so an ATTACHED tray — which +// holds no Process handle and has no shutdown endpoint to call — still has a +// mechanism to stop a stale core once the user consents. +func TestInfoEndpointReportsPID(t *testing.T) { + prev := pidFn + pidFn = func() int { return 4242 } + t.Cleanup(func() { pidFn = prev }) + + logger := zaptest.NewLogger(t).Sugar() + server := NewServer(&MockServerController{}, logger, nil) + + req := httptest.NewRequest("GET", "/api/v1/info", http.NoBody) + w := httptest.NewRecorder() + server.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + + var response contracts.APIResponse + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) + data, ok := response.Data.(map[string]interface{}) + require.True(t, ok, "response data should be a map") + + require.Contains(t, data, "pid", "info response must always carry pid") + // JSON numbers decode as float64 through interface{}. + assert.InDelta(t, 4242, data["pid"], 0.0) +} + +// The default wiring is the process's real pid — a tray that kills what this +// reports must be killing the core, not a constant. +func TestInfoEndpointPIDDefaultsToProcessID(t *testing.T) { + assert.Equal(t, os.Getpid(), pidFn(), + "pidFn must default to this process's pid") +} diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 36befaea..862aa5d9 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -9,6 +9,7 @@ import ( "math" "net/http" "net/url" + "os" "sort" "strconv" "strings" @@ -1156,6 +1157,11 @@ func (s *Server) handleGetInfo(w http.ResponseWriter, r *http.Request) { // dies with the launching tray. "" means user/unknown → consent // required (FR-002). "launched_by": launchedByFn(), + // Spec 092 FR-002: the core's own PID. An attached tray has no Process + // handle and the core exposes no shutdown endpoint, so this is the only + // stop mechanism available to it — and the difference between a consent + // action that works and one that can only print instructions. + "pid": pidFn(), } if versionInfo != nil { response["update"] = versionInfo.ToAPIResponse() @@ -1210,6 +1216,11 @@ var buildVersion = "development" // in internal/launch. var launchedByFn = launch.LaunchedBy +// pidFn reports this core process's OS pid for /api/v1/info (Spec 092 FR-002). +// Indirected for the same reason as launchedByFn: a handler test must be able +// to assert the wiring without depending on the test binary's own pid. +var pidFn = os.Getpid + // editionValue identifies the MCPProxy edition (personal or server). var editionValue = "personal" diff --git a/oas/docs.go b/oas/docs.go index f4c77c32..e1433287 100644 --- a/oas/docs.go +++ b/oas/docs.go @@ -6,7 +6,7 @@ import "github.com/swaggo/swag/v2" const docTemplate = `{ "schemes": {{ marshal .Schemes }}, - "components": {"schemas":{"config.Config":{"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"},"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_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"},"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_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"},"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"},"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"},"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"},"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"},"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.","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"},"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\"","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"},"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"},"update":{"$ref":"#/components/schemas/contracts.UpdateInfo"},"version":{"description":"Current MCPProxy version","type":"string"},"web_ui_url":{"description":"URL to access the web control panel","type":"string"}},"type":"object"},"contracts.IsolationConfig":{"properties":{"cpu_limit":{"type":"string"},"enabled":{"type":"boolean"},"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.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"},"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"},"name":{"type":"string"},"oauth":{"$ref":"#/components/schemas/contracts.OAuthConfig"},"oauth_status":{"description":"OAuth status: \"authenticated\", \"expired\", \"error\", \"none\"","type":"string"},"protocol":{"type":"string"},"quarantine":{"$ref":"#/components/schemas/contracts.QuarantineStats"},"quarantined":{"type":"boolean"},"reconnect_count":{"type":"integer"},"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"},"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.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"},"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"},"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"},"name":{"type":"string"},"protocol":{"type":"string"},"quarantined":{"type":"boolean"},"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"},"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.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"},"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_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"},"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_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"},"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"},"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"},"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"},"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"},"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.","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"},"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\"","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"},"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"},"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.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"},"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"},"name":{"type":"string"},"oauth":{"$ref":"#/components/schemas/contracts.OAuthConfig"},"oauth_status":{"description":"OAuth status: \"authenticated\", \"expired\", \"error\", \"none\"","type":"string"},"protocol":{"type":"string"},"quarantine":{"$ref":"#/components/schemas/contracts.QuarantineStats"},"quarantined":{"type":"boolean"},"reconnect_count":{"type":"integer"},"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"},"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.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"},"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"},"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"},"name":{"type":"string"},"protocol":{"type":"string"},"quarantined":{"type":"boolean"},"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"},"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"],"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"],"type":"string"}},{"description":"Filter by intent operation type (Spec 018)","in":"query","name":"intent_type","schema":{"enum":["read","write","destructive"],"type":"string"}},{"description":"Filter by HTTP request ID for log correlation (Spec 021)","in":"query","name":"request_id","schema":{"type":"string"}},{"description":"Include successful call_tool_* internal tool calls (default: false, excluded to avoid duplicates)","in":"query","name":"include_call_tool","schema":{"type":"boolean"}},{"description":"Filter by sensitive data detection (true=has detections, false=no detections)","in":"query","name":"sensitive_data","schema":{"type":"boolean"}},{"description":"Filter by specific detection type (e.g., 'aws_access_key', 'credit_card')","in":"query","name":"detection_type","schema":{"type":"string"}},{"description":"Filter by severity level","in":"query","name":"severity","schema":{"enum":["critical","high","medium","low"],"type":"string"}},{"description":"Filter by agent token name (Spec 028)","in":"query","name":"agent","schema":{"type":"string"}},{"description":"Filter by auth type (Spec 028)","in":"query","name":"auth_type","schema":{"enum":["admin","agent"],"type":"string"}},{"description":"Filter activities after this time (RFC3339)","in":"query","name":"start_time","schema":{"type":"string"}},{"description":"Filter activities before this time (RFC3339)","in":"query","name":"end_time","schema":{"type":"string"}},{"description":"Maximum records to return (1-100, default 50)","in":"query","name":"limit","schema":{"type":"integer"}},{"description":"Pagination offset (default 0)","in":"query","name":"offset","schema":{"type":"integer"}},{"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 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"],"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/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/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/tool-calls":{"get":{"description":"Retrieves paginated tool call history across all upstream servers or filtered by session ID. Includes execution timestamps, arguments, results, and error information for debugging and auditing.","parameters":[{"description":"Maximum number of records to return (1-100, default 50)","in":"query","name":"limit","schema":{"type":"integer"}},{"description":"Number of records to skip for pagination (default 0)","in":"query","name":"offset","schema":{"type":"integer"}},{"description":"Filter tool calls by MCP session ID","in":"query","name":"session_id","schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetToolCallsResponse"}}},"description":"Tool calls retrieved successfully"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to get tool calls"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get tool call history","tags":["tool-calls"]}},"/api/v1/tool-calls/{id}":{"get":{"description":"Retrieves detailed information about a specific tool call execution including full request arguments, response data, execution time, and any errors encountered.","parameters":[{"description":"Tool call ID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetToolCallDetailResponse"}}},"description":"Tool call details retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Tool call ID required"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Tool call not found"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get tool call details by ID","tags":["tool-calls"]}},"/api/v1/tool-calls/{id}/replay":{"post":{"description":"Re-executes a previous tool call with optional modified arguments. Useful for debugging and testing tool behavior with different inputs. Creates a new tool call record linked to the original.","parameters":[{"description":"Original tool call ID to replay","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ReplayToolCallRequest"}}},"description":"Optional modified arguments for replay"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ReplayToolCallResponse"}}},"description":"Tool call replayed successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Tool call ID required or invalid JSON payload"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to replay tool call"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Replay a tool call","tags":["tool-calls"]}},"/api/v1/tools":{"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)"},"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 c6632dc3..29b5de98 100644 --- a/oas/swagger.yaml +++ b/oas/swagger.yaml @@ -1676,6 +1676,15 @@ components: 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 + FR-002). A tray that merely ATTACHED to a core holds no Process handle + for it, so without this there is no mechanism at all to stop a stale + core — the consent action would have nothing to act on and could only + print instructions. Paired with LaunchedBy it is what lets a newer tray + supersede a core an older tray started. + type: integer update: $ref: '#/components/schemas/contracts.UpdateInfo' version: From 118e19e6bbe1f973ac0593fa239c8f0f98f2140f Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Sat, 8 Aug 2026 06:20:46 +0300 Subject: [PATCH 09/37] fix(tray): compare versions by SemVer 2.0 precedence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Related #957 FR-006. The tray's only version comparison sorted prerelease identifiers as whole strings, so 1.0.0-rc.10 ranked BELOW 1.0.0-rc.2. On the update path that offers an RC user a downgrade as an "update"; the supersede logic Phase 0 builds on top of it would have killed a newer core to start an older one. Extracted into a shared type so both callers get the same ordering and it has tests of its own. ## Changes - new `SemanticVersion`: SemVer 2.0 parse + §11 precedence (numeric identifiers numerically, numeric < alphanumeric, prerelease < release, larger identifier set wins), tolerating a leading "v" and a two-part core, discarding build metadata per §10 - malformed input returns nil — "no decision" — instead of 0; only the update-nudge adapter (`UpdateService.compareSemver`) collapses that to 0, where "not greater" already means "no update" ## Testing - swift test --filter SemanticVersionTests: 13 tests, 0 failures --- .../MCPProxy/Core/SemanticVersion.swift | 190 ++++++++++++++++++ .../MCPProxy/Services/UpdateService.swift | 37 ++-- .../MCPProxyTests/SemanticVersionTests.swift | 134 ++++++++++++ 3 files changed, 336 insertions(+), 25 deletions(-) create mode 100644 native/macos/MCPProxy/MCPProxy/Core/SemanticVersion.swift create mode 100644 native/macos/MCPProxy/MCPProxyTests/SemanticVersionTests.swift diff --git a/native/macos/MCPProxy/MCPProxy/Core/SemanticVersion.swift b/native/macos/MCPProxy/MCPProxy/Core/SemanticVersion.swift new file mode 100644 index 00000000..0e437c22 --- /dev/null +++ b/native/macos/MCPProxy/MCPProxy/Core/SemanticVersion.swift @@ -0,0 +1,190 @@ +// SemanticVersion.swift +// MCPProxy +// +// SemVer 2.0 precedence (Spec 092 FR-006), shared by every version comparison +// that drives a decision: the update nudge in `UpdateService` and the +// stale-core supersede in `CoreSupersede`. +// +// It exists because the tray's previous comparison sorted prerelease +// identifiers as plain strings, which makes `rc.10` *older* than `rc.2` — so a +// tray on an RC would have offered a downgrade, and the supersede logic built +// on top of it would have killed a newer core to start an older one. That is +// destructive, not merely cosmetic, which is why the corrected comparison is a +// type of its own with its own tests rather than a patch to one call site. + +import Foundation + +/// A parsed SemVer 2.0 version. +/// +/// Build metadata is parsed and then DISCARDED: §10 of the spec says it must +/// be ignored when determining precedence, so `1.2.3+abc` and `1.2.3+def` are +/// the same version. Keeping it in the type would invite an `Equatable` +/// conformance that disagrees with `<` and `>`. +struct SemanticVersion: Equatable, Comparable, CustomStringConvertible { + + let major: Int + let minor: Int + let patch: Int + + /// Dot-separated prerelease identifiers, empty for a release version. + let prerelease: [String] + + var isPrerelease: Bool { !prerelease.isEmpty } + + var description: String { + let core = "\(major).\(minor).\(patch)" + return prerelease.isEmpty ? core : core + "-" + prerelease.joined(separator: ".") + } + + // MARK: - Parsing + + /// Parse a version string, or return nil when it is not a version. + /// + /// Deliberately tolerant in exactly two ways, because both appear in real + /// payloads the tray reads: + /// - a leading `v` (GitHub tags, `mcpproxy --version`), + /// - a two-component core (`0.54` → `0.54.0`). + /// + /// Deliberately strict about everything else. `nil` is the signal that no + /// comparison is possible, and every caller must treat it as "no decision" + /// — a lenient parse that turns `development` into `0.0.0` would make a + /// dev build look older than every release and invite the supersede logic + /// to kill it. + static func parse(_ raw: String) -> SemanticVersion? { + var text = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { return nil } + + if text.hasPrefix("v") || text.hasPrefix("V") { + text = String(text.dropFirst()) + } + + // §10: build metadata is ignored for precedence. Drop it before + // anything else so `1.2.3+build.5` and `1.2.3-rc.1+build.5` both work. + if let plus = text.firstIndex(of: "+") { + let build = text[text.index(after: plus)...] + // An empty or non-identifier build section makes the whole string + // malformed rather than "the same version with junk on the end". + guard isValidDotSeparatedIdentifiers(String(build), numericMayHaveLeadingZeros: true) else { + return nil + } + text = String(text[.. Bool { + guard !text.isEmpty else { return false } + let parts = text.split(separator: ".", omittingEmptySubsequences: false) + for part in parts { + guard !part.isEmpty else { return false } + let ok = part.allSatisfy { ch in + ch.isASCII && (ch.isLetter || ch.isNumber || ch == "-") + } + guard ok else { return false } + if !numericMayHaveLeadingZeros, + part.allSatisfy({ $0.isNumber }), part.count > 1, part.hasPrefix("0") { + return false + } + } + return true + } + + // MARK: - Precedence (§11) + + static func < (lhs: SemanticVersion, rhs: SemanticVersion) -> Bool { + compare(lhs, rhs) < 0 + } + + /// -1, 0 or +1 — the ordering the whole file exists for. + static func compare(_ lhs: SemanticVersion, _ rhs: SemanticVersion) -> Int { + if lhs.major != rhs.major { return lhs.major < rhs.major ? -1 : 1 } + if lhs.minor != rhs.minor { return lhs.minor < rhs.minor ? -1 : 1 } + if lhs.patch != rhs.patch { return lhs.patch < rhs.patch ? -1 : 1 } + + // §11.3: a version WITH a prerelease has lower precedence than the + // matching release. 1.0.0-rc.1 < 1.0.0. + switch (lhs.prerelease.isEmpty, rhs.prerelease.isEmpty) { + case (true, true): return 0 + case (true, false): return 1 + case (false, true): return -1 + case (false, false): break + } + + // §11.4: compare identifiers left to right. + for index in 0.. 2. + if x != y { return x < y ? -1 : 1 } + case (.some, .none): + // §11.4.3: numeric identifiers always have lower precedence. + return -1 + case (.none, .some): + return 1 + case (.none, .none): + // §11.4.2: ASCII sort order. + return a < b ? -1 : 1 + } + } + + // §11.4.4: a larger set of fields, all else equal, has higher + // precedence. 1.0.0-rc.1 < 1.0.0-rc.1.1. + if lhs.prerelease.count != rhs.prerelease.count { + return lhs.prerelease.count < rhs.prerelease.count ? -1 : 1 + } + return 0 + } + + /// Compare two version STRINGS. Returns nil when either side is not a + /// version — the "no decision, log a reason" case FR-006 requires. Every + /// caller must handle nil explicitly rather than defaulting it to `0`, + /// which is how a malformed version becomes "equal, carry on". + static func compare(_ lhs: String, _ rhs: String) -> Int? { + guard let a = parse(lhs), let b = parse(rhs) else { return nil } + return compare(a, b) + } +} diff --git a/native/macos/MCPProxy/MCPProxy/Services/UpdateService.swift b/native/macos/MCPProxy/MCPProxy/Services/UpdateService.swift index 5f3afa2b..400a6911 100644 --- a/native/macos/MCPProxy/MCPProxy/Services/UpdateService.swift +++ b/native/macos/MCPProxy/MCPProxy/Services/UpdateService.swift @@ -87,36 +87,23 @@ final class UpdateService: ObservableObject { return "amd64" } - /// Compare two semver-ish version strings (no leading "v"). Returns: + /// Compare two semver version strings. Returns: /// - positive if `a` > `b` /// - negative if `a` < `b` /// - zero if equal or unparseable /// - /// Pre-release identifiers (e.g. `1.2.3-rc.1`) sort *before* the matching - /// release per semver §11. Anything we can't parse is treated as equal so - /// the caller falls back to its existing behaviour. + /// A thin adapter over `SemanticVersion.compare` (Spec 092 FR-006). The + /// hand-rolled comparison that used to live here compared prerelease + /// identifiers as whole strings, which ordered `rc.10` *below* `rc.2` — so + /// an RC user was offered a downgrade as an "update". The shared type + /// compares numeric identifiers numerically; see its header. + /// + /// Unparseable input keeps mapping to `0` HERE, and only here: the update + /// nudge treats "not greater" as "no update", so an unknown version can + /// only ever suppress a nudge. Decisions that can stop a process must use + /// `SemanticVersion.compare(_:_:) -> Int?` and handle nil explicitly. static func compareSemver(_ a: String, _ b: String) -> Int { - func parse(_ s: String) -> (core: [Int], pre: String)? { - let parts = s.split(separator: "-", maxSplits: 1, omittingEmptySubsequences: false) - let coreParts = parts[0].split(separator: ".") - var core: [Int] = [] - for p in coreParts { - guard let n = Int(p) else { return nil } - core.append(n) - } - while core.count < 3 { core.append(0) } - let pre = parts.count > 1 ? String(parts[1]) : "" - return (core, pre) - } - guard let pa = parse(a), let pb = parse(b) else { return 0 } - for i in 0.. 1.2.9 + XCTAssertEqual(SemanticVersion.compare("2.0.0", "10.0.0"), -1, + "major is numeric, not lexicographic") + XCTAssertEqual(SemanticVersion.compare("0.54", "0.54.0"), 0, + "a two-component core is padded, not rejected") + } + + // MARK: - Tolerated forms + + func testLeadingVIsTolerated() { + XCTAssertEqual(SemanticVersion.compare("v1.2.3", "1.2.3"), 0) + XCTAssertEqual(SemanticVersion.compare("V1.2.4", "v1.2.3"), 1) + XCTAssertEqual(SemanticVersion.parse("v0.54.0-rc.1")?.description, "0.54.0-rc.1") + } + + func testBuildMetadataIsIgnoredForPrecedence() { + XCTAssertEqual(SemanticVersion.compare("1.2.3+build.5", "1.2.3+build.9"), 0, + "SemVer §10: build metadata does not affect precedence") + XCTAssertEqual(SemanticVersion.compare("1.2.3+abc", "1.2.3"), 0) + XCTAssertEqual(SemanticVersion.compare("1.0.0-rc.2+sha.deadbee", "1.0.0-rc.10+sha.0000000"), -1, + "metadata is stripped before the prerelease comparison") + XCTAssertEqual(SemanticVersion.parse("1.2.3+abc")?.description, "1.2.3", + "metadata is discarded, not retained") + } + + // MARK: - Malformed input yields NO decision + + func testMalformedVersionsReturnNil() { + for bad in ["", " ", "development", "dev", "1.2.3.4", "1..3", "abc.def.ghi", + "1.2.x", "-1.2.3", "1.2.3-", "v", "1.2.3-rc..1", "1.2.3+"] { + XCTAssertNil(SemanticVersion.compare(bad, "1.2.3"), + "\(bad.debugDescription) must not be comparable") + XCTAssertNil(SemanticVersion.parse(bad), + "\(bad.debugDescription) must not parse") + } + } + + /// The adapter's documented lenient behaviour: unknown → 0 → "no update". + /// Pinned so the leniency stays confined to the nudge path. + func testUpdateServiceAdapterTreatsMalformedAsNoUpdate() { + XCTAssertEqual(UpdateService.compareSemver("development", "1.2.3"), 0) + XCTAssertEqual(UpdateService.compareSemver("1.2.3", "not-a-version"), 0) + } + + // MARK: - §11.4 identifier rules + + func testAlphanumericIdentifiersUseASCIIOrder() { + XCTAssertEqual(SemanticVersion.compare("1.0.0-alpha", "1.0.0-beta"), -1) + XCTAssertEqual(SemanticVersion.compare("1.0.0-beta", "1.0.0-alpha"), 1) + } + + func testNumericIdentifiersRankBelowAlphanumericOnes() { + // §11.4.3 + XCTAssertEqual(SemanticVersion.compare("1.0.0-1", "1.0.0-alpha"), -1) + XCTAssertEqual(SemanticVersion.compare("1.0.0-alpha", "1.0.0-1"), 1) + } + + func testLargerIdentifierSetWinsWhenPrefixesMatch() { + // §11.4.4 + XCTAssertEqual(SemanticVersion.compare("1.0.0-rc.1", "1.0.0-rc.1.1"), -1) + XCTAssertEqual(SemanticVersion.compare("1.0.0-alpha", "1.0.0-alpha.1"), -1) + } + + /// The canonical example chain from semver.org §11. + func testSpecExampleChainIsStrictlyIncreasing() { + let chain = [ + "1.0.0-alpha", "1.0.0-alpha.1", "1.0.0-alpha.beta", "1.0.0-beta", + "1.0.0-beta.2", "1.0.0-beta.11", "1.0.0-rc.1", "1.0.0" + ] + for (lower, higher) in zip(chain, chain.dropFirst()) { + XCTAssertEqual(SemanticVersion.compare(lower, higher), -1, + "\(lower) must sort below \(higher)") + } + } + + // MARK: - Comparable conformance agrees with compare() + + func testComparableConformanceMatchesCompare() { + let older = SemanticVersion.parse("0.54.0-rc.2")! + let newer = SemanticVersion.parse("0.54.0-rc.10")! + XCTAssertTrue(older < newer) + XCTAssertFalse(newer < older) + XCTAssertEqual([newer, older].sorted(), [older, newer]) + XCTAssertTrue(newer.isPrerelease) + XCTAssertFalse(SemanticVersion.parse("0.54.0")!.isPrerelease) + } +} From 20d186b6420f13e3ae103480b3ebef47db93141d Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Sat, 8 Aug 2026 06:29:04 +0300 Subject: [PATCH 10/37] feat(tray): supersede a stale core after an upgrade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Related #957 FR-001/FR-001a/FR-002/FR-005. After an upgrade the new tray finds the OLD core still running: the previous tray spawned it, that tray is gone, and nothing ever stopped it. The tray attached and served the old version indefinitely — the reported bug. Ownership was in-memory in the launching tray, so every pre-existing core classified as external and could never be superseded. The core now reports durable provenance (launched_by) and its pid, which is what makes the decision possible at all. ## Changes - `CoreSupersede.decide`: pure verdict — restartManaged (we hold the Process), stopAndRespawn (tray provenance + usable pid), askForConsent (user-launched, or tray-launched with no pid), or none with a logged reason. `installer` counts as tray provenance because launchCore deliberately preserves that marker on the core it spawns. - `CoreProcessIdentity`: re-checks that a pid is an mcpproxy process immediately before signalling it — pids are recycled, and the core may have died between the info read and the signal. - `BundledCore.respawnVersion`: asks the bundled binary (`version -o json`, a pure print — no config, no BBolt lock), falling back to CFBundleShortVersionString. Nil for dev builds and for a MCPPROXY_CORE_PATH override, where a restart would re-launch the same binary in a circle. - `CoreProcessManager` evaluates after every connect (attach, launch, reconnect) — the only points a version report arrives — and publishes `AppState.staleCorePrompt` for the consent case. One attempt per session; shutdown retracts the offer. - InfoResponse gains optional launched_by/pid (optional because a pre-092 core omits them, and an old core is the whole subject). ## Testing - swift test --filter CoreSupersede…|CoreProcessIdentity…|BundledCore…: 21 tests, 0 failures - swift test --filter CoreSupersedeAttachTests: 8 tests, 0 failures (real attach path against a Unix-socket stub core) - swift test --filter ModelsTests: 97 tests, 0 failures --- .../macos/MCPProxy/MCPProxy/API/Models.swift | 13 + .../MCPProxy/MCPProxy/Core/BundledCore.swift | 127 ++++++++ .../MCPProxy/Core/CoreProcessManager.swift | 260 ++++++++++++++- .../MCPProxy/Core/CoreSupersede.swift | 253 +++++++++++++++ .../MCPProxy/MCPProxy/State/AppState.swift | 11 + .../CoreSupersedeAttachTests.swift | 172 ++++++++++ .../MCPProxyTests/CoreSupersedeTests.swift | 295 ++++++++++++++++++ .../MCPProxy/MCPProxyTests/ModelsTests.swift | 22 ++ .../Support/UnixSocketHTTPStub.swift | 20 +- 9 files changed, 1168 insertions(+), 5 deletions(-) create mode 100644 native/macos/MCPProxy/MCPProxy/Core/BundledCore.swift create mode 100644 native/macos/MCPProxy/MCPProxy/Core/CoreSupersede.swift create mode 100644 native/macos/MCPProxy/MCPProxyTests/CoreSupersedeAttachTests.swift create mode 100644 native/macos/MCPProxy/MCPProxyTests/CoreSupersedeTests.swift diff --git a/native/macos/MCPProxy/MCPProxy/API/Models.swift b/native/macos/MCPProxy/MCPProxy/API/Models.swift index 41dfac8e..481fb8c5 100644 --- a/native/macos/MCPProxy/MCPProxy/API/Models.swift +++ b/native/macos/MCPProxy/MCPProxy/API/Models.swift @@ -794,12 +794,25 @@ struct InfoResponse: Codable, Equatable { let endpoints: InfoEndpoints let update: UpdateInfo? + /// Spec 092 FR-001a — durable launch provenance: "tray"/"installer" when a + /// tray process started this core, "" when the user did. Optional in the + /// model, not in the contract: a core from before this field existed omits + /// it, and an older core is precisely the one the supersede logic has to + /// reason about, so decoding must not fail on its absence. + let launchedBy: String? + + /// Spec 092 FR-002 — the core's own pid. Nil from a core too old to report + /// one, which downgrades the supersede action to "show instructions". + let pid: Int32? + enum CodingKeys: String, CodingKey { case version case webUiUrl = "web_ui_url" case listenAddr = "listen_addr" case endpoints case update + case launchedBy = "launched_by" + case pid } } diff --git a/native/macos/MCPProxy/MCPProxy/Core/BundledCore.swift b/native/macos/MCPProxy/MCPProxy/Core/BundledCore.swift new file mode 100644 index 00000000..3ec932a9 --- /dev/null +++ b/native/macos/MCPProxy/MCPProxy/Core/BundledCore.swift @@ -0,0 +1,127 @@ +// BundledCore.swift +// MCPProxy +// +// "Which core version would this tray start if it started one right now?" +// (Spec 092 FR-001). The supersede decision is a comparison against THAT +// number, not against the app's own version: restarting a core only helps if +// the binary the tray would launch is genuinely newer. + +import Foundation + +/// Reads a version out of an mcpproxy binary by asking it. +/// +/// Asking is the ground truth. The alternative — assume the bundled core +/// carries the app's `CFBundleShortVersionString` because the build script +/// stamps both from one `--version` argument — is true for every build the +/// pipeline produces and false for exactly the case that matters: a bundle +/// whose core was replaced by hand, or a partially applied update. It is kept +/// as the FALLBACK (a binary that will not answer still tells us nothing), not +/// as the primary source. +enum CoreBinaryVersion { + + /// Run ` version -o json` and return the reported version. + /// + /// `version` is a pure print in the core's cobra tree — no config load, no + /// database open — so this cannot contend with a running core for the + /// BBolt lock. That property is load-bearing; do not switch this to a + /// subcommand that initializes anything. + static func read(at path: String, timeout: TimeInterval = 5.0) -> String? { + guard FileManager.default.isExecutableFile(atPath: path) else { return nil } + + let process = Process() + process.executableURL = URL(fileURLWithPath: path) + process.arguments = ["version", "-o", "json"] + let stdout = Pipe() + process.standardOutput = stdout + process.standardError = FileHandle.nullDevice + // A core binary must never inherit the tray's socket/home overrides for + // a question this trivial. + process.environment = ["PATH": "/usr/bin:/bin"] + + do { + try process.run() + } catch { + NSLog("[MCPProxy] Could not run %@ to read its version: %@", + path, error.localizedDescription) + return nil + } + + // A binary that hangs must not hang the tray with it. + let watchdog = DispatchWorkItem { [weak process] in + guard let process, process.isRunning else { return } + NSLog("[MCPProxy] %@ did not report a version within %.0fs — killing it", path, timeout) + process.terminate() + } + DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + timeout, execute: watchdog) + + // Drain BEFORE waiting: a child that fills the pipe buffer while we sit + // in waitUntilExit deadlocks both sides. The output is tiny today, and + // this ordering keeps that from being a requirement. + let data = stdout.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + watchdog.cancel() + + guard process.terminationStatus == 0 else { return nil } + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let version = json["version"] as? String, + !version.isEmpty else { return nil } + return version + } +} + +/// The core binary shipped inside this app bundle. +enum BundledCore { + + /// `MCPProxy.app/Contents/Resources/bin/mcpproxy`, or nil when the app is + /// not running from a bundle (a `swift run` dev build) or ships no core. + static func binaryPath(bundle: Bundle = .main) -> String? { + guard let execPath = bundle.executablePath else { return nil } + let contents = URL(fileURLWithPath: execPath) + .deletingLastPathComponent() // Contents/MacOS + .deletingLastPathComponent() // Contents + guard contents.lastPathComponent == "Contents" else { return nil } + let candidate = contents + .appendingPathComponent("Resources") + .appendingPathComponent("bin") + .appendingPathComponent("mcpproxy") + return FileManager.default.isExecutableFile(atPath: candidate.path) ? candidate.path : nil + } + + /// The app's own marketing version, read from the LOADED bundle (i.e. the + /// version of the code currently executing — see `BundleUpdateWatcher` for + /// the on-disk counterpart). + static func appVersion(bundle: Bundle = .main) -> String? { + (bundle.infoDictionary?["CFBundleShortVersionString"] as? String) + .flatMap { $0.isEmpty ? nil : $0 } + } + + /// The version the tray would respawn into, or nil when there is nothing + /// better to respawn into. + /// + /// Nil is returned — and supersede therefore never fires — when: + /// - the app has no bundled core (dev build, or a bundle built without + /// one): restarting would re-resolve to whatever is on `PATH`, which is + /// as likely to be the old core as the new one; + /// - `MCPPROXY_CORE_PATH` points somewhere else: the operator has said + /// which binary to run, and a supersede would relaunch that same binary + /// in a circle. + static func respawnVersion( + bundle: Bundle = .main, + environment: [String: String] = ProcessInfo.processInfo.environment + ) -> String? { + if let override = environment["MCPPROXY_CORE_PATH"], !override.isEmpty { + return nil + } + guard let path = binaryPath(bundle: bundle) else { return nil } + if let reported = CoreBinaryVersion.read(at: path) { + return reported + } + // The binary is there but would not answer. The build pipeline stamps + // the bundle and the core from the same version, so the Info.plist is + // the best remaining estimate — and it is only ever used to decide + // "is the RUNNING core older", never to claim what got installed. + NSLog("[MCPProxy] Bundled core at %@ did not report a version — " + + "falling back to CFBundleShortVersionString", path) + return appVersion(bundle: bundle) + } +} diff --git a/native/macos/MCPProxy/MCPProxy/Core/CoreProcessManager.swift b/native/macos/MCPProxy/MCPProxy/Core/CoreProcessManager.swift index 5ef65639..3ad810c1 100644 --- a/native/macos/MCPProxy/MCPProxy/Core/CoreProcessManager.swift +++ b/native/macos/MCPProxy/MCPProxy/Core/CoreProcessManager.swift @@ -193,6 +193,31 @@ actor CoreProcessManager { /// the ladder without waiting a real minute per rung. private let socketWaitTimeout: TimeInterval + // MARK: Stale-core supersede (Spec 092 FR-001/FR-002, issue #957) + + /// What the connected core last said about itself. Captured in + /// `connectToCore()` — the one place a version report arrives — and read by + /// the supersede evaluation that runs immediately after each connect. + private var latestVersionReport: CoreVersionReport? + + /// "Which version would a restart get us?" Injectable so the state machine + /// can be driven from a test without an app bundle or a subprocess. + private let respawnVersionProvider: @Sendable () -> String? + + /// Resolved answer, memoised. The double optional distinguishes "not asked + /// yet" from "asked, and there is no bundled core" — the provider shells + /// out to the bundled binary, and re-running that on every reconnect would + /// spawn a process per attempt. + private var cachedRespawnVersion: String?? + + /// Whether this manager has already acted on a stale core. + /// + /// FR-005 forbids restart loops, and one attempt is the honest budget: if + /// the core that comes back still reports an old version, the assumption + /// behind the whole mechanism ("the bundled binary is what starts") is + /// wrong, and killing it again cannot discover that. + private var supersedeAttempted: Bool = false + // MARK: - Initialization /// - Parameters: @@ -212,7 +237,8 @@ actor CoreProcessManager { refreshInterval: TimeInterval = 30.0, probeTimeout: TimeInterval = 5.0, unresponsiveCoreTimeout: TimeInterval = 60.0, - socketWaitTimeout: TimeInterval = 60.0 + socketWaitTimeout: TimeInterval = 60.0, + respawnVersionProvider: @escaping @Sendable () -> String? = { BundledCore.respawnVersion() } ) { self.appState = appState self.notificationService = notificationService @@ -221,6 +247,7 @@ actor CoreProcessManager { self.probeTimeout = probeTimeout self.unresponsiveCoreTimeout = unresponsiveCoreTimeout self.socketWaitTimeout = socketWaitTimeout + self.respawnVersionProvider = respawnVersionProvider // `~/.mcpproxy/mcpproxy.sock`, or wherever this instance has been // relocated to (GH #936). @@ -632,7 +659,12 @@ actor CoreProcessManager { apiClient = nil probeClient = nil consecutiveProbeFailures = 0 - await MainActor.run { appState.apiClient = nil } + await MainActor.run { + appState.apiClient = nil + // An offer to restart a core we are no longer watching is worse + // than no offer: clicking it would act on a pid nobody rechecked. + appState.staleCorePrompt = nil + } let ownsCore = await MainActor.run { appState.ownership.shouldTerminateOnShutdown } guard ownsCore else { @@ -711,6 +743,9 @@ actor CoreProcessManager { await refreshState() startSSEStream() startPeriodicRefresh() + // Spec 092 FR-001: the #957 case. The core we just attached to may + // be an OLD one that an earlier tray started and nobody stopped. + await evaluateSupersede() } catch { await transitionState( to: .error(.general("Failed to connect to external core: \(error.localizedDescription)")) @@ -746,6 +781,11 @@ actor CoreProcessManager { await refreshState() startSSEStream() startPeriodicRefresh() + // A launch can still land on a core we did not start: `waitForSocket` + // is satisfied by whichever core owns the socket. And a spawn that + // resolved to something other than the bundled binary reports an old + // version too. Both are supersede cases, so the check runs here as well. + await evaluateSupersede() } /// Terminate and REAP a process we launched, and return only once it is @@ -982,6 +1022,208 @@ actor CoreProcessManager { } } + // MARK: - Stale-core supersede (Spec 092 FR-001/FR-001a/FR-002, issue #957) + + /// Stage the version report `evaluateSupersede()` reasons over. + /// + /// The seam exists because the alternative — reaching the decision through + /// a real connect — needs a core on a socket answering `/api/v1/info`, and + /// the whole point of the state machine is the cases where that core is the + /// WRONG version. Production sets this in `connectToCore()` only. + func stageVersionReport(_ report: CoreVersionReport?) { + latestVersionReport = report + } + + /// Whether a supersede has already been attempted. Readable so a test can + /// assert the idempotency budget was consumed (or not). + var didAttemptSupersede: Bool { supersedeAttempted } + + /// The version a restart would bring, resolved once. + private func respawnVersion() -> String? { + if let cached = cachedRespawnVersion { return cached } + let resolved = respawnVersionProvider() + cachedRespawnVersion = .some(resolved) + return resolved + } + + /// Act on the core's version report: supersede it, offer to, or do nothing. + /// + /// Called after EVERY successful connect — attach, launch, reconnect — which + /// is precisely "at attach time and whenever a version report arrives" + /// (FR-001), because `/api/v1/info` is only read there. + /// + /// Caller MUST hold the connection gate: the automatic branches relaunch a + /// core, and `launchWithRetries()` requires it. + /// + /// Internal rather than private so the state machine can be driven from a + /// test that has already staged a report and a gate. + func evaluateSupersede() async { + guard !superseded, !shutdownRequested else { return } + guard let report = latestVersionReport else { return } + + let bundled = respawnVersion() + let ownership = await MainActor.run { appState.ownership } + let decision = CoreSupersede.decide( + report: report, + respawnVersion: bundled, + ownership: ownership, + alreadyAttempted: supersedeAttempted + ) + + // Publish (or clear) the consent prompt before acting: an automatic + // branch must not leave a stale offer in the menu, and a `.none` + // verdict must retract one that no longer applies. + let prompt = CoreSupersede.prompt(for: decision, report: report, respawnVersion: bundled) + await MainActor.run { appState.staleCorePrompt = prompt } + + switch decision.action { + case .none: + NSLog("[MCPProxy] Supersede check: no action (%@)", decision.reason) + + case .askForConsent: + NSLog("[MCPProxy] Supersede check: offering a restart (%@)", decision.reason) + + case .restartManaged: + NSLog("[MCPProxy] Superseding stale core (%@)", decision.reason) + supersedeAttempted = true + await restartManagedCoreForSupersede() + + case .stopAndRespawn(let pid): + NSLog("[MCPProxy] Superseding stale core PID %d (%@)", pid, decision.reason) + supersedeAttempted = true + await stopCoreByPIDAndRespawn(pid: pid) + } + } + + /// The user clicked the consent item (FR-002). Returns false when there is + /// nothing to act on — no prompt, or a prompt with no pid — so the caller + /// can present instructions instead of silently doing nothing. + func supersedeWithConsent() async -> Bool { + guard !superseded, !shutdownRequested else { return false } + guard let prompt = await MainActor.run(body: { appState.staleCorePrompt }), + let pid = prompt.pid else { return false } + + // Unlike the automatic branches, this one arrives from the menu without + // the gate. + guard beginConnectionWork() else { + NSLog("[MCPProxy] Consent restart declined: a connection attempt is already running") + return false + } + defer { endConnectionWork() } + + supersedeAttempted = true + await MainActor.run { appState.staleCorePrompt = nil } + await stopCoreByPIDAndRespawn(pid: pid) + return true + } + + /// A core we hold a Process handle for: the managed path is strictly better + /// than a signal — it carries the launch generation, so the exit callback + /// cannot be mistaken for a crash and drive a competing relaunch. + private func restartManagedCoreForSupersede() async { + await tearDownConnection() + await terminateManagedProcess(reason: "superseding stale core") + guard !superseded, !shutdownRequested else { return } + await MainActor.run { + appState.isStopped = false + appState.ownership = .trayManaged + } + beginSocketEvidence() + await launchWithRetries() + } + + /// A core we only attached to. Signal it, wait for it to actually go, then + /// take ownership and start the bundled one. + private func stopCoreByPIDAndRespawn(pid: Int32) async { + await tearDownConnection() + + guard await stopCore(pid: pid) else { + await transitionState(to: .error(.general( + "Could not stop the old core (PID \(pid)). Quit it manually, then use Retry." + ))) + return + } + guard !superseded, !shutdownRequested else { return } + + await MainActor.run { + appState.isStopped = false + appState.ownership = .trayManaged + appState.staleCorePrompt = nil + } + beginSocketEvidence() + await launchWithRetries() + } + + /// SIGTERM, then SIGKILL, then confirm the socket is free. + /// + /// The identity check is re-run HERE rather than trusted from the + /// `/api/v1/info` read: pids are recycled, and between the report and this + /// signal the core may have died and had its number handed to something + /// else. Signalling that would be an unbounded mistake. + private func stopCore(pid: Int32) async -> Bool { + guard CoreProcessIdentity.isMCPProxyCore(pid: pid) else { + NSLog("[MCPProxy] Refusing to signal PID %d — it is not an mcpproxy process", pid) + return false + } + + NSLog("[MCPProxy] Stopping stale core PID %d (SIGTERM)", pid) + AppLifecycle.shared.recordCoreTerminated(pid: pid, reason: "superseded by a newer tray") + if kill(pid, SIGTERM) != 0 && errno != ESRCH { + NSLog("[MCPProxy] SIGTERM to PID %d failed (errno %d)", pid, errno) + return false + } + + if await waitFor(seconds: 5.0, until: { !CoreProcessIdentity.isRunning(pid: pid) }) == false { + NSLog("[MCPProxy] PID %d ignored SIGTERM — SIGKILL", pid) + _ = kill(pid, SIGKILL) + guard await waitFor(seconds: 3.0, until: { !CoreProcessIdentity.isRunning(pid: pid) }) else { + return false + } + } + + // The process is gone; the socket it owned may take a moment to stop + // accepting. Launching before then trips `preflightLaunch`'s "a core is + // already running" check and turns a successful supersede into an error. + let socket = socketPath + _ = await waitFor(seconds: 5.0, until: { + SocketTransport.probeSocket(path: socket) != .connectable + }) + return true + } + + /// Poll `condition` until it holds or the budget runs out. Returns whether + /// it held. + private func waitFor(seconds: TimeInterval, until condition: @Sendable () -> Bool) async -> Bool { + let deadline = Date().addingTimeInterval(seconds) + while Date() < deadline { + if condition() { return true } + do { + try await Task.sleep(nanoseconds: 100_000_000) + } catch { + return condition() + } + } + return condition() + } + + /// Drop everything bound to the core we are about to stop. Without this the + /// SSE stream and the refresh tick keep running against a dying core and + /// race the relaunch. + private func tearDownConnection() async { + sseTask?.cancel() + sseTask = nil + refreshTask?.cancel() + refreshTask = nil + if let sseClient { + await sseClient.disconnect() + } + sseClient = nil + apiClient = nil + probeClient = nil + consecutiveProbeFailures = 0 + await MainActor.run { appState.apiClient = nil } + } + // MARK: - Private: Error Handling /// Handle a core error by transitioning state and sending a notification. @@ -1245,6 +1487,16 @@ actor CoreProcessManager { let info = try await client.info() NSLog("[MCPProxy] connectToCore: got version=%@", info.version) + // Spec 092 FR-001a: the version report the supersede check reasons over. + // Recorded, not acted on — acting here would restart a core from inside + // the function every connect path awaits. `evaluateSupersede()` runs at + // the end of those paths instead. + latestVersionReport = CoreVersionReport( + runningVersion: info.version, + launchedBy: info.launchedBy ?? "", + pid: info.pid + ) + // Extract API key from web_ui_url (e.g. "http://127.0.0.1:8080/ui/?apikey=abc123") if let urlComponents = URLComponents(string: info.webUiUrl), let apikeyItem = urlComponents.queryItems?.first(where: { $0.name == "apikey" }), @@ -1852,6 +2104,10 @@ actor CoreProcessManager { await refreshState() startSSEStream() startPeriodicRefresh() + // A reconnect can land on a DIFFERENT core than the one we + // lost (the socket is whoever owns it now), so the version + // report is fresh evidence and gets the same check. + await evaluateSupersede() return } catch { // Fall through to relaunch diff --git a/native/macos/MCPProxy/MCPProxy/Core/CoreSupersede.swift b/native/macos/MCPProxy/MCPProxy/Core/CoreSupersede.swift new file mode 100644 index 00000000..1a0a7732 --- /dev/null +++ b/native/macos/MCPProxy/MCPProxy/Core/CoreSupersede.swift @@ -0,0 +1,253 @@ +// CoreSupersede.swift +// MCPProxy +// +// Spec 092 Phase 0, FR-001 / FR-001a / FR-002 / FR-005 / FR-006 — issue #957 +// ("old version App still after upgrade"). +// +// After an upgrade the new tray frequently finds an OLD core already running: +// the previous tray spawned it, the previous tray is gone, and nothing in the +// system ever stops it. The tray attaches to it and serves the old version +// indefinitely, silently. This file is the decision that ends that. +// +// The decision is a pure function on purpose. Everything it governs is +// destructive — stopping a process the user may be depending on — so the rules +// have to be readable in one place and testable without spawning anything. +// `CoreProcessManager` supplies the inputs and executes the verdict; it makes +// no policy of its own. + +import Foundation +import Darwin + +// MARK: - Inputs + +/// What the core said about itself in `GET /api/v1/info`. +struct CoreVersionReport: Equatable { + /// `info.version` — the version the running core reports. + let runningVersion: String + + /// `info.launched_by` — DURABLE provenance (FR-001a): "tray"/"installer" + /// when a tray process started this core, "" when the user did or it is + /// unknown. Durable is the whole point: in-memory ownership dies with the + /// tray that spawned the core, so every pre-existing core would otherwise + /// classify as external — defeating FR-001 in exactly the tray-upgrade + /// scenario it targets. + let launchedBy: String + + /// `info.pid` — nil when the core is too old to report one. The only stop + /// mechanism available for a core the tray merely attached to. + let pid: Int32? +} + +// MARK: - Verdict + +/// What to do about the core we are connected to. +enum CoreSupersedeAction: Equatable { + /// Nothing. Also the answer for every ambiguity — see the `reason`. + case none + + /// The tray spawned this core in THIS session, so it holds a Process + /// handle: stop it through the normal managed path and relaunch. + case restartManaged + + /// A tray started this core (possibly a tray generation that no longer + /// exists) and it is older than the bundled core. FR-001 authorizes + /// stopping it without asking; the pid is the mechanism. + case stopAndRespawn(pid: Int32) + + /// User- or externally-launched, or tray-launched with no usable pid. + /// FR-002: surface an action, never act. `pid == nil` means the action can + /// only present instructions. + case askForConsent(pid: Int32?) +} + +/// The verdict plus why — the reason is logged for every outcome, including +/// (especially) the silent ones, because "the tray did nothing" is otherwise +/// indistinguishable from "the tray never looked". +struct CoreSupersedeDecision: Equatable { + let action: CoreSupersedeAction + let reason: String + + static func none(_ reason: String) -> CoreSupersedeDecision { + CoreSupersedeDecision(action: .none, reason: reason) + } +} + +/// The consent prompt the menu renders (FR-002). Nil when there is nothing to +/// offer, which is the steady state. +struct StaleCorePrompt: Equatable { + let runningVersion: String + let bundledVersion: String + /// nil → the menu item explains how to stop the core by hand instead of + /// offering to do it. + let pid: Int32? + + var menuTitle: String { + "Old core v\(runningVersion) running — Restart into v\(bundledVersion)" + } +} + +// MARK: - The decision + +enum CoreSupersede { + + /// Provenance values that mean "a tray process started this core". + /// + /// `installer` is in here deliberately. When the macOS PKG postinstall + /// launches the app it stamps `MCPPROXY_LAUNCHED_BY=installer`, and + /// `CoreProcessManager.launchCore` explicitly does NOT overwrite that + /// marker for the core it spawns (first-run telemetry attribution + /// outranks "tray"). So an `installer` core is a TRAY-SPAWNED core wearing + /// a different label, and excluding it would leave the PKG upgrade path — + /// one of the two paths #957 reports — asking for consent it should not + /// need. + static let trayProvenance: Set = ["tray", "installer"] + + /// Decide what to do about the running core. + /// + /// - Parameters: + /// - report: what the core said about itself. + /// - respawnVersion: the version the tray would get if it restarted the + /// core RIGHT NOW — i.e. the bundled core's version — or nil when the + /// tray has no bundled core to offer (dev builds, a + /// `MCPPROXY_CORE_PATH` override, a Homebrew core on `PATH`). + /// Restarting into the same binary cannot improve anything, and a + /// supersede that respawns the identical old version is a loop. + /// - ownership: whether the tray holds a Process handle for this core. + /// - alreadyAttempted: whether this manager already acted once. One + /// attempt per connection episode: if the respawned core still reports + /// an old version something is wrong that another kill will not fix + /// (FR-005 — no restart loops). + static func decide( + report: CoreVersionReport, + respawnVersion: String?, + ownership: CoreOwnership, + alreadyAttempted: Bool + ) -> CoreSupersedeDecision { + guard let respawnVersion, !respawnVersion.isEmpty else { + return .none("no bundled core to supersede into") + } + guard !report.runningVersion.isEmpty else { + return .none("the running core did not report a version") + } + + // FR-006: malformed on either side is "no decision", never "equal". + guard let order = SemanticVersion.compare(report.runningVersion, respawnVersion) else { + return .none( + "cannot compare running \(report.runningVersion) with bundled \(respawnVersion)" + ) + } + + if order == 0 { + // FR-005: the overwhelmingly common case. Silent, no churn. + return .none("running core v\(report.runningVersion) matches the bundled core") + } + if order > 0 { + // FR-005: a downgrade is never automatic. A user running a newer + // core than the tray ships (a locally built one, a newer Homebrew + // core) is doing it on purpose. + return .none( + "running core v\(report.runningVersion) is newer than the bundled " + + "v\(respawnVersion) — not downgrading" + ) + } + + if alreadyAttempted { + return .none("already superseded once in this session — not retrying") + } + + // FR-001: a core this tray spawned this session. The managed path is + // strictly better than a pid kill — it has the Process handle, the + // termination generation, and the reaper. + if ownership == .trayManaged { + return CoreSupersedeDecision( + action: .restartManaged, + reason: "tray-managed core v\(report.runningVersion) is older than the " + + "bundled v\(respawnVersion)" + ) + } + + // FR-001a: durable provenance. This is the #957 case — an old tray's + // core outliving the tray that made it. + if trayProvenance.contains(report.launchedBy) { + guard let pid = report.pid, pid > 1 else { + return CoreSupersedeDecision( + action: .askForConsent(pid: nil), + reason: "core v\(report.runningVersion) reports tray provenance but no " + + "usable pid — asking instead of guessing" + ) + } + return CoreSupersedeDecision( + action: .stopAndRespawn(pid: pid), + reason: "core v\(report.runningVersion) (launched_by=\(report.launchedBy)) is " + + "older than the bundled v\(respawnVersion)" + ) + } + + // FR-002: user-launched. Never touched without an explicit click. + let usablePID = (report.pid.map { $0 > 1 } ?? false) ? report.pid : nil + return CoreSupersedeDecision( + action: .askForConsent(pid: usablePID), + reason: "core v\(report.runningVersion) was not launched by a tray — consent required" + ) + } + + /// The prompt to publish for a verdict, or nil when there is nothing to + /// show. Kept next to `decide` so the menu can never disagree with it. + static func prompt( + for decision: CoreSupersedeDecision, + report: CoreVersionReport, + respawnVersion: String? + ) -> StaleCorePrompt? { + guard case .askForConsent(let pid) = decision.action, let respawnVersion else { + return nil + } + return StaleCorePrompt( + runningVersion: report.runningVersion, + bundledVersion: respawnVersion, + pid: pid + ) + } +} + +// MARK: - Killing by pid, safely + +/// Answers "is the process behind this pid actually an mcpproxy core?". +/// +/// The pid arrives over a trusted channel (the core's own Unix socket), but +/// acting on it means sending a signal to an arbitrary process id, and pids +/// are recycled. A core that died between the `/api/v1/info` response and the +/// signal could have had its pid reused by something else entirely — so the +/// identity is re-checked immediately before the signal, not once at read +/// time. +enum CoreProcessIdentity { + + /// Absolute path of the executable behind `pid`, or nil when the process + /// is gone or not inspectable (another user's process, in particular). + static func executablePath(ofPID pid: Int32) -> String? { + var buffer = [CChar](repeating: 0, count: Int(MAXPATHLEN)) + let length = proc_pidpath(pid, &buffer, UInt32(buffer.count)) + guard length > 0 else { return nil } + return String(cString: buffer) + } + + /// Whether it is safe to signal this pid as "the mcpproxy core". + /// + /// A name check, not a proof of identity — but it is the difference + /// between a bounded mistake (signalling a *different* mcpproxy) and an + /// unbounded one (signalling an unrelated process that inherited the pid). + static func isMCPProxyCore(pid: Int32) -> Bool { + guard pid > 1 else { return false } + guard let path = executablePath(ofPID: pid) else { return false } + let name = (path as NSString).lastPathComponent + return name == "mcpproxy" || name.hasPrefix("mcpproxy-") + } + + /// Whether the process still exists (and we may signal it). + static func isRunning(pid: Int32) -> Bool { + guard pid > 1 else { return false } + if kill(pid, 0) == 0 { return true } + // EPERM means it exists and belongs to someone else — which is exactly + // the "a different user owns it" edge case the spec calls out. + return errno == EPERM + } +} diff --git a/native/macos/MCPProxy/MCPProxy/State/AppState.swift b/native/macos/MCPProxy/MCPProxy/State/AppState.swift index 51e04c61..6d07ed18 100644 --- a/native/macos/MCPProxy/MCPProxy/State/AppState.swift +++ b/native/macos/MCPProxy/MCPProxy/State/AppState.swift @@ -217,6 +217,17 @@ final class AppState: ObservableObject { @Published var updateAvailable: String? = nil @Published var autoStartEnabled: Bool = false + /// Spec 092 FR-002 — an older core is running that the tray is NOT allowed + /// to stop on its own. Nil in the steady state; when set, the menu offers + /// the restart as an explicit user action. Published rather than derived so + /// the decision is made once, by `CoreSupersede`, and merely rendered here. + @Published var staleCorePrompt: StaleCorePrompt? = nil + + /// Spec 092 FR-003 — the app bundle ON DISK is a newer version than the one + /// running (a drag-install upgrade over a running app). Nil in the steady + /// state; when set, the menu offers a relaunch. + @Published var replacedBundleVersion: String? = nil + /// Base URL for the Web UI, populated from /api/v1/info on connect. /// Falls back to localhost:8080 until the actual URL is fetched. @Published var webUIBaseURL: String = "http://127.0.0.1:8080" diff --git a/native/macos/MCPProxy/MCPProxyTests/CoreSupersedeAttachTests.swift b/native/macos/MCPProxy/MCPProxyTests/CoreSupersedeAttachTests.swift new file mode 100644 index 00000000..856357b4 --- /dev/null +++ b/native/macos/MCPProxy/MCPProxyTests/CoreSupersedeAttachTests.swift @@ -0,0 +1,172 @@ +// CoreSupersedeAttachTests.swift +// MCPProxyTests +// +// Spec 092 FR-001/FR-002 through the REAL attach path: a core on a Unix socket +// answering `/api/v1/info`, the manager attaching to it, and the supersede +// verdict landing in `AppState` (or not). +// +// The decision table is covered by `CoreSupersedeDecisionTests`. What can only +// be shown here is the wiring: that a version report actually reaches the +// decision, that the prompt is published where the menu reads it, and that a +// verdict which would signal a process refuses to signal one that is not a +// core. + +import XCTest +@testable import MCPProxy + +final class CoreSupersedeAttachTests: XCTestCase { + + private var stub: UnixSocketHTTPStub? + private var manager: CoreProcessManager? + + override func tearDown() async throws { + if let manager { await manager.shutdown() } + manager = nil + stub?.stop() + stub = nil + try await super.tearDown() + } + + /// Attach to a stub core with a controllable info payload. + /// `maySpawn: false` guarantees no real binary can ever be launched here. + @discardableResult + private func attach( + coreVersion: String, + launchedBy: String?, + pid: Int32?, + bundledVersion: String? + ) async throws -> AppState { + let stub = UnixSocketHTTPStub.healthyCore( + version: coreVersion, launchedBy: launchedBy, pid: pid + ) + try stub.start() + self.stub = stub + + let appState = await MainActor.run { AppState() } + let manager = CoreProcessManager( + appState: appState, + notificationService: NotificationService(deliveryEnabled: false), + reconnectionPolicy: ReconnectionPolicy( + baseDelay: 0.05, maxDelay: 0.1, maxAttempts: 2, jitterFactor: 0.0 + ), + socketPath: stub.path, + refreshInterval: 60, + respawnVersionProvider: { bundledVersion } + ) + self.manager = manager + await manager.start(maySpawn: false) + return appState + } + + // MARK: - FR-002: the consent offer + + func testAttachingToAnOlderUserLaunchedCorePublishesTheOffer() async throws { + let appState = try await attach( + coreVersion: "0.53.0", launchedBy: "", pid: 4242, bundledVersion: "0.54.0" + ) + + let prompt = await MainActor.run { appState.staleCorePrompt } + XCTAssertEqual(prompt?.runningVersion, "0.53.0") + XCTAssertEqual(prompt?.bundledVersion, "0.54.0") + XCTAssertEqual(prompt?.pid, 4242) + let connected = await MainActor.run { appState.coreState } + XCTAssertEqual(connected, .connected, + "offering a restart must not disturb the connection") + let attempted = await manager?.didAttemptSupersede + XCTAssertEqual(attempted, false, "FR-002: no action without an explicit click") + } + + /// A core from before Spec 092 sends neither field. It must be treated as + /// user-launched (consent), never as tray-owned. + func testPre092CoreWithoutProvenanceGetsTheConsentPathOnly() async throws { + let appState = try await attach( + coreVersion: "0.53.0", launchedBy: nil, pid: nil, bundledVersion: "0.54.0" + ) + + let prompt = await MainActor.run { appState.staleCorePrompt } + XCTAssertNotNil(prompt, "an old core must still be surfaced") + XCTAssertNil(prompt?.pid, "no pid on the wire means the action can only instruct") + let connected = await MainActor.run { appState.coreState } + XCTAssertEqual(connected, .connected) + } + + // MARK: - FR-005: silence when versions match + + func testMatchingVersionsLeaveNoPrompt() async throws { + let appState = try await attach( + coreVersion: "0.54.0", launchedBy: "tray", pid: 4242, bundledVersion: "v0.54.0" + ) + + let prompt = await MainActor.run { appState.staleCorePrompt } + XCTAssertNil(prompt, "FR-005: matching versions must produce no prompt and no churn") + let connected = await MainActor.run { appState.coreState } + XCTAssertEqual(connected, .connected) + } + + func testNoBundledCoreMeansNoPrompt() async throws { + let appState = try await attach( + coreVersion: "0.1.0", launchedBy: "tray", pid: 4242, bundledVersion: nil + ) + let prompt = await MainActor.run { appState.staleCorePrompt } + XCTAssertNil(prompt, "with nothing better to offer, offering a restart is a lie") + } + + // MARK: - The kill guard + + /// The automatic branch resolves to `stopAndRespawn`, and the pid it is + /// handed belongs to the test runner. The tray must recognise that it is + /// not an mcpproxy process and refuse — surfacing an error instead of + /// signalling something it does not own. + func testAutomaticSupersedeRefusesAPIDThatIsNotACore() async throws { + let foreignPID = ProcessInfo.processInfo.processIdentifier + let appState = try await attach( + coreVersion: "0.53.0", launchedBy: "tray", pid: foreignPID, bundledVersion: "0.54.0" + ) + + // Still alive: the guard fired before any signal. + XCTAssertTrue(CoreProcessIdentity.isRunning(pid: foreignPID)) + + let state = await MainActor.run { appState.coreState } + guard case .error = state else { + return XCTFail("a refused supersede must surface, not fail silently (got \(state))") + } + let attempted = await manager?.didAttemptSupersede + XCTAssertEqual(attempted, true, + "the budget is consumed even on refusal — FR-005 forbids retry loops") + } + + // MARK: - The consent action + + func testConsentActionDeclinesWhenThereIsNothingToActOn() async throws { + _ = try await attach( + coreVersion: "0.54.0", launchedBy: "tray", pid: 4242, bundledVersion: "0.54.0" + ) + let acted = await manager?.supersedeWithConsent() + XCTAssertEqual(acted, false, "no prompt means nothing to restart") + } + + /// A prompt with no pid must report failure so the caller shows + /// instructions rather than pretending it did something (FR-002). + func testConsentActionReportsFailureWithoutAPID() async throws { + _ = try await attach( + coreVersion: "0.53.0", launchedBy: nil, pid: nil, bundledVersion: "0.54.0" + ) + let acted = await manager?.supersedeWithConsent() + XCTAssertEqual(acted, false, "with no pid the tray can only instruct") + } + + /// Shutting down retracts the offer: a pid nobody re-checked is not + /// something the menu may still invite the user to signal. + func testShutdownClearsTheOffer() async throws { + let appState = try await attach( + coreVersion: "0.53.0", launchedBy: "", pid: 4242, bundledVersion: "0.54.0" + ) + let offered = await MainActor.run { appState.staleCorePrompt } + XCTAssertNotNil(offered) + + await manager?.shutdown() + manager = nil + let retracted = await MainActor.run { appState.staleCorePrompt } + XCTAssertNil(retracted) + } +} diff --git a/native/macos/MCPProxy/MCPProxyTests/CoreSupersedeTests.swift b/native/macos/MCPProxy/MCPProxyTests/CoreSupersedeTests.swift new file mode 100644 index 00000000..753d01e5 --- /dev/null +++ b/native/macos/MCPProxy/MCPProxyTests/CoreSupersedeTests.swift @@ -0,0 +1,295 @@ +// CoreSupersedeTests.swift +// MCPProxyTests +// +// Spec 092 Phase 0 — issue #957 ("old version App still after upgrade"). +// +// The decision table first, driven from fixtures, because every branch of it +// either kills a process or declines to; then the two properties that cannot +// be expressed as a pure function: that the manager publishes the consent +// prompt it decided on, and that it refuses to signal a pid that is not an +// mcpproxy process. + +import XCTest +@testable import MCPProxy + +final class CoreSupersedeDecisionTests: XCTestCase { + + /// One row of the decision table. + private struct Fixture { + let name: String + var running: String = "0.53.0" + var bundled: String? = "0.54.0" + var launchedBy: String = "" + var pid: Int32? = 4242 + var ownership: CoreOwnership = .externalAttached + var alreadyAttempted: Bool = false + let expected: CoreSupersedeAction + } + + private func run(_ fixture: Fixture, file: StaticString = #filePath, line: UInt = #line) { + let report = CoreVersionReport( + runningVersion: fixture.running, + launchedBy: fixture.launchedBy, + pid: fixture.pid + ) + let decision = CoreSupersede.decide( + report: report, + respawnVersion: fixture.bundled, + ownership: fixture.ownership, + alreadyAttempted: fixture.alreadyAttempted + ) + XCTAssertEqual(decision.action, fixture.expected, + "\(fixture.name): \(decision.reason)", file: file, line: line) + XCTAssertFalse(decision.reason.isEmpty, + "\(fixture.name): every verdict must carry a reason (FR-006)", + file: file, line: line) + } + + // MARK: - FR-001 / FR-001a: automatic supersede + + func testTrayLaunchedOlderCoreIsSupersededAutomatically() { + run(Fixture( + name: "the #957 case: an older tray's core outliving that tray", + launchedBy: "tray", + expected: .stopAndRespawn(pid: 4242) + )) + } + + /// `installer` provenance means the PKG postinstall launched the app, and + /// `CoreProcessManager.launchCore` deliberately does not overwrite that + /// marker on the core it spawns. Treating it as external would leave the + /// PKG upgrade path — half of #957 — asking for consent it should not need. + func testInstallerProvenanceCountsAsTrayLaunched() { + run(Fixture( + name: "installer-launched core", + launchedBy: "installer", + expected: .stopAndRespawn(pid: 4242) + )) + } + + func testTrayManagedCoreUsesTheManagedRestartPath() { + run(Fixture( + name: "core this tray spawned this session", + launchedBy: "tray", + ownership: .trayManaged, + expected: .restartManaged + )) + run(Fixture( + name: "managed ownership outranks a missing provenance marker", + launchedBy: "", + pid: nil, + ownership: .trayManaged, + expected: .restartManaged + )) + } + + // MARK: - FR-002: consent + + func testUserLaunchedCoreOnlyGetsAnOffer() { + run(Fixture( + name: "user-launched core is never killed automatically", + launchedBy: "", + expected: .askForConsent(pid: 4242) + )) + } + + func testUnrecognisedProvenanceIsTreatedAsUserLaunched() { + run(Fixture( + name: "a marker we do not know is not permission", + launchedBy: "launchd", + expected: .askForConsent(pid: 4242) + )) + } + + func testMissingPIDDowngradesToInstructions() { + run(Fixture( + name: "tray provenance but no pid — nothing to signal", + launchedBy: "tray", + pid: nil, + expected: .askForConsent(pid: nil) + )) + run(Fixture( + name: "a pid of 1 or 0 is never a core", + launchedBy: "tray", + pid: 1, + expected: .askForConsent(pid: nil) + )) + run(Fixture( + name: "user-launched core with an unusable pid", + launchedBy: "", + pid: 0, + expected: .askForConsent(pid: nil) + )) + } + + // MARK: - FR-005: idempotent, no loops, no downgrades + + func testMatchingVersionsDoNothing() { + run(Fixture(name: "versions match", running: "0.54.0", bundled: "0.54.0", expected: .none)) + run(Fixture(name: "match modulo a v prefix and build metadata", + running: "v0.54.0+abc", bundled: "0.54.0", expected: .none)) + run(Fixture(name: "match, tray-managed", running: "0.54.0", bundled: "0.54.0", + launchedBy: "tray", ownership: .trayManaged, expected: .none)) + } + + func testNewerRunningCoreIsNeverDowngraded() { + run(Fixture(name: "running core is newer", running: "0.55.0", bundled: "0.54.0", + launchedBy: "tray", expected: .none)) + run(Fixture(name: "the release outranks the bundled RC", + running: "0.54.0", bundled: "0.54.0-rc.3", + launchedBy: "tray", ownership: .trayManaged, expected: .none)) + } + + /// FR-006 in the place it matters most: with a string comparison + /// `0.54.0-rc.10` looks older than `0.54.0-rc.2`, and this row would be a + /// kill instead of a no-op. + func testNumericPrereleaseOrderingDrivesTheVerdict() { + run(Fixture(name: "rc.10 running, rc.2 bundled — no supersede", + running: "0.54.0-rc.10", bundled: "0.54.0-rc.2", + launchedBy: "tray", expected: .none)) + run(Fixture(name: "rc.2 running, rc.10 bundled — supersede", + running: "0.54.0-rc.2", bundled: "0.54.0-rc.10", + launchedBy: "tray", expected: .stopAndRespawn(pid: 4242))) + } + + func testOneAttemptPerSession() { + run(Fixture(name: "already superseded once", launchedBy: "tray", + alreadyAttempted: true, expected: .none)) + run(Fixture(name: "already superseded once, managed", launchedBy: "tray", + ownership: .trayManaged, alreadyAttempted: true, expected: .none)) + } + + // MARK: - FR-006: no decision without comparable versions + + func testUncomparableVersionsMeanNoAction() { + run(Fixture(name: "development build running", running: "development", + launchedBy: "tray", expected: .none)) + run(Fixture(name: "core reported no version", running: "", + launchedBy: "tray", expected: .none)) + run(Fixture(name: "bundled version unreadable", bundled: "dev", + launchedBy: "tray", expected: .none)) + } + + func testNoBundledCoreMeansNothingToSupersedeInto() { + run(Fixture(name: "no bundled core (dev build / PATH core)", bundled: nil, + launchedBy: "tray", expected: .none)) + run(Fixture(name: "empty bundled version", bundled: "", + launchedBy: "tray", expected: .none)) + } + + // MARK: - The prompt the menu renders + + func testPromptIsOnlyProducedForTheConsentVerdict() { + let report = CoreVersionReport(runningVersion: "0.53.0", launchedBy: "", pid: 4242) + let decision = CoreSupersede.decide(report: report, respawnVersion: "0.54.0", + ownership: .externalAttached, alreadyAttempted: false) + let prompt = CoreSupersede.prompt(for: decision, report: report, respawnVersion: "0.54.0") + XCTAssertEqual(prompt, StaleCorePrompt(runningVersion: "0.53.0", + bundledVersion: "0.54.0", pid: 4242)) + XCTAssertEqual(prompt?.menuTitle, "Old core v0.53.0 running — Restart into v0.54.0") + + let quiet = CoreSupersede.decide(report: report, respawnVersion: "0.53.0", + ownership: .externalAttached, alreadyAttempted: false) + XCTAssertNil(CoreSupersede.prompt(for: quiet, report: report, respawnVersion: "0.53.0"), + "a no-action verdict must not leave an offer in the menu") + + let automatic = CoreSupersede.decide( + report: CoreVersionReport(runningVersion: "0.53.0", launchedBy: "tray", pid: 4242), + respawnVersion: "0.54.0", ownership: .externalAttached, alreadyAttempted: false + ) + XCTAssertNil(CoreSupersede.prompt(for: automatic, report: report, respawnVersion: "0.54.0"), + "the automatic branch acts; it must not also ask") + } +} + +// MARK: - Killing by pid, safely + +final class CoreProcessIdentityTests: XCTestCase { + + /// The guard that stands between "the core died and its pid was recycled" + /// and "the tray SIGKILLed an unrelated process". + func testOnlyMCPProxyProcessesAreRecognised() { + let ownPID = ProcessInfo.processInfo.processIdentifier + XCTAssertFalse(CoreProcessIdentity.isMCPProxyCore(pid: ownPID), + "the test runner is not an mcpproxy core") + XCTAssertNotNil(CoreProcessIdentity.executablePath(ofPID: ownPID), + "the path of our own process must be readable") + } + + func testDegenerateAndDeadPIDsAreRejected() { + XCTAssertFalse(CoreProcessIdentity.isMCPProxyCore(pid: 0)) + XCTAssertFalse(CoreProcessIdentity.isMCPProxyCore(pid: 1), "launchd is not a core") + XCTAssertFalse(CoreProcessIdentity.isRunning(pid: 0)) + XCTAssertFalse(CoreProcessIdentity.isRunning(pid: -1)) + } + + func testLiveProcessIsReportedRunning() { + XCTAssertTrue(CoreProcessIdentity.isRunning(pid: ProcessInfo.processInfo.processIdentifier)) + } + + func testExitedProcessIsNotReportedRunning() throws { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/sh") + process.arguments = ["-c", "exit 0"] + try process.run() + process.waitUntilExit() + // The pid is reaped by Foundation, so it no longer names a process. + XCTAssertFalse(CoreProcessIdentity.isRunning(pid: process.processIdentifier)) + } +} + +// MARK: - Which version would a restart bring? + +final class BundledCoreResolutionTests: XCTestCase { + + /// An operator who pinned `MCPPROXY_CORE_PATH` has said which binary runs. + /// Superseding would relaunch that same binary in a circle. + func testCorePathOverrideDisablesSupersede() { + XCTAssertNil(BundledCore.respawnVersion(environment: ["MCPPROXY_CORE_PATH": "/tmp/x"])) + } + + /// The test bundle is not an app bundle, so there is no bundled core — and + /// the honest answer is nil, not the test runner's version. + func testNoAppBundleMeansNoBundledCore() { + XCTAssertNil(BundledCore.binaryPath(bundle: Bundle(for: BundledCoreResolutionTests.self))) + XCTAssertNil(BundledCore.respawnVersion(bundle: Bundle(for: BundledCoreResolutionTests.self), + environment: [:])) + } + + func testVersionIsReadByAskingTheBinary() throws { + let dir = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("mcpproxy-bundledcore-\(UUID().uuidString.prefix(8))") + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + addTeardownBlock { try? FileManager.default.removeItem(at: dir) } + + let binary = dir.appendingPathComponent("mcpproxy") + try """ + #!/bin/sh + [ "$1" = version ] || exit 2 + echo '{"version":"v0.54.0","edition":"personal"}' + """.write(to: binary, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: binary.path) + + XCTAssertEqual(CoreBinaryVersion.read(at: binary.path), "v0.54.0") + } + + func testUnreadableOrFailingBinaryYieldsNoVersion() throws { + XCTAssertNil(CoreBinaryVersion.read(at: "/nonexistent/mcpproxy")) + + let dir = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("mcpproxy-badcore-\(UUID().uuidString.prefix(8))") + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + addTeardownBlock { try? FileManager.default.removeItem(at: dir) } + + let failing = dir.appendingPathComponent("mcpproxy") + try "#!/bin/sh\nexit 3\n".write(to: failing, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: failing.path) + XCTAssertNil(CoreBinaryVersion.read(at: failing.path)) + + let noisy = dir.appendingPathComponent("mcpproxy-noisy") + try "#!/bin/sh\necho not json\n".write(to: noisy, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: noisy.path) + XCTAssertNil(CoreBinaryVersion.read(at: noisy.path), + "output that is not the version JSON must not be guessed at") + } +} diff --git a/native/macos/MCPProxy/MCPProxyTests/ModelsTests.swift b/native/macos/MCPProxy/MCPProxyTests/ModelsTests.swift index 51013bc8..c11487d3 100644 --- a/native/macos/MCPProxy/MCPProxyTests/ModelsTests.swift +++ b/native/macos/MCPProxy/MCPProxyTests/ModelsTests.swift @@ -684,6 +684,28 @@ final class ModelsTests: XCTestCase { XCTAssertEqual(info.endpoints.http, "http://127.0.0.1:8080") XCTAssertEqual(info.endpoints.socket, "~/.mcpproxy/mcpproxy.sock") XCTAssertNil(info.update) + // Spec 092: a core from before FR-001a/FR-002 sends neither field, and + // an OLD core is exactly the one the supersede check must reason about + // — so their absence must not fail decoding. + XCTAssertNil(info.launchedBy) + XCTAssertNil(info.pid) + } + + /// Spec 092 FR-001a/FR-002: durable provenance and the core's pid. + func testDecodeInfoResponseWithLaunchProvenanceAndPID() throws { + let json = """ + { + "version": "v0.54.0", + "web_ui_url": "http://127.0.0.1:8080/ui/", + "listen_addr": "127.0.0.1:8080", + "endpoints": { "http": "http://127.0.0.1:8080", "socket": "s" }, + "launched_by": "tray", + "pid": 4711 + } + """ + let info = try decode(InfoResponse.self, from: json) + XCTAssertEqual(info.launchedBy, "tray") + XCTAssertEqual(info.pid, 4711) } func testDecodeInfoResponseWithUpdateAvailable() throws { diff --git a/native/macos/MCPProxy/MCPProxyTests/Support/UnixSocketHTTPStub.swift b/native/macos/MCPProxy/MCPProxyTests/Support/UnixSocketHTTPStub.swift index 908eb5f0..e3325e22 100644 --- a/native/macos/MCPProxy/MCPProxyTests/Support/UnixSocketHTTPStub.swift +++ b/native/macos/MCPProxy/MCPProxyTests/Support/UnixSocketHTTPStub.swift @@ -348,21 +348,35 @@ extension UnixSocketHTTPStub { /// `web_ui_url` points at 127.0.0.1:1, a port nothing can be listening on, /// so the SSE client (which is deliberately TCP-only) fails fast and retries /// in the background instead of reaching a real core on :8080. + /// - Parameters: + /// - version: what `/api/v1/info` reports as the running core's version. + /// The default is deliberately unparseable-as-a-release ("0.0.0-test") + /// and every existing test relies on the supersede check declining to + /// act on it. + /// - launchedBy: Spec 092 FR-001a provenance. Nil omits the field + /// entirely, which is what a pre-092 core looks like on the wire. + /// - pid: Spec 092 FR-002 pid. Nil omits the field. static func healthyCore( at socketPath: String? = nil, - ready: ReadyBehaviour = ReadyBehaviour() + ready: ReadyBehaviour = ReadyBehaviour(), + version: String = "0.0.0-test", + launchedBy: String? = nil, + pid: Int32? = nil ) -> UnixSocketHTTPStub { UnixSocketHTTPStub(at: socketPath) { _, path in switch path { case "/ready": return ready.current case "/api/v1/info": + var extras = "" + if let launchedBy { extras += ",\"launched_by\":\"\(launchedBy)\"" } + if let pid { extras += ",\"pid\":\(pid)" } return .json(""" {"success":true,"data":{ - "version":"0.0.0-test", + "version":"\(version)", "web_ui_url":"http://127.0.0.1:1/ui/?apikey=test-api-key", "listen_addr":"127.0.0.1:1", - "endpoints":{"http":"http://127.0.0.1:1","socket":"unix"} + "endpoints":{"http":"http://127.0.0.1:1","socket":"unix"}\(extras) }} """) default: From 387d15e5674b5f2eb406546eddeec269c8b49327 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Sat, 8 Aug 2026 06:32:25 +0300 Subject: [PATCH 11/37] feat(tray): offer a relaunch when the app bundle is replaced MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Related #957 FR-003 and the menu half of FR-002. A drag-install replaces /Applications/MCPProxy.app underneath the running process: macOS neither notifies nor restarts it, so the old code — and the old core it manages — keeps running until the user works out that they have to quit it. ## Changes - `BundleUpdateWatcher` reads CFBundleShortVersionString from the Info.plist ON DISK on every check. Not `Bundle(path:)`: Foundation caches bundles by path and would keep answering with the pre-upgrade dictionary, which is exactly the state being detected. Offers only on a strictly newer, SemVer-comparable version — equal, older and unparseable all stay silent. - Checked at launch, on every `applicationDidBecomeActive`, and on a 5-minute timer. - Menu: "MCPProxy was updated to vY — Relaunch" (stops the core, `open -n` the new bundle, then terminates — `-n` because plain `open` activates this stale process, the reported symptom) and "Old core vX running — Restart into vY" (FR-002 consent; with no pid to act on it presents instructions instead of failing silently). ## Testing - swift test --filter BundleUpdateWatcherTests: 7 tests, 0 failures (incl. rewriting a fixture bundle's plist mid-test) - swift test --filter SupersedeMenuTests: 6 tests, 0 failures (real controller + real rebuildMenu through the existing menu-host seam) --- .../macos/MCPProxy/MCPProxy/MCPProxyApp.swift | 138 ++++++++++++++++++ .../Services/BundleUpdateWatcher.swift | 69 +++++++++ .../BundleUpdateWatcherTests.swift | 130 +++++++++++++++++ .../MCPProxyTests/SupersedeMenuTests.swift | 131 +++++++++++++++++ 4 files changed, 468 insertions(+) create mode 100644 native/macos/MCPProxy/MCPProxy/Services/BundleUpdateWatcher.swift create mode 100644 native/macos/MCPProxy/MCPProxyTests/BundleUpdateWatcherTests.swift create mode 100644 native/macos/MCPProxy/MCPProxyTests/SupersedeMenuTests.swift diff --git a/native/macos/MCPProxy/MCPProxy/MCPProxyApp.swift b/native/macos/MCPProxy/MCPProxy/MCPProxyApp.swift index ecf1eeba..b2de64ce 100644 --- a/native/macos/MCPProxy/MCPProxy/MCPProxyApp.swift +++ b/native/macos/MCPProxy/MCPProxy/MCPProxyApp.swift @@ -284,6 +284,19 @@ final class AppController: NSObject, NSApplicationDelegate, NSWindowDelegate, NS } .store(in: &cancellables) + // Spec 092 FR-003: has the bundle on disk been replaced under us? + // Checked once at launch (a drag-install over a running app that the + // user never activates afterwards is still an upgrade that must not + // leave the old version serving) and every 5 minutes thereafter, plus + // on every activation — see applicationDidBecomeActive. + refreshReplacedBundleVersion() + Timer.publish(every: 300, on: .main, in: .common) + .autoconnect() + .sink { [weak self] _ in + MainActor.assumeIsolated { self?.refreshReplacedBundleVersion() } + } + .store(in: &cancellables) + // Listen for start requests from the core status banner NotificationCenter.default.addObserver( self, selector: #selector(handleStartCore), @@ -572,6 +585,10 @@ final class AppController: NSObject, NSApplicationDelegate, NSWindowDelegate, NS DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in self?.setupMainMenu() } + // Spec 092 FR-003. Activation is the natural moment to look: a + // drag-install is normally followed within seconds by the user + // returning to the app. + MainActor.assumeIsolated { refreshReplacedBundleVersion() } } // NSWindowDelegate — hide from Dock when the last managed window closes. @@ -1135,6 +1152,32 @@ final class AppController: NSObject, NSApplicationDelegate, NSWindowDelegate, NS menu.addItem(updateNote) } + // Spec 092 FR-003: the app on disk is newer than the one running — a + // drag-install landed underneath us. Offered, never forced. + if let replacement = appState.replacedBundleVersion { + let relaunch = NSMenuItem( + title: "MCPProxy was updated to v\(replacement) — Relaunch", + action: #selector(relaunchIntoReplacedBundle), keyEquivalent: "" + ) + relaunch.target = self + relaunch.toolTip = "Stops the core, starts the newly installed app, and quits this one." + menu.addItem(relaunch) + } + + // Spec 092 FR-002: an older core is running that the tray is not + // allowed to stop on its own. Activating this item IS the consent. + if let stale = appState.staleCorePrompt { + let restart = NSMenuItem( + title: stale.menuTitle, + action: #selector(restartStaleCore), keyEquivalent: "" + ) + restart.target = self + restart.toolTip = stale.pid == nil + ? "This core cannot be stopped from here — shows how to stop it by hand." + : "Stops the old core (PID \(stale.pid!)) and starts the bundled one." + menu.addItem(restart) + } + menu.addItem(.separator()) // Stop / Start @@ -1243,6 +1286,101 @@ final class AppController: NSObject, NSApplicationDelegate, NSWindowDelegate, NS startCoreAction() } + // MARK: - Spec 092 Phase 0: superseding stale versions (#957) + + /// Re-read the app bundle from disk and publish whether it has been + /// replaced by a newer version (FR-003). + /// + /// Cheap (one small plist read) and idempotent, which is what lets it run + /// on both triggers: every activation — the drag-install is usually + /// followed immediately by clicking the menu bar — and a slow timer for the + /// user who never activates the app at all. + @MainActor + private func refreshReplacedBundleVersion() { + let replacement = BundleUpdateWatcher.replacementVersion() + guard appState.replacedBundleVersion != replacement else { return } + if let replacement { + NSLog("[MCPProxy] The app bundle on disk is v%@ — this process is v%@", + replacement, BundledCore.appVersion() ?? "unknown") + AppLifecycle.shared.note("app bundle on disk replaced by v\(replacement)") + } + appState.replacedBundleVersion = replacement + } + + /// FR-003: stop the core we manage, launch the newly installed bundle, and + /// get out of its way. + /// + /// `open -n` rather than `open`: without it macOS activates THIS process — + /// the stale one — which is exactly the reported symptom. Terminating comes + /// last and only after the core is down, so the new instance does not race + /// us for the socket and the BBolt lock. + @objc private func relaunchIntoReplacedBundle() { + let bundlePath = Bundle.main.bundleURL.path + Task { [weak self] in + guard let self else { return } + AppLifecycle.shared.note("relaunching into the replaced bundle at \(bundlePath)") + await self.coreManager?.shutdown() + + await MainActor.run { + let launcher = Process() + launcher.executableURL = URL(fileURLWithPath: "/usr/bin/open") + launcher.arguments = ["-n", bundlePath] + do { + try launcher.run() + } catch { + NSLog("[MCPProxy] Could not launch %@: %@", bundlePath, error.localizedDescription) + self.presentAlert( + title: "Could not start the new version", + message: "Open \(bundlePath) manually to finish the upgrade.\n\n" + + error.localizedDescription + ) + return + } + NSApp.terminate(nil) + } + } + } + + /// FR-002: the user consented to stopping a core the tray did not start. + /// + /// When there is no pid to act on — a core too old to report one — the + /// action must still do something honest, so it explains how to stop the + /// core by hand rather than failing silently. + @objc private func restartStaleCore() { + guard let prompt = appState.staleCorePrompt else { return } + Task { [weak self] in + guard let self else { return } + let acted = await self.coreManager?.supersedeWithConsent() ?? false + guard !acted else { return } + await MainActor.run { self.presentStaleCoreInstructions(prompt) } + } + } + + @MainActor + private func presentStaleCoreInstructions(_ prompt: StaleCorePrompt) { + let pidHint = prompt.pid.map { "\n\nIts process id is \($0)." } ?? "" + presentAlert( + title: "Stop the old core to finish upgrading", + message: "MCPProxy v\(prompt.runningVersion) is still running and this app " + + "bundles v\(prompt.bundledVersion). MCPProxy could not stop that process " + + "automatically — it was started outside the app (a terminal, launchd, or " + + "`brew services`), so stopping it is up to whoever started it." + + pidHint + + "\n\nQuit it there, then choose “Start MCPProxy Core” from this menu." + ) + } + + @MainActor + private func presentAlert(title: String, message: String) { + let alert = NSAlert() + alert.messageText = title + alert.informativeText = message + alert.alertStyle = .informational + alert.addButton(withTitle: "OK") + NSApp.activate(ignoringOtherApps: true) + alert.runModal() + } + @objc private func handleAttentionAction(_ sender: NSMenuItem) { guard let server = sender.representedObject as? ServerStatus else { return } let action = server.health?.action ?? "" diff --git a/native/macos/MCPProxy/MCPProxy/Services/BundleUpdateWatcher.swift b/native/macos/MCPProxy/MCPProxy/Services/BundleUpdateWatcher.swift new file mode 100644 index 00000000..2a7dbdcb --- /dev/null +++ b/native/macos/MCPProxy/MCPProxy/Services/BundleUpdateWatcher.swift @@ -0,0 +1,69 @@ +// BundleUpdateWatcher.swift +// MCPProxy +// +// Spec 092 FR-003 — "MCPProxy was updated to vY — Relaunch". +// +// A drag-install replaces `/Applications/MCPProxy.app` underneath the running +// process. macOS does not notify the app, does not restart it, and does not +// stop it: the old code keeps running (and keeps its old core running) until +// the user notices and quits it by hand. That is the second half of #957. +// +// Detection is a version read from the Info.plist ON DISK. It has to be read +// from disk, deliberately and every time: `Bundle.main.infoDictionary` is the +// dictionary loaded at launch — the version of the code executing right now — +// and comparing it with itself can never be anything but equal. + +import Foundation + +enum BundleUpdateWatcher { + + /// `CFBundleShortVersionString` from `/Contents/Info.plist`, + /// read fresh off disk. Nil when the path is not a bundle, the plist is + /// unreadable, or it carries no version. + /// + /// `Bundle(path:)` is deliberately NOT used: Foundation caches bundles by + /// path, so a bundle replaced after the first read keeps answering with the + /// old dictionary — the exact failure this function exists to detect. + static func onDiskVersion(bundlePath: String) -> String? { + let plistPath = (bundlePath as NSString) + .appendingPathComponent("Contents/Info.plist") + guard let data = FileManager.default.contents(atPath: plistPath) else { return nil } + guard let plist = try? PropertyListSerialization.propertyList( + from: data, options: [], format: nil + ) as? [String: Any] else { return nil } + guard let version = plist["CFBundleShortVersionString"] as? String, + !version.isEmpty else { return nil } + return version + } + + /// The on-disk version when it is STRICTLY newer than the running one. + /// + /// Strictly, and by SemVer precedence (FR-006): + /// - equal → nil, the steady state, no prompt (FR-005); + /// - older on disk → nil. Someone reinstalled an older build over a newer + /// running one; relaunching into it is a downgrade the tray must not + /// propose on its own. + /// - either side unparseable → nil. A dev build (`CFBundleShortVersionString` + /// absent, or a non-version string) must not produce a relaunch offer on + /// every activation. + static func newerVersionOnDisk(runningVersion: String?, onDiskVersion: String?) -> String? { + guard let runningVersion, let onDiskVersion else { return nil } + guard let order = SemanticVersion.compare(onDiskVersion, runningVersion) else { return nil } + return order > 0 ? onDiskVersion : nil + } + + /// Production entry point: has the bundle this process is running from been + /// replaced by a newer one? + /// + /// `bundle.bundleURL.path` is a PATH, not a handle — after a replacement it + /// names the new bundle sitting where the old one was, which is precisely + /// what has to be read. (If the old bundle was moved to the Trash rather + /// than overwritten, the read fails and the answer is nil: nothing to + /// relaunch into.) + static func replacementVersion(bundle: Bundle = .main) -> String? { + newerVersionOnDisk( + runningVersion: BundledCore.appVersion(bundle: bundle), + onDiskVersion: onDiskVersion(bundlePath: bundle.bundleURL.path) + ) + } +} diff --git a/native/macos/MCPProxy/MCPProxyTests/BundleUpdateWatcherTests.swift b/native/macos/MCPProxy/MCPProxyTests/BundleUpdateWatcherTests.swift new file mode 100644 index 00000000..09a31cf3 --- /dev/null +++ b/native/macos/MCPProxy/MCPProxyTests/BundleUpdateWatcherTests.swift @@ -0,0 +1,130 @@ +// BundleUpdateWatcherTests.swift +// MCPProxyTests +// +// Spec 092 FR-003 — a drag-install replaced the app bundle underneath the +// running process. Fixture-driven: each test builds a throwaway `.app` +// skeleton on disk (Contents/Info.plist and nothing else, which is all the +// detector reads) and, where it matters, REWRITES it mid-test to reproduce the +// replacement itself. + +import XCTest +@testable import MCPProxy + +final class BundleUpdateWatcherTests: XCTestCase { + + /// A minimal `.app` on disk. Only `Contents/Info.plist` exists — the + /// detector must not need anything else. + private func makeBundle(version: String?, name: String = "Fixture") throws -> String { + let root = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("mcpproxy-bundle-\(UUID().uuidString.prefix(8))") + .appendingPathComponent("\(name).app") + try FileManager.default.createDirectory( + at: root.appendingPathComponent("Contents"), withIntermediateDirectories: true + ) + addTeardownBlock { + try? FileManager.default.removeItem(at: root.deletingLastPathComponent()) + } + try writeInfoPlist(version: version, into: root.path) + return root.path + } + + private func writeInfoPlist(version: String?, into bundlePath: String) throws { + var plist: [String: Any] = ["CFBundleIdentifier": "com.smartmcpproxy.mcpproxy"] + if let version { plist["CFBundleShortVersionString"] = version } + let data = try PropertyListSerialization.data( + fromPropertyList: plist, format: .xml, options: 0 + ) + try data.write(to: URL(fileURLWithPath: bundlePath) + .appendingPathComponent("Contents/Info.plist")) + } + + // MARK: - Reading the plist off disk + + func testReadsVersionFromTheOnDiskPlist() throws { + let path = try makeBundle(version: "0.54.0") + XCTAssertEqual(BundleUpdateWatcher.onDiskVersion(bundlePath: path), "0.54.0") + } + + /// The whole mechanism depends on this: the SAME path answering differently + /// after the bundle is replaced. `Bundle(path:)` caches and would keep + /// returning the first answer forever. + func testARewrittenPlistIsSeenAtTheSamePath() throws { + let path = try makeBundle(version: "0.53.0") + XCTAssertEqual(BundleUpdateWatcher.onDiskVersion(bundlePath: path), "0.53.0") + + try writeInfoPlist(version: "0.54.0", into: path) + XCTAssertEqual(BundleUpdateWatcher.onDiskVersion(bundlePath: path), "0.54.0", + "a replaced bundle must be re-read, not served from a cache") + } + + func testMissingOrUnreadableBundlesYieldNoVersion() throws { + XCTAssertNil(BundleUpdateWatcher.onDiskVersion(bundlePath: "/nonexistent/Nope.app")) + XCTAssertNil(BundleUpdateWatcher.onDiskVersion(bundlePath: try makeBundle(version: nil)), + "a plist without CFBundleShortVersionString is not a version") + + let corrupt = try makeBundle(version: "0.54.0") + try "not a plist".write( + toFile: (corrupt as NSString).appendingPathComponent("Contents/Info.plist"), + atomically: true, encoding: .utf8 + ) + XCTAssertNil(BundleUpdateWatcher.onDiskVersion(bundlePath: corrupt)) + } + + // MARK: - When to offer a relaunch + + func testOffersOnlyWhenTheDiskVersionIsStrictlyNewer() { + XCTAssertEqual( + BundleUpdateWatcher.newerVersionOnDisk(runningVersion: "0.53.0", onDiskVersion: "0.54.0"), + "0.54.0" + ) + XCTAssertNil( + BundleUpdateWatcher.newerVersionOnDisk(runningVersion: "0.54.0", onDiskVersion: "0.54.0"), + "FR-005: equal versions must produce no prompt" + ) + XCTAssertNil( + BundleUpdateWatcher.newerVersionOnDisk(runningVersion: "0.54.0", onDiskVersion: "0.53.0"), + "an older bundle on disk is a downgrade — never proposed automatically" + ) + } + + /// FR-006 again: `rc.10` on disk over `rc.2` running is an upgrade, and the + /// reverse is not. + func testPrereleaseOrderingIsNumeric() { + XCTAssertEqual( + BundleUpdateWatcher.newerVersionOnDisk( + runningVersion: "0.54.0-rc.2", onDiskVersion: "0.54.0-rc.10"), + "0.54.0-rc.10" + ) + XCTAssertNil( + BundleUpdateWatcher.newerVersionOnDisk( + runningVersion: "0.54.0-rc.10", onDiskVersion: "0.54.0-rc.2") + ) + XCTAssertEqual( + BundleUpdateWatcher.newerVersionOnDisk( + runningVersion: "0.54.0-rc.10", onDiskVersion: "0.54.0"), + "0.54.0", "the release supersedes its own release candidate" + ) + } + + func testUnparseableVersionsNeverOfferARelaunch() { + XCTAssertNil(BundleUpdateWatcher.newerVersionOnDisk( + runningVersion: nil, onDiskVersion: "0.54.0")) + XCTAssertNil(BundleUpdateWatcher.newerVersionOnDisk( + runningVersion: "0.53.0", onDiskVersion: nil)) + XCTAssertNil(BundleUpdateWatcher.newerVersionOnDisk( + runningVersion: "dev", onDiskVersion: "0.54.0"), + "a dev build must not nag on every activation") + XCTAssertNil(BundleUpdateWatcher.newerVersionOnDisk( + runningVersion: "0.53.0", onDiskVersion: "SNAPSHOT")) + } + + // MARK: - Production entry point + + /// The test runner is not an app bundle, so there is nothing to relaunch + /// into — and the honest answer is "no offer", not a crash or a false + /// positive on every timer tick. + func testNoOfferWhenNotRunningFromAnAppBundle() { + XCTAssertNil(BundleUpdateWatcher.replacementVersion( + bundle: Bundle(for: BundleUpdateWatcherTests.self))) + } +} diff --git a/native/macos/MCPProxy/MCPProxyTests/SupersedeMenuTests.swift b/native/macos/MCPProxy/MCPProxyTests/SupersedeMenuTests.swift new file mode 100644 index 00000000..ad8fd310 --- /dev/null +++ b/native/macos/MCPProxy/MCPProxyTests/SupersedeMenuTests.swift @@ -0,0 +1,131 @@ +// SupersedeMenuTests.swift +// MCPProxyTests +// +// Spec 092 FR-002 / FR-003 — the two Phase 0 prompts have to be REACHABLE. +// A verdict that only ever reaches a log is the bug (#957) with extra steps: +// the whole complaint is that nothing visible happened. +// +// Driven through the real controller and the real `rebuildMenu()`, via the +// same seams `MenuOpenNetworkTests` uses, so a rename or a reorder of the menu +// slots fails here rather than shipping. + +import XCTest +import AppKit +@testable import MCPProxy + +@MainActor +final class SupersedeMenuTests: XCTestCase { + + private final class TestMenuHost: TrayMenuHost { + var menu: NSMenu? + } + + private func makeController() -> (AppController, TestMenuHost) { + let host = TestMenuHost() + let controller = AppController( + glanceDataSource: CountingGlanceDataSource(), menuHost: host + ) + controller.appState.coreState = .connected + return (controller, host) + } + + private func titles(_ host: TestMenuHost) -> [String] { + (host.menu?.items ?? []).map(\.title) + } + + private func item(_ host: TestMenuHost, containing text: String) -> NSMenuItem? { + (host.menu?.items ?? []).first { $0.title.contains(text) } + } + + // MARK: - Steady state + + func testNeitherPromptAppearsWhenVersionsMatch() throws { + let (controller, host) = makeController() + controller.rebuildMenu() + + XCTAssertFalse(titles(host).contains { $0.contains("Restart into") }, + "FR-005: no supersede prompt when there is nothing to supersede") + XCTAssertFalse(titles(host).contains { $0.contains("Relaunch") }, + "FR-005: no relaunch prompt when the bundle has not changed") + } + + // MARK: - FR-002 + + func testStaleCorePromptIsRenderedAndActionable() throws { + let (controller, host) = makeController() + controller.appState.staleCorePrompt = StaleCorePrompt( + runningVersion: "0.53.0", bundledVersion: "0.54.0", pid: 4242 + ) + controller.rebuildMenu() + + let restart = try XCTUnwrap(item(host, containing: "Restart into"), + "the consent action must be in the menu") + XCTAssertEqual(restart.title, "Old core v0.53.0 running — Restart into v0.54.0") + XCTAssertNotNil(restart.action, "the item must do something when clicked") + XCTAssertTrue(restart.target === controller) + XCTAssertTrue(restart.toolTip?.contains("4242") ?? false, + "the tooltip should name the process the click will stop") + } + + /// A core too old to report a pid still gets an item — it just explains + /// instead of acting (FR-002's "presents instructions" branch). + func testStaleCorePromptWithoutAPIDStillOffersAnItem() throws { + let (controller, host) = makeController() + controller.appState.staleCorePrompt = StaleCorePrompt( + runningVersion: "0.40.0", bundledVersion: "0.54.0", pid: nil + ) + controller.rebuildMenu() + + let restart = try XCTUnwrap(item(host, containing: "Restart into")) + XCTAssertEqual(restart.title, "Old core v0.40.0 running — Restart into v0.54.0") + XCTAssertNotNil(restart.action) + XCTAssertTrue(restart.toolTip?.contains("by hand") ?? false) + } + + // MARK: - FR-003 + + func testReplacedBundlePromptIsRenderedAndActionable() throws { + let (controller, host) = makeController() + controller.appState.replacedBundleVersion = "0.55.0" + controller.rebuildMenu() + + let relaunch = try XCTUnwrap(item(host, containing: "Relaunch"), + "a drag-install upgrade must surface a relaunch action") + XCTAssertEqual(relaunch.title, "MCPProxy was updated to v0.55.0 — Relaunch") + XCTAssertNotNil(relaunch.action) + XCTAssertTrue(relaunch.target === controller) + } + + /// Both conditions can hold at once — a drag-install replaces the bundle + /// AND leaves the old core running. Neither prompt may hide the other. + func testBothPromptsCoexist() throws { + let (controller, host) = makeController() + controller.appState.replacedBundleVersion = "0.55.0" + controller.appState.staleCorePrompt = StaleCorePrompt( + runningVersion: "0.53.0", bundledVersion: "0.55.0", pid: 99 + ) + controller.rebuildMenu() + + XCTAssertNotNil(item(host, containing: "Relaunch")) + XCTAssertNotNil(item(host, containing: "Restart into")) + } + + /// Clearing the state must retract the offers: an item pointing at a pid + /// nobody re-checked is worse than no item at all. + func testPromptsAreRetractedWhenTheStateClears() throws { + let (controller, host) = makeController() + controller.appState.replacedBundleVersion = "0.55.0" + controller.appState.staleCorePrompt = StaleCorePrompt( + runningVersion: "0.53.0", bundledVersion: "0.55.0", pid: 99 + ) + controller.rebuildMenu() + XCTAssertNotNil(item(host, containing: "Restart into")) + + controller.appState.replacedBundleVersion = nil + controller.appState.staleCorePrompt = nil + controller.rebuildMenu() + + XCTAssertNil(item(host, containing: "Restart into")) + XCTAssertNil(item(host, containing: "Relaunch")) + } +} From f65df4caeef03ec27f287f3370f7b9d386e1e39f Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Sat, 8 Aug 2026 06:34:03 +0300 Subject: [PATCH 12/37] fix(packaging): quit the running app before the installer launches the new one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Related #957 FR-004. `open -a` ACTIVATES a running instance instead of starting one, so the postinstall step handed the user the OLD, just-overwritten app — still serving from the old core — and called the upgrade done. Quitting first is what makes the launch a launch. ## Changes - politeness ladder: `osascript … to quit` by bundle id, 5s wait, SIGTERM (3s), SIGKILL. The app routes SIGTERM through its normal quit path, so even the fallback stops the managed core. - the quit runs through the same `launchctl asuser … env -i` wrapper as the launch (factored into `run_as_user`) — an AppleScript sent from root's context cannot reach the user's app. - pkill matches `MCPProxy.app/Contents/MacOS/MCPProxy`, not the bare name: a looser pattern also matches the core (whose orderly shutdown belongs to the tray) and this script's own command line. - a survivor never fails the install; the new tray's stale-core supersede handles it. ## Testing - bash -n and shellcheck: clean - verified the pgrep/pkill pattern against a fixture process living at .../MCPProxy.app/Contents/MacOS/MCPProxy — matched, and cleared --- packaging/macos/postinstall.sh | 132 ++++++++++++++++++++++++++------- 1 file changed, 106 insertions(+), 26 deletions(-) diff --git a/packaging/macos/postinstall.sh b/packaging/macos/postinstall.sh index 02ed8303..a260358c 100755 --- a/packaging/macos/postinstall.sh +++ b/packaging/macos/postinstall.sh @@ -1,11 +1,26 @@ #!/bin/bash # -# Spec 044 (T057) — macOS post-install launcher. +# macOS post-install: quit the old instance, then launch the newly installed one. +# +# Spec 044 (T057) — launch the tray tagged as "installer-launched". +# Spec 092 (FR-004, issue #957) — quit whatever is already running FIRST. # # Invoked by the DMG "Install" step (when the DMG wraps an installer .pkg) or -# by a future productbuild-based installer. The sole purpose is to launch the -# tray app tagged as "installer-launched" so the core can emit a single -# telemetry heartbeat with launch_source=installer (see research.md R4/R10). +# by a future productbuild-based installer. +# +# Why the quit step exists. `open -a` ACTIVATES a running instance rather than +# starting a new one, so an installer that only calls `open -a` brings the OLD, +# just-overwritten app to the foreground: the user watches an upgrade complete +# and is handed the previous version, still serving from the previous core. +# That is the reported bug. Quitting first is what makes the launch below a +# launch. +# +# Politeness ladder — `osascript … to quit`, then SIGTERM, then SIGKILL — so a +# graceful exit is always tried first. The app installs signal handlers that +# route SIGTERM through its normal quit path (AppLifecycle.installSignalHandlers), +# so even the fallback stops the managed core rather than orphaning it. Should +# a core survive anyway (one the user started themselves), the new tray's +# stale-core supersede (Spec 092 FR-001/FR-002) picks it up. # # The `--env MCPPROXY_LAUNCHED_BY=installer` flag is honored by macOS's `open` # command and inherited by the tray and core child processes. The core @@ -23,16 +38,28 @@ # cached the failed lookup). The clean-env launch mirrors what users get when # they double-click the app from Finder. # -# This script is idempotent: re-running it simply re-launches the app. -# Existing instances stay up (`open -a` activates rather than duplicating). +# This script is idempotent: re-running it quits and relaunches the app. # # Exit codes: -# 0 — launched (or already running) +# 0 — launched # 1 — MCPProxy.app not found in /Applications (installer bug) set -euo pipefail APP_PATH="/Applications/MCPProxy.app" +BUNDLE_ID="com.smartmcpproxy.mcpproxy" + +# Matches the tray executable inside ANY MCPProxy.app, wherever it was +# installed. Deliberately the app's own executable path and not the bare name +# "MCPProxy": a looser pattern would also match this script's own command line +# and the core process, and killing the core directly would skip the tray's +# orderly shutdown. +APP_EXEC_PATTERN="MCPProxy.app/Contents/MacOS/MCPProxy" + +# How long the old instance gets to quit on its own, and then to die after +# SIGTERM. Tenths of a second. +GRACEFUL_TENTHS=50 # 5s +SIGTERM_TENTHS=30 # 3s if [ ! -d "$APP_PATH" ]; then echo "postinstall: $APP_PATH not found — installer did not copy the bundle." >&2 @@ -55,24 +82,77 @@ USER_HOME=$(/usr/bin/dscl . -read "/Users/$REAL_USER" NFSHomeDirectory 2>/dev/nu # the env launchd would give a normal user GUI session. SANE_PATH="/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin" -if [ -n "$REAL_UID" ] && [ "$REAL_UID" != "0" ]; then - # Preferred path: bootstrap into the user's GUI session with `launchctl - # asuser` so the app inherits launchd's per-user env, then `env -i` wipes - # any inherited installer vars before invoking `open`. - /bin/launchctl asuser "$REAL_UID" /usr/bin/env -i \ - HOME="$USER_HOME" \ - USER="$REAL_USER" \ - LOGNAME="$REAL_USER" \ - PATH="$SANE_PATH" \ - /usr/bin/open -a "$APP_PATH" --env MCPPROXY_LAUNCHED_BY=installer -else - # Fallback for unusual installers (no real user, e.g. CI imaging): launch - # with a clean PATH but skip the asuser hop. This still avoids leaking - # PKInstallSandbox env into the long-running daemon. - /usr/bin/env -i \ - HOME="$USER_HOME" \ - PATH="$SANE_PATH" \ - /usr/bin/open -a "$APP_PATH" --env MCPPROXY_LAUNCHED_BY=installer -fi +# Run a command in the installing user's GUI session with a clean environment. +# Both the AppleScript quit and the launch need this: an `osascript … tell +# application` sent from root's context cannot reach the user's running app. +run_as_user() { + if [ -n "$REAL_UID" ] && [ "$REAL_UID" != "0" ]; then + /bin/launchctl asuser "$REAL_UID" /usr/bin/env -i \ + HOME="$USER_HOME" \ + USER="$REAL_USER" \ + LOGNAME="$REAL_USER" \ + PATH="$SANE_PATH" \ + "$@" + else + # Fallback for unusual installers (no real user, e.g. CI imaging): + # clean env but no asuser hop. Still avoids leaking PKInstallSandbox + # env into the long-running daemon. + /usr/bin/env -i \ + HOME="$USER_HOME" \ + PATH="$SANE_PATH" \ + "$@" + fi +} + +app_is_running() { + /usr/bin/pgrep -f "$APP_EXEC_PATTERN" >/dev/null 2>&1 +} + +# Wait up to $1 tenths of a second for the app to disappear. Returns 0 if it +# did. +wait_for_exit() { + local budget="$1" waited=0 + while [ "$waited" -lt "$budget" ]; do + app_is_running || return 0 + /bin/sleep 0.1 + waited=$((waited + 1)) + done + ! app_is_running +} + +quit_running_instance() { + if ! app_is_running; then + echo "postinstall: no MCPProxy instance running" + return 0 + fi + + echo "postinstall: asking the running MCPProxy to quit" + run_as_user /usr/bin/osascript -e "tell application id \"$BUNDLE_ID\" to quit" \ + >/dev/null 2>&1 || true + if wait_for_exit "$GRACEFUL_TENTHS"; then + echo "postinstall: the old instance quit" + return 0 + fi + + echo "postinstall: MCPProxy did not quit in time — sending SIGTERM" >&2 + /usr/bin/pkill -f "$APP_EXEC_PATTERN" || true + if wait_for_exit "$SIGTERM_TENTHS"; then + echo "postinstall: the old instance terminated" + return 0 + fi + + echo "postinstall: MCPProxy ignored SIGTERM — sending SIGKILL" >&2 + /usr/bin/pkill -9 -f "$APP_EXEC_PATTERN" || true + # Never fail the install over this. A survivor is handled by the new tray's + # stale-core supersede rather than by aborting an upgrade that has already + # copied the bundle. + wait_for_exit "$SIGTERM_TENTHS" || \ + echo "postinstall: an MCPProxy process survived SIGKILL — continuing" >&2 + return 0 +} + +quit_running_instance + +run_as_user /usr/bin/open -a "$APP_PATH" --env MCPPROXY_LAUNCHED_BY=installer exit 0 From df36c8ecbeba0b91b3c1841c62133042a20e5c84 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Sat, 8 Aug 2026 06:36:16 +0300 Subject: [PATCH 13/37] fix(tray): refresh the legacy staged core copy when it is provably stale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Related #957 FR-030, with the analysis it asks for recorded in the file header. Findings: nothing in THIS tray resolves the staged copy at ~/Library/Application Support/mcpproxy/bin/mcpproxy ahead of the bundled core — resolveBinary() checks the bundle first, so that branch is reachable only for a build with no bundled core, where there is nothing to shadow. The legacy Go tray does prefer it, but re-stages it from its own bundle, so it tracks. The /usr/local/bin symlink points at the bundled binary. A user's PATH is theirs. The residual hazard is the file itself: a stale executable anything can still run. So it is REFRESHED, never removed, and only when provable — regular file (a symlink is deliberate wiring), answers `version -o json` so it really is an mcpproxy binary, and that version is strictly older by SemVer. Anything unprovable is a logged no-op. ## Changes - `StagedCoreBinary.decide` (pure) + `refreshIfStale` (copy to a sibling temp, preserve the existing mode, rename over the target — a legacy tray may be executing that image, and a rename leaves it on its own inode) - called detached at startup next to the symlink setup - FR-030 note on resolveBinary()'s step 3 explaining the ordering ## Testing - swift test --filter StagedCoreBinary: 13 tests, 0 failures — decision table (8) plus real file-system cases (5): stale copy swapped with its mode preserved and no temp left behind, current copy untouched, unidentifiable file neither refreshed nor deleted, absent path not created, symlink left pointing where it pointed --- .../MCPProxy/Core/CoreProcessManager.swift | 11 +- .../macos/MCPProxy/MCPProxy/MCPProxyApp.swift | 9 + .../MCPProxy/Services/StagedCoreBinary.swift | 178 ++++++++++++++++ .../MCPProxyTests/StagedCoreBinaryTests.swift | 191 ++++++++++++++++++ 4 files changed, 388 insertions(+), 1 deletion(-) create mode 100644 native/macos/MCPProxy/MCPProxy/Services/StagedCoreBinary.swift create mode 100644 native/macos/MCPProxy/MCPProxyTests/StagedCoreBinaryTests.swift diff --git a/native/macos/MCPProxy/MCPProxy/Core/CoreProcessManager.swift b/native/macos/MCPProxy/MCPProxy/Core/CoreProcessManager.swift index 3ad810c1..2afc5ac5 100644 --- a/native/macos/MCPProxy/MCPProxy/Core/CoreProcessManager.swift +++ b/native/macos/MCPProxy/MCPProxy/Core/CoreProcessManager.swift @@ -1264,7 +1264,16 @@ actor CoreProcessManager { } } - // 3. Managed binary in Application Support + // 3. The LEGACY staged copy in Application Support, written by the old + // Go tray (`cmd/mcpproxy-tray`'s ensureManagedCoreBinary). + // + // Spec 092 FR-030: it is checked AFTER the bundled core above, so it + // can never shadow it — this branch is reachable only for a build + // with no bundled core at all, where there is nothing to shadow. It + // stays in the list because that dev/legacy case still needs a core. + // The staleness of the file itself is handled separately, by + // `StagedCoreBinary.refreshIfStale()`; see that file's header for the + // full path analysis and for why it refreshes rather than deletes. let home = FileManager.default.homeDirectoryForCurrentUser.path let managedPath = "\(home)/Library/Application Support/mcpproxy/bin/mcpproxy" if fm.isExecutableFile(atPath: managedPath) { diff --git a/native/macos/MCPProxy/MCPProxy/MCPProxyApp.swift b/native/macos/MCPProxy/MCPProxy/MCPProxyApp.swift index b2de64ce..b4aec969 100644 --- a/native/macos/MCPProxy/MCPProxy/MCPProxyApp.swift +++ b/native/macos/MCPProxy/MCPProxy/MCPProxyApp.swift @@ -722,6 +722,15 @@ final class AppController: NSObject, NSApplicationDelegate, NSWindowDelegate, NS } } + // Spec 092 FR-030: bring the legacy staged core copy up to date if it + // is provably older than the bundled one. Detached because it can cost + // two subprocesses and a ~30 MB copy, and nothing in the startup path + // depends on it — this tray resolves the bundled core first (see + // StagedCoreBinary's header for the full path analysis). + Task.detached(priority: .utility) { + StagedCoreBinary.refreshIfStale() + } + let manager = CoreProcessManager( appState: appState, notificationService: notificationService, diff --git a/native/macos/MCPProxy/MCPProxy/Services/StagedCoreBinary.swift b/native/macos/MCPProxy/MCPProxy/Services/StagedCoreBinary.swift new file mode 100644 index 00000000..1fbb3700 --- /dev/null +++ b/native/macos/MCPProxy/MCPProxy/Services/StagedCoreBinary.swift @@ -0,0 +1,178 @@ +// StagedCoreBinary.swift +// MCPProxy +// +// Spec 092 FR-030 — the legacy staged core copy at +// `~/Library/Application Support/mcpproxy/bin/mcpproxy`. +// +// ## Which paths still resolve it ahead of the bundled core? +// +// The analysis FR-030 asks for, written down because the conclusion is mostly +// "none", and the tempting action (delete it) is the wrong one. +// +// 1. **This tray — no.** `CoreProcessManager.resolveBinary()` tries, in order: +// `MCPPROXY_CORE_PATH`, the BUNDLED core at `Contents/Resources/bin/mcpproxy`, +// then this staged copy, then `~/.mcpproxy/bin`, then Homebrew/`/usr/local`, +// then `PATH`. The bundled core is checked first, so the staged copy can +// only ever be reached when the app is not running from a bundle (a +// `swift run` dev build) — and in that case there IS no bundled core for it +// to shadow. +// +// 2. **The legacy Go tray (`cmd/mcpproxy-tray`) — yes, but self-healing.** +// Its `resolveCoreBinary()` calls `ensureManagedCoreBinary()`, which stages +// the bundled core here and returns THIS path, ahead of everything else. +// It re-copies whenever the sizes differ or the bundled binary is newer, so +// the copy it runs tracks the bundle it ships with. It is the only writer +// that has ever created this file. +// +// 3. **The `/usr/local/bin/mcpproxy` CLI symlink — no.** `SymlinkService` +// points it at the bundled binary. +// +// 4. **A user's `PATH` — out of scope by construction.** If someone put this +// directory on their `PATH`, the binary is theirs to manage, and FR-030 is +// explicit that nothing may be deleted without an analysis that covers it. +// +// ## What is actually done +// +// The residual hazard is not shadowing — it is that a stale executable sits +// there and anything (a legacy tray, a shell alias, a launchd plist written +// years ago) can still run it and serve an old version. So: REFRESH it from +// the bundled core, never remove it, and only when all of these hold: +// +// - it exists and is a regular file (a symlink is somebody's deliberate +// wiring and is left exactly as found); +// - it answers `version -o json`, i.e. it really is an mcpproxy binary; +// - that version is strictly OLDER than the bundled core's, by SemVer. +// +// Anything unprovable — unreadable version, unparseable version, no bundled +// core — means no action and a logged reason. Refreshing keeps every existing +// user of the path working while removing the "old version still served" +// outcome; deleting would break them for no additional benefit. + +import Foundation + +enum StagedCoreBinary { + + /// The legacy staged path. Constructed the same way the Go tray built it + /// (`getManagedBinDir`), and the same way `resolveBinary()` looks for it. + static func defaultPath( + home: String = FileManager.default.homeDirectoryForCurrentUser.path + ) -> String { + "\(home)/Library/Application Support/mcpproxy/bin/mcpproxy" + } + + /// What to do about the staged copy. + enum Action: Equatable { + case none(reason: String) + case refresh(from: String, staleVersion: String, freshVersion: String) + } + + /// The decision, isolated from the file system so every branch is testable. + static func decide( + stagedExists: Bool, + stagedIsSymlink: Bool, + stagedVersion: String?, + bundledPath: String?, + bundledVersion: String? + ) -> Action { + guard let bundledPath, let bundledVersion, !bundledVersion.isEmpty else { + return .none(reason: "no bundled core to refresh from") + } + guard stagedExists else { + // Deliberately NOT created. Creating one would make this tray a + // second writer of a legacy artifact it does not otherwise need. + return .none(reason: "no staged copy exists") + } + guard !stagedIsSymlink else { + return .none(reason: "the staged path is a symlink — someone wired it deliberately") + } + guard let stagedVersion, !stagedVersion.isEmpty else { + return .none(reason: "the staged copy did not report a version — not touching it") + } + guard let order = SemanticVersion.compare(stagedVersion, bundledVersion) else { + return .none(reason: "cannot compare staged \(stagedVersion) with bundled \(bundledVersion)") + } + guard order < 0 else { + return .none(reason: "staged copy v\(stagedVersion) is not older than the bundled core") + } + return .refresh(from: bundledPath, staleVersion: stagedVersion, freshVersion: bundledVersion) + } + + /// Inspect the staged copy and refresh it if the rules above allow. + /// Returns the action taken (`.none` carries the reason), for the log and + /// for tests. + @discardableResult + static func refreshIfStale( + bundledPath: String? = BundledCore.binaryPath(), + bundledVersion: String? = nil, + stagedPath: String = defaultPath() + ) -> Action { + let fm = FileManager.default + let attributes = try? fm.attributesOfItem(atPath: stagedPath) + let exists = attributes != nil + let isSymlink = (attributes?[.type] as? FileAttributeType) == .typeSymbolicLink + + // Only pay for the subprocesses when there is something to compare. + var stagedVersion: String? + var resolvedBundledVersion = bundledVersion + if exists, !isSymlink, let bundledPath { + stagedVersion = CoreBinaryVersion.read(at: stagedPath) + if resolvedBundledVersion == nil { + resolvedBundledVersion = CoreBinaryVersion.read(at: bundledPath) + } + } + + let action = decide( + stagedExists: exists, + stagedIsSymlink: isSymlink, + stagedVersion: stagedVersion, + bundledPath: bundledPath, + bundledVersion: resolvedBundledVersion + ) + + guard case .refresh(let source, let stale, let fresh) = action else { + if case .none(let reason) = action { + NSLog("[MCPProxy] Staged core copy: no action (%@)", reason) + } + return action + } + + NSLog("[MCPProxy] Refreshing the staged core copy at %@ from v%@ to v%@", + stagedPath, stale, fresh) + do { + try replace(stagedPath, withContentsOf: source, preserving: attributes) + } catch { + NSLog("[MCPProxy] Could not refresh the staged core copy: %@", + error.localizedDescription) + return .none(reason: "refresh failed: \(error.localizedDescription)") + } + return action + } + + /// Copy `source` over `target` through a sibling temp file and a rename. + /// + /// Rename rather than write-in-place: a legacy tray may be EXECUTING the + /// staged binary right now, and overwriting the bytes of a running image + /// crashes it (ETXTBSY at best). A rename swaps the directory entry and + /// leaves the running process on its own inode. + private static func replace( + _ target: String, withContentsOf source: String, preserving attributes: [FileAttributeKey: Any]? + ) throws { + let fm = FileManager.default + let staging = target + ".new-\(ProcessInfo.processInfo.processIdentifier)" + try? fm.removeItem(atPath: staging) + try fm.copyItem(atPath: source, toPath: staging) + + // Keep the mode the staged copy already had (the Go tray used 0755); + // fall back to 0755 when it could not be read. + let mode = (attributes?[.posixPermissions] as? NSNumber) ?? NSNumber(value: 0o755) + try fm.setAttributes([.posixPermissions: mode], ofItemAtPath: staging) + + guard rename(staging, target) == 0 else { + let code = errno + try? fm.removeItem(atPath: staging) + throw NSError(domain: NSPOSIXErrorDomain, code: Int(code), userInfo: [ + NSLocalizedDescriptionKey: "rename(\(staging), \(target)) failed (errno \(code))" + ]) + } + } +} diff --git a/native/macos/MCPProxy/MCPProxyTests/StagedCoreBinaryTests.swift b/native/macos/MCPProxy/MCPProxyTests/StagedCoreBinaryTests.swift new file mode 100644 index 00000000..469449fa --- /dev/null +++ b/native/macos/MCPProxy/MCPProxyTests/StagedCoreBinaryTests.swift @@ -0,0 +1,191 @@ +// StagedCoreBinaryTests.swift +// MCPProxyTests +// +// Spec 092 FR-030 — the legacy staged core copy at +// `~/Library/Application Support/mcpproxy/bin/mcpproxy`. +// +// The requirement is explicitly conservative ("never deleting a binary the +// user may manage themselves"), so the tests are mostly about what must NOT +// happen: no creation, no deletion, no touching a symlink, no action on a +// binary whose version cannot be established. + +import XCTest +@testable import MCPProxy + +final class StagedCoreBinaryDecisionTests: XCTestCase { + + private func decide( + exists: Bool = true, + isSymlink: Bool = false, + staged: String? = "0.40.0", + bundledPath: String? = "/Applications/MCPProxy.app/Contents/Resources/bin/mcpproxy", + bundled: String? = "0.54.0" + ) -> StagedCoreBinary.Action { + StagedCoreBinary.decide( + stagedExists: exists, stagedIsSymlink: isSymlink, stagedVersion: staged, + bundledPath: bundledPath, bundledVersion: bundled + ) + } + + private func assertNoAction( + _ action: StagedCoreBinary.Action, _ message: String, + file: StaticString = #filePath, line: UInt = #line + ) { + guard case .none(let reason) = action else { + return XCTFail("\(message) — got \(action)", file: file, line: line) + } + XCTAssertFalse(reason.isEmpty, "every no-action outcome must say why", + file: file, line: line) + } + + func testAnOlderStagedCopyIsRefreshed() { + guard case .refresh(_, let stale, let fresh) = decide() else { + return XCTFail("a provably older staged copy is the one case that acts") + } + XCTAssertEqual(stale, "0.40.0") + XCTAssertEqual(fresh, "0.54.0") + } + + func testAbsentStagedCopyIsNotCreated() { + assertNoAction(decide(exists: false), + "this tray must not become a writer of a legacy artifact") + } + + func testSymlinkIsLeftAlone() { + assertNoAction(decide(isSymlink: true), + "a symlink is deliberate wiring, not a stale copy") + } + + func testUnknownVersionsMeanNoAction() { + assertNoAction(decide(staged: nil), "a binary that will not answer is not provably stale") + assertNoAction(decide(staged: ""), "an empty version is not a version") + assertNoAction(decide(staged: "development"), "an unparseable version is not comparable") + assertNoAction(decide(bundled: "dev"), "an unparseable bundled version is not comparable") + } + + func testEqualOrNewerStagedCopyIsLeftAlone() { + assertNoAction(decide(staged: "0.54.0"), "same version — nothing to refresh") + assertNoAction(decide(staged: "0.55.0"), "a NEWER staged copy must never be overwritten") + assertNoAction(decide(staged: "0.54.0", bundled: "0.54.0-rc.9"), + "a release staged copy outranks a bundled release candidate") + } + + func testPrereleaseOrderingIsNumericHereToo() { + guard case .refresh = decide(staged: "0.54.0-rc.2", bundled: "0.54.0-rc.10") else { + return XCTFail("rc.2 is older than rc.10 and must be refreshed") + } + assertNoAction(decide(staged: "0.54.0-rc.10", bundled: "0.54.0-rc.2"), + "rc.10 is NEWER than rc.2 — a string comparison would overwrite it") + } + + func testNoBundledCoreMeansNoAction() { + assertNoAction(decide(bundledPath: nil), "nothing to refresh from") + assertNoAction(decide(bundled: nil), "nothing to refresh from") + } + + func testDefaultPathMatchesTheLegacyTrayLayout() { + XCTAssertEqual( + StagedCoreBinary.defaultPath(home: "/Users/someone"), + "/Users/someone/Library/Application Support/mcpproxy/bin/mcpproxy" + ) + } +} + +/// The file-system half: the refresh must swap the file, keep its mode, and +/// leave no temp file behind. +final class StagedCoreBinaryRefreshTests: XCTestCase { + + private var directory: URL! + + override func setUpWithError() throws { + directory = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("mcpproxy-staged-\(UUID().uuidString.prefix(8))") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + } + + override func tearDownWithError() throws { + try? FileManager.default.removeItem(at: directory) + } + + /// A script that reports `version` and can be identified by a marker line. + @discardableResult + private func makeCore(named name: String, version: String, mode: Int16 = 0o755) throws -> String { + let path = directory.appendingPathComponent(name).path + try """ + #!/bin/sh + # marker:\(name) + [ "$1" = version ] || exit 2 + echo '{"version":"\(version)"}' + """.write(toFile: path, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes( + [.posixPermissions: NSNumber(value: mode)], ofItemAtPath: path + ) + return path + } + + private func mode(of path: String) throws -> Int { + let attributes = try FileManager.default.attributesOfItem(atPath: path) + return (attributes[.posixPermissions] as! NSNumber).intValue + } + + func testStaleStagedCopyIsReplacedByTheBundledOne() throws { + let bundled = try makeCore(named: "bundled", version: "0.54.0") + let staged = try makeCore(named: "staged", version: "0.40.0", mode: 0o700) + + let action = StagedCoreBinary.refreshIfStale(bundledPath: bundled, stagedPath: staged) + guard case .refresh = action else { return XCTFail("expected a refresh, got \(action)") } + + XCTAssertEqual(CoreBinaryVersion.read(at: staged), "0.54.0", + "the staged path must now run the bundled core") + XCTAssertEqual(try mode(of: staged), 0o700, + "the existing mode is preserved, not reset to the source's") + + let leftovers = try FileManager.default.contentsOfDirectory(atPath: directory.path) + .filter { $0.contains(".new-") } + XCTAssertTrue(leftovers.isEmpty, "the staging file must not survive: \(leftovers)") + } + + func testCurrentStagedCopyIsUntouched() throws { + let bundled = try makeCore(named: "bundled", version: "0.54.0") + let staged = try makeCore(named: "staged", version: "0.54.0") + let before = try Data(contentsOf: URL(fileURLWithPath: staged)) + + let action = StagedCoreBinary.refreshIfStale(bundledPath: bundled, stagedPath: staged) + guard case .none = action else { return XCTFail("expected no action, got \(action)") } + XCTAssertEqual(try Data(contentsOf: URL(fileURLWithPath: staged)), before) + } + + /// The requirement's hard line: nothing is ever removed. + func testAnUnidentifiableStagedFileIsNeitherRefreshedNorRemoved() throws { + let bundled = try makeCore(named: "bundled", version: "0.54.0") + let staged = directory.appendingPathComponent("staged").path + try "not a core at all".write(toFile: staged, atomically: true, encoding: .utf8) + + let action = StagedCoreBinary.refreshIfStale(bundledPath: bundled, stagedPath: staged) + guard case .none = action else { return XCTFail("expected no action, got \(action)") } + XCTAssertTrue(FileManager.default.fileExists(atPath: staged), + "FR-030: never delete a binary whose provenance is unproven") + XCTAssertEqual(try String(contentsOfFile: staged, encoding: .utf8), "not a core at all") + } + + func testMissingStagedCopyIsNotCreated() throws { + let bundled = try makeCore(named: "bundled", version: "0.54.0") + let staged = directory.appendingPathComponent("absent").path + + let action = StagedCoreBinary.refreshIfStale(bundledPath: bundled, stagedPath: staged) + guard case .none = action else { return XCTFail("expected no action, got \(action)") } + XCTAssertFalse(FileManager.default.fileExists(atPath: staged)) + } + + func testSymlinkedStagedPathIsLeftPointingWhereItPointed() throws { + let bundled = try makeCore(named: "bundled", version: "0.54.0") + let real = try makeCore(named: "user-managed", version: "0.10.0") + let staged = directory.appendingPathComponent("staged").path + try FileManager.default.createSymbolicLink(atPath: staged, withDestinationPath: real) + + let action = StagedCoreBinary.refreshIfStale(bundledPath: bundled, stagedPath: staged) + guard case .none = action else { return XCTFail("expected no action, got \(action)") } + XCTAssertEqual(try FileManager.default.destinationOfSymbolicLink(atPath: staged), real) + XCTAssertEqual(CoreBinaryVersion.read(at: real), "0.10.0", "the target is untouched") + } +} From 3ef2e46884c610b7f81f4763b2d26dc07e752ad6 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Sat, 8 Aug 2026 06:40:34 +0300 Subject: [PATCH 14/37] fix(tray): keep the connection when a stale core cannot be stopped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Related #957 FR-002. The automatic branch tore the connection down and only then discovered it could not identify the pid — leaving the user with an error and no core, where a moment earlier they had a working (if old) one. The unidentifiable-pid case is not a failure, it is the "no safe stop mechanism" case the requirement answers with instructions. ## Changes - check the pid's identity BEFORE dropping the connection; on refusal downgrade the offer to the pid-less instructions prompt and leave the connection alone. `stopCore` keeps its own re-check immediately before the signal (pids are recycled). ## Testing - swift test --filter CoreSupersede: 21 tests, 0 failures (the refusal test now asserts .connected + a pid-less prompt) --- .../MCPProxy/Core/CoreProcessManager.swift | 22 +++++++++++++++++++ .../CoreSupersedeAttachTests.swift | 17 +++++++++----- 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/native/macos/MCPProxy/MCPProxy/Core/CoreProcessManager.swift b/native/macos/MCPProxy/MCPProxy/Core/CoreProcessManager.swift index 2afc5ac5..5b1e3e98 100644 --- a/native/macos/MCPProxy/MCPProxy/Core/CoreProcessManager.swift +++ b/native/macos/MCPProxy/MCPProxy/Core/CoreProcessManager.swift @@ -1135,6 +1135,17 @@ actor CoreProcessManager { /// A core we only attached to. Signal it, wait for it to actually go, then /// take ownership and start the bundled one. private func stopCoreByPIDAndRespawn(pid: Int32) async { + // Ask whether we CAN stop it before dropping a connection to a core + // that — old as it is — is working. A pid we cannot identify (it died + // and was recycled, or another user owns the process) means there is no + // safe stop mechanism, which FR-002 answers with instructions, not with + // an error and a severed connection. + guard CoreProcessIdentity.isMCPProxyCore(pid: pid) else { + NSLog("[MCPProxy] Cannot identify PID %d as an mcpproxy core — offering instructions", pid) + await offerInstructionsInstead() + return + } + await tearDownConnection() guard await stopCore(pid: pid) else { @@ -1154,6 +1165,17 @@ actor CoreProcessManager { await launchWithRetries() } + /// Downgrade the offer to "here is how to do it yourself" (FR-002), keeping + /// the connection to the old core intact. Silent when there is nothing left + /// to describe. + private func offerInstructionsInstead() async { + guard let report = latestVersionReport, let bundled = respawnVersion() else { return } + let prompt = StaleCorePrompt( + runningVersion: report.runningVersion, bundledVersion: bundled, pid: nil + ) + await MainActor.run { appState.staleCorePrompt = prompt } + } + /// SIGTERM, then SIGKILL, then confirm the socket is free. /// /// The identity check is re-run HERE rather than trusted from the diff --git a/native/macos/MCPProxy/MCPProxyTests/CoreSupersedeAttachTests.swift b/native/macos/MCPProxy/MCPProxyTests/CoreSupersedeAttachTests.swift index 856357b4..09ce5b45 100644 --- a/native/macos/MCPProxy/MCPProxyTests/CoreSupersedeAttachTests.swift +++ b/native/macos/MCPProxy/MCPProxyTests/CoreSupersedeAttachTests.swift @@ -115,8 +115,9 @@ final class CoreSupersedeAttachTests: XCTestCase { /// The automatic branch resolves to `stopAndRespawn`, and the pid it is /// handed belongs to the test runner. The tray must recognise that it is - /// not an mcpproxy process and refuse — surfacing an error instead of - /// signalling something it does not own. + /// not an mcpproxy process, refuse to signal it, and fall back to the + /// instructions branch — WITHOUT severing a connection to a core that, + /// old as it is, is working. func testAutomaticSupersedeRefusesAPIDThatIsNotACore() async throws { let foreignPID = ProcessInfo.processInfo.processIdentifier let appState = try await attach( @@ -126,10 +127,14 @@ final class CoreSupersedeAttachTests: XCTestCase { // Still alive: the guard fired before any signal. XCTAssertTrue(CoreProcessIdentity.isRunning(pid: foreignPID)) - let state = await MainActor.run { appState.coreState } - guard case .error = state else { - return XCTFail("a refused supersede must surface, not fail silently (got \(state))") - } + let connected = await MainActor.run { appState.coreState } + XCTAssertEqual(connected, .connected, + "a refused kill must not cost the user their working connection") + + let prompt = await MainActor.run { appState.staleCorePrompt } + XCTAssertEqual(prompt?.runningVersion, "0.53.0") + XCTAssertNil(prompt?.pid, "with no safe stop mechanism the item can only instruct") + let attempted = await manager?.didAttemptSupersede XCTAssertEqual(attempted, true, "the budget is consumed even on refusal — FR-005 forbids retry loops") From 1b2015a63e4b8831c8ba181828947c3cb31d5ac4 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Sat, 8 Aug 2026 06:48:36 +0300 Subject: [PATCH 15/37] feat(core): report the effective update policy in /api/v1/info MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Related #957 FR-015 asks for the update policy to be an *explicit* contract the tray can read, "not inferred from missing data". Today the only signal is the presence of the `update` object — and `Checker.GetVersionInfo()` returns nil BOTH when checking is disabled and when no check has produced a result yet. A tray that must decide "may I run a Sparkle feed check at all?" cannot tell those apart, so it either nudges after the operator disabled updates or stays silent when it should not. ## Changes - `internal/updatecheck/policy.go`: `Policy{enabled, channel, nudges_suppressed}` plus `Checker.Policy()` (computed live, so a `SetConfig` hot-reload and both environment overrides are visible on the very next read) and `UnavailablePolicy()` for runtimes constructed without a checker. - `update_policy` in `GET /api/v1/info`, always present with all three fields (no omitempty — an all-zero policy is exactly the disabled case that must not vanish). Wired `Runtime.UpdatePolicy` → `Server.UpdatePolicy` → `httpapi.ServerController`. - `internal/contracts`, `cmd/generate-types` (+ regenerated `frontend/src/types/contracts.ts`), `make swagger`, `docs/api/rest-api.md`. `enabled` governs AUTOMATIC checks only; a user-initiated "Check for Updates" stays available, which is what FR-015 requires. ## Testing - `internal/updatecheck/policy_test.go`: config/env precedence matrix, CI nudge suppression, unavailable-checker policy (CI pinned via t.Setenv). - `internal/httpapi/info_update_policy_test.go`: field always present for four policies, including the all-zero one. - go test -race ./internal/updatecheck/... ./internal/httpapi/... ./internal/contracts/... → ok - golangci-lint v2 → 0 issues; server edition build + tests ok. --- cmd/generate-types/main.go | 15 ++++ docs/api/rest-api.md | 9 ++ frontend/src/types/contracts.ts | 15 ++++ internal/contracts/types.go | 22 +++++ internal/httpapi/contracts_test.go | 4 + internal/httpapi/info_update_policy_test.go | 84 +++++++++++++++++++ internal/httpapi/security_test.go | 3 + internal/httpapi/server.go | 11 +++ internal/runtime/runtime.go | 11 +++ internal/server/server.go | 5 ++ internal/updatecheck/policy.go | 68 +++++++++++++++ internal/updatecheck/policy_test.go | 92 +++++++++++++++++++++ oas/docs.go | 2 +- oas/swagger.yaml | 25 ++++++ 14 files changed, 365 insertions(+), 1 deletion(-) create mode 100644 internal/httpapi/info_update_policy_test.go create mode 100644 internal/updatecheck/policy.go create mode 100644 internal/updatecheck/policy_test.go diff --git a/cmd/generate-types/main.go b/cmd/generate-types/main.go index 5db3e231..743516d6 100644 --- a/cmd/generate-types/main.go +++ b/cmd/generate-types/main.go @@ -452,6 +452,21 @@ export interface InfoResponse { // Spec 092 FR-002: OS process id of the running core, so a tray that only // attached to it still has a mechanism to stop a stale one. pid: number; + // Spec 092 FR-015: effective update policy. Always present — the optional + // "update" object above is absent both when checking is disabled and when + // no check has run yet, so it cannot be used to infer permission. + update_policy: UpdatePolicy; +} + +// Spec 092 FR-015: the effective, hot-reloadable update policy. +export interface UpdatePolicy { + // Automatic checks allowed? (update_check.enabled, overridden by + // MCPPROXY_DISABLE_AUTO_UPDATE=true). A user-initiated check stays allowed. + enabled: boolean; + // Tracked release channel: "stable" or "rc". + channel: string; + // UI surfaces must stay quiet (CI / non-interactive context). + nudges_suppressed: boolean; } `) diff --git a/docs/api/rest-api.md b/docs/api/rest-api.md index c85aa109..fdc7c613 100644 --- a/docs/api/rest-api.md +++ b/docs/api/rest-api.md @@ -894,6 +894,11 @@ Get application info, version, and update availability. }, "launched_by": "tray", "pid": 4711, + "update_policy": { + "enabled": true, + "channel": "stable", + "nudges_suppressed": false + }, "update": { "available": true, "latest_version": "v1.3.0", @@ -918,6 +923,10 @@ Get application info, version, and update availability. | `endpoints.socket` | string | Unix socket path (empty if disabled) | | `launched_by` | string | Durable launch provenance of the running core (Spec 092 FR-001a): `tray` when a tray spawned it, `installer` when the macOS PKG postinstall did, `""` when user-launched or unknown. Always present. A tray uses this to decide whether it may stop and respawn a stale core it did not itself start — an empty value means consent is required. | | `pid` | integer | OS process id of the running core (Spec 092 FR-002). A tray that only *attached* to a core holds no process handle for it and the core exposes no shutdown endpoint, so this is the mechanism behind the consent-gated "restart the stale core" action. | +| `update_policy` | object | Effective, hot-reloadable update policy (Spec 092 FR-015). **Always present**, including every field, because the `update` object below is absent both when checking is disabled *and* when no check has produced a result yet — its absence cannot tell a client whether it is allowed to check. | +| `update_policy.enabled` | boolean | Whether **automatic** update checks are allowed: `update_check.enabled`, with `MCPPROXY_DISABLE_AUTO_UPDATE=true` winning over it. A *user-initiated* "Check for Updates" stays available even when this is `false`. The macOS tray gates its Sparkle feed checks on this field. | +| `update_policy.channel` | string | Tracked release channel: `stable` or `rc` (`update_check.channel`, with `MCPPROXY_ALLOW_PRERELEASE_UPDATES=true` forcing `rc`). The tray maps `rc` onto the Sparkle `beta` feed channel. | +| `update_policy.nudges_suppressed` | boolean | The core runs in a CI / non-interactive context: UI surfaces must stay quiet while machine-readable fields keep reporting the facts. | | `update` | object | Update information (may be null if not checked yet; omitted entirely when update checking is disabled via `update_check.enabled: false` or `MCPPROXY_DISABLE_AUTO_UPDATE=true`) | | `update.available` | boolean | Whether a newer version is available | | `update.latest_version` | string | Latest version available on GitHub | diff --git a/frontend/src/types/contracts.ts b/frontend/src/types/contracts.ts index 04e15f7c..2d54819d 100644 --- a/frontend/src/types/contracts.ts +++ b/frontend/src/types/contracts.ts @@ -386,4 +386,19 @@ export interface InfoResponse { // Spec 092 FR-002: OS process id of the running core, so a tray that only // attached to it still has a mechanism to stop a stale one. pid: number; + // Spec 092 FR-015: effective update policy. Always present — the optional + // "update" object above is absent both when checking is disabled and when + // no check has run yet, so it cannot be used to infer permission. + update_policy: UpdatePolicy; +} + +// Spec 092 FR-015: the effective, hot-reloadable update policy. +export interface UpdatePolicy { + // Automatic checks allowed? (update_check.enabled, overridden by + // MCPPROXY_DISABLE_AUTO_UPDATE=true). A user-initiated check stays allowed. + enabled: boolean; + // Tracked release channel: "stable" or "rc". + channel: string; + // UI surfaces must stay quiet (CI / non-interactive context). + nudges_suppressed: boolean; } diff --git a/internal/contracts/types.go b/internal/contracts/types.go index 1800e43e..ae4c3635 100644 --- a/internal/contracts/types.go +++ b/internal/contracts/types.go @@ -1179,4 +1179,26 @@ type InfoResponse struct { // print instructions. Paired with LaunchedBy it is what lets a newer tray // supersede a core an older tray started. PID int `json:"pid"` + // UpdatePolicy is the effective, hot-reloadable update policy (Spec 092 + // FR-015). Always present: the `update` object above is omitted both when + // update checking is disabled AND when no check has produced a result + // yet, so its absence cannot tell a client whether it is allowed to run + // its own (e.g. Sparkle feed) check. This field states the answer. + UpdatePolicy UpdatePolicy `json:"update_policy"` +} + +// UpdatePolicy is the effective update policy reported by GET /api/v1/info +// (Spec 092 FR-015). Mirrors updatecheck.Policy; duplicated here because the +// contracts package is the single source the OpenAPI spec and the frontend +// types are generated from. +type UpdatePolicy struct { + // Enabled is the effective automatic-check kill switch: update_check.enabled + // with MCPPROXY_DISABLE_AUTO_UPDATE=true winning over it. A user-initiated + // "Check for Updates" stays available regardless. + Enabled bool `json:"enabled"` + // Channel is the tracked release channel: "stable" or "rc". + Channel string `json:"channel"` + // NudgesSuppressed asks UI surfaces to stay quiet (CI / non-interactive) + // while machine-readable fields keep reporting the facts. + NudgesSuppressed bool `json:"nudges_suppressed"` } diff --git a/internal/httpapi/contracts_test.go b/internal/httpapi/contracts_test.go index d55a50a6..1451435e 100644 --- a/internal/httpapi/contracts_test.go +++ b/internal/httpapi/contracts_test.go @@ -339,6 +339,10 @@ func (m *MockServerController) RefreshVersionInfo() *updatecheck.VersionInfo { return nil } +func (m *MockServerController) UpdatePolicy() updatecheck.Policy { + return updatecheck.Policy{Enabled: true, Channel: updatecheck.PolicyChannelStable} +} + // Tool discovery func (m *MockServerController) DiscoverServerTools(_ context.Context, _ string) error { return nil diff --git a/internal/httpapi/info_update_policy_test.go b/internal/httpapi/info_update_policy_test.go new file mode 100644 index 00000000..f28dd761 --- /dev/null +++ b/internal/httpapi/info_update_policy_test.go @@ -0,0 +1,84 @@ +package httpapi + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap/zaptest" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/contracts" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/updatecheck" +) + +// policyController serves a fixed policy so the handler wiring can be asserted +// without standing up a real update checker. +type policyController struct { + MockServerController + policy updatecheck.Policy +} + +func (c *policyController) UpdatePolicy() updatecheck.Policy { return c.policy } + +// Spec 092 FR-015: the effective update policy must be an EXPLICIT contract. +// Absence of the "update" object cannot carry the information, because it is +// absent both when checking is disabled and when no check has run yet. +func TestInfoEndpointAlwaysReportsUpdatePolicy(t *testing.T) { + tests := []struct { + name string + policy updatecheck.Policy + }{ + { + name: "enabled stable", + policy: updatecheck.Policy{Enabled: true, Channel: updatecheck.PolicyChannelStable}, + }, + { + name: "rc channel", + policy: updatecheck.Policy{Enabled: true, Channel: updatecheck.PolicyChannelRC}, + }, + { + // The interesting case: every field is the zero value, so an + // omitempty encoding would erase the whole object and the tray + // would read "no policy" as "no opinion" and check anyway. + name: "kill switch on", + policy: updatecheck.Policy{Enabled: false, Channel: updatecheck.PolicyChannelStable}, + }, + { + name: "nudges suppressed in CI", + policy: updatecheck.Policy{Enabled: true, Channel: updatecheck.PolicyChannelStable, NudgesSuppressed: true}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + logger := zaptest.NewLogger(t).Sugar() + server := NewServer(&policyController{policy: tt.policy}, logger, nil) + + req := httptest.NewRequest("GET", "/api/v1/info", http.NoBody) + w := httptest.NewRecorder() + server.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code) + + var response contracts.APIResponse + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) + data, ok := response.Data.(map[string]interface{}) + require.True(t, ok) + + require.Contains(t, data, "update_policy", "update_policy must always be present") + policy, ok := data["update_policy"].(map[string]interface{}) + require.True(t, ok, "update_policy must be an object") + + // All three keys present regardless of value — no omitempty. + require.Contains(t, policy, "enabled") + require.Contains(t, policy, "channel") + require.Contains(t, policy, "nudges_suppressed") + + assert.Equal(t, tt.policy.Enabled, policy["enabled"]) + assert.Equal(t, tt.policy.Channel, policy["channel"]) + assert.Equal(t, tt.policy.NudgesSuppressed, policy["nudges_suppressed"]) + }) + } +} diff --git a/internal/httpapi/security_test.go b/internal/httpapi/security_test.go index e2f0945d..887bf48b 100644 --- a/internal/httpapi/security_test.go +++ b/internal/httpapi/security_test.go @@ -321,6 +321,9 @@ func (m *baseController) GetToolCallsBySession(sessionID string, limit, offset i } func (m *baseController) GetVersionInfo() *updatecheck.VersionInfo { return nil } func (m *baseController) RefreshVersionInfo() *updatecheck.VersionInfo { return nil } +func (m *baseController) UpdatePolicy() updatecheck.Policy { + return updatecheck.UnavailablePolicy() +} func (m *baseController) DiscoverServerTools(_ context.Context, _ string) error { return nil } diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 862aa5d9..0121aa33 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -159,6 +159,12 @@ type ServerController interface { // Version and updates GetVersionInfo() *updatecheck.VersionInfo RefreshVersionInfo() *updatecheck.VersionInfo + // UpdatePolicy reports the effective update policy (Spec 092 FR-015). + // Separate from GetVersionInfo because that one returns nil BOTH when + // checking is disabled and when no result exists yet — the tray must be + // able to tell those apart before deciding whether it may run its own + // feed check. + UpdatePolicy() updatecheck.Policy // Activity logging (RFC-003) ListActivities(filter storage.ActivityFilter) ([]*storage.ActivityRecord, int, error) @@ -1162,6 +1168,11 @@ func (s *Server) handleGetInfo(w http.ResponseWriter, r *http.Request) { // stop mechanism available to it — and the difference between a consent // action that works and one that can only print instructions. "pid": pidFn(), + // Spec 092 FR-015: the effective update policy, ALWAYS present. The + // `update` object below is absent both when checking is disabled and + // when no check has produced a result yet, so it cannot be used to + // infer permission; this field states it. + "update_policy": s.controller.UpdatePolicy(), } if versionInfo != nil { response["update"] = versionInfo.ToAPIResponse() diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index 726db5f6..61d84799 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -2666,6 +2666,17 @@ func (r *Runtime) GetVersionInfo() *updatecheck.VersionInfo { return r.updateChecker.GetVersionInfo() } +// UpdatePolicy returns the effective update policy (Spec 092 FR-015): the +// kill-switch state, the release channel, and whether UI nudges are suppressed. +// Without an update checker automatic checking cannot happen at all, which is +// reported as an explicit disabled policy rather than as missing data. +func (r *Runtime) UpdatePolicy() updatecheck.Policy { + if r.updateChecker == nil { + return updatecheck.UnavailablePolicy() + } + return r.updateChecker.Policy() +} + // RefreshVersionInfo performs an immediate update check and returns the result. // Returns nil if the update checker has not been initialized. func (r *Runtime) RefreshVersionInfo() *updatecheck.VersionInfo { diff --git a/internal/server/server.go b/internal/server/server.go index eaba679b..062d3894 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -3155,6 +3155,11 @@ func (s *Server) RefreshVersionInfo() *updatecheck.VersionInfo { return s.runtime.RefreshVersionInfo() } +// UpdatePolicy returns the effective update policy (Spec 092 FR-015). +func (s *Server) UpdatePolicy() updatecheck.Policy { + return s.runtime.UpdatePolicy() +} + // Activity logging methods (RFC-003) // ListActivities returns activity records matching the filter. diff --git a/internal/updatecheck/policy.go b/internal/updatecheck/policy.go new file mode 100644 index 00000000..a83f6bda --- /dev/null +++ b/internal/updatecheck/policy.go @@ -0,0 +1,68 @@ +package updatecheck + +// Spec 092 FR-015: the effective update policy as an EXPLICIT contract. +// +// Before this file the only signal a client had was the presence or absence of +// the `update` object in /api/v1/info — and `GetVersionInfo` returns nil both +// when checking is disabled and when a check simply has not produced a result +// yet. A tray that has to decide "may I run a feed check at all?" cannot tell +// those apart, so it either nudges when the operator disabled updates or stays +// silent when it should not. The policy below is always reported, never +// inferred from missing data, and is recomputed on every read so a config +// hot-reload (SetConfig) or an environment override takes effect immediately. + +const ( + // PolicyChannelStable offers only stable releases. + PolicyChannelStable = "stable" + // PolicyChannelRC also offers prereleases (docs/prerelease-builds.md). + PolicyChannelRC = "rc" +) + +// Policy is the effective, hot-reloadable update policy visible to clients. +type Policy struct { + // Enabled is the effective kill-switch state: update_check.enabled with + // MCPPROXY_DISABLE_AUTO_UPDATE=true winning over it. When false, no + // surface may perform an automatic check. A USER-INITIATED check ("Check + // for Updates") stays available — this field governs automatic behaviour, + // which is what FR-015 asks for. + Enabled bool `json:"enabled"` + + // Channel is the release channel this install tracks: "stable" or "rc". + Channel string `json:"channel"` + + // NudgesSuppressed is the CI / non-interactive rule (Spec 079 FR-019): + // machine-readable fields keep reporting the facts, UI surfaces stay + // quiet. + NudgesSuppressed bool `json:"nudges_suppressed"` +} + +// Policy reports the checker's effective policy. Safe for concurrent use and +// cheap enough to call per request; both environment overrides are read live so +// the answer always matches what the checker itself would do right now. +func (c *Checker) Policy() Policy { + channel := PolicyChannelStable + if c.IncludePrereleases() { + channel = PolicyChannelRC + } + c.mu.RLock() + suppressed := c.nudgesSuppressed + c.mu.RUnlock() + return Policy{ + Enabled: c.Enabled(), + Channel: channel, + NudgesSuppressed: suppressed, + } +} + +// UnavailablePolicy is the policy to report when there is no checker at all +// (server edition, tests, a runtime constructed without one). Automatic checks +// cannot happen, so Enabled is false — reported as a fact rather than left for +// the client to guess. Nudge suppression is still answered from the +// environment, since that rule is about the context, not about the checker. +func UnavailablePolicy() Policy { + return Policy{ + Enabled: false, + Channel: PolicyChannelStable, + NudgesSuppressed: isQuietEnvironment(), + } +} diff --git a/internal/updatecheck/policy_test.go b/internal/updatecheck/policy_test.go new file mode 100644 index 00000000..79db9723 --- /dev/null +++ b/internal/updatecheck/policy_test.go @@ -0,0 +1,92 @@ +package updatecheck + +import ( + "testing" + + "go.uber.org/zap" +) + +// Spec 092 FR-015: the policy must be computed live so a config hot-reload or +// an environment override is visible on the very next read. +func TestCheckerPolicyReflectsConfigAndEnvironment(t *testing.T) { + t.Setenv("CI", "") + t.Setenv(EnvDisableAutoUpdate, "") + t.Setenv(EnvAllowPrereleaseUpdates, "") + + c := New(zap.NewNop(), "v1.0.0") + + p := c.Policy() + if !p.Enabled { + t.Fatalf("default policy should be enabled, got %+v", p) + } + if p.Channel != PolicyChannelStable { + t.Fatalf("default channel = %q, want %q", p.Channel, PolicyChannelStable) + } + if p.NudgesSuppressed { + t.Fatalf("nudges should not be suppressed outside CI, got %+v", p) + } + + // Hot reload onto the RC channel. + c.SetConfig(true, true) + if got := c.Policy().Channel; got != PolicyChannelRC { + t.Fatalf("after SetConfig(rc) channel = %q, want %q", got, PolicyChannelRC) + } + + // Config kill switch. + c.SetConfig(false, true) + if c.Policy().Enabled { + t.Fatalf("update_check.enabled=false must disable the policy") + } + + // Env kill switch wins over an enabled config. + c.SetConfig(true, false) + t.Setenv(EnvDisableAutoUpdate, "true") + if c.Policy().Enabled { + t.Fatalf("%s=true must win over update_check.enabled=true", EnvDisableAutoUpdate) + } + + // Env prerelease override wins over channel=stable. + t.Setenv(EnvDisableAutoUpdate, "") + t.Setenv(EnvAllowPrereleaseUpdates, "true") + if got := c.Policy().Channel; got != PolicyChannelRC { + t.Fatalf("%s=true must force the rc channel, got %q", EnvAllowPrereleaseUpdates, got) + } +} + +// The CI rule is captured at construction (Spec 079 FR-019) and reported by +// the policy, so a tray can stay quiet without re-deriving the rule itself. +func TestCheckerPolicyReportsNudgeSuppressionInCI(t *testing.T) { + t.Setenv("CI", "true") + t.Setenv(EnvDisableAutoUpdate, "") + t.Setenv(EnvAllowPrereleaseUpdates, "") + + c := New(zap.NewNop(), "v1.0.0") + p := c.Policy() + if !p.NudgesSuppressed { + t.Fatalf("CI=true must suppress nudges, got %+v", p) + } + if !p.Enabled { + t.Fatalf("nudge suppression is not a kill switch; checks stay enabled: %+v", p) + } +} + +// Without a checker there is nothing that could perform an automatic check, so +// the reported policy says so rather than leaving the client to infer it. +func TestUnavailablePolicyIsExplicitlyDisabled(t *testing.T) { + t.Setenv("CI", "") + p := UnavailablePolicy() + if p.Enabled { + t.Fatalf("UnavailablePolicy must be disabled, got %+v", p) + } + if p.Channel != PolicyChannelStable { + t.Fatalf("UnavailablePolicy channel = %q, want %q", p.Channel, PolicyChannelStable) + } + if p.NudgesSuppressed { + t.Fatalf("outside CI nudges are not suppressed, got %+v", p) + } + + t.Setenv("CI", "1") + if !UnavailablePolicy().NudgesSuppressed { + t.Fatalf("CI=1 must suppress nudges even without a checker") + } +} diff --git a/oas/docs.go b/oas/docs.go index e1433287..fdd3e7b6 100644 --- a/oas/docs.go +++ b/oas/docs.go @@ -6,7 +6,7 @@ import "github.com/swaggo/swag/v2" const docTemplate = `{ "schemes": {{ marshal .Schemes }}, - "components": {"schemas":{"config.Config":{"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"},"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_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"},"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_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"},"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"},"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"},"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"},"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"},"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.","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"},"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\"","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"},"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"},"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.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"},"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"},"name":{"type":"string"},"oauth":{"$ref":"#/components/schemas/contracts.OAuthConfig"},"oauth_status":{"description":"OAuth status: \"authenticated\", \"expired\", \"error\", \"none\"","type":"string"},"protocol":{"type":"string"},"quarantine":{"$ref":"#/components/schemas/contracts.QuarantineStats"},"quarantined":{"type":"boolean"},"reconnect_count":{"type":"integer"},"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"},"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.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"},"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"},"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"},"name":{"type":"string"},"protocol":{"type":"string"},"quarantined":{"type":"boolean"},"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"},"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.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"},"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_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"},"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_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"},"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"},"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"},"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"},"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"},"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.","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"},"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\"","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"},"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.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"},"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"},"name":{"type":"string"},"oauth":{"$ref":"#/components/schemas/contracts.OAuthConfig"},"oauth_status":{"description":"OAuth status: \"authenticated\", \"expired\", \"error\", \"none\"","type":"string"},"protocol":{"type":"string"},"quarantine":{"$ref":"#/components/schemas/contracts.QuarantineStats"},"quarantined":{"type":"boolean"},"reconnect_count":{"type":"integer"},"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"},"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"},"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"},"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"},"name":{"type":"string"},"protocol":{"type":"string"},"quarantined":{"type":"boolean"},"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"},"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"],"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"],"type":"string"}},{"description":"Filter by intent operation type (Spec 018)","in":"query","name":"intent_type","schema":{"enum":["read","write","destructive"],"type":"string"}},{"description":"Filter by HTTP request ID for log correlation (Spec 021)","in":"query","name":"request_id","schema":{"type":"string"}},{"description":"Include successful call_tool_* internal tool calls (default: false, excluded to avoid duplicates)","in":"query","name":"include_call_tool","schema":{"type":"boolean"}},{"description":"Filter by sensitive data detection (true=has detections, false=no detections)","in":"query","name":"sensitive_data","schema":{"type":"boolean"}},{"description":"Filter by specific detection type (e.g., 'aws_access_key', 'credit_card')","in":"query","name":"detection_type","schema":{"type":"string"}},{"description":"Filter by severity level","in":"query","name":"severity","schema":{"enum":["critical","high","medium","low"],"type":"string"}},{"description":"Filter by agent token name (Spec 028)","in":"query","name":"agent","schema":{"type":"string"}},{"description":"Filter by auth type (Spec 028)","in":"query","name":"auth_type","schema":{"enum":["admin","agent"],"type":"string"}},{"description":"Filter activities after this time (RFC3339)","in":"query","name":"start_time","schema":{"type":"string"}},{"description":"Filter activities before this time (RFC3339)","in":"query","name":"end_time","schema":{"type":"string"}},{"description":"Maximum records to return (1-100, default 50)","in":"query","name":"limit","schema":{"type":"integer"}},{"description":"Pagination offset (default 0)","in":"query","name":"offset","schema":{"type":"integer"}},{"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 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"],"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/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/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/tool-calls":{"get":{"description":"Retrieves paginated tool call history across all upstream servers or filtered by session ID. Includes execution timestamps, arguments, results, and error information for debugging and auditing.","parameters":[{"description":"Maximum number of records to return (1-100, default 50)","in":"query","name":"limit","schema":{"type":"integer"}},{"description":"Number of records to skip for pagination (default 0)","in":"query","name":"offset","schema":{"type":"integer"}},{"description":"Filter tool calls by MCP session ID","in":"query","name":"session_id","schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetToolCallsResponse"}}},"description":"Tool calls retrieved successfully"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to get tool calls"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get tool call history","tags":["tool-calls"]}},"/api/v1/tool-calls/{id}":{"get":{"description":"Retrieves detailed information about a specific tool call execution including full request arguments, response data, execution time, and any errors encountered.","parameters":[{"description":"Tool call ID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetToolCallDetailResponse"}}},"description":"Tool call details retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Tool call ID required"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Tool call not found"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get tool call details by ID","tags":["tool-calls"]}},"/api/v1/tool-calls/{id}/replay":{"post":{"description":"Re-executes a previous tool call with optional modified arguments. Useful for debugging and testing tool behavior with different inputs. Creates a new tool call record linked to the original.","parameters":[{"description":"Original tool call ID to replay","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ReplayToolCallRequest"}}},"description":"Optional modified arguments for replay"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ReplayToolCallResponse"}}},"description":"Tool call replayed successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Tool call ID required or invalid JSON payload"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to replay tool call"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Replay a tool call","tags":["tool-calls"]}},"/api/v1/tools":{"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)"},"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 29b5de98..08d51a5e 100644 --- a/oas/swagger.yaml +++ b/oas/swagger.yaml @@ -1687,6 +1687,8 @@ components: type: integer update: $ref: '#/components/schemas/contracts.UpdateInfo' + update_policy: + $ref: '#/components/schemas/contracts.UpdatePolicy' version: description: Current MCPProxy version type: string @@ -2503,6 +2505,29 @@ components: 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 + FR-015). Always present: the `update` object above is omitted both when + update checking is disabled AND when no check has produced a result + yet, so its absence cannot tell a client whether it is allowed to run + its 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 + with MCPPROXY_DISABLE_AUTO_UPDATE=true winning over it. A user-initiated + "Check for Updates" stays available regardless. + type: boolean + nudges_suppressed: + description: |- + NudgesSuppressed asks UI surfaces to stay quiet (CI / non-interactive) + while machine-readable fields keep reporting the facts. + type: boolean + type: object contracts.UpstreamError: properties: error_message: From b994e8b941740f5c48b13a702cba075860c787a3 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Sat, 8 Aug 2026 07:04:18 +0300 Subject: [PATCH 16/37] feat(tray): wire the Sparkle one-click updater MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Related #957 Sparkle 2.9.3 has been declared in Package.swift, dynamically linked, bundled into Contents/Frameworks and code-signed by both build scripts since Spec 037 — and never imported by a single Swift file. `UpdateService.checkWithSparkle()` was a `NSClassFromString` stub that fell straight through to a raw GitHub API call. FIRST, the one unproven assumption in the decision report: `import Sparkle` DOES resolve under plain `swift build` with no Xcode project. Verified by compiling `#if !canImport(Sparkle) #error(...) #endif` against the package — the error does not fire, so the whole framework path below is really compiled, not silently `#if`-ed away. ## Changes - `Services/FeedUpdater.swift` — `FeedUpdating` / `FeedUpdaterObserver` protocols plus the real `SparkleFeedUpdater`: * programmatic `SPUStandardUpdaterController(startingUpdater: false, …)` and an explicit `try updater.start()`. Deliberately NOT `startingUpdater: true`: that variant logs a misconfiguration and puts a "contact the developer" alert in front of the user a few seconds later — which is exactly what every build shipped before the CI appcast job would do, since Info.plist still carries `SPARKLE_PUBLIC_KEY_PLACEHOLDER`. A start failure now degrades to the browser path with a reason (FR-016/FR-017); * gentle reminders (FR-010): `supportsGentleScheduledUpdateReminders = true`, `standardUserDriverShouldHandleShowingScheduledUpdate` → false so a scheduled check never throws a window at a menu-bar user, and `standardUserDriverWillHandleShowingUpdate` feeds the published state; * `allowedChannelsForUpdater:` from the policy — empty set (Sparkle's "default channel only") for stable, `["beta"]` for RC (FR-014); * FR-012: the core is stopped in `updater(_:willInstallUpdate:)` — "called immediately before installing", i.e. the last moment the OLD bundle is still on disk. `shouldPostponeRelaunchForUpdate:` and `updaterWillRelaunchApplication:` both run AFTER the swap; the latter is kept as an idempotent second stop, the former is not used. - `Services/ManagedCoreStop.swift` — the synchronous SIGTERM → bounded wait → SIGKILL ladder. Sparkle's pre-install hooks are synchronous main-thread callbacks, and `CoreProcessManager.shutdown()` hops to the main actor, so blocking on it would deadlock rather than stop anything. Phase 0's `CoreProcessIdentity` re-check still gates the signal. - `Services/UpdatePolicy.swift` — FR-015 resolution: tray `MCPPROXY_DISABLE_AUTO_UPDATE`, CI suppression, and the core's new `update_policy`. A user-initiated "Check for Updates" bypasses all of it. - `Services/UpdateInstallability.swift` — FR-016: translocation, read-only volume and unwritable parent, each with an explanation AND a fallback. - `Services/UpdateMenuState.swift` — FR-017: one owner. Feed offer wins for the same or lower legacy version; equal versions dedupe; a legacy version the feed does not carry renders as browser guidance, never as a one-click action; a blocked app never gets a one-click item at all. - `UpdateService.swift` rewritten around those pieces; `MCPProxyApp.swift` renders `updateService.menuEntries`, installs the pre-update core stop, and routes the launch/hourly checks through the policy-gated entry point (they used to call the user-initiated one). - `API/Models.swift`, `Core/CoreProcessManager.swift`, `State/AppState.swift` — carry `update_policy` from `/api/v1/info` to the service on every connect. - `scripts/build-swift-app.sh` — stamp `SUPublicEDKey` / `SUFeedURL` from `SPARKLE_PUBLIC_ED_KEY` / `SPARKLE_FEED_URL` when present; without them the bundle keeps the placeholder and one-click updates stay off, which is the correct failure direction. ## Testing - `swift build` → Build complete (only the two pre-existing warnings). - `cd native/macos/MCPProxy && swift test` → 911 tests, 1 failure — the known environmental `AppLifecycleTests.testTheSharedJournalNeverWritesToTheRealInstanceRootUnderTests` (this machine's live tray owns ~/.mcpproxy/tray-lifecycle.jsonl). - New: UpdatePolicyTests 13 · UpdateMenuStateTests 14 · UpdateInstallabilityTests 6 · ManagedCoreStopTests 8 · UpdateServiceFeedTests 16 — 0 failures. The last suite drives the REAL `AppController.rebuildMenu()`, so a renamed menu slot fails there. - Delegate spelling verified against the Sparkle headers AND by the compiler: deliberately misspelling one method produces "nearly matches optional requirement"; the committed code produces zero such warnings. ## NOTES - FR-018 (Homebrew cask `auto_updates true`) lives in the tap repo (smart-mcp-proxy/homebrew-mcpproxy) and is out of scope for this repository. It must be set before the first Sparkle-capable release, or `brew upgrade` will fight the in-app updater. - No live upgrade QA: a real one-click run needs a notarized older build and a published appcast, neither of which exists yet. --- .../macos/MCPProxy/MCPProxy/API/Models.swift | 8 + .../MCPProxy/Core/CoreProcessManager.swift | 4 + .../macos/MCPProxy/MCPProxy/MCPProxyApp.swift | 119 ++++++-- .../MCPProxy/Services/FeedUpdater.swift | 265 ++++++++++++++++++ .../MCPProxy/Services/ManagedCoreStop.swift | 86 ++++++ .../Services/UpdateInstallability.swift | 97 +++++++ .../MCPProxy/Services/UpdateMenuState.swift | 118 ++++++++ .../MCPProxy/Services/UpdatePolicy.swift | 174 ++++++++++++ .../MCPProxy/Services/UpdateService.swift | 243 ++++++++++++++-- .../MCPProxy/MCPProxy/State/AppState.swift | 8 + .../MCPProxyTests/ManagedCoreStopTests.swift | 106 +++++++ .../UpdateInstallabilityTests.swift | 70 +++++ .../MCPProxyTests/UpdateMenuStateTests.swift | 104 +++++++ .../MCPProxyTests/UpdatePolicyTests.swift | 131 +++++++++ .../UpdateServiceFeedTests.swift | 229 +++++++++++++++ scripts/build-swift-app.sh | 20 ++ 16 files changed, 1728 insertions(+), 54 deletions(-) create mode 100644 native/macos/MCPProxy/MCPProxy/Services/FeedUpdater.swift create mode 100644 native/macos/MCPProxy/MCPProxy/Services/ManagedCoreStop.swift create mode 100644 native/macos/MCPProxy/MCPProxy/Services/UpdateInstallability.swift create mode 100644 native/macos/MCPProxy/MCPProxy/Services/UpdateMenuState.swift create mode 100644 native/macos/MCPProxy/MCPProxy/Services/UpdatePolicy.swift create mode 100644 native/macos/MCPProxy/MCPProxyTests/ManagedCoreStopTests.swift create mode 100644 native/macos/MCPProxy/MCPProxyTests/UpdateInstallabilityTests.swift create mode 100644 native/macos/MCPProxy/MCPProxyTests/UpdateMenuStateTests.swift create mode 100644 native/macos/MCPProxy/MCPProxyTests/UpdatePolicyTests.swift create mode 100644 native/macos/MCPProxy/MCPProxyTests/UpdateServiceFeedTests.swift diff --git a/native/macos/MCPProxy/MCPProxy/API/Models.swift b/native/macos/MCPProxy/MCPProxy/API/Models.swift index 481fb8c5..f3f36e98 100644 --- a/native/macos/MCPProxy/MCPProxy/API/Models.swift +++ b/native/macos/MCPProxy/MCPProxy/API/Models.swift @@ -805,6 +805,13 @@ struct InfoResponse: Codable, Equatable { /// one, which downgrades the supersede action to "show instructions". let pid: Int32? + /// Spec 092 FR-015 — the effective update policy. Optional for the same + /// reason as the two fields above: a pre-092 core omits it, and the tray + /// must keep working against one. Absence maps to the permissive default + /// those builds already behaved as (see `UpdatePolicyResolver`), never to + /// "silently disable updates". + let updatePolicy: CoreUpdatePolicy? + enum CodingKeys: String, CodingKey { case version case webUiUrl = "web_ui_url" @@ -813,6 +820,7 @@ struct InfoResponse: Codable, Equatable { case update case launchedBy = "launched_by" case pid + case updatePolicy = "update_policy" } } diff --git a/native/macos/MCPProxy/MCPProxy/Core/CoreProcessManager.swift b/native/macos/MCPProxy/MCPProxy/Core/CoreProcessManager.swift index 5b1e3e98..30105e25 100644 --- a/native/macos/MCPProxy/MCPProxy/Core/CoreProcessManager.swift +++ b/native/macos/MCPProxy/MCPProxy/Core/CoreProcessManager.swift @@ -1552,6 +1552,10 @@ actor CoreProcessManager { await MainActor.run { appState.version = info.version appState.webUIBaseURL = webUIBase + // Spec 092 FR-015: the explicit policy contract. Assigned on every + // connect (including reconnects), which is how a config hot-reload + // on the core side reaches the tray. + appState.coreUpdatePolicy = info.updatePolicy if let update = info.update, update.available, let latest = update.latestVersion { appState.updateAvailable = latest.hasPrefix("v") ? String(latest.dropFirst()) : latest } diff --git a/native/macos/MCPProxy/MCPProxy/MCPProxyApp.swift b/native/macos/MCPProxy/MCPProxy/MCPProxyApp.swift index b4aec969..0f89e83a 100644 --- a/native/macos/MCPProxy/MCPProxy/MCPProxyApp.swift +++ b/native/macos/MCPProxy/MCPProxy/MCPProxyApp.swift @@ -34,7 +34,10 @@ extension NSStatusItem: TrayMenuHost {} final class AppController: NSObject, NSApplicationDelegate, NSWindowDelegate, NSMenuDelegate { let appState = AppState() let notificationService = NotificationService() - let updateService = UpdateService() + /// Owns everything the tray knows about updates (Spec 092 FR-017). A `var` + /// so a test can substitute a service built around a fixture bundle and a + /// stub feed updater; production never reassigns it. + var updateService = UpdateService() var coreManager: CoreProcessManager? private var statusItem: NSStatusItem? @@ -121,10 +124,15 @@ final class AppController: NSObject, NSApplicationDelegate, NSWindowDelegate, NS /// `applicationDidFinishLaunching`, which installs the status item as the /// host and leaves the data source resolving to the live core client. @MainActor - convenience init(glanceDataSource: GlanceDataSource, menuHost: TrayMenuHost) { + convenience init( + glanceDataSource: GlanceDataSource, + menuHost: TrayMenuHost, + updateService: UpdateService? = nil + ) { self.init() self.injectedGlanceDataSource = glanceDataSource self.menuHost = menuHost + if let updateService { self.updateService = updateService } } func applicationWillFinishLaunching(_ notification: Notification) { @@ -261,9 +269,36 @@ final class AppController: NSObject, NSApplicationDelegate, NSWindowDelegate, NS } .store(in: &cancellables) - // Auto-check GitHub for a newer release as soon as the core reports its version, - // and again every hour. This avoids relying solely on the core's 4h cache, which - // can lag behind freshly published releases. + // Spec 092 FR-012: give the updater a synchronous way to stop the core + // it manages. Installed before the feed updater starts, because a + // Sparkle session resumed from a previous launch can reach the install + // hook almost immediately. + updateService.stopManagedCore = { [weak self] in + guard let self else { return } + let pid = self.coreManager?.managedProcess?.processIdentifier + let outcome = ManagedCoreStop.stop(pid: pid) + NSLog("[MCPProxy] Pre-update core stop: pid=%d outcome=%@", + pid ?? -1, String(describing: outcome)) + AppLifecycle.shared.note("pre-update core stop: \(outcome)") + } + + // Spec 092 FR-015: every tray-side check is governed by the policy the + // core publishes. Applied before the first check is scheduled. + appState.$coreUpdatePolicy + .removeDuplicates() + .sink { [weak self] policy in + self?.updateService.applyCorePolicy(policy) + } + .store(in: &cancellables) + + // Spec 092 FR-010: start the feed updater. It gates its own scheduled + // cycle on the policy above and reports back through UpdateService. + updateService.startFeedUpdater() + + // Auto-check for a newer release as soon as the core reports its version, + // and again every hour. Both are UNATTENDED checks, so they go through + // the policy-gated entry point (FR-015) — unlike the menu's "Check for + // Updates", which is always allowed. appState.$version .removeDuplicates() .filter { !$0.isEmpty } @@ -271,7 +306,7 @@ final class AppController: NSObject, NSApplicationDelegate, NSWindowDelegate, NS .sink { [weak self] version in guard let self else { return } self.updateService.currentVersion = version - self.updateService.checkForUpdates() + self.updateService.checkForUpdatesInBackground() } .store(in: &cancellables) @@ -280,7 +315,7 @@ final class AppController: NSObject, NSApplicationDelegate, NSWindowDelegate, NS .sink { [weak self] _ in guard let self, !self.appState.version.isEmpty else { return } self.updateService.currentVersion = self.appState.version - self.updateService.checkForUpdates() + self.updateService.checkForUpdatesInBackground() } .store(in: &cancellables) @@ -1140,25 +1175,45 @@ final class AppController: NSObject, NSApplicationDelegate, NSWindowDelegate, NS checkUpdates.isEnabled = updateService.canCheckForUpdates menu.addItem(checkUpdates) - // Show update from either appState (from core /api/v1/info) or UpdateService (direct - // GitHub check). Prefer whichever source advertises the newer version so a stale - // core cache never masks a freshly-published release. - let updateVersion: String? = { - switch (appState.updateAvailable, updateService.latestVersion) { - case let (.some(a), .some(b)): - return UpdateService.compareSemver(a, b) >= 0 ? a : b - case let (.some(a), .none): - return a - case let (.none, .some(b)): - return b - case (.none, .none): - return nil + // Spec 092 FR-017: exactly one source of truth owns the update item. + // The core's cached result (`appState.updateAvailable`) is merged into + // the service's legacy version first, so the resolver sees ONE legacy + // input and one feed input — the two used to be rendered independently, + // which is how a single release produced two competing menu items. + updateService.setCoreReportedVersion(appState.updateAvailable) + for entry in updateService.menuEntries { + switch entry { + case .oneClick(let version): + // FR-010's exact shape: gentle, and honest about what happens. + let item = NSMenuItem( + title: "Update \(version) — ready to restart?", + action: #selector(installFeedUpdate), keyEquivalent: "" + ) + item.target = self + item.toolTip = "Downloads and verifies the update, stops the core, " + + "replaces MCPProxy and relaunches it." + menu.addItem(item) + + case .browserGuidance(let version): + let item = NSMenuItem( + title: "Update available: v\(version) — Download", + action: #selector(openDownloadPage), keyEquivalent: "" + ) + item.target = self + item.toolTip = "Opens the download page. This version cannot be installed " + + "from here." + menu.addItem(item) + + case .blocked(let reason): + // FR-016: never silent. The title says it, the action explains. + let item = NSMenuItem( + title: reason.menuTitle, + action: #selector(showUpdateBlockedReason), keyEquivalent: "" + ) + item.target = self + item.toolTip = reason.explanation + menu.addItem(item) } - }() - if let available = updateVersion { - let updateNote = NSMenuItem(title: "Update available: v\(available)", action: #selector(openDownloadPage), keyEquivalent: "") - updateNote.target = self - menu.addItem(updateNote) } // Spec 092 FR-003: the app on disk is newer than the one running — a @@ -1539,6 +1594,20 @@ final class AppController: NSObject, NSApplicationDelegate, NSWindowDelegate, NS updateService.openDownloadPage() } + /// Spec 092 FR-010: one click — download, verify, replace, relaunch. + @objc private func installFeedUpdate() { + updateService.installFeedUpdate() + } + + /// Spec 092 FR-016: say why an in-place update is impossible, and what to + /// do instead. The alternative — an update item that quietly does nothing — + /// is the failure mode the requirement names. + @MainActor + @objc private func showUpdateBlockedReason() { + guard let reason = updateService.blockedReason else { return } + presentAlert(title: reason.menuTitle, message: reason.message) + } + @objc private func quitApp() { // Claimed before anything else runs: the core teardown below would // otherwise get there first and record its own mechanical description diff --git a/native/macos/MCPProxy/MCPProxy/Services/FeedUpdater.swift b/native/macos/MCPProxy/MCPProxy/Services/FeedUpdater.swift new file mode 100644 index 00000000..8706951b --- /dev/null +++ b/native/macos/MCPProxy/MCPProxy/Services/FeedUpdater.swift @@ -0,0 +1,265 @@ +// FeedUpdater.swift +// MCPProxy +// +// Spec 092 FR-010 / FR-012 / FR-015 / FR-016 — the one-click updater. +// +// Sparkle 2.9.3 has been declared in Package.swift, dynamically linked, bundled +// into Contents/Frameworks and code-signed by both build scripts since Spec 037 +// — and never imported by a single Swift file. `import Sparkle` was verified to +// resolve under plain `swift build` (no Xcode project) before this file was +// written; that was the one unproven assumption in the decision report. +// +// Everything Sparkle-specific lives behind `FeedUpdating` for two reasons that +// are not "testability" alone: +// +// · the framework cannot be instantiated outside a real .app bundle (it reads +// SUFeedURL / SUPublicEDKey from the main bundle's Info.plist), so a +// `swift test` run has no way to exercise the real object at all; +// · a misconfigured bundle — which is precisely what ships until the CI +// appcast job runs with a real EdDSA key — must degrade to the legacy +// GitHub path instead of putting a Sparkle error alert in front of a user. +// +// Hence `startingUpdater: false` plus an explicit `start()` we can catch. The +// stock `SPUStandardUpdaterController(startingUpdater: true, …)` logs the error +// and shows a "contact the developer" alert a few seconds later, which is the +// single worst outcome available here. + +import Foundation + +// MARK: - Protocol + +/// The tray's view of a feed-based updater. One implementation (Sparkle), one +/// stub (tests). +protocol FeedUpdating: AnyObject { + /// Whether a feed updater is actually usable right now. False for dev + /// builds run outside a bundle, and for a bundle Sparkle refused to start. + var isAvailable: Bool { get } + + /// Why the updater is unavailable, when it is. Surfaced in the menu rather + /// than swallowed (FR-016). + var unavailableReason: String? { get } + + /// Apply the effective policy: gates the SCHEDULED check only (FR-015 — + /// a user-initiated check stays available regardless) and selects the feed + /// channel. + func apply(policy: EffectiveUpdatePolicy) + + /// Run a check. `userInitiated` bypasses the policy's automatic-check gate + /// and lets Sparkle show its own progress UI. + func check(userInitiated: Bool) +} + +/// Callbacks from the updater. Delivered on the main thread. +protocol FeedUpdaterObserver: AnyObject { + /// A version is available from the feed. + func feedUpdater(didFindVersion version: String) + + /// The feed has nothing newer (or the offer was withdrawn). + func feedUpdaterDidNotFindUpdate() + + /// A check or install failed in a way the user should see (FR-016). + func feedUpdater(didFailWith message: String) + + /// FR-012: the bundle is about to be replaced. MUST stop the tray-managed + /// core before returning — this call is synchronous and the installer runs + /// as soon as it comes back. + func feedUpdaterWillInstallUpdate() +} + +// MARK: - Sparkle implementation + +#if canImport(Sparkle) + +import AppKit +import Sparkle + +/// The real updater. +/// +/// Not `@MainActor`: every method here is either called BY Sparkle on the main +/// thread (the delegate callbacks) or from the main thread (the menu). Adding +/// the isolation would make the `@objc` delegate conformances non-isolated +/// mismatches without buying anything. +final class SparkleFeedUpdater: NSObject, FeedUpdating { + + private weak var observer: FeedUpdaterObserver? + private var controller: SPUStandardUpdaterController? + private var policy: EffectiveUpdatePolicy = .permissive + private(set) var unavailableReason: String? + + /// The last version the feed offered. Kept by us rather than read back out + /// of Sparkle, because Sparkle's update *session* ends when the user + /// dismisses the window while the update itself remains available — and + /// FR-010's menu item is supposed to stay there, gently, until it is + /// installed. + private(set) var offeredVersion: String? + + var isAvailable: Bool { controller != nil } + + init(observer: FeedUpdaterObserver?) { + self.observer = observer + super.init() + } + + /// Create and start the updater. Separate from `init` so a failure has + /// somewhere to be reported instead of leaving a half-built object. + /// + /// - Parameter bundle: injected only so the bundle precondition can be + /// exercised; production passes `Bundle.main`. + func start(bundle: Bundle = .main, policy: EffectiveUpdatePolicy) { + self.policy = policy + + // Sparkle reads SUFeedURL / SUPublicEDKey from the host bundle. Running + // from `.build/debug/MCPProxy` there is no host bundle to read, and the + // framework's own diagnostics would fire. Say so plainly instead. + guard bundle.bundleURL.pathExtension == "app", bundle.bundleIdentifier != nil else { + unavailableReason = "not running from an app bundle" + NSLog("[MCPProxy] Sparkle not started: %@", unavailableReason ?? "") + return + } + + let controller = SPUStandardUpdaterController( + startingUpdater: false, + updaterDelegate: self, + userDriverDelegate: self + ) + do { + try controller.updater.start() + } catch { + // Misconfiguration (placeholder EdDSA key, missing feed URL, an + // unsigned bundle). The legacy GitHub path keeps working; the menu + // shows the reason instead of a one-click item that cannot run. + unavailableReason = error.localizedDescription + NSLog("[MCPProxy] Sparkle failed to start: %@", error.localizedDescription) + return + } + + self.controller = controller + apply(policy: policy) + NSLog("[MCPProxy] Sparkle updater started (feed channel=%@, scheduled checks=%@)", + policy.channel.rawValue, + policy.automaticChecksAllowed ? "on" : "off") + } + + // MARK: FeedUpdating + + func apply(policy: EffectiveUpdatePolicy) { + self.policy = policy + guard let updater = controller?.updater else { return } + // FR-015: the kill switch governs the SCHEDULED cycle. `check(userInitiated:)` + // deliberately does not consult it. + updater.automaticallyChecksForUpdates = policy.automaticChecksAllowed + // Changing the channel set mid-session needs a cycle reset for the next + // scheduled check to use it (`allowedChannelsForUpdater:` is consulted + // per check, but the schedule is not). + updater.resetUpdateCycle() + } + + func check(userInitiated: Bool) { + guard let updater = controller?.updater else { return } + if userInitiated { + // Always allowed (FR-015, last sentence). + updater.checkForUpdates() + return + } + guard policy.automaticChecksAllowed else { return } + updater.checkForUpdatesInBackground() + } +} + +// MARK: - SPUUpdaterDelegate + +extension SparkleFeedUpdater: SPUUpdaterDelegate { + + /// FR-014: stable users must never be offered RCs. An empty set means + /// "default channel only", which is exactly the stable feed; RC users also + /// accept the `beta` channel the prerelease pipeline tags. + func allowedChannels(for updater: SPUUpdater) -> Set { + policy.channel.allowedSparkleChannels + } + + func updater(_ updater: SPUUpdater, didFindValidUpdate item: SUAppcastItem) { + let version = item.displayVersionString + offeredVersion = version + onMain { [weak self] in self?.observer?.feedUpdater(didFindVersion: version) } + } + + func updaterDidNotFindUpdate(_ updater: SPUUpdater) { + offeredVersion = nil + onMain { [weak self] in self?.observer?.feedUpdaterDidNotFindUpdate() } + } + + /// FR-012 — the hook that matters. + /// + /// "Called immediately before installing the specified update": the last + /// point at which the old bundle is still the one on disk. Synchronous, so + /// the core is down before the swap rather than racing it. Chosen over + /// `shouldPostponeRelaunchForUpdate:` (too late — the bundle is already + /// replaced) and over `updaterWillRelaunchApplication:` (also too late, + /// though it is kept below as a belt-and-braces second stop, which is + /// harmless because the stop is idempotent). + func updater(_ updater: SPUUpdater, willInstallUpdate item: SUAppcastItem) { + observer?.feedUpdaterWillInstallUpdate() + } + + func updaterWillRelaunchApplication(_ updater: SPUUpdater) { + observer?.feedUpdaterWillInstallUpdate() + } + + func updater(_ updater: SPUUpdater, didAbortWithError error: Error) { + let nsError = error as NSError + // "You already have the newest version" arrives here as an abort too. + // It is not a failure and must not become a menu-visible error. + if nsError.domain == SUSparkleErrorDomain, + nsError.code == Int(SUError.noUpdateError.rawValue) { + offeredVersion = nil + onMain { [weak self] in self?.observer?.feedUpdaterDidNotFindUpdate() } + return + } + let message = error.localizedDescription + NSLog("[MCPProxy] Sparkle aborted: %@", message) + onMain { [weak self] in self?.observer?.feedUpdater(didFailWith: message) } + } + + private func onMain(_ work: @escaping () -> Void) { + if Thread.isMainThread { + work() + } else { + DispatchQueue.main.async(execute: work) + } + } +} + +// MARK: - SPUStandardUserDriverDelegate (gentle reminders) + +extension SparkleFeedUpdater: SPUStandardUserDriverDelegate { + + /// FR-010: "a gentle, non-interrupting menu item". A menu-bar app has no + /// business throwing a window at someone because a background timer fired. + var supportsGentleScheduledUpdateReminders: Bool { true } + + /// Never let a SCHEDULED check put a window on screen; the menu item is the + /// notification. A user-initiated check is a different matter — Sparkle's + /// own progress and release-notes UI is the right answer there, and this + /// method is not consulted for it. + func standardUserDriverShouldHandleShowingScheduledUpdate( + _ update: SUAppcastItem, + andInImmediateFocus immediateFocus: Bool + ) -> Bool { + false + } + + /// Sparkle tells us whether IT will show the update. When it will not, the + /// menu item is the only thing the user will ever see, so publish it. + func standardUserDriverWillHandleShowingUpdate( + _ handleShowingUpdate: Bool, + forUpdate update: SUAppcastItem, + state: SPUUserUpdateState + ) { + guard !handleShowingUpdate else { return } + let version = update.displayVersionString + offeredVersion = version + observer?.feedUpdater(didFindVersion: version) + } +} + +#endif diff --git a/native/macos/MCPProxy/MCPProxy/Services/ManagedCoreStop.swift b/native/macos/MCPProxy/MCPProxy/Services/ManagedCoreStop.swift new file mode 100644 index 00000000..a5766b0f --- /dev/null +++ b/native/macos/MCPProxy/MCPProxy/Services/ManagedCoreStop.swift @@ -0,0 +1,86 @@ +// ManagedCoreStop.swift +// MCPProxy +// +// Spec 092 FR-012 — "the updater MUST stop the tray-managed core gracefully +// before the app bundle is replaced". +// +// Sparkle's pre-install hooks are SYNCHRONOUS main-thread callbacks: whatever +// stopping happens must be finished by the time the delegate method returns, +// because the installer runs the moment it does. That rules out the tray's +// normal shutdown path — `CoreProcessManager.shutdown()` is an actor method +// that hops to the main actor several times, so blocking the main thread on it +// deadlocks instead of stopping anything. +// +// So the pre-install stop is done here, with nothing but POSIX: signal, poll, +// escalate. No actor, no run loop, no allocation of a new async context. The +// identity re-check from Phase 0 (`CoreProcessIdentity`) still gates the +// signal — a pid that died between reading it and using it can belong to +// anything by now. + +import Foundation +import Darwin + +/// What happened to the core. +enum ManagedCoreStopOutcome: Equatable { + /// No pid to act on, or the process was already gone. + case notRunning + /// SIGTERM was enough. + case terminated + /// SIGTERM was ignored for the whole grace period; SIGKILL was sent. + case killed + /// The pid does not (any longer) belong to an mcpproxy process. Nothing was + /// signalled — see the type header. + case refused +} + +enum ManagedCoreStop { + + /// How long the core gets to exit on its own before SIGKILL. + /// + /// Bounded on purpose (FR-012 edge case: "in-flight tool calls fail visibly + /// rather than hanging forever"). Five seconds matches the tray's existing + /// SIGTERM→SIGKILL ladder in `stopCore`, and the whole budget is spent on + /// the main thread inside a Sparkle callback, so it cannot grow. + static let defaultGracePeriod: TimeInterval = 5.0 + + /// Poll interval while waiting for the process to disappear. + static let pollInterval: TimeInterval = 0.05 + + /// Stop `pid` synchronously. + /// + /// Every dependency is injected so the ladder can be tested without a real + /// process: the tests drive a fake clock and a fake process table. + @discardableResult + static func stop( + pid: Int32?, + gracePeriod: TimeInterval = defaultGracePeriod, + isCore: (Int32) -> Bool = CoreProcessIdentity.isMCPProxyCore, + isRunning: (Int32) -> Bool = CoreProcessIdentity.isRunning, + send: (Int32, Int32) -> Void = { pid, sig in _ = kill(pid, sig) }, + wait: (TimeInterval) -> Void = { Thread.sleep(forTimeInterval: $0) } + ) -> ManagedCoreStopOutcome { + guard let pid, pid > 1 else { return .notRunning } + guard isRunning(pid) else { return .notRunning } + + // Phase 0's rule, unchanged: never signal a pid we cannot still + // identify as an mcpproxy process. + guard isCore(pid) else { return .refused } + + send(pid, SIGTERM) + + var waited: TimeInterval = 0 + while waited < gracePeriod { + if !isRunning(pid) { return .terminated } + wait(pollInterval) + waited += pollInterval + } + + if !isRunning(pid) { return .terminated } + + // Still there. The bundle is about to be replaced underneath it; a core + // running from a deleted inode is the #957 failure mode this whole spec + // exists to end, so it does not get to survive the swap. + send(pid, SIGKILL) + return .killed + } +} diff --git a/native/macos/MCPProxy/MCPProxy/Services/UpdateInstallability.swift b/native/macos/MCPProxy/MCPProxy/Services/UpdateInstallability.swift new file mode 100644 index 00000000..e100a90a --- /dev/null +++ b/native/macos/MCPProxy/MCPProxy/Services/UpdateInstallability.swift @@ -0,0 +1,97 @@ +// UpdateInstallability.swift +// MCPProxy +// +// Spec 092 FR-016 — "when the app cannot be updated in place (translocated, +// read-only volume, insufficient permissions), the updater MUST tell the user +// why and offer a fallback path rather than failing silently." +// +// Sparkle's own behaviour in these situations is the reason this file exists: +// it declines to install and the failure surfaces, if at all, in a place a +// menu-bar app never shows. So the tray decides for itself, BEFORE offering a +// one-click item, whether a one-click install could even work — and when it +// cannot, the menu says so in words the user can act on. +// +// The check is a pure function of a URL plus two injected probes, so every +// branch is testable against a temp directory instead of against a DMG. + +import Foundation + +/// Why an in-place update is impossible, and what the user can do instead. +struct UpdateBlockedReason: Equatable { + /// Short enough for a menu item. + let menuTitle: String + /// The explanation shown when the item is activated. + let explanation: String + /// What to do instead. + let fallback: String + + var message: String { explanation + "\n\n" + fallback } +} + +enum UpdateInstallability { + + /// Path marker macOS uses for App Translocation: an app launched from a + /// quarantined DMG or download folder runs from a read-only, randomly named + /// mount under this directory. Updating it would replace a disk image copy + /// that vanishes on quit. + static let translocationMarker = "/AppTranslocation/" + + /// Evaluate whether the bundle at `bundleURL` can be replaced in place. + /// + /// - Parameters: + /// - bundleURL: the running app bundle (`Bundle.main.bundleURL`). + /// - isReadOnlyVolume: probe for the volume's read-only flag. + /// - isWritable: probe for write permission on a path. + /// - Returns: nil when an in-place update is possible. + static func evaluate( + bundleURL: URL, + isReadOnlyVolume: (URL) -> Bool = UpdateInstallability.volumeIsReadOnly, + isWritable: (String) -> Bool = { FileManager.default.isWritableFile(atPath: $0) } + ) -> UpdateBlockedReason? { + let path = bundleURL.path + + if path.contains(translocationMarker) { + return UpdateBlockedReason( + menuTitle: "Can’t update — move MCPProxy to Applications first", + explanation: "MCPProxy is running from a temporary, read-only copy that macOS " + + "created because the app was opened straight from a download or a disk " + + "image. Nothing can be updated in that copy — it disappears when the app " + + "quits.", + fallback: "Quit MCPProxy, drag MCPProxy.app into your Applications folder, and " + + "open it from there. Updates will work from then on." + ) + } + + if isReadOnlyVolume(bundleURL) { + return UpdateBlockedReason( + menuTitle: "Can’t update — MCPProxy is on a read-only volume", + explanation: "MCPProxy is running from \(path), which is on a read-only volume " + + "(most often a mounted disk image).", + fallback: "Quit MCPProxy, copy MCPProxy.app to your Applications folder, and open " + + "it from there." + ) + } + + // The parent directory is what has to be writable: the installer + // replaces the bundle as a whole, it does not edit it in place. + let parent = bundleURL.deletingLastPathComponent().path + if !isWritable(parent) { + return UpdateBlockedReason( + menuTitle: "Can’t update — no permission to replace MCPProxy", + explanation: "MCPProxy is installed in \(parent), which this user account cannot " + + "write to. Replacing the app there needs an administrator.", + fallback: "Reinstall MCPProxy from the latest download, or move it into your " + + "personal Applications folder (~/Applications) where updates need no " + + "administrator." + ) + } + + return nil + } + + /// Real volume probe. Anything unreadable answers "not read-only": a failed + /// probe must not manufacture a blocked state and hide a working updater. + static func volumeIsReadOnly(_ url: URL) -> Bool { + (try? url.resourceValues(forKeys: [.volumeIsReadOnlyKey]))?.volumeIsReadOnly ?? false + } +} diff --git a/native/macos/MCPProxy/MCPProxy/Services/UpdateMenuState.swift b/native/macos/MCPProxy/MCPProxy/Services/UpdateMenuState.swift new file mode 100644 index 00000000..94cac58e --- /dev/null +++ b/native/macos/MCPProxy/MCPProxy/Services/UpdateMenuState.swift @@ -0,0 +1,118 @@ +// UpdateMenuState.swift +// MCPProxy +// +// Spec 092 FR-017 — "exactly one source of truth MUST own the update menu item +// at any time". +// +// The tray has two update brains and always has had: Sparkle's appcast (which +// can actually install) and the legacy release check (core `/api/v1/info` and a +// direct GitHub call, which can only open a browser). Before this file they +// were rendered independently, so a single available release could produce two +// items — one of them offering a one-click install and one of them offering a +// download page — and a feed that lagged behind GitHub could offer a one-click +// install of an older version than the browser item advertised. +// +// The rules, from FR-017: +// · feed offer present → the feed owns the item; the legacy result must not +// surface a competing nudge for the SAME OR LOWER version; +// · legacy result only (feed unreachable, or the feed does not carry that +// version) → present as browser-download guidance, never as a one-click +// action it cannot perform; +// · equal versions from both sources → exactly one item. +// +// Pure, so the whole matrix is a table test. + +import Foundation + +/// What the update section of the menu should contain, in order. +enum UpdateMenuEntry: Equatable { + /// Sparkle can install this. One click does everything (FR-010). + case oneClick(version: String) + + /// Only the legacy check knows about this version; all the tray can do is + /// open the download page (FR-017). + case browserGuidance(version: String) + + /// An in-place update is impossible here (FR-016). Rendered whether or not + /// an update is known, because the user needs to know before they go + /// looking for the update item that will never appear. + case blocked(UpdateBlockedReason) +} + +enum UpdateMenuState { + + /// Build the update section. + /// + /// - Parameters: + /// - feedVersion: version Sparkle is offering, nil when it has none. + /// - legacyVersion: version the core / GitHub check advertises, nil when + /// there is none. + /// - blocked: FR-016 reason, when in-place updating is impossible. + /// - nudgesSuppressed: CI / non-interactive — offer nothing unasked. + static func entries( + feedVersion: String?, + legacyVersion: String?, + blocked: UpdateBlockedReason?, + nudgesSuppressed: Bool + ) -> [UpdateMenuEntry] { + var entries: [UpdateMenuEntry] = [] + + // A blocked reason is not a nudge — it is the answer to "why is there no + // update item?", and suppressing it is how FR-016's "fails silently" + // happens. It is only worth saying when there is in fact something to + // install, though; on an up-to-date app it is noise. + let haveSomething = feedVersion != nil || legacyVersion != nil + if let blocked, haveSomething { + entries.append(.blocked(blocked)) + } + + guard !nudgesSuppressed else { return entries } + + // Nothing can be installed in place: offering a one-click item that + // cannot work is exactly what FR-016 forbids, so the offer degrades to + // the browser path the blocked message already points at. + let canInstall = blocked == nil + + switch (feedVersion, legacyVersion) { + case (nil, nil): + break + + case let (.some(feed), nil): + entries.append(canInstall ? .oneClick(version: feed) : .browserGuidance(version: feed)) + + case let (nil, .some(legacy)): + entries.append(.browserGuidance(version: legacy)) + + case let (.some(feed), .some(legacy)): + // Same version from both sources → one item, owned by the feed. + // A legacy version that is merely LOWER is also swallowed: the feed + // already offers at least as much. + let order = SemanticVersion.compare(legacy, feed) + if let order, order > 0 { + // The feed is behind what GitHub publishes. The one-click item + // stays (it is real and installable), and the newer version is + // offered as guidance — never as a one-click action the feed + // cannot perform. + if canInstall { entries.append(.oneClick(version: feed)) } + entries.append(.browserGuidance(version: legacy)) + } else if order == nil, normalized(legacy) != normalized(feed) { + // Incomparable and not textually identical: say nothing clever. + // Prefer the source that can actually install. + entries.append(canInstall + ? .oneClick(version: feed) + : .browserGuidance(version: feed)) + } else { + entries.append(canInstall + ? .oneClick(version: feed) + : .browserGuidance(version: feed)) + } + } + + return entries + } + + /// Version strings differ across surfaces only by a leading "v". + static func normalized(_ version: String) -> String { + version.hasPrefix("v") ? String(version.dropFirst()) : version + } +} diff --git a/native/macos/MCPProxy/MCPProxy/Services/UpdatePolicy.swift b/native/macos/MCPProxy/MCPProxy/Services/UpdatePolicy.swift new file mode 100644 index 00000000..9a52ccaa --- /dev/null +++ b/native/macos/MCPProxy/MCPProxy/Services/UpdatePolicy.swift @@ -0,0 +1,174 @@ +// UpdatePolicy.swift +// MCPProxy +// +// Spec 092 FR-015 — the update policy, as an explicit contract. +// +// Three parties get a vote on whether the tray may check for updates: +// +// 1. the core, over `GET /api/v1/info` → `update_policy` (config +// `update_check.enabled` / `channel`, plus the core's own environment +// overrides, already resolved for us); +// 2. the tray's OWN environment — a user who exports +// MCPPROXY_DISABLE_AUTO_UPDATE expects the menu-bar app to obey it too, +// and the tray can (and does) run with no core attached at all; +// 3. the context — CI / non-interactive, where a nudge has no one to read it. +// +// Resolving them is a pure function so the precedence is testable without a +// Sparkle framework, a core, or a menu. The one rule that is NOT expressed here +// is the one FR-015 states last: a user-initiated "Check for Updates" is always +// allowed. That is enforced at the call site (`UpdateService.checkForUpdates`), +// because the policy has nothing to say about an action the user just took. + +import Foundation + +// MARK: - Channel + +/// The release channel this install tracks. Mirrors the core's +/// `update_policy.channel` and `docs/prerelease-builds.md`. +enum UpdateChannel: String, Equatable { + case stable + case rc + + /// Unrecognized values fall back to `stable`: a channel the tray does not + /// understand must never be read as "offer this user prereleases". + init(apiValue: String?) { + switch apiValue?.lowercased() { + case "rc": self = .rc + default: self = .stable + } + } + + /// Sparkle channel names allowed for this release channel. Sparkle treats + /// the EMPTY set as "default channel only" — items with no `sparkle:channel` + /// tag — which is exactly what a stable user must see. RC users additionally + /// accept the `beta` channel the prerelease pipeline tags. + var allowedSparkleChannels: Set { + switch self { + case .stable: return [] + case .rc: return ["beta"] + } + } +} + +// MARK: - What the core said + +/// `update_policy` from `GET /api/v1/info` (Spec 092 FR-015). +struct CoreUpdatePolicy: Codable, Equatable { + let enabled: Bool + let channel: String + let nudgesSuppressed: Bool + + enum CodingKeys: String, CodingKey { + case enabled + case channel + case nudgesSuppressed = "nudges_suppressed" + } +} + +// MARK: - The resolved answer + +/// What the tray is actually allowed to do right now. +struct EffectiveUpdatePolicy: Equatable { + /// May the tray run checks nobody asked for (Sparkle's scheduled cycle, and + /// the legacy GitHub poll)? A user-initiated check ignores this. + let automaticChecksAllowed: Bool + + /// Must UI surfaces stay quiet? Suppresses the nudge item itself, so an + /// update found by a user-initiated check is still installable — the user is + /// standing right there — but nothing appears unasked. + let nudgesSuppressed: Bool + + /// Which feed channel to accept. + let channel: UpdateChannel + + /// Why automatic checks are off, for the log and the tooltip. Empty when + /// they are on. + let disabledReason: String + + /// The policy in force before a core has been reached, and for cores older + /// than Spec 092 that report no `update_policy` at all. Permissive, because + /// that is the behaviour those builds already had — silently disabling + /// updates on a version skew would be a worse failure than checking once. + static let permissive = EffectiveUpdatePolicy( + automaticChecksAllowed: true, + nudgesSuppressed: false, + channel: .stable, + disabledReason: "" + ) +} + +// MARK: - Resolution + +enum UpdatePolicyResolver { + + /// Environment variable name shared with the core + /// (`internal/updatecheck.EnvDisableAutoUpdate`). Same name, same meaning: + /// one export silences both processes. + static let disableEnvKey = "MCPPROXY_DISABLE_AUTO_UPDATE" + + /// Resolve the effective policy. + /// + /// - Parameters: + /// - core: `update_policy` from the attached core, or nil when no core has + /// been reached yet / the core predates the field. + /// - environment: the tray process environment (injected for tests). + static func resolve( + core: CoreUpdatePolicy?, + environment: [String: String] + ) -> EffectiveUpdatePolicy { + let channel = UpdateChannel(apiValue: core?.channel) + + // The tray's own kill switch. Checked first because it is the most + // explicit statement of intent available, and it works with no core. + if environment[disableEnvKey]?.lowercased() == "true" { + return EffectiveUpdatePolicy( + automaticChecksAllowed: false, + nudgesSuppressed: true, + channel: channel, + disabledReason: "\(disableEnvKey)=true" + ) + } + + // CI / non-interactive. The core's rule (Spec 079 FR-019) is + // "machine-readable facts stay, UI nudges go". The tray has no + // machine-readable surface — everything it does with an update check + // ends in a menu item — so a suppressed nudge makes the scheduled check + // pointless work, and it is switched off with it. A user-initiated + // check still runs: someone typed it. + let ciValue = environment["CI"]?.lowercased() + if ciValue == "true" || ciValue == "1" { + return EffectiveUpdatePolicy( + automaticChecksAllowed: false, + nudgesSuppressed: true, + channel: channel, + disabledReason: "running in CI (CI=\(environment["CI"] ?? ""))" + ) + } + + guard let core else { + // No core yet, or a pre-092 core. Keep the previous behaviour. + return EffectiveUpdatePolicy( + automaticChecksAllowed: true, + nudgesSuppressed: false, + channel: channel, + disabledReason: "" + ) + } + + if !core.enabled { + return EffectiveUpdatePolicy( + automaticChecksAllowed: false, + nudgesSuppressed: true, + channel: channel, + disabledReason: "the core reports update checks are disabled" + ) + } + + return EffectiveUpdatePolicy( + automaticChecksAllowed: true, + nudgesSuppressed: core.nudgesSuppressed, + channel: channel, + disabledReason: "" + ) + } +} diff --git a/native/macos/MCPProxy/MCPProxy/Services/UpdateService.swift b/native/macos/MCPProxy/MCPProxy/Services/UpdateService.swift index 400a6911..38e7c912 100644 --- a/native/macos/MCPProxy/MCPProxy/Services/UpdateService.swift +++ b/native/macos/MCPProxy/MCPProxy/Services/UpdateService.swift @@ -1,10 +1,21 @@ // UpdateService.swift // MCPProxy // -// Update checking service. Uses GitHub Releases API as a lightweight -// alternative to Sparkle when Sparkle SPM dependency is not available. -// When Sparkle IS linked, it takes precedence for the full update UX -// (download, verify, replace, relaunch). +// Spec 092 Phase 1 (FR-010 / FR-015 / FR-016 / FR-017) — the single owner of +// everything the tray knows about updates. +// +// Two update brains exist and both are legitimate: +// +// · the FEED (Sparkle appcast) — can download, verify and install; the only +// one that can deliver FR-010's one click; +// · the LEGACY check (the core's `/api/v1/info` + a direct GitHub call) — +// always reachable, but can only open a browser. +// +// Before Phase 1 they were rendered independently by the menu, which is what +// FR-017 forbids. This service now resolves them into ONE ordered list of menu +// entries (`UpdateMenuState`), gates every unattended check on the effective +// policy (`UpdatePolicyResolver`), and stops the managed core before Sparkle +// replaces the bundle (`ManagedCoreStop`). import Foundation import AppKit @@ -12,30 +23,46 @@ import Darwin // MARK: - Update Service -/// Manages software update checks. -/// -/// Strategy: -/// 1. If Sparkle framework is linked → use SPUStandardUpdaterController -/// 2. Otherwise → check GitHub Releases API directly (notify only, no auto-install) +/// Manages software update checks and owns the update section of the menu. final class UpdateService: ObservableObject { - /// Whether an update check can be performed. + /// Whether an update check can be performed. A user-initiated check is + /// always allowed (FR-015) — including when the policy has disabled the + /// automatic ones. var canCheckForUpdates: Bool { true } /// Whether an update check is currently in progress. @Published private(set) var isChecking: Bool = false - /// Latest available version (nil if current or unknown). + /// Latest version the LEGACY check knows about (nil if current or unknown). @Published private(set) var latestVersion: String? + /// Latest version the FEED offers (nil when Sparkle has nothing, or is + /// unavailable). + @Published private(set) var feedVersion: String? + /// URL to download the latest release. @Published private(set) var downloadURL: String? /// Release notes for the latest version. @Published private(set) var releaseNotes: String? - /// Whether Sparkle framework is linked. - private let sparkleAvailable: Bool + /// FR-016: why an in-place update cannot work here, when it cannot. + @Published private(set) var blockedReason: UpdateBlockedReason? + + /// Last error worth telling the user about (feed check/install failures). + @Published private(set) var lastErrorMessage: String? + + /// The effective policy in force (FR-015). + @Published private(set) var policy: EffectiveUpdatePolicy = .permissive + + /// The feed updater, when one could be built. Injected in tests. + private var feedUpdater: (any FeedUpdating)? + + /// Stops the tray-managed core synchronously before the bundle is replaced + /// (FR-012). Set by the app delegate, which is the only thing that knows + /// the managed process. Nil in tests and before the core starts. + var stopManagedCore: (() -> Void)? /// GitHub API endpoint for latest release. private let githubReleaseURL = "https://api.github.com/repos/smart-mcp-proxy/mcpproxy-go/releases/latest" @@ -43,23 +70,147 @@ final class UpdateService: ObservableObject { /// Current version from the core (set by AppController). var currentVersion: String = "" + /// Environment used for policy resolution; injected in tests. + private let environment: [String: String] + + /// Bundle whose in-place updatability is evaluated; injected in tests. + private let hostBundleURL: URL + + /// The legacy GitHub check. Injected in tests so the suite issues no + /// network requests — nil means "use the real one". + private let legacyCheck: (() -> Void)? + // MARK: - Initialization - init() { - self.sparkleAvailable = NSClassFromString("SPUStandardUpdaterController") != nil + init( + environment: [String: String] = ProcessInfo.processInfo.environment, + hostBundleURL: URL = Bundle.main.bundleURL, + feedUpdater: (any FeedUpdating)? = nil, + legacyCheck: (() -> Void)? = nil + ) { + self.environment = environment + self.hostBundleURL = hostBundleURL + self.feedUpdater = feedUpdater + self.legacyCheck = legacyCheck + self.policy = UpdatePolicyResolver.resolve(core: nil, environment: environment) + self.blockedReason = UpdateInstallability.evaluate(bundleURL: hostBundleURL) + } + + // MARK: - Wiring + + /// Build and start the real Sparkle updater. Called once at launch by the + /// app delegate; a no-op when Sparkle is not compiled in, and harmless when + /// the bundle is not one Sparkle can work with (it reports why). + func startFeedUpdater() { + guard feedUpdater == nil else { return } + #if canImport(Sparkle) + let sparkle = SparkleFeedUpdater(observer: self) + sparkle.start(policy: policy) + feedUpdater = sparkle + if !sparkle.isAvailable, let reason = sparkle.unavailableReason { + NSLog("[MCPProxy] One-click updates unavailable: %@", reason) + } + #endif + } + + /// Inject a feed updater (tests, and the fallback build). + func installFeedUpdater(_ updater: any FeedUpdating) { + feedUpdater = updater + updater.apply(policy: policy) + } + + /// Whether the feed updater can actually install (FR-017: only then may the + /// menu offer a one-click item). + var feedUpdaterAvailable: Bool { feedUpdater?.isAvailable ?? false } + + /// FR-015: apply the policy the core reports. Idempotent; safe to call on + /// every connect and on every config hot-reload. + func applyCorePolicy(_ corePolicy: CoreUpdatePolicy?) { + let resolved = UpdatePolicyResolver.resolve(core: corePolicy, environment: environment) + guard resolved != policy else { return } + policy = resolved + feedUpdater?.apply(policy: resolved) + if !resolved.automaticChecksAllowed { + NSLog("[MCPProxy] Automatic update checks are off: %@", resolved.disabledReason) + // Anything already advertised was advertised under the old policy. + // Retract it rather than leave a nudge the operator just disabled. + feedVersion = nil + latestVersion = nil + } } // MARK: - Public API - /// Check for updates. Uses Sparkle if available, otherwise GitHub API. + /// A check nobody asked for: the launch check and the periodic one. Gated + /// on the effective policy (FR-015). + func checkForUpdatesInBackground() { + guard policy.automaticChecksAllowed else { + AppLifecycle.shared.recordUpdateCheck("skipped — \(policy.disabledReason)") + return + } + runCheck(userInitiated: false) + } + + /// The menu's "Check for Updates". Always allowed (FR-015). func checkForUpdates() { - if sparkleAvailable { - checkWithSparkle() + runCheck(userInitiated: true) + } + + private func runCheck(userInitiated: Bool) { + lastErrorMessage = nil + // Re-evaluate: the app may have been moved into /Applications since + // launch, which is exactly the fallback FR-016's message asks for. + blockedReason = UpdateInstallability.evaluate(bundleURL: hostBundleURL) + + if let feedUpdater, feedUpdater.isAvailable { + feedUpdater.check(userInitiated: userInitiated) + // The legacy check still runs: it is the FR-017 fallback for a + // version the feed does not carry, and the only source of the + // browser-download URL. + } + if let legacyCheck { + legacyCheck() } else { checkWithGitHub() } } + /// FR-010: activating the one-click item. Sparkle already has the update in + /// hand from the gentle-reminder check; `check(userInitiated:)` surfaces its + /// own UI and drives download → verify → replace → relaunch. + func installFeedUpdate() { + guard let feedUpdater, feedUpdater.isAvailable else { + openDownloadPage() + return + } + feedUpdater.check(userInitiated: true) + } + + /// The update section of the tray menu, resolved from both sources + /// (FR-017). + var menuEntries: [UpdateMenuEntry] { + UpdateMenuState.entries( + feedVersion: feedUpdaterAvailable ? feedVersion : nil, + legacyVersion: latestVersion, + blocked: blockedReason, + nudgesSuppressed: policy.nudgesSuppressed + ) + } + + /// Merge the legacy version reported by the core (`/api/v1/info` → + /// `update.latest_version`). Kept behind a setter so the FR-017 resolution + /// has exactly one input path per source. + func setCoreReportedVersion(_ version: String?) { + guard !policy.nudgesSuppressed else { return } + guard let version, !version.isEmpty else { return } + let normalized = UpdateMenuState.normalized(version) + if let existing = latestVersion, + let order = SemanticVersion.compare(existing, normalized), order >= 0 { + return + } + latestVersion = normalized + } + /// Returns the release-asset architecture token for the host machine /// ("arm64" on Apple Silicon, "amd64" on Intel). Rosetta-translated /// processes report the underlying Apple Silicon machine so the user @@ -114,17 +265,6 @@ final class UpdateService: ObservableObject { } } - // MARK: - Sparkle - - private func checkWithSparkle() { - // When Sparkle is linked: - // import Sparkle - // let controller = SPUStandardUpdaterController(startingUpdater: true, ...) - // controller.checkForUpdates(nil) - // For now, fall through to GitHub check - checkWithGitHub() - } - // MARK: - GitHub Releases API private func checkWithGitHub() { @@ -200,3 +340,48 @@ final class UpdateService: ObservableObject { } } } + +// MARK: - FeedUpdaterObserver + +extension UpdateService: FeedUpdaterObserver { + + func feedUpdater(didFindVersion version: String) { + let normalized = UpdateMenuState.normalized(version) + onMain { + self.feedVersion = normalized + AppLifecycle.shared.recordUpdateCheck("feed offers v\(normalized)") + } + } + + func feedUpdaterDidNotFindUpdate() { + onMain { self.feedVersion = nil } + } + + func feedUpdater(didFailWith message: String) { + onMain { + self.lastErrorMessage = message + AppLifecycle.shared.recordUpdateCheck("feed check failed: \(message)") + } + } + + /// Publish on the main thread — synchronously when already there. + /// + /// The synchronous branch is not an optimization: Sparkle's callbacks + /// arrive on the main thread, and an unconditional `async` hop would leave + /// the menu one run-loop turn behind the state that produced it (and make + /// every test of this path a waiting game). + private func onMain(_ work: @escaping () -> Void) { + if Thread.isMainThread { + work() + } else { + DispatchQueue.main.async(execute: work) + } + } + + /// FR-012. Synchronous by contract: Sparkle replaces the bundle the moment + /// this returns, so the core has to already be down. + func feedUpdaterWillInstallUpdate() { + AppLifecycle.shared.note("stopping the managed core before the update is installed") + stopManagedCore?() + } +} diff --git a/native/macos/MCPProxy/MCPProxy/State/AppState.swift b/native/macos/MCPProxy/MCPProxy/State/AppState.swift index 6d07ed18..b71c3851 100644 --- a/native/macos/MCPProxy/MCPProxy/State/AppState.swift +++ b/native/macos/MCPProxy/MCPProxy/State/AppState.swift @@ -217,6 +217,14 @@ final class AppState: ObservableObject { @Published var updateAvailable: String? = nil @Published var autoStartEnabled: Bool = false + /// Spec 092 FR-015 — the effective update policy the attached core reports + /// (`/api/v1/info` → `update_policy`). Nil before a core is reached and for + /// cores older than 092; `UpdatePolicyResolver` maps that to the permissive + /// default rather than to "updates off". Published so a config hot-reload + /// picked up on the next connect reaches `UpdateService` without the + /// service having to poll anything. + @Published var coreUpdatePolicy: CoreUpdatePolicy? = nil + /// Spec 092 FR-002 — an older core is running that the tray is NOT allowed /// to stop on its own. Nil in the steady state; when set, the menu offers /// the restart as an explicit user action. Published rather than derived so diff --git a/native/macos/MCPProxy/MCPProxyTests/ManagedCoreStopTests.swift b/native/macos/MCPProxy/MCPProxyTests/ManagedCoreStopTests.swift new file mode 100644 index 00000000..0e66d514 --- /dev/null +++ b/native/macos/MCPProxy/MCPProxyTests/ManagedCoreStopTests.swift @@ -0,0 +1,106 @@ +// ManagedCoreStopTests.swift +// MCPProxyTests +// +// Spec 092 FR-012 — the synchronous pre-install stop. Everything here runs +// against a fake process table: the real ladder is five seconds long and lives +// inside a Sparkle callback on the main thread, which is exactly why it is +// worth pinning without spawning anything. + +import XCTest +@testable import MCPProxy + +final class ManagedCoreStopTests: XCTestCase { + + /// A process that dies after `diesAfterSignals` deliveries of any signal. + private final class FakeProcess { + var alive = true + var received: [Int32] = [] + var diesOnSIGTERMAfter: Int // poll iterations before it exits + var polls = 0 + + init(diesOnSIGTERMAfter: Int = 0) { self.diesOnSIGTERMAfter = diesOnSIGTERMAfter } + } + + private func stop( + pid: Int32?, + process: FakeProcess, + isCore: Bool = true, + gracePeriod: TimeInterval = ManagedCoreStop.defaultGracePeriod + ) -> ManagedCoreStopOutcome { + ManagedCoreStop.stop( + pid: pid, + gracePeriod: gracePeriod, + isCore: { _ in isCore }, + isRunning: { _ in + if process.received.contains(SIGTERM) { + process.polls += 1 + if process.polls > process.diesOnSIGTERMAfter { process.alive = false } + } + return process.alive + }, + send: { _, sig in + process.received.append(sig) + if sig == SIGKILL { process.alive = false } + }, + wait: { _ in } // no real sleeping in tests + ) + } + + func testNoPidIsNotAnError() { + let proc = FakeProcess() + XCTAssertEqual(stop(pid: nil, process: proc), .notRunning) + XCTAssertTrue(proc.received.isEmpty) + } + + func testPidZeroAndOneAreRefusedOutright() { + for pid in Int32(0)...1 { + let proc = FakeProcess() + XCTAssertEqual(stop(pid: pid, process: proc), .notRunning, + "pid \(pid) must never be signalled") + XCTAssertTrue(proc.received.isEmpty) + } + } + + func testAlreadyDeadProcessIsLeftAlone() { + let proc = FakeProcess() + proc.alive = false + XCTAssertEqual(stop(pid: 4242, process: proc), .notRunning) + XCTAssertTrue(proc.received.isEmpty) + } + + func testAPidThatIsNoLongerAnMCPProxyIsNotSignalled() { + let proc = FakeProcess() + XCTAssertEqual(stop(pid: 4242, process: proc, isCore: false), .refused, + "pids are recycled; a stale one may belong to anything by now") + XCTAssertTrue(proc.received.isEmpty, "nothing may be signalled after a refusal") + } + + func testSIGTERMIsEnoughForAWellBehavedCore() { + let proc = FakeProcess(diesOnSIGTERMAfter: 0) + XCTAssertEqual(stop(pid: 4242, process: proc), .terminated) + XCTAssertEqual(proc.received, [SIGTERM]) + } + + func testASlowButCooperativeCoreStillOnlyGetsSIGTERM() { + let proc = FakeProcess(diesOnSIGTERMAfter: 10) + XCTAssertEqual(stop(pid: 4242, process: proc), .terminated) + XCTAssertEqual(proc.received, [SIGTERM]) + } + + func testAStuckCoreIsKilledRatherThanSurvivingTheBundleSwap() { + // diesOnSIGTERMAfter larger than the number of polls in the grace + // period: it never exits on its own. + let proc = FakeProcess(diesOnSIGTERMAfter: 1_000_000) + XCTAssertEqual(stop(pid: 4242, process: proc), .killed, + "a core running from a deleted inode is the #957 failure itself") + XCTAssertEqual(proc.received, [SIGTERM, SIGKILL]) + XCTAssertFalse(proc.alive) + } + + func testTheGracePeriodIsBoundedAndFinite() { + // A zero grace period must still terminate the loop (and escalate), + // which is what guarantees the main thread is never held indefinitely. + let proc = FakeProcess(diesOnSIGTERMAfter: 1_000_000) + XCTAssertEqual(stop(pid: 4242, process: proc, gracePeriod: 0), .killed) + } +} diff --git a/native/macos/MCPProxy/MCPProxyTests/UpdateInstallabilityTests.swift b/native/macos/MCPProxy/MCPProxyTests/UpdateInstallabilityTests.swift new file mode 100644 index 00000000..fb13c48a --- /dev/null +++ b/native/macos/MCPProxy/MCPProxyTests/UpdateInstallabilityTests.swift @@ -0,0 +1,70 @@ +// UpdateInstallabilityTests.swift +// MCPProxyTests +// +// Spec 092 FR-016 — the three ways an in-place update is impossible, and the +// requirement that each one produces an explanation plus a fallback rather +// than a silent no-op. + +import XCTest +@testable import MCPProxy + +final class UpdateInstallabilityTests: XCTestCase { + + private func evaluate( + _ path: String, + readOnly: Bool = false, + writable: Bool = true + ) -> UpdateBlockedReason? { + UpdateInstallability.evaluate( + bundleURL: URL(fileURLWithPath: path), + isReadOnlyVolume: { _ in readOnly }, + isWritable: { _ in writable } + ) + } + + func testANormalApplicationsInstallIsNotBlocked() { + XCTAssertNil(evaluate("/Applications/MCPProxy.app")) + } + + func testTranslocatedAppIsBlockedWithAMoveFallback() throws { + let reason = try XCTUnwrap(evaluate( + "/private/var/folders/ab/xyz/X/AppTranslocation/1234-ABCD/d/MCPProxy.app" + )) + XCTAssertTrue(reason.menuTitle.contains("Applications"), + "the menu title must carry the action, not just the diagnosis") + XCTAssertFalse(reason.explanation.isEmpty) + XCTAssertTrue(reason.fallback.contains("Applications")) + XCTAssertTrue(reason.message.contains(reason.fallback)) + } + + func testReadOnlyVolumeIsBlocked() throws { + let reason = try XCTUnwrap(evaluate("/Volumes/MCPProxy/MCPProxy.app", readOnly: true)) + XCTAssertTrue(reason.explanation.contains("/Volumes/MCPProxy/MCPProxy.app"), + "name the path the user has to act on") + XCTAssertFalse(reason.fallback.isEmpty) + } + + func testUnwritableParentIsBlockedAndNamesTheDirectory() throws { + let reason = try XCTUnwrap(evaluate("/Applications/MCPProxy.app", writable: false)) + XCTAssertTrue(reason.explanation.contains("/Applications")) + XCTAssertTrue(reason.fallback.contains("~/Applications"), + "FR-016 asks for a fallback path, not just a refusal") + } + + func testTranslocationOutranksTheOtherChecks() throws { + // A translocated app is also on a read-only volume; the translocation + // message is the one that tells the user what actually happened. + let reason = try XCTUnwrap(evaluate( + "/private/var/folders/ab/AppTranslocation/1/d/MCPProxy.app", + readOnly: true, writable: false + )) + XCTAssertTrue(reason.explanation.contains("temporary")) + } + + func testAFailingVolumeProbeDoesNotManufactureABlock() { + // The real probe answers "not read-only" when it cannot read the + // resource values — a failed probe must not hide a working updater. + let url = URL(fileURLWithPath: "/nonexistent-\(UUID().uuidString)/MCPProxy.app") + XCTAssertFalse(UpdateInstallability.volumeIsReadOnly(url)) + } +} diff --git a/native/macos/MCPProxy/MCPProxyTests/UpdateMenuStateTests.swift b/native/macos/MCPProxy/MCPProxyTests/UpdateMenuStateTests.swift new file mode 100644 index 00000000..b41d9121 --- /dev/null +++ b/native/macos/MCPProxy/MCPProxyTests/UpdateMenuStateTests.swift @@ -0,0 +1,104 @@ +// UpdateMenuStateTests.swift +// MCPProxyTests +// +// Spec 092 FR-016 / FR-017 — one owner for the update item, and never a +// one-click action the app cannot perform. + +import XCTest +@testable import MCPProxy + +final class UpdateMenuStateTests: XCTestCase { + + private func entries( + feed: String? = nil, + legacy: String? = nil, + blocked: UpdateBlockedReason? = nil, + suppressed: Bool = false + ) -> [UpdateMenuEntry] { + UpdateMenuState.entries( + feedVersion: feed, legacyVersion: legacy, blocked: blocked, + nudgesSuppressed: suppressed + ) + } + + private let blockedFixture = UpdateBlockedReason( + menuTitle: "Can’t update", explanation: "because", fallback: "do this instead" + ) + + // MARK: - Nothing to say + + func testNoSourcesProduceNoEntries() { + XCTAssertTrue(entries().isEmpty) + } + + func testABlockedReasonIsNotAnnouncedWhenThereIsNoUpdate() { + XCTAssertTrue(entries(blocked: blockedFixture).isEmpty, + "telling an up-to-date user their app cannot be updated is noise") + } + + // MARK: - Single source + + func testFeedOnlyOwnsTheOneClickItem() { + XCTAssertEqual(entries(feed: "0.55.0"), [.oneClick(version: "0.55.0")]) + } + + func testLegacyOnlyPresentsAsBrowserGuidance() { + XCTAssertEqual(entries(legacy: "0.55.0"), [.browserGuidance(version: "0.55.0")], + "FR-017: never a one-click action the legacy check cannot perform") + } + + // MARK: - Both sources (the FR-017 dedupe) + + func testEqualVersionsDeduplicateToASingleItem() { + XCTAssertEqual(entries(feed: "0.55.0", legacy: "0.55.0"), + [.oneClick(version: "0.55.0")]) + } + + func testEqualVersionsAcrossTheVPrefixStillDeduplicate() { + XCTAssertEqual(entries(feed: "0.55.0", legacy: "v0.55.0").count, 1) + } + + func testALowerLegacyVersionIsSwallowedByTheFeed() { + XCTAssertEqual(entries(feed: "0.55.0", legacy: "0.54.1"), + [.oneClick(version: "0.55.0")], + "FR-017: no competing nudge for the same or lower version") + } + + func testANewerLegacyVersionIsOfferedAlongsideTheInstallableOne() { + // The feed lags behind GitHub (the appcast job has not run yet). Both + // facts are true and neither may masquerade as the other. + XCTAssertEqual(entries(feed: "0.55.0", legacy: "0.56.0"), + [.oneClick(version: "0.55.0"), .browserGuidance(version: "0.56.0")]) + } + + func testPrereleaseOrderingUsesSemVerPrecedence() { + // rc.10 > rc.2 — the bug FR-006 fixed must not come back through here. + XCTAssertEqual(entries(feed: "0.55.0-rc.10", legacy: "0.55.0-rc.2"), + [.oneClick(version: "0.55.0-rc.10")]) + } + + // MARK: - FR-016 + + func testABlockedAppNeverGetsAOneClickItem() { + let result = entries(feed: "0.55.0", blocked: blockedFixture) + XCTAssertEqual(result, [.blocked(blockedFixture), .browserGuidance(version: "0.55.0")], + "an item that cannot install must not pretend it can") + } + + func testTheBlockedReasonAccompaniesLegacyGuidanceToo() { + let result = entries(legacy: "0.55.0", blocked: blockedFixture) + XCTAssertEqual(result, [.blocked(blockedFixture), .browserGuidance(version: "0.55.0")]) + } + + // MARK: - FR-015 suppression + + func testSuppressedNudgesHideEveryOffer() { + XCTAssertTrue(entries(feed: "0.55.0", legacy: "0.56.0", suppressed: true).isEmpty) + } + + func testSuppressionStillReportsWhyUpdatingIsImpossible() { + XCTAssertEqual(entries(feed: "0.55.0", blocked: blockedFixture, suppressed: true), + [.blocked(blockedFixture)], + "FR-016 forbids failing silently; that is not a nudge") + } +} diff --git a/native/macos/MCPProxy/MCPProxyTests/UpdatePolicyTests.swift b/native/macos/MCPProxy/MCPProxyTests/UpdatePolicyTests.swift new file mode 100644 index 00000000..78647118 --- /dev/null +++ b/native/macos/MCPProxy/MCPProxyTests/UpdatePolicyTests.swift @@ -0,0 +1,131 @@ +// UpdatePolicyTests.swift +// MCPProxyTests +// +// Spec 092 FR-015 — the kill switches. Every branch of the precedence between +// the tray's environment, the CI rule and the core-reported policy, plus the +// one rule that is NOT in the resolver: a user-initiated check is always +// allowed. + +import XCTest +@testable import MCPProxy + +final class UpdatePolicyTests: XCTestCase { + + private func resolve( + core: CoreUpdatePolicy? = nil, + env: [String: String] = [:] + ) -> EffectiveUpdatePolicy { + UpdatePolicyResolver.resolve(core: core, environment: env) + } + + // MARK: - No core yet + + func testNoCorePolicyKeepsThePreviousPermissiveBehaviour() { + let policy = resolve() + XCTAssertTrue(policy.automaticChecksAllowed, + "a core that predates 092 reports nothing; disabling updates on a " + + "version skew would be a worse failure than checking once") + XCTAssertFalse(policy.nudgesSuppressed) + XCTAssertEqual(policy.channel, .stable) + } + + // MARK: - Tray environment kill switch + + func testDisableEnvironmentVariableStopsAutomaticChecks() { + let policy = resolve( + core: CoreUpdatePolicy(enabled: true, channel: "stable", nudgesSuppressed: false), + env: ["MCPPROXY_DISABLE_AUTO_UPDATE": "true"] + ) + XCTAssertFalse(policy.automaticChecksAllowed) + XCTAssertTrue(policy.nudgesSuppressed) + XCTAssertEqual(policy.disabledReason, "MCPPROXY_DISABLE_AUTO_UPDATE=true") + } + + func testDisableEnvironmentVariableWinsOverAnEnabledCore() { + let policy = resolve( + core: CoreUpdatePolicy(enabled: true, channel: "rc", nudgesSuppressed: false), + env: ["MCPPROXY_DISABLE_AUTO_UPDATE": "true"] + ) + XCTAssertFalse(policy.automaticChecksAllowed) + XCTAssertEqual(policy.channel, .rc, "the channel survives — only checking is off") + } + + func testOtherValuesOfTheEnvironmentVariableAreNotAKillSwitch() { + // The core compares against exactly "true"; the tray must not be more + // eager than the process it mirrors. + for value in ["1", "yes", "TRUE ", ""] { + let policy = resolve(env: ["MCPPROXY_DISABLE_AUTO_UPDATE": value]) + XCTAssertTrue(policy.automaticChecksAllowed, + "\"\(value)\" must not disable updates") + } + } + + // MARK: - CI + + func testCISuppressesNudgesAndUnattendedChecks() { + for value in ["true", "TRUE", "1"] { + let policy = resolve(env: ["CI": value]) + XCTAssertFalse(policy.automaticChecksAllowed, + "CI=\(value): a scheduled check exists to produce a nudge nobody " + + "will read") + XCTAssertTrue(policy.nudgesSuppressed) + } + } + + func testNonCIValuesDoNotSuppress() { + let policy = resolve(env: ["CI": "false"]) + XCTAssertTrue(policy.automaticChecksAllowed) + XCTAssertFalse(policy.nudgesSuppressed) + } + + // MARK: - Core-reported policy + + func testCoreDisabledStopsAutomaticChecks() { + let policy = resolve( + core: CoreUpdatePolicy(enabled: false, channel: "stable", nudgesSuppressed: false) + ) + XCTAssertFalse(policy.automaticChecksAllowed) + XCTAssertTrue(policy.nudgesSuppressed) + XCTAssertFalse(policy.disabledReason.isEmpty, "the reason must be loggable") + } + + func testCoreNudgeSuppressionDoesNotDisableChecking() { + let policy = resolve( + core: CoreUpdatePolicy(enabled: true, channel: "stable", nudgesSuppressed: true) + ) + XCTAssertTrue(policy.automaticChecksAllowed, + "Spec 079 FR-019: suppression is about UI, not about the check") + XCTAssertTrue(policy.nudgesSuppressed) + } + + // MARK: - Channels (FR-014) + + func testStableChannelAcceptsOnlyTheDefaultSparkleChannel() { + let policy = resolve( + core: CoreUpdatePolicy(enabled: true, channel: "stable", nudgesSuppressed: false) + ) + XCTAssertEqual(policy.channel, .stable) + XCTAssertTrue(policy.channel.allowedSparkleChannels.isEmpty, + "an empty set is Sparkle's 'default channel only' — stable users must " + + "never be offered an RC") + } + + func testRCChannelAlsoAcceptsBeta() { + let policy = resolve( + core: CoreUpdatePolicy(enabled: true, channel: "rc", nudgesSuppressed: false) + ) + XCTAssertEqual(policy.channel, .rc) + XCTAssertEqual(policy.channel.allowedSparkleChannels, ["beta"]) + } + + func testUnknownChannelFallsBackToStable() { + for value in ["nightly", "", "STABLE", "beta"] { + let channel = UpdateChannel(apiValue: value) + if value.lowercased() == "rc" { continue } + XCTAssertEqual(channel, .stable, + "\"\(value)\" must not be read as 'offer prereleases'") + } + XCTAssertEqual(UpdateChannel(apiValue: "RC"), .rc, "case-insensitive") + XCTAssertEqual(UpdateChannel(apiValue: nil), .stable) + } +} diff --git a/native/macos/MCPProxy/MCPProxyTests/UpdateServiceFeedTests.swift b/native/macos/MCPProxy/MCPProxyTests/UpdateServiceFeedTests.swift new file mode 100644 index 00000000..ae59bcc6 --- /dev/null +++ b/native/macos/MCPProxy/MCPProxyTests/UpdateServiceFeedTests.swift @@ -0,0 +1,229 @@ +// UpdateServiceFeedTests.swift +// MCPProxyTests +// +// Spec 092 FR-010 / FR-012 / FR-015 / FR-017 — the service that owns both +// update brains, driven through the same seams Sparkle drives in production +// (`FeedUpdating` for the calls out, `FeedUpdaterObserver` for the calls back), +// and then through the REAL tray menu so a renamed slot fails here. + +import XCTest +import AppKit +@testable import MCPProxy + +/// Records what the service asks of a feed updater and lets a test answer. +final class StubFeedUpdater: FeedUpdating { + var isAvailable: Bool + var unavailableReason: String? + private(set) var appliedPolicies: [EffectiveUpdatePolicy] = [] + private(set) var checks: [Bool] = [] // userInitiated flags + + init(isAvailable: Bool = true) { self.isAvailable = isAvailable } + + func apply(policy: EffectiveUpdatePolicy) { appliedPolicies.append(policy) } + func check(userInitiated: Bool) { checks.append(userInitiated) } +} + +@MainActor +final class UpdateServiceFeedTests: XCTestCase { + + /// Counts legacy checks so the suite can assert FR-017's fallback fires + /// WITHOUT issuing a real GitHub request from a unit test. + private final class LegacyCheckCounter { var count = 0 } + private var legacyCounter = LegacyCheckCounter() + + override func setUp() { + super.setUp() + legacyCounter = LegacyCheckCounter() + } + + private func makeService( + env: [String: String] = [:], + bundlePath: String = "/Applications/MCPProxy.app", + feed: StubFeedUpdater? = StubFeedUpdater() + ) -> (UpdateService, StubFeedUpdater?) { + let counter = legacyCounter + let service = UpdateService( + environment: env, + hostBundleURL: URL(fileURLWithPath: bundlePath), + feedUpdater: feed, + legacyCheck: { counter.count += 1 } + ) + return (service, feed) + } + + // MARK: - FR-015: gating + + func testUnattendedCheckIsSkippedWhenThePolicyDisablesIt() { + let (service, feed) = makeService(env: ["MCPPROXY_DISABLE_AUTO_UPDATE": "true"]) + service.checkForUpdatesInBackground() + XCTAssertEqual(feed?.checks, [], "an automatic check must not reach the feed") + XCTAssertEqual(legacyCounter.count, 0, + "FR-015 governs EVERY tray-side check, not only the feed one") + } + + func testUserInitiatedCheckRunsEvenWithTheKillSwitchOn() { + let (service, feed) = makeService(env: ["MCPPROXY_DISABLE_AUTO_UPDATE": "true"]) + service.checkForUpdates() + XCTAssertEqual(feed?.checks, [true], + "FR-015: user-initiated 'Check for Updates' remains available") + XCTAssertTrue(service.canCheckForUpdates) + } + + func testUnattendedCheckRunsWhenAllowed() { + let (service, feed) = makeService() + service.checkForUpdatesInBackground() + XCTAssertEqual(feed?.checks, [false]) + XCTAssertEqual(legacyCounter.count, 1, + "the legacy check stays alive as FR-017's fallback source") + } + + func testCorePolicyIsForwardedToTheFeedUpdater() { + let (service, feed) = makeService() + service.applyCorePolicy( + CoreUpdatePolicy(enabled: true, channel: "rc", nudgesSuppressed: false) + ) + XCTAssertEqual(service.policy.channel, .rc) + XCTAssertEqual(feed?.appliedPolicies.last?.channel, .rc, + "the feed must learn the channel or FR-014 cannot hold") + } + + func testDisablingThePolicyRetractsAnAlreadyAdvertisedUpdate() { + let (service, _) = makeService() + service.feedUpdater(didFindVersion: "0.55.0") + service.setCoreReportedVersion("0.55.0") + XCTAssertFalse(service.menuEntries.isEmpty) + + service.applyCorePolicy( + CoreUpdatePolicy(enabled: false, channel: "stable", nudgesSuppressed: false) + ) + XCTAssertTrue(service.menuEntries.isEmpty, + "a nudge the operator just disabled must not linger in the menu") + } + + // MARK: - FR-017: one owner + + func testFeedOfferBeatsTheLegacyResultForTheSameVersion() { + let (service, _) = makeService() + service.feedUpdater(didFindVersion: "v0.55.0") + service.setCoreReportedVersion("0.55.0") + XCTAssertEqual(service.menuEntries, [.oneClick(version: "0.55.0")]) + } + + func testWithoutAFeedTheOfferDegradesToGuidance() { + let (service, _) = makeService(feed: StubFeedUpdater(isAvailable: false)) + service.setCoreReportedVersion("0.55.0") + XCTAssertEqual(service.menuEntries, [.browserGuidance(version: "0.55.0")]) + } + + func testAnUnavailableFeedNeverContributesAOneClickItem() { + let (service, _) = makeService(feed: StubFeedUpdater(isAvailable: false)) + // Even if a stale offer is somehow published, it cannot be actioned. + service.feedUpdater(didFindVersion: "0.55.0") + XCTAssertEqual(service.menuEntries, []) + } + + func testTheCoreReportedVersionNeverGoesBackwards() { + let (service, _) = makeService(feed: StubFeedUpdater(isAvailable: false)) + service.setCoreReportedVersion("0.56.0") + service.setCoreReportedVersion("0.55.0") + XCTAssertEqual(service.menuEntries, [.browserGuidance(version: "0.56.0")]) + } + + func testFeedWithdrawalClearsTheOneClickItem() { + let (service, _) = makeService() + service.feedUpdater(didFindVersion: "0.55.0") + service.feedUpdaterDidNotFindUpdate() + XCTAssertEqual(service.menuEntries, []) + } + + // MARK: - FR-016 + + func testATranslocatedAppGetsAnExplanationInsteadOfAOneClickItem() { + let (service, _) = makeService( + bundlePath: "/private/var/folders/x/AppTranslocation/1/d/MCPProxy.app" + ) + service.feedUpdater(didFindVersion: "0.55.0") + guard case .blocked = service.menuEntries.first else { + return XCTFail("expected the blocked reason first, got \(service.menuEntries)") + } + XCTAssertEqual(service.menuEntries.last, .browserGuidance(version: "0.55.0")) + XCTAssertNotNil(service.blockedReason) + } + + func testFeedErrorsAreRecordedRatherThanSwallowed() { + let (service, _) = makeService() + service.feedUpdater(didFailWith: "the update is improperly signed") + XCTAssertEqual(service.lastErrorMessage, "the update is improperly signed") + } + + // MARK: - FR-012 + + func testInstallHookStopsTheManagedCoreSynchronously() { + let (service, _) = makeService() + var stopped = 0 + service.stopManagedCore = { stopped += 1 } + service.feedUpdaterWillInstallUpdate() + XCTAssertEqual(stopped, 1, + "the core must be down BEFORE the delegate call returns — Sparkle " + + "replaces the bundle the moment it does") + } + + func testInstallHookIsSafeWithoutACore() { + let (service, _) = makeService() + service.feedUpdaterWillInstallUpdate() // must not trap + } + + // MARK: - FR-010 through the real menu + + private final class TestMenuHost: TrayMenuHost { + var menu: NSMenu? + } + + func testTheOneClickItemIsRenderedAndActionable() throws { + let (service, _) = makeService() + service.feedUpdater(didFindVersion: "0.55.0") + + let host = TestMenuHost() + let controller = AppController( + glanceDataSource: CountingGlanceDataSource(), + menuHost: host, + updateService: service + ) + controller.appState.coreState = .connected + controller.rebuildMenu() + + let titles = (host.menu?.items ?? []).map(\.title) + let item = try XCTUnwrap((host.menu?.items ?? []).first { + $0.title.hasPrefix("Update 0.55.0") + }, "FR-010's item is missing from \(titles)") + XCTAssertEqual(item.title, "Update 0.55.0 — ready to restart?") + XCTAssertNotNil(item.action) + XCTAssertTrue(item.target === controller) + + XCTAssertFalse(titles.contains { $0.hasPrefix("Update available:") }, + "FR-017: the legacy nudge must not compete with the one-click item") + } + + func testTheBlockedReasonIsRenderedAsItsOwnItem() throws { + let (service, _) = makeService( + bundlePath: "/private/var/folders/x/AppTranslocation/1/d/MCPProxy.app" + ) + service.feedUpdater(didFindVersion: "0.55.0") + + let host = TestMenuHost() + let controller = AppController( + glanceDataSource: CountingGlanceDataSource(), + menuHost: host, + updateService: service + ) + controller.appState.coreState = .connected + controller.rebuildMenu() + + let items = host.menu?.items ?? [] + let blocked = try XCTUnwrap(items.first { $0.title.contains("Can’t update") }, + "FR-016 must be visible, not logged") + XCTAssertNotNil(blocked.action, "clicking it must explain what to do") + XCTAssertFalse(items.contains { $0.title.hasPrefix("Update 0.55.0 —") }, + "no one-click item where a one-click install cannot work") + } +} diff --git a/scripts/build-swift-app.sh b/scripts/build-swift-app.sh index 08a0b5e1..3d196709 100755 --- a/scripts/build-swift-app.sh +++ b/scripts/build-swift-app.sh @@ -71,6 +71,26 @@ if [ -f "MCPProxy/Info.plist" ]; then cp "MCPProxy/Info.plist" "$APP_BUNDLE/Contents/Info.plist" /usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString ${VERSION#v}" "$APP_BUNDLE/Contents/Info.plist" 2>/dev/null || true /usr/libexec/PlistBuddy -c "Set :CFBundleVersion ${VERSION#v}" "$APP_BUNDLE/Contents/Info.plist" 2>/dev/null || true + + # Spec 092 FR-011: pin the Sparkle EdDSA PUBLIC key into the shipped bundle. + # The checked-in Info.plist carries SPARKLE_PUBLIC_KEY_PLACEHOLDER; a release + # build stamps the real key from the environment. Deliberately optional: + # a local/dev/fork build without the key produces a bundle whose updater + # refuses to start, which the tray reports as "one-click updates + # unavailable" and falls back to the browser path (FR-017) — never a bundle + # that would accept an unverified update. + if [ -n "${SPARKLE_PUBLIC_ED_KEY:-}" ]; then + /usr/libexec/PlistBuddy -c "Set :SUPublicEDKey ${SPARKLE_PUBLIC_ED_KEY}" "$APP_BUNDLE/Contents/Info.plist" 2>/dev/null \ + || /usr/libexec/PlistBuddy -c "Add :SUPublicEDKey string ${SPARKLE_PUBLIC_ED_KEY}" "$APP_BUNDLE/Contents/Info.plist" + echo "✅ Sparkle public key stamped into Info.plist" + else + echo "ℹ️ SPARKLE_PUBLIC_ED_KEY not set — Sparkle stays disabled in this build (browser-download fallback)" + fi + if [ -n "${SPARKLE_FEED_URL:-}" ]; then + /usr/libexec/PlistBuddy -c "Set :SUFeedURL ${SPARKLE_FEED_URL}" "$APP_BUNDLE/Contents/Info.plist" 2>/dev/null \ + || /usr/libexec/PlistBuddy -c "Add :SUFeedURL string ${SPARKLE_FEED_URL}" "$APP_BUNDLE/Contents/Info.plist" + echo "✅ Sparkle feed URL set to ${SPARKLE_FEED_URL}" + fi else # Generate minimal Info.plist cat > "$APP_BUNDLE/Contents/Info.plist" << EOF From 09cc72ca1ef17ab9d0df6b23381f598c7520388d Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Sat, 8 Aug 2026 07:12:57 +0300 Subject: [PATCH 17/37] feat(tray): request the architecture-specific Sparkle feed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Related #957 A Sparkle appcast has no architecture selector, and the release pipeline builds one app bundle per architecture — each carrying a per-arch core in Contents/Resources/bin — both reporting the same CFBundleShortVersionString. A single merged feed would therefore hand an Intel user the Apple-Silicon build, and the failure would arrive as a crash after a "successful" update. Until a universal enclosure exists (open decision #4 in the decision report), the pipeline publishes one feed per architecture and the tray asks for its own. ## Changes - `Services/SparkleFeedURL.swift` — pure rewrite of the configured `SUFeedURL`: only the exact default file name `appcast.xml` becomes `appcast-.xml`. Any other name is used verbatim, so an operator already serving a merged or universal feed is not second-guessed. - `SparkleFeedUpdater.feedURLString(for:)` applies it, returning nil (= "use Info.plist") when the rewrite is a no-op. - The file names are half of a contract with `.github/workflows/release.yml`, which generates exactly those names; the test file says so. ## Testing - `SparkleFeedURLTests` — 5 tests, 0 failures (default rewrite per arch, nested paths, operator-supplied names left alone, query strings preserved). - `swift build` → Build complete, no "nearly matches optional requirement" warning, i.e. the delegate method really binds. --- .../MCPProxy/Services/FeedUpdater.swift | 17 ++++++ .../MCPProxy/Services/SparkleFeedURL.swift | 45 ++++++++++++++ .../MCPProxyTests/SparkleFeedURLTests.swift | 60 +++++++++++++++++++ 3 files changed, 122 insertions(+) create mode 100644 native/macos/MCPProxy/MCPProxy/Services/SparkleFeedURL.swift create mode 100644 native/macos/MCPProxy/MCPProxyTests/SparkleFeedURLTests.swift diff --git a/native/macos/MCPProxy/MCPProxy/Services/FeedUpdater.swift b/native/macos/MCPProxy/MCPProxy/Services/FeedUpdater.swift index 8706951b..6f0c229e 100644 --- a/native/macos/MCPProxy/MCPProxy/Services/FeedUpdater.swift +++ b/native/macos/MCPProxy/MCPProxy/Services/FeedUpdater.swift @@ -86,6 +86,9 @@ final class SparkleFeedUpdater: NSObject, FeedUpdating { private var policy: EffectiveUpdatePolicy = .permissive private(set) var unavailableReason: String? + /// The bundle Sparkle reads its configuration from. Captured at `start`. + private var hostBundle: Bundle = .main + /// The last version the feed offered. Kept by us rather than read back out /// of Sparkle, because Sparkle's update *session* ends when the user /// dismisses the window while the update itself remains available — and @@ -107,6 +110,7 @@ final class SparkleFeedUpdater: NSObject, FeedUpdating { /// exercised; production passes `Bundle.main`. func start(bundle: Bundle = .main, policy: EffectiveUpdatePolicy) { self.policy = policy + self.hostBundle = bundle // Sparkle reads SUFeedURL / SUPublicEDKey from the host bundle. Running // from `.build/debug/MCPProxy` there is no host bundle to read, and the @@ -170,6 +174,19 @@ final class SparkleFeedUpdater: NSObject, FeedUpdating { extension SparkleFeedUpdater: SPUUpdaterDelegate { + /// FR-013 / decision-report open item #4: ask for the feed that matches + /// this machine's architecture. Returning nil means "use Info.plist", which + /// is what happens for any operator-set feed URL that is not the default + /// name — see `SparkleFeedURL`. + func feedURLString(for updater: SPUUpdater) -> String? { + guard let configured = hostBundle.object(forInfoDictionaryKey: "SUFeedURL") as? String, + !configured.isEmpty else { return nil } + let resolved = SparkleFeedURL.archSpecific( + configured, arch: UpdateService.hostArchToken() + ) + return resolved == configured ? nil : resolved + } + /// FR-014: stable users must never be offered RCs. An empty set means /// "default channel only", which is exactly the stable feed; RC users also /// accept the `beta` channel the prerelease pipeline tags. diff --git a/native/macos/MCPProxy/MCPProxy/Services/SparkleFeedURL.swift b/native/macos/MCPProxy/MCPProxy/Services/SparkleFeedURL.swift new file mode 100644 index 00000000..77f1823f --- /dev/null +++ b/native/macos/MCPProxy/MCPProxy/Services/SparkleFeedURL.swift @@ -0,0 +1,45 @@ +// SparkleFeedURL.swift +// MCPProxy +// +// Spec 092 FR-013, and open decision #4 in the decision report ("per-arch zips +// vs a universal binary"). +// +// A Sparkle appcast has NO architecture selector. The release pipeline builds +// one app bundle per architecture, each carrying a per-architecture core in +// Contents/Resources/bin, and both bundles report the same +// CFBundleShortVersionString — so a single feed containing both enclosures +// would hand an Intel user the Apple-Silicon build (or the reverse), and the +// failure would arrive as a mysterious crash after a successful "update". +// +// Until a universal enclosure is built, the pipeline therefore publishes one +// feed per architecture and the tray asks for its own. That rewrite is here, +// as a pure function, so it can be tested without the framework and so the +// contract with `.github/workflows/release.yml` (which names the files) is +// stated in one readable place. + +import Foundation + +enum SparkleFeedURL { + + /// Base name the release pipeline suffixes: `appcast.xml` → + /// `appcast-arm64.xml` / `appcast-amd64.xml`. + static let defaultFeedFileName = "appcast.xml" + + /// Rewrite a feed URL to the architecture-specific one. + /// + /// Only the exact default file name is rewritten. An operator who points + /// `SUFeedURL` at something else has said what they want, and silently + /// mangling their URL would be worse than fetching it. + /// + /// - Parameters: + /// - feedURL: the URL from Info.plist (`SUFeedURL`). + /// - arch: `"arm64"` or `"amd64"` (see `UpdateService.hostArchToken`). + static func archSpecific(_ feedURL: String, arch: String) -> String { + guard var components = URLComponents(string: feedURL) else { return feedURL } + let last = (components.path as NSString).lastPathComponent + guard last == defaultFeedFileName else { return feedURL } + let directory = (components.path as NSString).deletingLastPathComponent + components.path = (directory as NSString).appendingPathComponent("appcast-\(arch).xml") + return components.string ?? feedURL + } +} diff --git a/native/macos/MCPProxy/MCPProxyTests/SparkleFeedURLTests.swift b/native/macos/MCPProxy/MCPProxyTests/SparkleFeedURLTests.swift new file mode 100644 index 00000000..8365502d --- /dev/null +++ b/native/macos/MCPProxy/MCPProxyTests/SparkleFeedURLTests.swift @@ -0,0 +1,60 @@ +// SparkleFeedURLTests.swift +// MCPProxyTests +// +// Spec 092 FR-013 — the per-architecture feed. A Sparkle appcast has no +// architecture selector, so this rewrite is the only thing standing between an +// Intel user and a successful "update" to an Apple-Silicon build. It is also +// half of a contract with `.github/workflows/release.yml`, which names the +// generated files `appcast-arm64.xml` / `appcast-amd64.xml`. + +import XCTest +@testable import MCPProxy + +final class SparkleFeedURLTests: XCTestCase { + + func testTheDefaultFeedIsRewrittenPerArchitecture() { + XCTAssertEqual( + SparkleFeedURL.archSpecific("https://mcpproxy.app/appcast.xml", arch: "arm64"), + "https://mcpproxy.app/appcast-arm64.xml" + ) + XCTAssertEqual( + SparkleFeedURL.archSpecific("https://mcpproxy.app/appcast.xml", arch: "amd64"), + "https://mcpproxy.app/appcast-amd64.xml" + ) + } + + func testNestedPathsKeepTheirDirectory() { + XCTAssertEqual( + SparkleFeedURL.archSpecific( + "https://github.com/o/r/releases/latest/download/appcast.xml", arch: "arm64" + ), + "https://github.com/o/r/releases/latest/download/appcast-arm64.xml" + ) + } + + func testAnOperatorSuppliedFeedNameIsLeftAlone() { + // Rewriting a URL the operator chose deliberately would be worse than + // fetching it: they may already be serving a merged or universal feed. + for url in [ + "https://example.com/feeds/mcpproxy.xml", + "https://example.com/appcast-arm64.xml", + "https://example.com/appcast.rss" + ] { + XCTAssertEqual(SparkleFeedURL.archSpecific(url, arch: "arm64"), url) + } + } + + func testQueryStringsSurviveTheRewrite() { + XCTAssertEqual( + SparkleFeedURL.archSpecific("https://example.com/appcast.xml?t=1", arch: "amd64"), + "https://example.com/appcast-amd64.xml?t=1" + ) + } + + func testGarbageInputIsReturnedUnchanged() { + XCTAssertEqual(SparkleFeedURL.archSpecific("", arch: "arm64"), "") + XCTAssertEqual(SparkleFeedURL.archSpecific("appcast.xml", arch: "arm64"), + "appcast-arm64.xml", + "a bare relative name still resolves; Sparkle rejects it later") + } +} From f0bc89165949b8d047fa5f72e55d1f6e7cd955a8 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Sat, 8 Aug 2026 07:13:24 +0300 Subject: [PATCH 18/37] ci(release): publish a signed Sparkle enclosure and appcast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Related #957 FR-013/FR-014: the one-click updater needs two things this pipeline never produced — a notarized, stapled, symlink-preserving archive of the .app, and a signed update feed pointing at it. (The existing macOS assets do not qualify: the bare DMG is signed but NOT notarized, and only the PKG-wrapping `-installer.dmg` is notarized + stapled. Neither is an .app archive.) ## release.yml (stable) - **Build Sparkle enclosure (macOS)**, inserted AFTER the existing notarization step and before keychain cleanup. The signing step order is untouched — the bundle was already signed nested-first; this step only archives, notarizes, staples and verifies it. * `ditto -c -k --sequesterRsrc --keepParent` — the only archiver Apple documents for this. `zip` drops symlinks and xattrs, which breaks the bundle's signature seal and surfaces on the user's machine as `Killed: 9`. * The .app is notarized and stapled SEPARATELY from the PKG: a stapled ticket lives inside the bundle it was stapled to, and the copy extracted from this zip is not the copy inside the PKG. Unstapled, the first launch after every update would wait on Apple — or fail offline. * Post-condition proven, not assumed: the archive is extracted again and run through `codesign --verify --deep --strict`, `stapler validate` and `spctl`. * Named `*.app.zip`, so the release job's existing "copy archives" glob picks it up and it lands in `checksums.txt` with everything else. - **sparkle-appcast** job, `needs: [release]` — deliberately after publication, because `generate_appcast` bakes absolute download URLs into the feed and the first client to read it would otherwise 404. Runs Sparkle 2.9.3's `generate_appcast` (version pinned to Package.resolved) with the private key on **stdin** (`--ed-key-file -`), so it never touches disk or `ps`. Fails the job if a generated feed lacks `sparkle:edSignature`. - ONE FEED PER ARCHITECTURE (`appcast-arm64.xml` / `appcast-amd64.xml`): an appcast has no arch selector and both bundles carry the same version, so a merged feed would offer Intel users the arm64 build. The tray-side half of this contract is `Services/SparkleFeedURL.swift`. - `SPARKLE_PUBLIC_ED_KEY` / `SPARKLE_FEED_URL` passed to the Swift app build so `scripts/build-swift-app.sh` can stamp `SUPublicEDKey` / `SUFeedURL`. ## prerelease.yml (RC) Same enclosure step and the same appcast job with `--channel beta`, gated on a TAG (this workflow also runs on `next` pushes, which have no release to attach a feed to). The job asserts the `beta` tag is present — that tag is the only thing keeping stable users from being offered an RC, since Sparkle offers a tagged item only to clients that request the channel. ## Fork / no-key behaviour Every new step no-ops with a `::notice::` when `SPARKLE_ED_PRIVATE_KEY` is absent, and both artifact uploads use `if-no-files-found: ignore`. A build without `SPARKLE_ED_PUBLIC_KEY` keeps `SPARKLE_PUBLIC_KEY_PLACEHOLDER`, which makes the updater refuse to start — no key, no one-click, never an unverified install. ## Testing - `python3 -c "import yaml; yaml.safe_load(...)"` on both files → parse. - Every new `run:` block extracted and run through `bash -n` and `shellcheck -S warning` → clean. - `actionlint` on both files reports nothing new (the pre-existing `matrix.edition` notices come from the commented-out server matrix rows). - The workflows themselves cannot be executed here; the first real tag is the first true test. Riskiest untested lines: the `notarytool` submission of the .app zip and the `generate_appcast` invocation. ## NOTES - FR-018 (`auto_updates true` on the Homebrew cask) lives in the tap repo, smart-mcp-proxy/homebrew-mcpproxy — out of scope here, and required before the first Sparkle-capable release. - TODO (maintainer decision #1): the shipped Info.plist points at https://mcpproxy.app/appcast.xml, served by the WEBSITE repo, which this pipeline cannot publish to. Feeds are attached to the release and exported as the `sparkle-appcast` / `sparkle-appcast-beta` artifacts for it to consume. - STILL MISSING from FR-014: the RC pipeline publishes no checksums.txt and no cosign bundle, so RC tarballs stay guidance-only for `mcpproxy update`. --- .github/workflows/prerelease.yml | 181 ++++++++++++++++++++++++++ .github/workflows/release.yml | 215 +++++++++++++++++++++++++++++++ 2 files changed, 396 insertions(+) diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml index f1eb12c0..7cea3c5f 100644 --- a/.github/workflows/prerelease.yml +++ b/.github/workflows/prerelease.yml @@ -193,6 +193,13 @@ jobs: # Defensive CGO flags to ensure proper deployment target CGO_CFLAGS: "-mmacosx-version-min=13.0" CGO_LDFLAGS: "-mmacosx-version-min=13.0" + # Spec 092 FR-011/FR-014: pin the Sparkle EdDSA PUBLIC key so RC builds + # can consume the beta feed. Same secret as the stable pipeline — one + # keypair, two channels; the CHANNEL is what separates them, not the key. + # No secret ⇒ scripts/build-swift-app.sh leaves the placeholder and the + # updater stays off. + SPARKLE_PUBLIC_ED_KEY: ${{ secrets.SPARKLE_ED_PUBLIC_KEY }} + SPARKLE_FEED_URL: ${{ vars.SPARKLE_FEED_URL }} run: | # For prerelease, determine version differently if [[ "${{ github.ref }}" == refs/tags/* ]]; then @@ -698,6 +705,73 @@ jobs: echo "✅ Notarization and stapling complete" + # Spec 092 FR-014 — RC builds get the same enclosure the stable pipeline + # produces. See the identical step in release.yml for why `ditto -c -k + # --sequesterRsrc --keepParent` and why the .app is notarized and stapled + # separately from the PKG. Signing step order is untouched. + # + # Only TAGGED RCs produce an enclosure: this workflow also runs on pushes to + # `next`, and a branch build has no release to attach a feed to. + - name: Build Sparkle enclosure (macOS) + if: matrix.goos == 'darwin' && startsWith(github.ref, 'refs/tags/') + env: + SPARKLE_ED_PRIVATE_KEY: ${{ secrets.SPARKLE_ED_PRIVATE_KEY }} + run: | + set -euo pipefail + + mkdir -p sparkle-enclosure + + if [ -z "${SPARKLE_ED_PRIVATE_KEY:-}" ]; then + echo "ℹ️ SPARKLE_ED_PRIVATE_KEY is not configured — skipping the Sparkle enclosure." + exit 0 + fi + + if [ -z "${SWIFT_APP_PATH:-}" ] || [ ! -d "${SWIFT_APP_PATH}" ]; then + echo "❌ SWIFT_APP_PATH is not a bundle: '${SWIFT_APP_PATH:-}'" + exit 1 + fi + + VERSION=${GITHUB_REF#refs/tags/} + ENCLOSURE="mcpproxy-${VERSION#v}-darwin-${{ matrix.goarch }}.app.zip" + NOTARIZE_ZIP="sparkle-notarize-${{ matrix.goarch }}.zip" + + ditto -c -k --sequesterRsrc --keepParent "${SWIFT_APP_PATH}" "${NOTARIZE_ZIP}" + + SUBMISSION_OUTPUT=$(xcrun notarytool submit "${NOTARIZE_ZIP}" \ + --apple-id "${{ secrets.APPLE_ID_USERNAME }}" \ + --password "${{ secrets.APPLE_ID_APP_PASSWORD }}" \ + --team-id "${{ secrets.APPLE_TEAM_ID }}" \ + --wait \ + --output-format json) + STATUS=$(echo "${SUBMISSION_OUTPUT}" | jq -r '.status // empty') + if [ "${STATUS}" != "Accepted" ]; then + echo "❌ App-bundle notarization did not succeed" + echo "Response: ${SUBMISSION_OUTPUT}" + exit 1 + fi + + xcrun stapler staple "${SWIFT_APP_PATH}" + xcrun stapler validate "${SWIFT_APP_PATH}" + + ditto -c -k --sequesterRsrc --keepParent "${SWIFT_APP_PATH}" "sparkle-enclosure/${ENCLOSURE}" + rm -f "${NOTARIZE_ZIP}" + + VERIFY_DIR=$(mktemp -d) + ditto -x -k "sparkle-enclosure/${ENCLOSURE}" "${VERIFY_DIR}" + codesign --verify --deep --strict --verbose=2 "${VERIFY_DIR}/MCPProxy.app" + xcrun stapler validate "${VERIFY_DIR}/MCPProxy.app" + rm -rf "${VERIFY_DIR}" + + echo "✅ Sparkle enclosure ready: ${ENCLOSURE}" + + - name: Upload Sparkle enclosure artifact + if: matrix.goos == 'darwin' && startsWith(github.ref, 'refs/tags/') + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: sparkle-enclosure-${{ matrix.goarch }} + path: sparkle-enclosure/* + if-no-files-found: ignore + - name: Cleanup isolated keychain (macOS) if: matrix.goos == 'darwin' && always() run: | @@ -951,3 +1025,110 @@ jobs: done env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # Spec 092 FR-014 — the RC (beta) update feed. + # + # Same shape as release.yml's `sparkle-appcast`, with one difference that IS + # the requirement: `--channel beta`. Sparkle only offers a channel-tagged item + # to a client that lists that channel in `allowedChannelsForUpdater:`, and the + # tray lists "beta" only when the core reports update_policy.channel == "rc" + # (see UpdateChannel.allowedSparkleChannels). That is what keeps stable users + # from ever being offered an RC. + # + # TODO(maintainer decision #1 / #8): the beta feed needs its own stable URL — + # the GitHub `releases/latest/download/...` shortcut resolves to the newest + # NON-prerelease release, so it cannot serve this feed. Until the website repo + # hosts appcast-beta-.xml, the files are attached to the RC release and + # exported as the `sparkle-appcast-beta` artifact. + # + # NOT YET DONE (FR-014's other half): this pipeline still publishes no + # checksums.txt and no cosign bundle, so RC tarballs remain guidance-only for + # `mcpproxy update` — see the note in the tarball-stamp block above. + sparkle-appcast: + needs: [release] + runs-on: macos-15 + permissions: + contents: write + steps: + - name: Download Sparkle enclosures + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: sparkle-enclosure-* + path: enclosures + + - name: Generate and publish the beta appcast + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SPARKLE_ED_PRIVATE_KEY: ${{ secrets.SPARKLE_ED_PRIVATE_KEY }} + # Keep in step with Package.resolved (Sparkle 2.9.3). + SPARKLE_VERSION: "2.9.3" + run: | + set -euo pipefail + + mkdir -p appcast-out + + if [ -z "${SPARKLE_ED_PRIVATE_KEY:-}" ]; then + echo "::notice::SPARKLE_ED_PRIVATE_KEY is not configured — no beta appcast generated." + exit 0 + fi + if [ -z "$(find enclosures -name '*.app.zip' -print -quit 2>/dev/null)" ]; then + echo "::notice::No Sparkle enclosures were produced — no beta appcast generated." + exit 0 + fi + + curl -fsSL -o sparkle-tools.tar.xz \ + "https://github.com/sparkle-project/Sparkle/releases/download/${SPARKLE_VERSION}/Sparkle-${SPARKLE_VERSION}.tar.xz" + mkdir -p sparkle-tools + tar xf sparkle-tools.tar.xz -C sparkle-tools + GENERATE_APPCAST=$(find sparkle-tools -name generate_appcast -type f -perm +111 | head -1) + if [ -z "${GENERATE_APPCAST}" ]; then + echo "❌ generate_appcast not found in the Sparkle ${SPARKLE_VERSION} tarball" + exit 1 + fi + + PREFIX="https://github.com/${GITHUB_REPOSITORY}/releases/download/${GITHUB_REF_NAME}/" + + for ARCH in arm64 amd64; do + SRC=$(find enclosures -name "*-darwin-${ARCH}.app.zip" | head -1) + if [ -z "${SRC}" ]; then + echo "::warning::No ${ARCH} enclosure — skipping its beta feed." + continue + fi + WORK="appcast-work-${ARCH}" + rm -rf "${WORK}" + mkdir -p "${WORK}" + cp "${SRC}" "${WORK}/" + + echo "=== Generating appcast-beta-${ARCH}.xml from $(basename "${SRC}") ===" + echo "${SPARKLE_ED_PRIVATE_KEY}" | "${GENERATE_APPCAST}" \ + --ed-key-file - \ + --channel beta \ + --download-url-prefix "${PREFIX}" \ + --link "https://github.com/${GITHUB_REPOSITORY}/releases/tag/${GITHUB_REF_NAME}" \ + -o "appcast-out/appcast-beta-${ARCH}.xml" \ + "${WORK}" + done + + if [ -z "$(ls -A appcast-out 2>/dev/null)" ]; then + echo "❌ generate_appcast produced no feeds" + exit 1 + fi + + for f in appcast-out/*.xml; do + echo "--- ${f} ---" + cat "${f}" + grep -q 'sparkle:edSignature' "${f}" || { echo "❌ ${f} has no EdDSA signature"; exit 1; } + # The channel tag is the whole point of this job: without it every + # stable user would be offered an RC (FR-014). + grep -q 'beta' "${f}" \ + || { echo "❌ ${f} is not tagged as the beta channel"; exit 1; } + done + + gh release upload "${GITHUB_REF_NAME}" appcast-out/*.xml --clobber + + - name: Upload beta appcast artifact for the website repo + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: sparkle-appcast-beta + path: appcast-out/* + if-no-files-found: ignore diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3daaec4c..1d176693 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -390,6 +390,16 @@ jobs: # Defensive CGO flags to ensure proper deployment target CGO_CFLAGS: "-mmacosx-version-min=13.0" CGO_LDFLAGS: "-mmacosx-version-min=13.0" + # Spec 092 FR-011: pin the Sparkle EdDSA PUBLIC key into the shipped + # Info.plist. scripts/build-swift-app.sh no-ops when this is empty + # (forks, and every build until the maintainer generates the keypair), + # leaving SPARKLE_PUBLIC_KEY_PLACEHOLDER in place — which makes the + # updater refuse to start and the tray fall back to the browser path + # rather than trusting an unverified update. + SPARKLE_PUBLIC_ED_KEY: ${{ secrets.SPARKLE_ED_PUBLIC_KEY }} + # Optional override of the compiled-in feed URL (defaults to the + # Info.plist value, https://mcpproxy.app/appcast.xml). + SPARKLE_FEED_URL: ${{ vars.SPARKLE_FEED_URL }} run: | VERSION=${GITHUB_REF#refs/tags/} # NOTE (Spec 079/092): the shared matrix binary stays UNSTAMPED. It feeds @@ -985,6 +995,97 @@ jobs: echo "✅ Notarization and stapling complete" + # Spec 092 FR-013 — the Sparkle update enclosure. + # + # NOT the DMG or the PKG: Sparkle installs an .app, and the archive has to + # preserve symlinks and extended attributes or the bundle's code signature + # seal breaks and macOS answers with "Killed: 9". `ditto -c -k + # --sequesterRsrc --keepParent` is the only archiver Apple documents for + # this; `zip` is not a substitute. + # + # Notarized and stapled here rather than relying on the PKG's ticket: + # a stapled ticket lives inside the bundle it was stapled to, and the .app + # extracted from this zip is a different copy from the one inside the PKG. + # An unstapled enclosure would make the first launch after every update + # wait on Apple's servers — or fail offline. + # + # The signing step order above is untouched: the bundle was already signed + # nested-first, and this step only archives, notarizes and staples it. + - name: Build Sparkle enclosure (macOS) + if: matrix.goos == 'darwin' && matrix.edition != 'server' + env: + SPARKLE_ED_PRIVATE_KEY: ${{ secrets.SPARKLE_ED_PRIVATE_KEY }} + run: | + set -euo pipefail + + mkdir -p sparkle-enclosure + + # Fork guard: without the signing key there is no appcast job to feed, + # so the (slow) extra notarization is pure waste. Skipping is not a + # failure — the DMG/PKG assets are unaffected. + if [ -z "${SPARKLE_ED_PRIVATE_KEY:-}" ]; then + echo "ℹ️ SPARKLE_ED_PRIVATE_KEY is not configured — skipping the Sparkle enclosure." + exit 0 + fi + + if [ -z "${SWIFT_APP_PATH:-}" ] || [ ! -d "${SWIFT_APP_PATH}" ]; then + echo "❌ SWIFT_APP_PATH is not a bundle: '${SWIFT_APP_PATH:-}'" + exit 1 + fi + + VERSION=${GITHUB_REF#refs/tags/} + ENCLOSURE="mcpproxy-${VERSION#v}-darwin-${{ matrix.goarch }}.app.zip" + NOTARIZE_ZIP="sparkle-notarize-${{ matrix.goarch }}.zip" + + echo "=== Archiving ${SWIFT_APP_PATH} for notarization ===" + ditto -c -k --sequesterRsrc --keepParent "${SWIFT_APP_PATH}" "${NOTARIZE_ZIP}" + + echo "=== Submitting the app bundle for notarization ===" + SUBMISSION_OUTPUT=$(xcrun notarytool submit "${NOTARIZE_ZIP}" \ + --apple-id "${{ secrets.APPLE_ID_USERNAME }}" \ + --password "${{ secrets.APPLE_ID_APP_PASSWORD }}" \ + --team-id "${{ secrets.APPLE_TEAM_ID }}" \ + --wait \ + --output-format json) + STATUS=$(echo "${SUBMISSION_OUTPUT}" | jq -r '.status // empty') + if [ "${STATUS}" != "Accepted" ]; then + echo "❌ App-bundle notarization did not succeed" + echo "Response: ${SUBMISSION_OUTPUT}" + exit 1 + fi + echo "✅ App bundle notarization accepted" + + # Staple the BUNDLE, then re-archive it: the ticket must travel inside + # the .app the user ends up running. + xcrun stapler staple "${SWIFT_APP_PATH}" + xcrun stapler validate "${SWIFT_APP_PATH}" + + echo "=== Creating the stapled enclosure ===" + ditto -c -k --sequesterRsrc --keepParent "${SWIFT_APP_PATH}" "sparkle-enclosure/${ENCLOSURE}" + rm -f "${NOTARIZE_ZIP}" + + # Prove the archive round-trips with its signature intact before it + # becomes a release asset — a broken seal only shows up as a crash on + # the user's machine otherwise. + VERIFY_DIR=$(mktemp -d) + ditto -x -k "sparkle-enclosure/${ENCLOSURE}" "${VERIFY_DIR}" + codesign --verify --deep --strict --verbose=2 "${VERIFY_DIR}/MCPProxy.app" + xcrun stapler validate "${VERIFY_DIR}/MCPProxy.app" + spctl -a -t exec -vv "${VERIFY_DIR}/MCPProxy.app" + rm -rf "${VERIFY_DIR}" + + ls -la sparkle-enclosure/ + echo "✅ Sparkle enclosure ready: ${ENCLOSURE}" + + - name: Upload Sparkle enclosure artifact + if: matrix.goos == 'darwin' && matrix.edition != 'server' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: sparkle-enclosure-${{ matrix.goarch }} + path: sparkle-enclosure/* + # Empty on forks and until the signing key exists — see the guard above. + if-no-files-found: ignore + - name: Cleanup isolated keychain (macOS) if: matrix.goos == 'darwin' && always() run: | @@ -1449,6 +1550,120 @@ jobs: # WP-C3: SLSA build provenance — generates a SLSA v1 attestation for all # release artifacts and uploads it to the GitHub release as a .intoto.jsonl file. + # Spec 092 FR-013 — the Sparkle update feed. + # + # Runs AFTER `release` on purpose: generate_appcast bakes absolute download + # URLs into the feed, so the assets those URLs point at must already be + # published or the very first client to read the feed gets a 404. + # + # ONE FEED PER ARCHITECTURE. A Sparkle appcast has no architecture selector, + # and both macOS bundles carry the same CFBundleShortVersionString — a single + # merged feed would hand an Intel user the Apple-Silicon build. The tray asks + # for its own feed; the rewrite rule lives in + # native/macos/MCPProxy/MCPProxy/Services/SparkleFeedURL.swift, and the file + # names below are the other half of that contract. + # + # TODO(maintainer decision #1 in docs/research/auto-updater-issue-957-2026-08-07.html): + # the shipped Info.plist points at https://mcpproxy.app/appcast.xml, which the + # WEBSITE repo would have to serve — this repository cannot publish there. Until + # that is wired, the feeds are (a) attached to the release and (b) exported as a + # workflow artifact (`sparkle-appcast`) for the website repo to consume. The + # GitHub-hosted stable URL + # https://github.com/OWNER/REPO/releases/latest/download/appcast-.xml + # works today as an interim SUFeedURL for the STABLE channel only (it resolves to + # the newest non-prerelease release); set repository variable SPARKLE_FEED_URL to + # use it. It does NOT serve the beta channel — see prerelease.yml. + sparkle-appcast: + needs: [release] + runs-on: macos-15 + permissions: + contents: write + steps: + - name: Download Sparkle enclosures + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: sparkle-enclosure-* + path: enclosures + + - name: Generate and publish the appcast + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SPARKLE_ED_PRIVATE_KEY: ${{ secrets.SPARKLE_ED_PRIVATE_KEY }} + # Keep in step with Package.resolved (Sparkle 2.9.3). + SPARKLE_VERSION: "2.9.3" + run: | + set -euo pipefail + + mkdir -p appcast-out + + if [ -z "${SPARKLE_ED_PRIVATE_KEY:-}" ]; then + echo "::notice::SPARKLE_ED_PRIVATE_KEY is not configured — no appcast generated." + exit 0 + fi + if [ -z "$(find enclosures -name '*.app.zip' -print -quit 2>/dev/null)" ]; then + echo "::notice::No Sparkle enclosures were produced — no appcast generated." + exit 0 + fi + + echo "=== Fetching Sparkle ${SPARKLE_VERSION} tools ===" + curl -fsSL -o sparkle-tools.tar.xz \ + "https://github.com/sparkle-project/Sparkle/releases/download/${SPARKLE_VERSION}/Sparkle-${SPARKLE_VERSION}.tar.xz" + mkdir -p sparkle-tools + tar xf sparkle-tools.tar.xz -C sparkle-tools + GENERATE_APPCAST=$(find sparkle-tools -name generate_appcast -type f -perm +111 | head -1) + if [ -z "${GENERATE_APPCAST}" ]; then + echo "❌ generate_appcast not found in the Sparkle ${SPARKLE_VERSION} tarball" + find sparkle-tools -maxdepth 3 -type d + exit 1 + fi + echo "Using ${GENERATE_APPCAST}" + + PREFIX="https://github.com/${GITHUB_REPOSITORY}/releases/download/${GITHUB_REF_NAME}/" + + for ARCH in arm64 amd64; do + SRC=$(find enclosures -name "*-darwin-${ARCH}.app.zip" | head -1) + if [ -z "${SRC}" ]; then + echo "::warning::No ${ARCH} enclosure — skipping its feed." + continue + fi + WORK="appcast-work-${ARCH}" + rm -rf "${WORK}" + mkdir -p "${WORK}" + cp "${SRC}" "${WORK}/" + + echo "=== Generating appcast-${ARCH}.xml from $(basename "${SRC}") ===" + # The key goes in on stdin so it never lands on disk or in `ps`. + echo "${SPARKLE_ED_PRIVATE_KEY}" | "${GENERATE_APPCAST}" \ + --ed-key-file - \ + --download-url-prefix "${PREFIX}" \ + --link "https://github.com/${GITHUB_REPOSITORY}/releases/tag/${GITHUB_REF_NAME}" \ + -o "appcast-out/appcast-${ARCH}.xml" \ + "${WORK}" + done + + if [ -z "$(ls -A appcast-out 2>/dev/null)" ]; then + echo "❌ generate_appcast produced no feeds" + exit 1 + fi + + echo "=== Generated feeds ===" + for f in appcast-out/*.xml; do + echo "--- ${f} ---" + cat "${f}" + # Fail loudly rather than publish a feed with no enclosure signature: + # an unsigned item is one Sparkle will refuse, silently, forever. + grep -q 'sparkle:edSignature' "${f}" || { echo "❌ ${f} has no EdDSA signature"; exit 1; } + done + + gh release upload "${GITHUB_REF_NAME}" appcast-out/*.xml --clobber + + - name: Upload appcast artifact for the website repo + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: sparkle-appcast + path: appcast-out/* + if-no-files-found: ignore + provenance: needs: [release] permissions: From ba5b1c131cc80b3befc061ee4275424979beb9d5 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Sat, 8 Aug 2026 07:13:34 +0300 Subject: [PATCH 19/37] docs: document the auto-updater end to end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Related #957 New `docs/features/auto-update.md`: the per-channel behaviour matrix (CLI and tray side by side), the one-click flow and its two independent verifications, why the core is stopped before the swap rather than after, what happens when an in-place update is impossible, the FR-017 "one item, one owner" rule, every kill switch, the `update_policy` contract, how the RC/beta channel keeps stable users away from prereleases, and the release infrastructure (enclosure, per-arch feeds, the three signing key/variable names, and the fork/no-key behaviour). Linked from `docs/configuration.md`'s env-var table — which also gains the `CI` row that was previously only documented for the core — and from `docs/features/version-updates.md`, which covers *noticing* an update rather than applying one. The unresolved feed-hosting decision is called out in an admonition rather than buried: the shipped Info.plist points at https://mcpproxy.app/appcast.xml, which only the website repo can serve, and the interim GitHub `releases/latest/download/...` URL works for stable but structurally cannot serve the beta channel. --- docs/configuration.md | 9 +- docs/features/auto-update.md | 208 +++++++++++++++++++++++++++++++ docs/features/version-updates.md | 6 + 3 files changed, 222 insertions(+), 1 deletion(-) create mode 100644 docs/features/auto-update.md diff --git a/docs/configuration.md b/docs/configuration.md index 7f5ad535..204f0daf 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1165,8 +1165,15 @@ triggers a prompt re-check. | Variable | Effect | |----------|--------| -| `MCPPROXY_DISABLE_AUTO_UPDATE=true` | Force-disables update checking even when `update_check.enabled` is `true`. | +| `MCPPROXY_DISABLE_AUTO_UPDATE=true` | Force-disables update checking even when `update_check.enabled` is `true`. Read by the **core and the macOS tray** — one export silences both, including the tray's one-click updater. | | `MCPPROXY_ALLOW_PRERELEASE_UPDATES=true` | Force-selects the prerelease (`rc`) channel even when `update_check.channel` is `stable`. | +| `CI=true` / `CI=1` | Suppresses every update nudge and the tray's unattended checks (non-interactive context). Machine-readable fields keep reporting the facts, and a user-initiated "Check for Updates" still runs. | + +Both keys and both switches are reported to the tray as an explicit contract — +`update_policy` in `GET /api/v1/info` — rather than inferred from missing data. +The macOS one-click updater, the channel matrix and the release-infrastructure +side (feed, enclosure, signing keys) are documented in +[Auto-Update](/features/auto-update). The env vars only widen in one direction (disable checks / enable prereleases); they cannot force-enable checking that config disabled — with diff --git a/docs/features/auto-update.md b/docs/features/auto-update.md new file mode 100644 index 00000000..4e54afb3 --- /dev/null +++ b/docs/features/auto-update.md @@ -0,0 +1,208 @@ +--- +id: auto-update +title: Auto-Update +sidebar_label: Auto-Update +description: One-click updates in the macOS tray, the channel-aware mcpproxy update CLI, and every switch that turns them off +keywords: [update, auto-update, sparkle, appcast, upgrade, channel, rc] +--- + +# Auto-Update + +MCPProxy can update itself. What "update itself" means depends on **how it was +installed** — a package manager owns its own files and must never be fought +over, so MCPProxy only ever replaces what it owns and prints the exact command +for everything else. + +Related: [Version Updates](/features/version-updates) (how the *check* works), +[Prerelease Builds](/prerelease-builds) (the RC channel). + +## Channel matrix + +| Install channel | `mcpproxy update` (CLI) | macOS tray | +|---|---|---| +| **DMG / PKG** (`dmg`) | Points at the tray updater; never touches the app bundle or a staged copy of it | **One-click update** — download, verify, replace, relaunch | +| **tarball** | **Self-update**: downloads the release archive, verifies it against the signed `checksums.txt`, swaps the binary atomically | n/a | +| **Homebrew** | Prints `brew upgrade mcpproxy` | n/a (cask `auto_updates true`, see below) | +| **deb** | Prints the `apt` command | n/a | +| **rpm** | Prints the `dnf`/`yum` command | n/a | +| **go-install** | Prints the `go install` command | n/a | +| **Docker** | Prints the `docker pull` guidance | n/a | +| **windows-installer** | Prints download guidance | n/a | +| **unknown** | **Guidance only.** Writability does not establish ownership — AUR, MacPorts and Nix-like layouts all land here. `--self` is the explicit override, with a warning | n/a | + +`mcpproxy update --check` reports the current version, the latest version and +the detected channel for **every** channel, with no side effects. + +Self-update refuses a downgrade unless you pass **both** an explicit +`--version` and `--force`; it never escalates privileges, and it never writes +inside `MCPProxy.app`. + +## The macOS one-click flow + +1. A background check reads the update feed (an *appcast*) at most every four + hours. +2. When something newer exists, the tray menu grows one gentle line: + **"Update 0.55.0 — ready to restart?"**. No popup, no dock bounce, no + interruption — the menu item *is* the notification. +3. Clicking it runs the whole thing: download → **verify** → stop the managed + core → replace the installed app → relaunch on the new version. + +### Verification (two independent checks) + +Nothing is installed unless both pass: + +- **EdDSA signature** over the downloaded archive, checked against the public + key baked into the running app's `Info.plist` (`SUPublicEDKey`). The feed XML + is not what carries the signature — each enclosure is signed individually. +- **macOS code-signature and notarization** of the replacement bundle. + +A failure of either aborts with nothing replaced and an actionable error. The +running version keeps working. + +### Why the core is stopped first + +The updater stops the tray-managed core **before** the bundle is replaced, not +after. A core still executing from a replaced (deleted-inode) bundle is exactly +the failure reported in issue #957. In-flight tool calls fail visibly rather +than hanging: the stop is `SIGTERM`, a five-second grace period, then `SIGKILL`. + +The Phase-0 supersede check stays active permanently as the safety net for the +paths the updater does not control — drag-installs, PKG runs, install-on-quit. + +### When one-click cannot work + +If the app is **translocated** (opened straight from a download or a mounted +disk image), on a **read-only volume**, or in a directory this user cannot +write, the menu says so and offers the fallback — it never shows an update item +that would quietly do nothing: + +> *Can't update — move MCPProxy to Applications first* + +Move `MCPProxy.app` into `/Applications` (or `~/Applications`) and open it from +there; updates work from then on. + +### One item, one owner + +Two mechanisms know about releases: the feed, and the daily GitHub check that +predates it. They never both nudge: + +- feed offer present → the feed owns the item, and the GitHub check is silent + for the same or an older version; +- GitHub only (feed unreachable, or it does not carry that version) → the item + reads **"Update available: vX.Y.Z — Download"** and opens the browser, never + a one-click action it cannot perform; +- equal versions → exactly one item. + +## Kill switches + +| Switch | Effect | +|---|---| +| `update_check.enabled: false` | No automatic check anywhere: no core poll, no tray feed check, no nudge. Reported to the tray as `update_policy.enabled = false`. | +| `MCPPROXY_DISABLE_AUTO_UPDATE=true` | Same, and wins over the config value. Read by **both** the core and the tray, so one export silences the whole app. | +| `CI=true` / `CI=1` | Nudges are suppressed and the tray performs no unattended checks — a scheduled check exists only to produce a nudge nobody will read. Machine-readable fields keep reporting the facts. | +| `update_check.channel: "rc"` | Offers prereleases; maps to the Sparkle **beta** channel. | +| `MCPPROXY_ALLOW_PRERELEASE_UPDATES=true` | Forces the `rc` channel over the config value. | + +**A user-initiated "Check for Updates" is always available**, including with +every switch above turned on. The switches govern what happens *unasked*. + +### The policy contract + +The tray does not guess. `GET /api/v1/info` always carries: + +```json +"update_policy": { "enabled": true, "channel": "stable", "nudges_suppressed": false } +``` + +All three fields are always present. This exists because the optional `update` +object is absent both when checking is disabled **and** when no check has run +yet — its absence cannot tell a client whether it is allowed to check. The +policy is recomputed per request, so editing `update_check` in the config file +takes effect on the tray's next connect with no restart. + +## Release channels + +Stable users are never offered a release candidate. The mechanism is Sparkle +channels: + +- stable releases are published with **no channel tag**, which Sparkle offers + to everyone; +- RC releases are tagged `beta`, and Sparkle + offers a tagged item only to clients that ask for that channel. The tray asks + for `beta` only when the core reports `update_policy.channel == "rc"`. + +See [Prerelease Builds](/prerelease-builds) for how to get on the RC channel. + +## Release infrastructure + +Generated by the release pipeline, per stable release and per RC: + +| Artifact | What it is | +|---|---| +| `mcpproxy--darwin-.app.zip` | The update **enclosure**: the notarized, stapled app bundle archived with `ditto -c -k --sequesterRsrc --keepParent` (symlink-preserving — anything else breaks the signature seal and macOS answers `Killed: 9`). Listed in `checksums.txt`. | +| `appcast-arm64.xml` / `appcast-amd64.xml` | The stable feeds, EdDSA-signed. | +| `appcast-beta-arm64.xml` / `appcast-beta-amd64.xml` | The RC feeds, additionally tagged `sparkle:channel = beta`. | + +**One feed per architecture.** A Sparkle appcast has no architecture selector, +and both macOS bundles report the same version — a single merged feed would +hand an Intel user the Apple-Silicon build. The tray rewrites the configured +`SUFeedURL` (`…/appcast.xml` → `…/appcast-arm64.xml`) to request its own; a feed +URL with any other file name is used verbatim, so an operator serving a +universal feed is not second-guessed. + +### Signing keys + +| Name | Kind | Used for | +|---|---|---| +| `SPARKLE_ED_PRIVATE_KEY` | repository **secret** | Signing enclosures and feeds in CI (`generate_appcast --ed-key-file -`; passed on stdin so it never lands on disk). | +| `SPARKLE_ED_PUBLIC_KEY` | repository **secret** | Stamped into the shipped `Info.plist` as `SUPublicEDKey`. | +| `SPARKLE_FEED_URL` | repository **variable** (optional) | Overrides the compiled-in feed URL. | + +Generate the pair once with Sparkle's `generate_keys`. Every new CI step is +guarded on the private key being present, so forks and key-less builds skip +enclosure and appcast generation gracefully — and a build without +`SPARKLE_ED_PUBLIC_KEY` keeps the `SPARKLE_PUBLIC_KEY_PLACEHOLDER` value, which +makes the updater refuse to start and the tray fall back to browser downloads. +That is the correct failure direction: no key, no one-click, never an +unverified install. + +:::warning Feed hosting is not yet decided +The shipped `Info.plist` points at `https://mcpproxy.app/appcast.xml`, which the +**website** repository would have to serve — this repository cannot publish +there. Until that is wired, each release attaches its feeds as release assets +and exports them as the `sparkle-appcast` (and `sparkle-appcast-beta`) workflow +artifact for the website repo to consume. + +As an interim stable-channel feed URL, +`https://github.com/smart-mcp-proxy/mcpproxy-go/releases/latest/download/appcast-.xml` +works today (GitHub resolves `latest` to the newest non-prerelease release). It +cannot serve the beta channel, because RC releases are prereleases and are never +`latest`. +::: + +### Homebrew + +The cask must be marked self-updating so `brew` does not fight the in-app +updater: + +```ruby +auto_updates true +``` + +This lives in the **tap** repository (`smart-mcp-proxy/homebrew-mcpproxy`), not +here, and has to be set before the first Sparkle-capable release ships. + +## Troubleshooting + +**"One-click updates unavailable" in the log.** The updater could not start: +either the app is not running from a bundle (a `swift build` binary), or the +bundle carries the placeholder public key. The browser-download path still +works. + +**The old core keeps answering after an upgrade.** That is the Phase-0 +supersede path, not the updater — see the tray's "Old core vX running — Restart +into vY" item, and `mcpproxy status` (`Launched by:`) to see who started it. + +**An RC was offered on a stable install.** Check `update_check.channel` and +`MCPPROXY_ALLOW_PRERELEASE_UPDATES`; `GET /api/v1/info` reports the effective +value under `update_policy.channel`. diff --git a/docs/features/version-updates.md b/docs/features/version-updates.md index 9576fe2f..35fa11c7 100644 --- a/docs/features/version-updates.md +++ b/docs/features/version-updates.md @@ -11,6 +11,12 @@ keywords: [updates, version, upgrade, notifications] MCPProxy includes built-in update checking to help you stay current with the latest features and security fixes. +:::tip Installing the update +This page covers how MCPProxy *notices* a new version. For actually applying +one — the macOS one-click updater, `mcpproxy update`, the per-channel behaviour +matrix and the kill switches — see [Auto-Update](/features/auto-update). +::: + ## How It Works MCPProxy automatically checks for new versions by querying GitHub Releases: From 3a824630f504e5f5e8b4cd72f4116f4fa088356e Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Sat, 8 Aug 2026 07:18:45 +0300 Subject: [PATCH 20/37] docs: list Auto-Update in the sidebar and the second env-var surface Related #957 Two follow-ups to the auto-update doc: - `website/sidebars.js` is an EXPLICIT sidebar, not filesystem-generated, so a new page under docs/features/ is built and routed but invisible in navigation until it is listed. Added next to Version Updates. - `docs/configuration/environment-variables.md` is a second env-var surface (alongside the table in `docs/configuration.md`); its Auto-Update section never mentioned the CI rule and had no pointer to the updater itself. Both surfaces now say the same thing and link to the same page. --- docs/configuration/environment-variables.md | 10 +++++++++- website/sidebars.js | 1 + 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/docs/configuration/environment-variables.md b/docs/configuration/environment-variables.md index 64622722..6eece120 100644 --- a/docs/configuration/environment-variables.md +++ b/docs/configuration/environment-variables.md @@ -121,10 +121,18 @@ The tray application doesn't read the config file directly. It launches the core | `MCPPROXY_ALLOW_PRERELEASE_UPDATES` | Allow prerelease/beta version updates (core + tray) | `false` | | `MCPPROXY_UPDATE_APP_BUNDLE` | Enable app bundle updates (macOS tray) | `false` | +`CI=true` (or `CI=1`) additionally suppresses every update nudge and the tray's +unattended checks — a non-interactive run has nobody to nudge. Machine-readable +fields keep reporting the facts, and a user-initiated "Check for Updates" still +runs. + Update checking can also be controlled from the config file via the `update_check` block (`enabled`, `channel`) — see [Version Updates](/features/version-updates). When both are set, the -environment variables **win** over the config keys. +environment variables **win** over the config keys. The resolved answer is +published to the macOS tray as `update_policy` in `GET /api/v1/info`; the +one-click updater, the per-channel behaviour matrix and the release +infrastructure are documented in [Auto-Update](/features/auto-update). ### Setting Tray Variables on macOS diff --git a/website/sidebars.js b/website/sidebars.js index 774e4d4c..b571fedd 100644 --- a/website/sidebars.js +++ b/website/sidebars.js @@ -74,6 +74,7 @@ const sidebars = { 'features/tool-scanner', 'features/search-discovery', 'features/version-updates', + 'features/auto-update', ], }, { From d5c98c9f1ce9dad77d1c11c4513adde14f6803a1 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Sat, 8 Aug 2026 07:56:27 +0300 Subject: [PATCH 21/37] fix(ci): give the sparkle-appcast jobs a repo for gh to upload to MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Related #957 Both new `sparkle-appcast` jobs deliberately skip `actions/checkout` — they only need the enclosure artifacts. But `gh` resolves the target repository from the current directory's git remote, and it does NOT read `GITHUB_REPOSITORY`; the only env var it consults is `GH_REPO`. So the final `gh release upload "${GITHUB_REF_NAME}" appcast-out/*.xml` would have failed with "could not determine what repo to use" on the first real tag — after generating and signing the feeds, so the failure would land at the very last step of a release. Every other `gh release upload` in both workflows lives in a job that ran `actions/checkout`, which is why this is the only pair that needed it. ## Changes - `GH_REPO: ${{ github.repository }}` in the appcast step's env, in release.yml and prerelease.yml, with a note explaining why a checkout is not the fix. ## Testing - Both workflows re-parse with `yaml.safe_load`; asserted the resolved env block of `jobs.sparkle-appcast` now carries GH_REPO in each file. --- .github/workflows/prerelease.yml | 4 ++++ .github/workflows/release.yml | 7 +++++++ 2 files changed, 11 insertions(+) diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml index 7cea3c5f..40354809 100644 --- a/.github/workflows/prerelease.yml +++ b/.github/workflows/prerelease.yml @@ -1059,6 +1059,10 @@ jobs: - name: Generate and publish the beta appcast env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # No actions/checkout in this job, so `gh` cannot infer the repository + # from a git remote — and it does not read GITHUB_REPOSITORY. See the + # same note in release.yml's sparkle-appcast job. + GH_REPO: ${{ github.repository }} SPARKLE_ED_PRIVATE_KEY: ${{ secrets.SPARKLE_ED_PRIVATE_KEY }} # Keep in step with Package.resolved (Sparkle 2.9.3). SPARKLE_VERSION: "2.9.3" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1d176693..30090cf6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1588,6 +1588,13 @@ jobs: - name: Generate and publish the appcast env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # This job deliberately does not check the repo out (it only needs the + # enclosure artifacts), so `gh` has no git remote to infer the + # repository from — it does NOT read GITHUB_REPOSITORY. Without + # GH_REPO the final `gh release upload` fails with "could not + # determine what repo to use". Every other `gh release upload` in this + # workflow lives in a job that ran actions/checkout. + GH_REPO: ${{ github.repository }} SPARKLE_ED_PRIVATE_KEY: ${{ secrets.SPARKLE_ED_PRIVATE_KEY }} # Keep in step with Package.resolved (Sparkle 2.9.3). SPARKLE_VERSION: "2.9.3" From 04b357a0c41295ea7e4e2a3147e97233744bd1ba Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Sat, 8 Aug 2026 07:56:37 +0300 Subject: [PATCH 22/37] feat(cli): warn when --self asserts ownership of an unknown install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Related #957 FR-020 allows `--self` to turn the guidance-only `unknown` channel into a self-update, but attaches "with a clear warning" to that override — and the override shipped without one. The requirement is not cosmetic: `unknown` is precisely the set of installs mcpproxy could NOT identify, which includes AUR, MacPorts and Nix-like layouts that are writable and still owned by a package manager whose bookkeeping the swap silently invalidates. A user who types --self after reading the guidance line deserves to be told what they just claimed. A positively identified tarball install stays silent — there is nothing uncertain about it to warn over — and the warning goes to stderr so `-o json` stays machine-parseable. ## Changes - `updateRunner.run`: emit the warning on the self-update branch only when the effective channel is `unknown`. ## Testing - New `TestUpdateCommand_SelfOverrideWarnsOnUnknownChannel`: 3 cases (unknown+--self warns, tarball silent, tarball+--self silent), each also asserting the self-update branch really ran and that stdout's JSON report never contains the warning. - `go test -race ./cmd/mcpproxy/... ./internal/updatecheck/... ./internal/httpapi/...` → 3/3 packages ok. --- cmd/mcpproxy/update_cmd.go | 13 ++++++++++ cmd/mcpproxy/update_cmd_test.go | 46 +++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/cmd/mcpproxy/update_cmd.go b/cmd/mcpproxy/update_cmd.go index 47286fee..c7c26eee 100644 --- a/cmd/mcpproxy/update_cmd.go +++ b/cmd/mcpproxy/update_cmd.go @@ -335,6 +335,19 @@ func (r *updateRunner) run() error { switch action { case actionSelfUpdate: + // FR-020: --self is the user asserting ownership of a binary mcpproxy + // could NOT positively identify. Say plainly what that assertion means + // before acting on it — an AUR/MacPorts/Nix-style layout is writable + // and still owned by a package manager whose bookkeeping this would + // silently invalidate. Positively identified tarball installs get no + // warning; there is nothing uncertain about them. + if channel == updatecheck.ChannelUnknown { + fmt.Fprintf(r.errOut, + "WARNING: --self: mcpproxy could not identify how %s was installed and is replacing it "+ + "because you asserted you manage it yourself. If a package manager owns this path, "+ + "its bookkeeping will no longer match what is on disk.\n", + r.execPath) + } apply := r.selfUpdateFn if apply == nil { apply = r.selfUpdate diff --git a/cmd/mcpproxy/update_cmd_test.go b/cmd/mcpproxy/update_cmd_test.go index 8ad97f68..c1dc29c7 100644 --- a/cmd/mcpproxy/update_cmd_test.go +++ b/cmd/mcpproxy/update_cmd_test.go @@ -344,6 +344,52 @@ func TestUpdateCommand_RefusesAppBundlePaths(t *testing.T) { } } +// FR-020: `--self` lets a user assert ownership of an install mcpproxy could +// not identify, and the requirement attaches "with a clear warning" to that +// override. The warning goes to stderr so it never contaminates `-o json`. +// A positively identified tarball install is not an assertion and must stay +// silent. +func TestUpdateCommand_SelfOverrideWarnsOnUnknownChannel(t *testing.T) { + tests := []struct { + name string + channel string + self bool + wantWarn bool + }{ + {name: "unknown with --self warns", channel: updatecheck.ChannelUnknown, self: true, wantWarn: true}, + {name: "tarball does not warn", channel: updatecheck.ChannelTarball, wantWarn: false}, + {name: "tarball with --self does not warn", channel: updatecheck.ChannelTarball, self: true, wantWarn: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + src := &fakeReleaseSource{latest: fixtureRelease("v0.60.0", false)} + runner, out, errOut := newTestRunner(t, tt.channel, updateFlags{self: tt.self}, src) + applied := false + runner.selfUpdateFn = func(_ *updatecheck.GitHubRelease) error { + applied = true + return nil + } + + if err := runner.run(); err != nil { + t.Fatalf("run() error = %v", err) + } + if !applied { + t.Fatalf("expected the self-update branch to run for channel %q (self=%v)", tt.channel, tt.self) + } + + warned := strings.Contains(errOut.String(), "WARNING: --self") + if warned != tt.wantWarn { + t.Errorf("stderr warning = %v, want %v (stderr: %q)", warned, tt.wantWarn, errOut.String()) + } + // The warning must never leak into the machine-readable report. + if strings.Contains(out.String(), "WARNING") { + t.Errorf("the JSON report must stay free of the warning: %s", out.String()) + } + }) + } +} + // Even if the decision table were bypassed, selfUpdate itself refuses bundle // paths (defence in depth for FR-022). func TestSelfUpdate_RefusesBundlePathDirectly(t *testing.T) { From 6a209482cbcb593a3a4f4319c2c792ced7ba9a52 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Sat, 8 Aug 2026 07:56:51 +0300 Subject: [PATCH 23/37] docs(update): correct the tarball channel comments and the dev-bundle plist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Related #957 Two leftovers from the tarball channel becoming command-carrying (FR-020). `UpdateBanner.vue` still listed `tarball` among the channels with no safe update command, in both template comments and the guidance switch. The rendered output was already correct — every branch keys off the presence of `update_command`, never the channel name — so this is comment-only and changes no markup, no class, and no computed result. `build-macos-tray.sh`'s from-scratch Info.plist (the branch that runs only when the source Info.plist is missing) emitted `SUFeedURL` with no `SUPublicEDKey`. A feed URL with no pinned key is the one bundle shape FR-011 must never produce; the checked-in plist and `build-swift-app.sh` both carry the placeholder, and this branch should not be the one that diverges. ## Changes - `frontend/src/components/UpdateBanner.vue`: comments only. - `native/macos/MCPProxy/scripts/build-macos-tray.sh`: add the `SUPublicEDKey` placeholder to the fallback plist. ## Testing - `bash -n` + `shellcheck -S warning` on build-macos-tray.sh: clean apart from the pre-existing SC2155 at line 25. - Scanned every HTML comment in UpdateBanner.vue for an embedded `--` (which would break the comment) — none. --- frontend/src/components/UpdateBanner.vue | 13 ++++++++++--- native/macos/MCPProxy/scripts/build-macos-tray.sh | 9 +++++++++ 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/UpdateBanner.vue b/frontend/src/components/UpdateBanner.vue index 1d451da6..94f48882 100644 --- a/frontend/src/components/UpdateBanner.vue +++ b/frontend/src/components/UpdateBanner.vue @@ -23,7 +23,11 @@ >Release notes + command (dmg/windows-installer/docker/unknown). Spec 092 FR-020 + added `tarball` to the CHANNELS WITH a command (`mcpproxy update`), + so it is no longer in that list. Nothing here needed changing: the + branch keys off the presence of update_command, never the channel + name. -->
@@ -115,7 +119,10 @@ const guidance = computed(() => { return 'Pull or rebuild the newer image for your deployment.' case '': return '' - default: // tarball, unknown, prerelease-suppressed command channels, … + // Spec 092: `tarball` carries `mcpproxy update` for stable AND prerelease + // offers alike, so it never reaches this switch — the `updateCommand` + // guard above returns first. Listing it here would be misleading. + default: // unknown, prerelease-suppressed command channels, … return 'Download the latest release from the releases page.' } }) diff --git a/native/macos/MCPProxy/scripts/build-macos-tray.sh b/native/macos/MCPProxy/scripts/build-macos-tray.sh index d35e3332..cf210730 100755 --- a/native/macos/MCPProxy/scripts/build-macos-tray.sh +++ b/native/macos/MCPProxy/scripts/build-macos-tray.sh @@ -378,6 +378,15 @@ else public.app-category.utilities SUFeedURL https://mcpproxy.app/appcast.xml + + SUPublicEDKey + SPARKLE_PUBLIC_KEY_PLACEHOLDER SUEnableAutomaticChecks SUScheduledCheckInterval From 20caffafae1d2f16a26c27263d55b1d63218a3da Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Sat, 8 Aug 2026 08:13:15 +0300 Subject: [PATCH 24/37] fix(cli): make the self-update swap crash-recoverable and its version check exact MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Related #957 Two defects in the mechanical half of `mcpproxy update --self` (FR-021). The pair of renames that swaps the binary is not atomic, and the retry path made an interrupted swap unrecoverable: it removed a leftover `.old` before ever looking at the target, so a crash in the window where the target path is empty and `.old` holds the only copy of the binary was turned into a destroyed install by the next attempt. The post-swap probe matched the reported version as a substring, so a binary announcing 0.54.10 satisfied a request for 0.54.1 — the wrong release would have passed verification and had its predecessor deleted. ## Changes - `restoreInterruptedSwap` runs first: target missing + backup present moves the backup back; target missing with no backup is a refusal, not a silent install over nothing. A leftover backup is only removed once the target is known to be present. - `reportsVersion` compares whole whitespace-delimited tokens (tolerating a `v` prefix and surrounding punctuation) instead of a substring. ## Testing - go test ./cmd/mcpproxy/ — interrupted-swap recovery, interrupted swap with no staged binary, missing target and backup, 0.54.10 vs 0.54.1 --- cmd/mcpproxy/update_apply.go | 85 ++++++++++++++++++- cmd/mcpproxy/update_apply_test.go | 131 ++++++++++++++++++++++++++++++ 2 files changed, 213 insertions(+), 3 deletions(-) diff --git a/cmd/mcpproxy/update_apply.go b/cmd/mcpproxy/update_apply.go index 2fa4ed58..66f6ebd5 100644 --- a/cmd/mcpproxy/update_apply.go +++ b/cmd/mcpproxy/update_apply.go @@ -204,7 +204,19 @@ func ensureTargetWritable(target string) error { // binary runs. The previous binary is restored on any failure and only removed // once verification passed. Callers must pass an already-resolved (symlink // free) target so a symlinked launcher keeps pointing at the file we replace. +// +// The two renames are individually atomic but the pair is not, so there is a +// window in which the target path is empty and target.old holds the ONLY copy +// of the binary. A crash in that window must be survivable: the first thing +// this function does is recover from it (restoreInterruptedSwap), and a +// leftover backup is never removed while the target is absent. func applyNewBinary(target, staged string, verify func(path string) error) (err error) { + backup := target + ".old" + + if recoverErr := restoreInterruptedSwap(target, backup); recoverErr != nil { + return recoverErr + } + mode := os.FileMode(0o755) if fi, statErr := os.Stat(target); statErr == nil { mode = fi.Mode().Perm() @@ -213,8 +225,8 @@ func applyNewBinary(target, staged string, verify func(path string) error) (err return fmt.Errorf("preserve file mode %o: %w", mode, chmodErr) } - backup := target + ".old" - // A leftover .old from an interrupted run must not block the rename. + // The target is present (restoreInterruptedSwap guarantees it), so a + // leftover .old is genuinely expendable and must not block the rename. _ = os.Remove(backup) if renameErr := os.Rename(target, backup); renameErr != nil { @@ -252,6 +264,53 @@ func applyNewBinary(target, staged string, verify func(path string) error) (err return nil } +// restoreInterruptedSwap puts the world back together after a crash between +// the two renames in applyNewBinary. +// +// The dangerous state is "target missing, backup present": the backup is then +// the only copy of the binary, and removing it — which the swap used to do +// before it had even looked at the target — destroys the install. So the +// backup is moved back FIRST, and a missing target with no backup is an error +// rather than something to plough on through. +func restoreInterruptedSwap(target, backup string) error { + targetExists, err := regularFileExists(target) + if err != nil { + return fmt.Errorf("inspect %s: %w", target, err) + } + if targetExists { + return nil + } + + backupExists, err := regularFileExists(backup) + if err != nil { + return fmt.Errorf("inspect %s: %w", backup, err) + } + if !backupExists { + return fmt.Errorf("%s does not exist and there is no %s to restore from; "+ + "reinstall mcpproxy rather than letting an update invent a binary", target, backup) + } + + if renameErr := os.Rename(backup, target); renameErr != nil { + return fmt.Errorf("a previous update was interrupted: %s is the only copy of the binary "+ + "and it could not be moved back to %s: %w", backup, target, renameErr) + } + fmt.Fprintf(os.Stderr, + "note: a previous update left %s missing; restored it from %s before continuing\n", + target, backup) + return nil +} + +func regularFileExists(path string) (bool, error) { + fi, err := os.Lstat(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return false, nil + } + return false, err + } + return fi.Mode().IsRegular(), nil +} + // verifyInstalledVersion runs ` --version` and requires the output to // mention wantVersion. FR-021: "success" is the new binary executing and // reporting the expected version, not merely a rename returning nil. @@ -266,8 +325,28 @@ func verifyInstalledVersion(path, wantVersion string) error { filepath.Base(path), err, strings.TrimSpace(string(out))) } got := strings.TrimSpace(string(out)) - if !strings.Contains(got, strings.TrimPrefix(wantVersion, "v")) { + if !reportsVersion(got, wantVersion) { return fmt.Errorf("installed binary reports %q, expected version %s", got, wantVersion) } return nil } + +// reportsVersion reports whether output announces exactly wantVersion. +// +// Whole tokens, not a substring: `mcpproxy --version` prints +// "MCPProxy 0.54.10 (personal) darwin/arm64", and a substring test would let +// that output satisfy a request for 0.54.1 — an update that silently installed +// the wrong release would pass verification. +func reportsVersion(output, wantVersion string) bool { + want := strings.TrimPrefix(strings.TrimSpace(wantVersion), "v") + if want == "" { + return false + } + for _, field := range strings.Fields(output) { + token := strings.TrimPrefix(strings.Trim(field, "(),;\"'"), "v") + if token == want { + return true + } + } + return false +} diff --git a/cmd/mcpproxy/update_apply_test.go b/cmd/mcpproxy/update_apply_test.go index 4bc3eda3..b2696108 100644 --- a/cmd/mcpproxy/update_apply_test.go +++ b/cmd/mcpproxy/update_apply_test.go @@ -229,6 +229,95 @@ func TestApplyNewBinary_OverwritesStaleBackup(t *testing.T) { } } +// The pair of renames in applyNewBinary is not atomic: a crash between them +// leaves the target path empty with target.old holding the only copy of the +// binary. A retry must put it back, not delete it (FR-021). +func TestApplyNewBinary_RecoversFromAnInterruptedSwap(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "mcpproxy") + staged := filepath.Join(dir, ".mcpproxy.new") + + // The exact on-disk state after a crash between the two renames. + if err := os.WriteFile(target+".old", []byte("old"), 0o750); err != nil { + t.Fatalf("write backup: %v", err) + } + if err := os.WriteFile(staged, []byte("new"), 0o600); err != nil { + t.Fatalf("write staged: %v", err) + } + + if err := applyNewBinary(target, staged, func(path string) error { + content, readErr := os.ReadFile(path) + if readErr != nil { + return readErr + } + if string(content) != "new" { + t.Errorf("verification saw %q, want the new binary", string(content)) + } + return nil + }); err != nil { + t.Fatalf("applyNewBinary: %v", err) + } + + got, err := os.ReadFile(target) + if err != nil { + t.Fatalf("target must exist: %v", err) + } + if string(got) != "new" { + t.Errorf("target content = %q, want new", string(got)) + } + fi, err := os.Stat(target) + if err != nil { + t.Fatalf("stat: %v", err) + } + if fi.Mode().Perm() != 0o750 { + t.Errorf("mode = %o, want the recovered binary's 0750", fi.Mode().Perm()) + } + if _, err := os.Stat(target + ".old"); !os.IsNotExist(err) { + t.Errorf("backup must be gone once the new binary verified") + } +} + +// The same interrupted state, but this attempt cannot proceed either. The +// previous binary must survive: it is the only one there is. +func TestApplyNewBinary_InterruptedSwapKeepsTheOnlyBinary(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "mcpproxy") + staged := filepath.Join(dir, ".mcpproxy.new") // deliberately never created + + if err := os.WriteFile(target+".old", []byte("old"), 0o755); err != nil { + t.Fatalf("write backup: %v", err) + } + + if err := applyNewBinary(target, staged, nil); err == nil { + t.Fatal("expected an error: there is no staged binary to install") + } + + got, err := os.ReadFile(target) + if err != nil { + t.Fatalf("the previous binary must have been restored: %v", err) + } + if string(got) != "old" { + t.Errorf("target content = %q, want the restored old binary", string(got)) + } +} + +func TestApplyNewBinary_MissingTargetAndBackupIsAnError(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "mcpproxy") + staged := filepath.Join(dir, ".mcpproxy.new") + if err := os.WriteFile(staged, []byte("new"), 0o600); err != nil { + t.Fatalf("write staged: %v", err) + } + + err := applyNewBinary(target, staged, nil) + if err == nil || !strings.Contains(err.Error(), "does not exist") { + t.Fatalf("error = %v, want a refusal to install over nothing", err) + } + if _, statErr := os.Stat(target); !os.IsNotExist(statErr) { + t.Error("nothing may be installed where there was no binary to update") + } +} + func TestEnsureTargetWritable(t *testing.T) { dir := t.TempDir() target := filepath.Join(dir, "mcpproxy") @@ -274,3 +363,45 @@ func TestVerifyInstalledVersion(t *testing.T) { t.Error("a binary that cannot run must fail verification") } } + +// A substring test would accept 0.54.10 as proof that 0.54.1 was installed. +func TestVerifyInstalledVersion_RejectsAVersionPrefix(t *testing.T) { + requirePOSIXShell(t) + + dir := t.TempDir() + bin := filepath.Join(dir, "fake") + if err := os.WriteFile(bin, + []byte("#!/bin/sh\necho \"MCPProxy 0.54.10 (personal) darwin/arm64\"\n"), 0o755); err != nil { + t.Fatalf("write: %v", err) + } + + if err := verifyInstalledVersion(bin, "v0.54.1"); err == nil { + t.Error("0.54.10 must not satisfy a request for 0.54.1") + } + if err := verifyInstalledVersion(bin, "v0.54.10"); err != nil { + t.Errorf("the exact version should verify: %v", err) + } +} + +func TestReportsVersion(t *testing.T) { + const line = "MCPProxy 0.54.10 (personal) darwin/arm64" + + for _, tc := range []struct { + output, want string + match bool + }{ + {line, "v0.54.10", true}, + {line, "0.54.10", true}, + {line, "v0.54.1", false}, + {line, "0.54.100", false}, + {line, "", false}, + {"MCPProxy v1.0.0-rc.2 (personal)", "v1.0.0-rc.2", true}, + {"MCPProxy v1.0.0-rc.2 (personal)", "v1.0.0-rc.20", false}, + // Parentheses around a bare version must not defeat the token match. + {"mcpproxy (1.2.3)", "v1.2.3", true}, + } { + if got := reportsVersion(tc.output, tc.want); got != tc.match { + t.Errorf("reportsVersion(%q, %q) = %v, want %v", tc.output, tc.want, got, tc.match) + } + } +} From 75fa63d7f40863566c1793109a3be7a3a25158a7 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Sat, 8 Aug 2026 08:22:05 +0300 Subject: [PATCH 25/37] =?UTF-8?q?fix(tray):=20honour=20SemVer=20=C2=A79=20?= =?UTF-8?q?and=20compare=20unbounded=20numeric=20identifiers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Related #957 Two gaps in the comparison FR-006 makes every supersede and update decision depend on. Leading zeros in a numeric prerelease identifier were accepted, so "rc.01" and "rc.1" were two spellings of one version and one of the two orderings they produced had to be wrong. §9 forbids them; FR-006 says a malformed version is a no-decision, not a guess. Numeric identifiers were coerced through `Int`, which returns nil past Int64. Two identifiers that both overflowed fell through to the alphanumeric branch and were compared as ASCII, ordering "10000000000000000000000" below "9999999999999999999999" — a downgrade offered as an update. ## Changes - Prerelease identifiers are validated with `numericMayHaveLeadingZeros: false`; build metadata keeps the §10 exemption - `compareNumericIdentifiers` compares digit strings by length then lexicographically, with no upper bound ## Testing - swift test — leading-zero rejection, a lone "0" still valid, build metadata unaffected, 22- and 23-digit identifiers both directions --- .../MCPProxy/Core/SemanticVersion.swift | 50 ++++++++++++++----- .../MCPProxyTests/SemanticVersionTests.swift | 43 ++++++++++++++++ 2 files changed, 80 insertions(+), 13 deletions(-) diff --git a/native/macos/MCPProxy/MCPProxy/Core/SemanticVersion.swift b/native/macos/MCPProxy/MCPProxy/Core/SemanticVersion.swift index 0e437c22..40123424 100644 --- a/native/macos/MCPProxy/MCPProxy/Core/SemanticVersion.swift +++ b/native/macos/MCPProxy/MCPProxy/Core/SemanticVersion.swift @@ -92,7 +92,11 @@ struct SemanticVersion: Equatable, Comparable, CustomStringConvertible { var prerelease: [String] = [] if let prereleaseText { - guard isValidDotSeparatedIdentifiers(prereleaseText, numericMayHaveLeadingZeros: true) else { + // §9 bans leading zeros in a NUMERIC prerelease identifier, and the + // ban is load-bearing here: "rc.01" and "rc.1" would otherwise be + // two spellings of one version, and whichever way they compared, + // one of them would be wrong. Malformed means no decision (FR-006). + guard isValidDotSeparatedIdentifiers(prereleaseText, numericMayHaveLeadingZeros: false) else { return nil } prerelease = prereleaseText.split(separator: ".").map(String.init) @@ -103,9 +107,9 @@ struct SemanticVersion: Equatable, Comparable, CustomStringConvertible { } /// Every identifier must be non-empty and made of ASCII alphanumerics and - /// hyphens. `numericMayHaveLeadingZeros` relaxes §9's ban on `01`, which no - /// producer in this project emits and which would only ever turn a - /// comparable version into an incomparable one. + /// hyphens. `numericMayHaveLeadingZeros` is true only for build metadata, + /// where §10 places no such restriction and precedence does not depend on + /// it anyway; prerelease identifiers are held to §9. private static func isValidDotSeparatedIdentifiers( _ text: String, numericMayHaveLeadingZeros: Bool ) -> Bool { @@ -152,20 +156,18 @@ struct SemanticVersion: Equatable, Comparable, CustomStringConvertible { let b = rhs.prerelease[index] if a == b { continue } - let aNumber = Int(a).flatMap { a.allSatisfy(\.isNumber) ? $0 : nil } - let bNumber = Int(b).flatMap { b.allSatisfy(\.isNumber) ? $0 : nil } - - switch (aNumber, bNumber) { - case let (.some(x), .some(y)): + switch (isNumericIdentifier(a), isNumericIdentifier(b)) { + case (true, true): // §11.4.1 — NUMERICALLY. This is the rc.10 vs rc.2 case: as // strings "10" < "2", as numbers 10 > 2. - if x != y { return x < y ? -1 : 1 } - case (.some, .none): + let order = compareNumericIdentifiers(a, b) + if order != 0 { return order } + case (true, false): // §11.4.3: numeric identifiers always have lower precedence. return -1 - case (.none, .some): + case (false, true): return 1 - case (.none, .none): + case (false, false): // §11.4.2: ASCII sort order. return a < b ? -1 : 1 } @@ -179,6 +181,28 @@ struct SemanticVersion: Equatable, Comparable, CustomStringConvertible { return 0 } + /// A §9 numeric identifier: ASCII digits only. + private static func isNumericIdentifier(_ identifier: String) -> Bool { + !identifier.isEmpty && identifier.allSatisfy { $0.isASCII && $0.isNumber } + } + + /// Compare two numeric identifiers of ARBITRARY length. + /// + /// Not `Int(_:)`: a prerelease identifier is a digit string with no upper + /// bound, and coercing it to a bounded integer either overflows to nil (so + /// two huge identifiers fall through to an ASCII comparison that orders + /// "10000000000000000000" below "9") or silently truncates. Leading zeros + /// are rejected at parse time, so length-then-lexicographic IS the numeric + /// order; they are stripped here anyway so the function is correct on its + /// own terms. + private static func compareNumericIdentifiers(_ lhs: String, _ rhs: String) -> Int { + let a = Substring(lhs).drop(while: { $0 == "0" }) + let b = Substring(rhs).drop(while: { $0 == "0" }) + if a.count != b.count { return a.count < b.count ? -1 : 1 } + if a == b { return 0 } + return a.lexicographicallyPrecedes(b) ? -1 : 1 + } + /// Compare two version STRINGS. Returns nil when either side is not a /// version — the "no decision, log a reason" case FR-006 requires. Every /// caller must handle nil explicitly rather than defaulting it to `0`, diff --git a/native/macos/MCPProxy/MCPProxyTests/SemanticVersionTests.swift b/native/macos/MCPProxy/MCPProxyTests/SemanticVersionTests.swift index 1fb5c1e8..1a543633 100644 --- a/native/macos/MCPProxy/MCPProxyTests/SemanticVersionTests.swift +++ b/native/macos/MCPProxy/MCPProxyTests/SemanticVersionTests.swift @@ -120,6 +120,49 @@ final class SemanticVersionTests: XCTestCase { } } + // MARK: - §9: numeric prerelease identifiers may not have leading zeros + + func testLeadingZeroNumericPrereleaseIdentifiersAreMalformed() { + // Accepting "rc.01" would make it a second spelling of "rc.1", and + // whichever way the two compared, one answer would be wrong. FR-006 + // says malformed means no decision, so parsing must refuse. + for raw in ["1.0.0-rc.01", "1.0.0-01", "1.0.0-0123", "1.0.0-rc.1.007"] { + XCTAssertNil(SemanticVersion.parse(raw), "\(raw) is not a SemVer version") + XCTAssertNil(SemanticVersion.compare(raw, "1.0.0-rc.1"), + "\(raw) must produce no decision, not a guess") + } + } + + func testASingleZeroIdentifierIsStillValid() { + // §9 bans leading zeros, not the number zero. + XCTAssertNotNil(SemanticVersion.parse("1.0.0-rc.0")) + XCTAssertEqual(SemanticVersion.compare("1.0.0-rc.0", "1.0.0-rc.1"), -1) + // Alphanumeric identifiers are unaffected: "0abc" is not numeric. + XCTAssertNotNil(SemanticVersion.parse("1.0.0-0abc")) + // Build metadata is exempt (§10) and never affects precedence. + XCTAssertEqual(SemanticVersion.compare("1.0.0+007", "1.0.0+8"), 0) + } + + // MARK: - §11.4.1: numeric identifiers are unbounded + + func testHugeNumericIdentifiersCompareNumerically() { + // Beyond Int64. Coercing to a bounded integer overflows to nil, and the + // fallback ASCII comparison would order "10000000000000000000000" below + // "9999999999999999999999" — a downgrade offered as an update. + XCTAssertEqual( + SemanticVersion.compare("1.0.0-rc.9999999999999999999999", + "1.0.0-rc.10000000000000000000000"), -1) + XCTAssertEqual( + SemanticVersion.compare("1.0.0-rc.10000000000000000000000", + "1.0.0-rc.9999999999999999999999"), 1) + XCTAssertEqual( + SemanticVersion.compare("1.0.0-rc.99999999999999999999999", + "1.0.0-rc.99999999999999999999999"), 0) + // A huge numeric identifier still ranks below an alphanumeric one. + XCTAssertEqual( + SemanticVersion.compare("1.0.0-99999999999999999999999", "1.0.0-alpha"), -1) + } + // MARK: - Comparable conformance agrees with compare() func testComparableConformanceMatchesCompare() { From 95809cf1813ba3fb601a9a8bb855d30b93476070 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Sat, 8 Aug 2026 08:22:05 +0300 Subject: [PATCH 26/37] fix(tray): let exactly one source own the update menu item MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Related #957 FR-017 allows one update item at a time. When the feed offered a version and the legacy GitHub check advertised a NEWER one, the resolver rendered both: a one-click item and a browser item, side by side, asking the user to choose between an install the tray can perform and a download it cannot verify. The feed now owns the slot whenever it has an offer, whatever the legacy check found. What the feed offers is real and installable in one click, and the next scheduled check picks up the newer version. The legacy result is only rendered when the feed has nothing. ## Changes - Collapse the two-source switch to "feed if present, else legacy guidance" - Drop the now-unreachable comparison branches ## Testing - swift test — the full feed × legacy × blocked matrix asserts at most one offer and that the feed owns it --- .../MCPProxy/Services/UpdateMenuState.swift | 60 +++++++------------ .../MCPProxyTests/UpdateMenuStateTests.swift | 36 ++++++++++- 2 files changed, 54 insertions(+), 42 deletions(-) diff --git a/native/macos/MCPProxy/MCPProxy/Services/UpdateMenuState.swift b/native/macos/MCPProxy/MCPProxy/Services/UpdateMenuState.swift index 94cac58e..ecdf55d4 100644 --- a/native/macos/MCPProxy/MCPProxy/Services/UpdateMenuState.swift +++ b/native/macos/MCPProxy/MCPProxy/Services/UpdateMenuState.swift @@ -12,13 +12,17 @@ // download page — and a feed that lagged behind GitHub could offer a one-click // install of an older version than the browser item advertised. // -// The rules, from FR-017: -// · feed offer present → the feed owns the item; the legacy result must not -// surface a competing nudge for the SAME OR LOWER version; -// · legacy result only (feed unreachable, or the feed does not carry that -// version) → present as browser-download guidance, never as a one-click -// action it cannot perform; -// · equal versions from both sources → exactly one item. +// The rules, from FR-017 — "EXACTLY ONE source of truth owns the update menu +// item at any time": +// · feed offer present → the feed owns the slot, whatever the legacy check +// says. Not just for the same-or-lower version: rendering a one-click item +// AND a browser item because GitHub is a release ahead is two items, which +// is the thing the requirement forbids, and it asks the user to choose +// between an install they can do and a download they cannot verify; +// · legacy result only (feed unreachable, or the feed carries nothing) → +// browser-download guidance, never a one-click action it cannot perform; +// · nothing installable in place (FR-016) → the feed's offer degrades to +// guidance, still one item. // // Pure, so the whole matrix is a table test. @@ -73,39 +77,17 @@ enum UpdateMenuState { // the browser path the blocked message already points at. let canInstall = blocked == nil - switch (feedVersion, legacyVersion) { - case (nil, nil): - break - - case let (.some(feed), nil): - entries.append(canInstall ? .oneClick(version: feed) : .browserGuidance(version: feed)) - - case let (nil, .some(legacy)): + if let feed = feedVersion { + // The feed owns the slot whenever it has an offer — higher, lower + // or incomparable to what the legacy check found. A feed that lags + // behind GitHub still installs something real in one click, and the + // next scheduled check picks the newer version up; two competing + // items would not. + entries.append(canInstall + ? .oneClick(version: feed) + : .browserGuidance(version: feed)) + } else if let legacy = legacyVersion { entries.append(.browserGuidance(version: legacy)) - - case let (.some(feed), .some(legacy)): - // Same version from both sources → one item, owned by the feed. - // A legacy version that is merely LOWER is also swallowed: the feed - // already offers at least as much. - let order = SemanticVersion.compare(legacy, feed) - if let order, order > 0 { - // The feed is behind what GitHub publishes. The one-click item - // stays (it is real and installable), and the newer version is - // offered as guidance — never as a one-click action the feed - // cannot perform. - if canInstall { entries.append(.oneClick(version: feed)) } - entries.append(.browserGuidance(version: legacy)) - } else if order == nil, normalized(legacy) != normalized(feed) { - // Incomparable and not textually identical: say nothing clever. - // Prefer the source that can actually install. - entries.append(canInstall - ? .oneClick(version: feed) - : .browserGuidance(version: feed)) - } else { - entries.append(canInstall - ? .oneClick(version: feed) - : .browserGuidance(version: feed)) - } } return entries diff --git a/native/macos/MCPProxy/MCPProxyTests/UpdateMenuStateTests.swift b/native/macos/MCPProxy/MCPProxyTests/UpdateMenuStateTests.swift index b41d9121..fdaae483 100644 --- a/native/macos/MCPProxy/MCPProxyTests/UpdateMenuStateTests.swift +++ b/native/macos/MCPProxy/MCPProxyTests/UpdateMenuStateTests.swift @@ -64,11 +64,41 @@ final class UpdateMenuStateTests: XCTestCase { "FR-017: no competing nudge for the same or lower version") } - func testANewerLegacyVersionIsOfferedAlongsideTheInstallableOne() { + func testANewerLegacyVersionStillDoesNotEarnASecondItem() { // The feed lags behind GitHub (the appcast job has not run yet). Both - // facts are true and neither may masquerade as the other. + // facts are true, but FR-017 allows exactly one item and the feed owns + // it: what it offers is real and installable in one click, and the next + // check picks the newer version up. XCTAssertEqual(entries(feed: "0.55.0", legacy: "0.56.0"), - [.oneClick(version: "0.55.0"), .browserGuidance(version: "0.56.0")]) + [.oneClick(version: "0.55.0")]) + } + + func testIncomparableVersionsStillProduceASingleFeedOwnedItem() { + XCTAssertEqual(entries(feed: "0.55.0", legacy: "nightly-build"), + [.oneClick(version: "0.55.0")]) + } + + /// The whole resolver matrix, asserting the invariant rather than each + /// cell: at most one offer, and it is the feed's whenever the feed has one. + func testTheResolverNeverRendersTwoOffers() { + let versions: [String?] = [nil, "0.54.1", "0.55.0", "0.56.0", "0.55.0-rc.10", "garbage"] + for feed in versions { + for legacy in versions { + for blocked in [nil, blockedFixture] { + let result = entries(feed: feed, legacy: legacy, blocked: blocked) + let offers = result.filter { if case .blocked = $0 { return false } else { return true } } + XCTAssertLessThanOrEqual(offers.count, 1, + "feed=\(feed ?? "nil") legacy=\(legacy ?? "nil") " + + "blocked=\(blocked != nil) produced \(result)") + if let feed, let only = offers.first { + let expected: UpdateMenuEntry = blocked == nil + ? .oneClick(version: feed) + : .browserGuidance(version: feed) + XCTAssertEqual(only, expected, "the feed must own the slot") + } + } + } + } } func testPrereleaseOrderingUsesSemVerPrecedence() { From 6914bb664a688518dffc1ef13ba1b0c2c30b7137 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Sat, 8 Aug 2026 08:22:30 +0300 Subject: [PATCH 27/37] fix(tray): make the update pipeline refuse to act on what it has not confirmed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Related #957 Five interlocking defects in the tray half of the updater. They share one shape: something was assumed — a policy, a feed name, a dead process — where nothing had been established. **The launch check ran before the policy arrived.** `/api/v1/info` carries the core version and the update policy, and Combine delivers `@Published` subscribers from `willSet` — so assigning `version` first ran the launch check under the previous (permissive-by-default) policy, checking for updates for a user who had switched them off. The default is now restrictive and the two assignments are ordered. A core that predates 092 is a different case and stays permissive: the tray stamps `CoreUpdatePolicy.legacyDefault` at the point it has actually talked to one, which is what keeps "an old core said nothing" apart from "we have not asked yet" (FR-015). **RC clients read the stable feed.** `prerelease.yml` publishes `appcast-beta-.xml`; the rewrite only ever produced `appcast-.xml`. An RC user was pointed at a feed that by FR-014's design never carries an RC, so the beta channel could not offer anything at all. **SIGKILL was reported as success.** `ManagedCoreStop` returned `.killed` the instant it sent the signal. Sparkle used that answer to decide the bundle could be replaced, so a core that outlived SIGKILL kept serving from a deleted inode — issue #957 exactly. The stop now confirms the exit, and a stop that ends in a question mark POSTPONES the installation through `shouldPostponeRelaunchForUpdate:untilInvokingBlock:` (verified against Sparkle 2.9.3: it runs at the top of `installWithToolAndRelaunch:`, before the installer is contacted) rather than being logged and ignored. **SIGKILL trusted a five-second-old identity check.** Both kill ladders proved the pid was an mcpproxy process, waited out the SIGTERM grace period, then killed whatever held the pid by then. The identity is re-proven immediately before the signal. **One click was two clicks with a download in between.** `automatically- DownloadsUpdates` now follows the same switch as scheduled checks, so the bytes are verified and on disk before the menu item is touched. The residual Sparkle confirmation cannot be removed through the public API — `SPUUpdater` has no "install what you are holding" method — so the honest count is one click on our item plus one on Sparkle's install prompt, and that is written down where the next reader will look. ## Changes - `EffectiveUpdatePolicy.awaitingCore`; `CoreUpdatePolicy.legacyDefault` - `coreUpdatePolicy` assigned before `version` in `connectToCore` - `SparkleFeedURL.archSpecific(_:arch:channel:)` + `fileName(channel:arch:)` - `ManagedCoreStopOutcome.failed` and `.coreIsDown`; bounded post-SIGKILL wait - `feedUpdaterWillInstallUpdate()` returns whether the core is down; Sparkle postpones when it is not - Identity re-check before SIGKILL in `ManagedCoreStop` and `stopCore` - `automaticallyDownloadsUpdates`, `updateIsReadyToInstall` ## Testing - swift test — 87 tests over the policy precedence, both channels × both arches, the post-SIGKILL confirmation, pid recycling across the grace period, and the install veto --- .../MCPProxy/Core/CoreProcessManager.swift | 25 ++++- .../macos/MCPProxy/MCPProxy/MCPProxyApp.swift | 5 +- .../MCPProxy/Services/FeedUpdater.swift | 97 ++++++++++++++++--- .../MCPProxy/Services/ManagedCoreStop.swift | 67 +++++++++++-- .../MCPProxy/Services/SparkleFeedURL.swift | 26 ++++- .../MCPProxy/Services/UpdatePolicy.swift | 42 ++++++-- .../MCPProxy/Services/UpdateService.swift | 47 +++++++-- .../MCPProxyTests/ManagedCoreStopTests.swift | 46 ++++++++- .../MCPProxyTests/SparkleFeedURLTests.swift | 27 ++++++ .../MCPProxyTests/UpdatePolicyTests.swift | 27 +++++- .../UpdateServiceFeedTests.swift | 63 +++++++++++- 11 files changed, 414 insertions(+), 58 deletions(-) diff --git a/native/macos/MCPProxy/MCPProxy/Core/CoreProcessManager.swift b/native/macos/MCPProxy/MCPProxy/Core/CoreProcessManager.swift index 30105e25..e564afc7 100644 --- a/native/macos/MCPProxy/MCPProxy/Core/CoreProcessManager.swift +++ b/native/macos/MCPProxy/MCPProxy/Core/CoreProcessManager.swift @@ -1196,6 +1196,14 @@ actor CoreProcessManager { } if await waitFor(seconds: 5.0, until: { !CoreProcessIdentity.isRunning(pid: pid) }) == false { + // Five seconds have passed since the check above. A core that + // exited during them can have had its pid recycled, and SIGKILL is + // the one rung of this ladder the recipient cannot survive — so the + // identity is proven again immediately before it, not inherited. + guard CoreProcessIdentity.isMCPProxyCore(pid: pid) else { + NSLog("[MCPProxy] PID %d is no longer an mcpproxy process — not sending SIGKILL", pid) + return false + } NSLog("[MCPProxy] PID %d ignored SIGTERM — SIGKILL", pid) _ = kill(pid, SIGKILL) guard await waitFor(seconds: 3.0, until: { !CoreProcessIdentity.isRunning(pid: pid) }) else { @@ -1550,12 +1558,23 @@ actor CoreProcessManager { } await MainActor.run { - appState.version = info.version - appState.webUIBaseURL = webUIBase // Spec 092 FR-015: the explicit policy contract. Assigned on every // connect (including reconnects), which is how a config hot-reload // on the core side reaches the tray. - appState.coreUpdatePolicy = info.updatePolicy + // + // A pre-092 core reports no `update_policy`; that is a different + // fact from "no core has answered yet", and only this line can tell + // them apart. Stamping the legacy default here is what keeps those + // cores on their old behaviour while the tray stays quiet until it + // has actually heard from one. + // + // BEFORE `version`, and that order is load-bearing: @Published + // fires its subscribers synchronously from willSet, and the launch + // update check hangs off `$version`. Assigning version first ran + // that check under whatever policy preceded this response. + appState.coreUpdatePolicy = info.updatePolicy ?? .legacyDefault + appState.version = info.version + appState.webUIBaseURL = webUIBase if let update = info.update, update.available, let latest = update.latestVersion { appState.updateAvailable = latest.hasPrefix("v") ? String(latest.dropFirst()) : latest } diff --git a/native/macos/MCPProxy/MCPProxy/MCPProxyApp.swift b/native/macos/MCPProxy/MCPProxy/MCPProxyApp.swift index 0f89e83a..19868717 100644 --- a/native/macos/MCPProxy/MCPProxy/MCPProxyApp.swift +++ b/native/macos/MCPProxy/MCPProxy/MCPProxyApp.swift @@ -273,13 +273,16 @@ final class AppController: NSObject, NSApplicationDelegate, NSWindowDelegate, NS // it manages. Installed before the feed updater starts, because a // Sparkle session resumed from a previous launch can reach the install // hook almost immediately. + // The Bool it returns is not advisory: `false` makes Sparkle postpone + // the installation rather than replace the bundle under a live core. updateService.stopManagedCore = { [weak self] in - guard let self else { return } + guard let self else { return true } let pid = self.coreManager?.managedProcess?.processIdentifier let outcome = ManagedCoreStop.stop(pid: pid) NSLog("[MCPProxy] Pre-update core stop: pid=%d outcome=%@", pid ?? -1, String(describing: outcome)) AppLifecycle.shared.note("pre-update core stop: \(outcome)") + return outcome.coreIsDown } // Spec 092 FR-015: every tray-side check is governed by the policy the diff --git a/native/macos/MCPProxy/MCPProxy/Services/FeedUpdater.swift b/native/macos/MCPProxy/MCPProxy/Services/FeedUpdater.swift index 6f0c229e..a270c4c1 100644 --- a/native/macos/MCPProxy/MCPProxy/Services/FeedUpdater.swift +++ b/native/macos/MCPProxy/MCPProxy/Services/FeedUpdater.swift @@ -47,6 +47,17 @@ protocol FeedUpdating: AnyObject { /// Run a check. `userInitiated` bypasses the policy's automatic-check gate /// and lets Sparkle show its own progress UI. func check(userInitiated: Bool) + + /// Whether an update is already downloaded and waiting to be installed, so + /// activating the menu item costs a confirmation rather than a download + /// (FR-010). See `SparkleFeedUpdater.updateIsReadyToInstall`. + var updateIsReadyToInstall: Bool { get } +} + +extension FeedUpdating { + /// Most implementations (the test stub, the no-Sparkle fallback) never + /// pre-download anything. + var updateIsReadyToInstall: Bool { false } } /// Callbacks from the updater. Delivered on the main thread. @@ -63,7 +74,12 @@ protocol FeedUpdaterObserver: AnyObject { /// FR-012: the bundle is about to be replaced. MUST stop the tray-managed /// core before returning — this call is synchronous and the installer runs /// as soon as it comes back. - func feedUpdaterWillInstallUpdate() + /// + /// Returns whether the core is CONFIRMED down. `false` means the swap must + /// not go ahead: a core still executing from the bundle that is about to be + /// replaced is issue #957 itself. + @discardableResult + func feedUpdaterWillInstallUpdate() -> Bool } // MARK: - Sparkle implementation @@ -96,6 +112,26 @@ final class SparkleFeedUpdater: NSObject, FeedUpdating { /// installed. private(set) var offeredVersion: String? + /// Whether Sparkle has the update downloaded and is only waiting to be told + /// to install it. + /// + /// FR-010 asks for one click doing download → verify → swap → relaunch. + /// Sparkle's PUBLIC API cannot deliver a literal single click on top of + /// `SPUStandardUserDriver`: there is no "install the update you are holding" + /// method on `SPUUpdater` (see SPUUpdater.h — the only entry points are the + /// three check methods), so the menu item has to resume the session with + /// `checkForUpdates()`, and the standard driver then shows its confirmation. + /// + /// What we can do is make sure that confirmation is the LAST step rather + /// than the first: with `automaticallyDownloadsUpdates` on, the scheduled + /// check downloads and verifies in the background, so activating the menu + /// item lands directly on "Install and Relaunch" with nothing left to wait + /// for. Honest click count: ONE on our menu item, plus ONE on Sparkle's + /// install confirmation. Removing the second one needs a custom + /// `SPUUserDriver` implementation, which replaces every piece of update UI + /// Sparkle ships and is out of scope here. + private(set) var updateIsReadyToInstall: Bool = false + var isAvailable: Bool { controller != nil } init(observer: FeedUpdaterObserver?) { @@ -152,6 +188,12 @@ final class SparkleFeedUpdater: NSObject, FeedUpdating { // FR-015: the kill switch governs the SCHEDULED cycle. `check(userInitiated:)` // deliberately does not consult it. updater.automaticallyChecksForUpdates = policy.automaticChecksAllowed + // FR-010: pre-download so the one click that follows is an install and + // not a download. Tied to the same switch — an install that downloads + // ~40 MB unasked is exactly what the kill switch is for. Sparkle only + // honours this where the host allows automatic updates; when it does + // not, the flow degrades to download-on-click, which is what it was. + updater.automaticallyDownloadsUpdates = policy.automaticChecksAllowed && updater.allowsAutomaticUpdates // Changing the channel set mid-session needs a cycle reset for the next // scheduled check to use it (`allowedChannelsForUpdater:` is consulted // per check, but the schedule is not). @@ -181,8 +223,11 @@ extension SparkleFeedUpdater: SPUUpdaterDelegate { func feedURLString(for updater: SPUUpdater) -> String? { guard let configured = hostBundle.object(forInfoDictionaryKey: "SUFeedURL") as? String, !configured.isEmpty else { return nil } + // The channel is part of the file name, not only of the item tags: the + // two pipelines publish two sets of files (FR-014), so an RC client + // that asked for the stable feed would be told there is no update. let resolved = SparkleFeedURL.archSpecific( - configured, arch: UpdateService.hostArchToken() + configured, arch: UpdateService.hostArchToken(), channel: policy.channel ) return resolved == configured ? nil : resolved } @@ -202,18 +247,43 @@ extension SparkleFeedUpdater: SPUUpdaterDelegate { func updaterDidNotFindUpdate(_ updater: SPUUpdater) { offeredVersion = nil + updateIsReadyToInstall = false onMain { [weak self] in self?.observer?.feedUpdaterDidNotFindUpdate() } } - /// FR-012 — the hook that matters. + /// FR-012 — the hook that matters, and the only one that can say no. + /// + /// Despite the name, Sparkle calls this at the TOP of + /// `-[SPUInstallerDriver installWithToolAndRelaunch:displayingUserInterface:]`, + /// before the installer is contacted and therefore before the bundle is + /// touched (verified against Sparkle 2.9.3's SPUInstallerDriver.m). That + /// makes it the last point at which the update can still be called off, + /// which `willInstallUpdate:` — a `void` notification — cannot do. /// - /// "Called immediately before installing the specified update": the last - /// point at which the old bundle is still the one on disk. Synchronous, so - /// the core is down before the swap rather than racing it. Chosen over - /// `shouldPostponeRelaunchForUpdate:` (too late — the bundle is already - /// replaced) and over `updaterWillRelaunchApplication:` (also too late, - /// though it is kept below as a belt-and-braces second stop, which is - /// harmless because the stop is idempotent). + /// Returning `true` without ever invoking `installHandler` leaves the + /// update downloaded and uninstalled: the menu item stays, the running + /// version keeps working, and the next attempt starts from a clean state. + /// That is the right answer when the core will not die, because replacing + /// the bundle under a live core is issue #957 happening again. + func updater( + _ updater: SPUUpdater, + shouldPostponeRelaunchForUpdate item: SUAppcastItem, + untilInvokingBlock installHandler: @escaping () -> Void + ) -> Bool { + guard let observer else { return false } + if observer.feedUpdaterWillInstallUpdate() { + return false // core is down; let Sparkle carry straight on + } + observer.feedUpdater(didFailWith: + "The update was not installed: the MCPProxy core is still running and could not " + + "be stopped. Quit it and try again.") + NSLog("[MCPProxy] Postponing the update: the managed core could not be confirmed stopped") + return true + } + + /// Belt and braces for the paths that never reach the postpone hook + /// (install-on-quit, a resumed session that already postponed once). The + /// stop is idempotent, and neither of these can refuse anything. func updater(_ updater: SPUUpdater, willInstallUpdate item: SUAppcastItem) { observer?.feedUpdaterWillInstallUpdate() } @@ -265,13 +335,16 @@ extension SparkleFeedUpdater: SPUStandardUserDriverDelegate { false } - /// Sparkle tells us whether IT will show the update. When it will not, the - /// menu item is the only thing the user will ever see, so publish it. + /// Sparkle tells us whether IT will show the update, and at what stage the + /// update session is. The stage is what makes FR-010's click cheap: at + /// `.downloaded` or `.installing` the bytes are already on disk and + /// verified, so resuming the session goes straight to the install prompt. func standardUserDriverWillHandleShowingUpdate( _ handleShowingUpdate: Bool, forUpdate update: SUAppcastItem, state: SPUUserUpdateState ) { + updateIsReadyToInstall = state.stage == .downloaded || state.stage == .installing guard !handleShowingUpdate else { return } let version = update.displayVersionString offeredVersion = version diff --git a/native/macos/MCPProxy/MCPProxy/Services/ManagedCoreStop.swift b/native/macos/MCPProxy/MCPProxy/Services/ManagedCoreStop.swift index a5766b0f..6b0dfd48 100644 --- a/native/macos/MCPProxy/MCPProxy/Services/ManagedCoreStop.swift +++ b/native/macos/MCPProxy/MCPProxy/Services/ManagedCoreStop.swift @@ -26,11 +26,30 @@ enum ManagedCoreStopOutcome: Equatable { case notRunning /// SIGTERM was enough. case terminated - /// SIGTERM was ignored for the whole grace period; SIGKILL was sent. + /// SIGTERM was ignored for the whole grace period; SIGKILL was sent AND the + /// process was then confirmed gone. case killed /// The pid does not (any longer) belong to an mcpproxy process. Nothing was /// signalled — see the type header. case refused + /// The process was still there after SIGKILL and the confirmation wait. The + /// caller MUST NOT let the bundle be replaced under it. + case failed + + /// Whether the core is CONFIRMED down — the only basis on which the bundle + /// may be replaced. + /// + /// `refused` is not confirmation. It means the pid is alive but could not + /// be identified as an mcpproxy process, and `CoreProcessIdentity` cannot + /// tell "the pid was recycled by something unrelated" apart from "the + /// process belongs to a user whose executable path we may not read". A stop + /// that ends in a question mark is a stop that failed. + var coreIsDown: Bool { + switch self { + case .notRunning, .terminated, .killed: return true + case .refused, .failed: return false + } + } } enum ManagedCoreStop { @@ -43,6 +62,15 @@ enum ManagedCoreStop { /// the main thread inside a Sparkle callback, so it cannot grow. static let defaultGracePeriod: TimeInterval = 5.0 + /// How long to wait for the process to actually disappear after SIGKILL. + /// + /// SIGKILL is not instantaneous — the kernel still has to tear the process + /// down, and a core blocked in an uninterruptible syscall can outlive the + /// signal. Returning "killed" the microsecond after sending it is a claim + /// nobody checked, and the caller uses that claim to decide whether the app + /// bundle may be replaced. + static let defaultKillGracePeriod: TimeInterval = 2.0 + /// Poll interval while waiting for the process to disappear. static let pollInterval: TimeInterval = 0.05 @@ -54,6 +82,7 @@ enum ManagedCoreStop { static func stop( pid: Int32?, gracePeriod: TimeInterval = defaultGracePeriod, + killGracePeriod: TimeInterval = defaultKillGracePeriod, isCore: (Int32) -> Bool = CoreProcessIdentity.isMCPProxyCore, isRunning: (Int32) -> Bool = CoreProcessIdentity.isRunning, send: (Int32, Int32) -> Void = { pid, sig in _ = kill(pid, sig) }, @@ -68,19 +97,39 @@ enum ManagedCoreStop { send(pid, SIGTERM) - var waited: TimeInterval = 0 - while waited < gracePeriod { - if !isRunning(pid) { return .terminated } - wait(pollInterval) - waited += pollInterval + if waitForExit(pid: pid, budget: gracePeriod, isRunning: isRunning, wait: wait) { + return .terminated } - if !isRunning(pid) { return .terminated } - // Still there. The bundle is about to be replaced underneath it; a core // running from a deleted inode is the #957 failure mode this whole spec // exists to end, so it does not get to survive the swap. + // + // The identity is re-checked HERE and not only at the top: the grace + // period is seconds long, and a core that exited during it can have had + // its pid handed to something else. SIGTERM to a stranger is rude; + // SIGKILL to a stranger is unrecoverable. + guard isCore(pid) else { return .refused } + send(pid, SIGKILL) - return .killed + return waitForExit(pid: pid, budget: killGracePeriod, isRunning: isRunning, wait: wait) + ? .killed + : .failed + } + + /// Poll until `pid` is gone or the budget runs out. Returns whether it went. + private static func waitForExit( + pid: Int32, + budget: TimeInterval, + isRunning: (Int32) -> Bool, + wait: (TimeInterval) -> Void + ) -> Bool { + var waited: TimeInterval = 0 + while waited < budget { + if !isRunning(pid) { return true } + wait(pollInterval) + waited += pollInterval + } + return !isRunning(pid) } } diff --git a/native/macos/MCPProxy/MCPProxy/Services/SparkleFeedURL.swift b/native/macos/MCPProxy/MCPProxy/Services/SparkleFeedURL.swift index 77f1823f..77520ea0 100644 --- a/native/macos/MCPProxy/MCPProxy/Services/SparkleFeedURL.swift +++ b/native/macos/MCPProxy/MCPProxy/Services/SparkleFeedURL.swift @@ -22,10 +22,22 @@ import Foundation enum SparkleFeedURL { /// Base name the release pipeline suffixes: `appcast.xml` → - /// `appcast-arm64.xml` / `appcast-amd64.xml`. + /// `appcast-arm64.xml` / `appcast-amd64.xml`, and on the RC channel + /// `appcast-beta-arm64.xml` / `appcast-beta-amd64.xml`. static let defaultFeedFileName = "appcast.xml" - /// Rewrite a feed URL to the architecture-specific one. + /// File name `.github/workflows/release.yml` and `prerelease.yml` generate + /// for a given channel and architecture. The two pipelines are separate + /// and write separate files; naming them in one function is what keeps the + /// tray and CI from drifting apart silently. + static func fileName(channel: UpdateChannel, arch: String) -> String { + switch channel { + case .stable: return "appcast-\(arch).xml" + case .rc: return "appcast-beta-\(arch).xml" + } + } + + /// Rewrite a feed URL to the one for this channel and architecture. /// /// Only the exact default file name is rewritten. An operator who points /// `SUFeedURL` at something else has said what they want, and silently @@ -34,12 +46,18 @@ enum SparkleFeedURL { /// - Parameters: /// - feedURL: the URL from Info.plist (`SUFeedURL`). /// - arch: `"arm64"` or `"amd64"` (see `UpdateService.hostArchToken`). - static func archSpecific(_ feedURL: String, arch: String) -> String { + /// - channel: the effective update channel. RC clients must land on the + /// prerelease pipeline's `appcast-beta-*` files: the stable feed never + /// carries an RC (that is FR-014's whole point), so an RC user pointed + /// at it would simply never be offered one. + static func archSpecific(_ feedURL: String, arch: String, channel: UpdateChannel = .stable) -> String { guard var components = URLComponents(string: feedURL) else { return feedURL } let last = (components.path as NSString).lastPathComponent guard last == defaultFeedFileName else { return feedURL } let directory = (components.path as NSString).deletingLastPathComponent - components.path = (directory as NSString).appendingPathComponent("appcast-\(arch).xml") + components.path = (directory as NSString).appendingPathComponent( + fileName(channel: channel, arch: arch) + ) return components.string ?? feedURL } } diff --git a/native/macos/MCPProxy/MCPProxy/Services/UpdatePolicy.swift b/native/macos/MCPProxy/MCPProxy/Services/UpdatePolicy.swift index 9a52ccaa..6d9501e9 100644 --- a/native/macos/MCPProxy/MCPProxy/Services/UpdatePolicy.swift +++ b/native/macos/MCPProxy/MCPProxy/Services/UpdatePolicy.swift @@ -63,6 +63,18 @@ struct CoreUpdatePolicy: Codable, Equatable { case channel case nudgesSuppressed = "nudges_suppressed" } + + /// What a core that predates Spec 092 means when it reports no + /// `update_policy` at all: the behaviour those builds already had. + /// + /// Stamped by the tray at the moment it has actually TALKED to such a core + /// (`CoreProcessManager.connectToCore`), which is the only place that can + /// tell "an old core said nothing" apart from "we have not asked yet". + /// Without that distinction the absent field and the absent core look the + /// same, and FR-015's "not inferred from missing data" is unenforceable. + static let legacyDefault = CoreUpdatePolicy( + enabled: true, channel: "stable", nudgesSuppressed: false + ) } // MARK: - The resolved answer @@ -85,16 +97,29 @@ struct EffectiveUpdatePolicy: Equatable { /// they are on. let disabledReason: String - /// The policy in force before a core has been reached, and for cores older - /// than Spec 092 that report no `update_policy` at all. Permissive, because - /// that is the behaviour those builds already had — silently disabling - /// updates on a version skew would be a worse failure than checking once. + /// Automatic checks on, nothing suppressed, stable channel. static let permissive = EffectiveUpdatePolicy( automaticChecksAllowed: true, nudgesSuppressed: false, channel: .stable, disabledReason: "" ) + + /// The policy in force before any core has been reached. + /// + /// Restrictive on purpose. The tray publishes the core's version and the + /// core's update policy from the same `/api/v1/info` response, and Combine + /// delivers the version first — so a permissive default let the launch + /// check fire on the OLD policy, checking for updates for a user who had + /// switched them off. Nothing unattended runs until the contract arrives; + /// a user-initiated "Check for Updates" is unaffected (FR-015), and the + /// wait is one round trip to a socket on the same machine. + static let awaitingCore = EffectiveUpdatePolicy( + automaticChecksAllowed: false, + nudgesSuppressed: false, + channel: .stable, + disabledReason: "no core has reported its update policy yet" + ) } // MARK: - Resolution @@ -146,12 +171,15 @@ enum UpdatePolicyResolver { } guard let core else { - // No core yet, or a pre-092 core. Keep the previous behaviour. + // Nobody has told us anything yet. Not "permissive by default" — + // see EffectiveUpdatePolicy.awaitingCore. A core that predates + // Spec 092 is NOT this case: the tray stamps + // CoreUpdatePolicy.legacyDefault for it on connect. return EffectiveUpdatePolicy( - automaticChecksAllowed: true, + automaticChecksAllowed: false, nudgesSuppressed: false, channel: channel, - disabledReason: "" + disabledReason: EffectiveUpdatePolicy.awaitingCore.disabledReason ) } diff --git a/native/macos/MCPProxy/MCPProxy/Services/UpdateService.swift b/native/macos/MCPProxy/MCPProxy/Services/UpdateService.swift index 38e7c912..728e81a6 100644 --- a/native/macos/MCPProxy/MCPProxy/Services/UpdateService.swift +++ b/native/macos/MCPProxy/MCPProxy/Services/UpdateService.swift @@ -60,9 +60,10 @@ final class UpdateService: ObservableObject { private var feedUpdater: (any FeedUpdating)? /// Stops the tray-managed core synchronously before the bundle is replaced - /// (FR-012). Set by the app delegate, which is the only thing that knows - /// the managed process. Nil in tests and before the core starts. - var stopManagedCore: (() -> Void)? + /// (FR-012), returning whether it is CONFIRMED down. Set by the app + /// delegate, which is the only thing that knows the managed process. Nil in + /// tests and before the core starts. + var stopManagedCore: (() -> Bool)? /// GitHub API endpoint for latest release. private let githubReleaseURL = "https://api.github.com/repos/smart-mcp-proxy/mcpproxy-go/releases/latest" @@ -175,14 +176,30 @@ final class UpdateService: ObservableObject { } } - /// FR-010: activating the one-click item. Sparkle already has the update in - /// hand from the gentle-reminder check; `check(userInitiated:)` surfaces its - /// own UI and drives download → verify → replace → relaunch. + /// FR-010: activating the one-click item. + /// + /// `check(userInitiated:)` RESUMES the session Sparkle already has open for + /// the update it found during the gentle-reminder check, and drives + /// download → verify → replace → relaunch from wherever that session got to. + /// + /// Click count, honestly: one here, plus one on Sparkle's own confirmation. + /// `SPUUpdater`'s public surface has no "install the update you are already + /// holding" method (only the three check entry points), so the standard + /// user driver's prompt cannot be skipped without writing a bespoke + /// `SPUUserDriver` — a replacement for every piece of update UI Sparkle + /// ships. What the pre-download in `SparkleFeedUpdater.apply(policy:)` buys + /// is that the second click installs immediately instead of starting a + /// download: see `updateIsReadyToInstall`. func installFeedUpdate() { guard let feedUpdater, feedUpdater.isAvailable else { openDownloadPage() return } + AppLifecycle.shared.note( + feedUpdater.updateIsReadyToInstall + ? "installing the pre-downloaded update" + : "resuming the update session (nothing downloaded yet)" + ) feedUpdater.check(userInitiated: true) } @@ -378,10 +395,20 @@ extension UpdateService: FeedUpdaterObserver { } } - /// FR-012. Synchronous by contract: Sparkle replaces the bundle the moment - /// this returns, so the core has to already be down. - func feedUpdaterWillInstallUpdate() { + /// FR-012. Synchronous by contract: Sparkle proceeds with the install the + /// moment this returns, so the core has to already be down. + /// + /// No core to stop is a stopped core: the tray runs without one, and an + /// update must not be blocked by the absence of the thing it was going to + /// shut down. + @discardableResult + func feedUpdaterWillInstallUpdate() -> Bool { AppLifecycle.shared.note("stopping the managed core before the update is installed") - stopManagedCore?() + guard let stopManagedCore else { return true } + let stopped = stopManagedCore() + if !stopped { + AppLifecycle.shared.note("the managed core could not be stopped — update postponed") + } + return stopped } } diff --git a/native/macos/MCPProxy/MCPProxyTests/ManagedCoreStopTests.swift b/native/macos/MCPProxy/MCPProxyTests/ManagedCoreStopTests.swift index 0e66d514..5e008526 100644 --- a/native/macos/MCPProxy/MCPProxyTests/ManagedCoreStopTests.swift +++ b/native/macos/MCPProxy/MCPProxyTests/ManagedCoreStopTests.swift @@ -25,12 +25,14 @@ final class ManagedCoreStopTests: XCTestCase { pid: Int32?, process: FakeProcess, isCore: Bool = true, + identityChecks: (() -> Bool)? = nil, + survivesSIGKILL: Bool = false, gracePeriod: TimeInterval = ManagedCoreStop.defaultGracePeriod ) -> ManagedCoreStopOutcome { ManagedCoreStop.stop( pid: pid, gracePeriod: gracePeriod, - isCore: { _ in isCore }, + isCore: { _ in identityChecks?() ?? isCore }, isRunning: { _ in if process.received.contains(SIGTERM) { process.polls += 1 @@ -40,7 +42,7 @@ final class ManagedCoreStopTests: XCTestCase { }, send: { _, sig in process.received.append(sig) - if sig == SIGKILL { process.alive = false } + if sig == SIGKILL && !survivesSIGKILL { process.alive = false } }, wait: { _ in } // no real sleeping in tests ) @@ -103,4 +105,44 @@ final class ManagedCoreStopTests: XCTestCase { let proc = FakeProcess(diesOnSIGTERMAfter: 1_000_000) XCTAssertEqual(stop(pid: 4242, process: proc, gracePeriod: 0), .killed) } + + // MARK: - The exit after SIGKILL is confirmed, not assumed + + func testAProcessThatOutlivesSIGKILLReportsFailureRatherThanSuccess() { + // SIGKILL is not instantaneous, and the caller uses this answer to + // decide whether the app bundle may be replaced. Claiming "killed" + // without looking is how a live core ends up running from a deleted + // inode — issue #957 itself. + let proc = FakeProcess(diesOnSIGTERMAfter: 1_000_000) + let outcome = stop(pid: 4242, process: proc, survivesSIGKILL: true) + XCTAssertEqual(outcome, .failed) + XCTAssertEqual(proc.received, [SIGTERM, SIGKILL]) + XCTAssertFalse(outcome.coreIsDown, "the install must not proceed") + } + + func testOnlyAConfirmedStopClearsTheBundleSwap() { + XCTAssertTrue(ManagedCoreStopOutcome.notRunning.coreIsDown) + XCTAssertTrue(ManagedCoreStopOutcome.terminated.coreIsDown) + XCTAssertTrue(ManagedCoreStopOutcome.killed.coreIsDown) + XCTAssertFalse(ManagedCoreStopOutcome.failed.coreIsDown) + XCTAssertFalse(ManagedCoreStopOutcome.refused.coreIsDown, + "an unidentifiable pid is a stop that ended in a question mark") + } + + // MARK: - PID reuse across the grace period + + func testIdentityIsProvenAgainImmediatelyBeforeSIGKILL() { + // The core exits during the (seconds-long) SIGTERM grace period and its + // pid is handed to something else. The second identity check is the + // only thing standing between that stranger and a SIGKILL. + let proc = FakeProcess(diesOnSIGTERMAfter: 1_000_000) + var checks = 0 + let outcome = stop(pid: 4242, process: proc, identityChecks: { + checks += 1 + return checks == 1 // ours at the start, someone else's by SIGKILL time + }) + XCTAssertEqual(outcome, .refused) + XCTAssertEqual(proc.received, [SIGTERM], "SIGKILL must not reach a recycled pid") + XCTAssertEqual(checks, 2, "the identity must be re-proven, not inherited") + } } diff --git a/native/macos/MCPProxy/MCPProxyTests/SparkleFeedURLTests.swift b/native/macos/MCPProxy/MCPProxyTests/SparkleFeedURLTests.swift index 8365502d..fb5e8813 100644 --- a/native/macos/MCPProxy/MCPProxyTests/SparkleFeedURLTests.swift +++ b/native/macos/MCPProxy/MCPProxyTests/SparkleFeedURLTests.swift @@ -23,6 +23,33 @@ final class SparkleFeedURLTests: XCTestCase { ) } + /// Both channels × both architectures. The RC row is the one that was + /// wrong: `prerelease.yml` publishes `appcast-beta-.xml`, so an RC + /// client asking for `appcast-.xml` reads the stable feed — which by + /// FR-014's design never carries an RC — and is never offered anything. + func testTheChannelSelectsTheFeedFile() { + let cases: [(UpdateChannel, String, String)] = [ + (.stable, "arm64", "https://mcpproxy.app/appcast-arm64.xml"), + (.stable, "amd64", "https://mcpproxy.app/appcast-amd64.xml"), + (.rc, "arm64", "https://mcpproxy.app/appcast-beta-arm64.xml"), + (.rc, "amd64", "https://mcpproxy.app/appcast-beta-amd64.xml") + ] + for (channel, arch, expected) in cases { + XCTAssertEqual( + SparkleFeedURL.archSpecific("https://mcpproxy.app/appcast.xml", + arch: arch, channel: channel), + expected, + "\(channel) / \(arch)" + ) + } + } + + /// The file names are half a contract with the two release workflows. + func testFileNamesMatchWhatTheWorkflowsGenerate() { + XCTAssertEqual(SparkleFeedURL.fileName(channel: .stable, arch: "arm64"), "appcast-arm64.xml") + XCTAssertEqual(SparkleFeedURL.fileName(channel: .rc, arch: "amd64"), "appcast-beta-amd64.xml") + } + func testNestedPathsKeepTheirDirectory() { XCTAssertEqual( SparkleFeedURL.archSpecific( diff --git a/native/macos/MCPProxy/MCPProxyTests/UpdatePolicyTests.swift b/native/macos/MCPProxy/MCPProxyTests/UpdatePolicyTests.swift index 78647118..e7be4af7 100644 --- a/native/macos/MCPProxy/MCPProxyTests/UpdatePolicyTests.swift +++ b/native/macos/MCPProxy/MCPProxyTests/UpdatePolicyTests.swift @@ -20,11 +20,27 @@ final class UpdatePolicyTests: XCTestCase { // MARK: - No core yet - func testNoCorePolicyKeepsThePreviousPermissiveBehaviour() { + func testNoCorePolicyBlocksUnattendedChecksUntilOneArrives() { let policy = resolve() + XCTAssertFalse(policy.automaticChecksAllowed, + "FR-015: the policy is a contract, not an inference from missing " + + "data. Checking before it arrives is checking under a policy the " + + "user may have switched off") + XCTAssertFalse(policy.disabledReason.isEmpty, "the wait must be loggable") + XCTAssertFalse(policy.nudgesSuppressed, + "nothing has been found yet, so there is nothing to suppress") + XCTAssertEqual(policy.channel, .stable) + XCTAssertEqual(policy, EffectiveUpdatePolicy.awaitingCore) + } + + func testAPre092CoreKeepsItsPreviousPermissiveBehaviour() { + // The tray stamps this the moment it reaches a core that reports no + // update_policy, which is how "an old core said nothing" stays + // distinguishable from "we have not asked yet". + let policy = resolve(core: .legacyDefault) XCTAssertTrue(policy.automaticChecksAllowed, - "a core that predates 092 reports nothing; disabling updates on a " - + "version skew would be a worse failure than checking once") + "disabling updates on a version skew would be a worse failure than " + + "checking once") XCTAssertFalse(policy.nudgesSuppressed) XCTAssertEqual(policy.channel, .stable) } @@ -54,7 +70,8 @@ final class UpdatePolicyTests: XCTestCase { // The core compares against exactly "true"; the tray must not be more // eager than the process it mirrors. for value in ["1", "yes", "TRUE ", ""] { - let policy = resolve(env: ["MCPPROXY_DISABLE_AUTO_UPDATE": value]) + let policy = resolve(core: .legacyDefault, + env: ["MCPPROXY_DISABLE_AUTO_UPDATE": value]) XCTAssertTrue(policy.automaticChecksAllowed, "\"\(value)\" must not disable updates") } @@ -73,7 +90,7 @@ final class UpdatePolicyTests: XCTestCase { } func testNonCIValuesDoNotSuppress() { - let policy = resolve(env: ["CI": "false"]) + let policy = resolve(core: .legacyDefault, env: ["CI": "false"]) XCTAssertTrue(policy.automaticChecksAllowed) XCTAssertFalse(policy.nudgesSuppressed) } diff --git a/native/macos/MCPProxy/MCPProxyTests/UpdateServiceFeedTests.swift b/native/macos/MCPProxy/MCPProxyTests/UpdateServiceFeedTests.swift index ae59bcc6..1db4cf48 100644 --- a/native/macos/MCPProxy/MCPProxyTests/UpdateServiceFeedTests.swift +++ b/native/macos/MCPProxy/MCPProxyTests/UpdateServiceFeedTests.swift @@ -21,6 +21,12 @@ final class StubFeedUpdater: FeedUpdating { func apply(policy: EffectiveUpdatePolicy) { appliedPolicies.append(policy) } func check(userInitiated: Bool) { checks.append(userInitiated) } + + /// Forget the setup traffic so a test asserts only what it provoked. + func resetRecordings() { + appliedPolicies.removeAll() + checks.removeAll() + } } @MainActor @@ -36,10 +42,13 @@ final class UpdateServiceFeedTests: XCTestCase { legacyCounter = LegacyCheckCounter() } + /// A service whose core policy has already arrived, which is the state + /// every test below except the FR-015 startup ones is about. private func makeService( env: [String: String] = [:], bundlePath: String = "/Applications/MCPProxy.app", - feed: StubFeedUpdater? = StubFeedUpdater() + feed: StubFeedUpdater? = StubFeedUpdater(), + corePolicy: CoreUpdatePolicy? = .legacyDefault ) -> (UpdateService, StubFeedUpdater?) { let counter = legacyCounter let service = UpdateService( @@ -48,9 +57,44 @@ final class UpdateServiceFeedTests: XCTestCase { feedUpdater: feed, legacyCheck: { counter.count += 1 } ) + if let corePolicy { + service.applyCorePolicy(corePolicy) + feed?.resetRecordings() + } return (service, feed) } + // MARK: - FR-015: nothing unattended before the policy arrives + + func testNoUnattendedCheckRunsBeforeTheCoreReportsItsPolicy() { + let (service, feed) = makeService(corePolicy: nil) + service.checkForUpdatesInBackground() + XCTAssertEqual(feed?.checks, [], + "the launch check rides on the core's version, which Combine " + + "delivers before the policy — checking here checks under a " + + "policy the user may have switched off") + XCTAssertEqual(legacyCounter.count, 0) + XCTAssertFalse(service.policy.automaticChecksAllowed) + } + + func testAUserInitiatedCheckStillWorksBeforeThePolicyArrives() { + let (service, feed) = makeService(corePolicy: nil) + service.checkForUpdates() + XCTAssertEqual(feed?.checks, [true], + "FR-015: someone standing at the menu is not an unattended check") + } + + func testTheCheckResumesOnceThePolicyArrives() { + let (service, feed) = makeService(corePolicy: nil) + service.checkForUpdatesInBackground() + XCTAssertEqual(feed?.checks, []) + + service.applyCorePolicy(.legacyDefault) + service.checkForUpdatesInBackground() + XCTAssertEqual(feed?.checks, [false]) + XCTAssertEqual(legacyCounter.count, 1) + } + // MARK: - FR-015: gating func testUnattendedCheckIsSkippedWhenThePolicyDisablesIt() { @@ -161,16 +205,25 @@ final class UpdateServiceFeedTests: XCTestCase { func testInstallHookStopsTheManagedCoreSynchronously() { let (service, _) = makeService() var stopped = 0 - service.stopManagedCore = { stopped += 1 } - service.feedUpdaterWillInstallUpdate() + service.stopManagedCore = { stopped += 1; return true } + XCTAssertTrue(service.feedUpdaterWillInstallUpdate()) XCTAssertEqual(stopped, 1, "the core must be down BEFORE the delegate call returns — Sparkle " - + "replaces the bundle the moment it does") + + "proceeds with the install the moment it does") } func testInstallHookIsSafeWithoutACore() { let (service, _) = makeService() - service.feedUpdaterWillInstallUpdate() // must not trap + XCTAssertTrue(service.feedUpdaterWillInstallUpdate(), + "no core to stop is a stopped core; the update must not be blocked " + + "by the absence of the thing it was going to shut down") + } + + func testAnUnstoppableCoreVetoesTheInstall() { + let (service, _) = makeService() + service.stopManagedCore = { false } + XCTAssertFalse(service.feedUpdaterWillInstallUpdate(), + "replacing the bundle under a live core is issue #957 happening again") } // MARK: - FR-010 through the real menu From 1daaa3750fce36b951823cf39f7c08c0b63058e0 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Sat, 8 Aug 2026 08:23:12 +0300 Subject: [PATCH 28/37] fix(packaging): quit only the installing user's tray, not everybody's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Related #957 postinstall runs as root, where `pgrep -f` and `pkill -f` match every user's processes. Installing an upgrade for one account therefore quit — and, two rungs down the ladder, SIGKILLed — the MCPProxy tray of every logged-in user on the machine, including sessions nobody was upgrading. ## Changes - Resolve `-U ` once (the same uid `run_as_user` already hops to) and pass it to every pgrep/pkill; empty when there is no real user, which is the CI-imaging case where there is nobody else's session to protect - A plain string rather than an array: macOS bash 3.2 errors on an empty array expansion under `set -u` ## Testing - bash -n packaging/macos/postinstall.sh --- packaging/macos/postinstall.sh | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/packaging/macos/postinstall.sh b/packaging/macos/postinstall.sh index a260358c..4db6e034 100755 --- a/packaging/macos/postinstall.sh +++ b/packaging/macos/postinstall.sh @@ -104,8 +104,25 @@ run_as_user() { fi } +# Scope every process operation to the installing user. +# +# postinstall runs as root, and `pgrep -f` / `pkill -f` as root match EVERY +# user's processes. On a shared or multi-session Mac that turned "upgrade my +# copy" into "quit everybody's tray" — including sessions that are not being +# upgraded and whose owner is not at the keyboard. `-U ` restricts the +# match to the console user resolved above; without a uid (CI imaging, no real +# user) there is nobody else's session to protect. +# +# A plain string rather than an array: macOS ships bash 3.2, where expanding an +# empty array under `set -u` is itself an error. +PGREP_SCOPE="" +if [ -n "$REAL_UID" ] && [ "$REAL_UID" != "0" ]; then + PGREP_SCOPE="-U $REAL_UID" +fi + +# shellcheck disable=SC2086 # PGREP_SCOPE is empty or "-U " app_is_running() { - /usr/bin/pgrep -f "$APP_EXEC_PATTERN" >/dev/null 2>&1 + /usr/bin/pgrep $PGREP_SCOPE -f "$APP_EXEC_PATTERN" >/dev/null 2>&1 } # Wait up to $1 tenths of a second for the app to disappear. Returns 0 if it @@ -135,14 +152,16 @@ quit_running_instance() { fi echo "postinstall: MCPProxy did not quit in time — sending SIGTERM" >&2 - /usr/bin/pkill -f "$APP_EXEC_PATTERN" || true + # shellcheck disable=SC2086 + /usr/bin/pkill $PGREP_SCOPE -f "$APP_EXEC_PATTERN" || true if wait_for_exit "$SIGTERM_TENTHS"; then echo "postinstall: the old instance terminated" return 0 fi echo "postinstall: MCPProxy ignored SIGTERM — sending SIGKILL" >&2 - /usr/bin/pkill -9 -f "$APP_EXEC_PATTERN" || true + # shellcheck disable=SC2086 + /usr/bin/pkill $PGREP_SCOPE -9 -f "$APP_EXEC_PATTERN" || true # Never fail the install over this. A survivor is handled by the new tray's # stale-core supersede rather than by aborting an upgrade that has already # copied the bundle. From b78949c3037a52bb775ff70971367492470fe6a9 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Sat, 8 Aug 2026 08:25:14 +0300 Subject: [PATCH 29/37] ci(prerelease): publish signed checksums for RC artifacts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Related #957 FR-014 requires RC builds to be covered by a signed checksum manifest, and the prerelease pipeline published neither checksums.txt nor a cosign bundle. That made every RC uninstallable by `mcpproxy update`: the command refuses an artifact it cannot verify, so a user who opted into the prerelease channel had no update path at all — the CLI would find the RC and then decline it. ## Changes - `Generate checksums` in the prerelease `release` job, mirroring release.yml including the self-reference guard - `Sign checksums (keyless)` + bundle upload, guarded on an OIDC token being available so a fork run publishes unsigned rather than failing - `id-token: write` on the job (release.yml grants it workflow-wide) - `cosignIdentityRegexp` accepts prerelease.yml as well as release.yml; both are pinned to `@refs/tags/v`, so this widens the trusted set by nothing a tag push could not already do - Corrected the two comments that described the missing artifacts as a standing gap ## Testing - python3 -c 'yaml.safe_load(...)' on both workflow files - go build ./... ; go test ./cmd/mcpproxy/... --- .github/workflows/prerelease.yml | 72 ++++++++++++++++++++++++++++---- cmd/mcpproxy/update_cmd.go | 14 ++++--- 2 files changed, 72 insertions(+), 14 deletions(-) diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml index 40354809..3da0b293 100644 --- a/.github/workflows/prerelease.yml +++ b/.github/workflows/prerelease.yml @@ -226,12 +226,13 @@ jobs: # Create clean core binary for archive # # NOTE (Spec 092 FR-014/FR-020): unlike release.yml, the RC archive is - # deliberately NOT stamped with updatecheck.buildChannel=tarball yet. - # A tarball-stamped binary makes `mcpproxy update` self-update, which - # requires the release to carry checksums.txt + its cosign bundle — - # artifacts this prerelease pipeline does not publish today. Stamp this - # build in the same change that adds signed checksum manifests here, so - # RC tarball users never get a self-update path that cannot verify. + # deliberately NOT stamped with updatecheck.buildChannel=tarball. The + # verification half of that prerequisite is now met — the release job + # below publishes checksums.txt and its cosign bundle, so an RC IS + # installable by a stable tarball user who opted into prereleases — + # but stamping the RC archive itself is a separate decision about + # whether a binary extracted from a prerelease should self-update, and + # it is not made here. go build -ldflags "${LDFLAGS}" -o ${CLEAN_BINARY} ./cmd/mcpproxy # Build tray binary for macOS @@ -854,6 +855,12 @@ jobs: needs: [build, qa-gate] runs-on: ubuntu-latest environment: staging + permissions: + contents: write + # Spec 092 FR-014: cosign keyless signing takes its identity from the + # GitHub Actions OIDC token. release.yml grants this at the workflow + # level; here only this job needs it. + id-token: write # Only create releases for tag pushes, not branch pushes if: startsWith(github.ref, 'refs/tags/v') && (contains(github.ref, '-rc.') || contains(github.ref, '-next.')) @@ -932,6 +939,20 @@ jobs: fi done + # Spec 092 FR-014: RC artifacts must be covered by a signed checksum + # manifest, exactly as stable ones are. Without it `mcpproxy update` + # refuses every RC — it will not install an artifact it cannot verify — + # so a user who opted into the prerelease channel had no update path at + # all. Same shape as release.yml's step, including the self-reference + # guard so a re-run cannot make checksums.txt list itself. + - name: Generate checksums + run: | + cd release-files + find . -maxdepth 1 -type f ! -name 'checksums.txt' ! -name 'checksums.txt.cosign.bundle' -printf '%P\n' \ + | sort | xargs -r sha256sum > checksums.txt + echo "Generated checksums:" + cat checksums.txt + - name: List files for upload run: | echo "Files to upload:" @@ -1013,6 +1034,40 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Install cosign + uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 + + - name: Sign checksums (keyless) + # Same recipe as release.yml, with the identity naming THIS workflow. + # `mcpproxy update` pins both (cmd/mcpproxy/update_cmd.go: + # cosignIdentityRegexp) and pins the ref to a tag, so a branch run can + # never sign something the CLI would install. + # + # To verify after download: + # cosign verify-blob \ + # --bundle checksums.txt.cosign.bundle \ + # --certificate-identity-regexp "^https://github.com/smart-mcp-proxy/mcpproxy-go/.github/workflows/prerelease.yml@refs/tags/v" \ + # --certificate-oidc-issuer https://token.actions.githubusercontent.com \ + # checksums.txt + run: | + set -euo pipefail + # Guarded like the other new jobs: a fork or a run without OIDC has no + # identity to sign with, and an unsigned RC is better than a failed + # one — `mcpproxy update` refuses it and says why. + if [ -z "${ACTIONS_ID_TOKEN_REQUEST_URL:-}" ]; then + echo "::notice::No OIDC token available — RC checksums are published unsigned." + exit 0 + fi + cosign sign-blob \ + --yes \ + --bundle release-files/checksums.txt.cosign.bundle \ + release-files/checksums.txt + gh release upload "${{ github.ref_name }}" \ + release-files/checksums.txt.cosign.bundle \ + --clobber + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Upload pending notarizations if: hashFiles('pending-notarizations/*.pending') != '' run: | @@ -1041,9 +1096,8 @@ jobs: # hosts appcast-beta-.xml, the files are attached to the RC release and # exported as the `sparkle-appcast-beta` artifact. # - # NOT YET DONE (FR-014's other half): this pipeline still publishes no - # checksums.txt and no cosign bundle, so RC tarballs remain guidance-only for - # `mcpproxy update` — see the note in the tarball-stamp block above. + # FR-014's other half — signed checksums for RC artifacts — is handled in the + # `release` job above (Generate checksums / Sign checksums). sparkle-appcast: needs: [release] runs-on: macos-15 diff --git a/cmd/mcpproxy/update_cmd.go b/cmd/mcpproxy/update_cmd.go index c7c26eee..738ca6a7 100644 --- a/cmd/mcpproxy/update_cmd.go +++ b/cmd/mcpproxy/update_cmd.go @@ -37,12 +37,16 @@ const ( ) // Cosign identity pinning for checksums.txt (FR-021). These mirror the verify -// recipe documented in .github/workflows/release.yml next to the signing step: -// releases are only ever cut from a tag, so the ref is pinned too — a -// workflow_dispatch run on a branch must not be able to sign an artifact this -// command will install. +// recipe documented next to the signing step in each workflow: releases are +// only ever cut from a tag, so the ref is pinned too — a workflow_dispatch run +// on a branch must not be able to sign an artifact this command will install. +// +// Two workflows, because RCs are cut by a separate pipeline (FR-014) and a +// user on the prerelease channel must be able to verify what it publishes. +// Both are tag-gated and both live in this repository, so accepting the second +// one widens the trusted set by nothing a tag push could not already do. const ( - cosignIdentityRegexp = `^https://github\.com/smart-mcp-proxy/mcpproxy-go/\.github/workflows/release\.yml@refs/tags/v` + cosignIdentityRegexp = `^https://github\.com/smart-mcp-proxy/mcpproxy-go/\.github/workflows/(release|prerelease)\.yml@refs/tags/v` cosignOIDCIssuer = "https://token.actions.githubusercontent.com" ) From 1ee15aeab24d6b49088fe14571d60a0f154a5751 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Sat, 8 Aug 2026 08:30:04 +0300 Subject: [PATCH 30/37] docs(update): spell out what still has to happen before one-click works MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Related #957 The feed URL baked into every bundle (https://mcpproxy.app/appcast.xml) is served by nobody: the release pipeline attaches the feeds to the GitHub release and exports them as workflow artifacts, and the website repository would have to publish them. That is a maintainer decision, not something this branch can make — but it was recorded only as a warning box halfway down the page, which is not enough for someone trying to work out why nobody gets one-click updates. An explicit activation checklist replaces it, naming the exact files the website repo must serve, at the exact URLs the tray derives, and what the serving side must not do to them. The graceful side was already correct and is now pinned by tests: a feed 404 reads as "the feed has nothing" and hands the menu to the browser-download path, rather than surfacing as an error that eats the offer. ## Changes - "Activation checklist" section: five steps, the artifact→URL table, the three serving constraints, and the interim stable-only GitHub URL - Banner at the top pointing at it - Brought the page in line with this round's behaviour changes: honest two-click count, the postpone-on-failed-stop rule, one item always, the per-channel feed table, and the restrictive pre-policy default - Troubleshooting entries for the feed 404 and the postponed install ## Testing - swift test — feed failure falls back to browser guidance, and a failure after a withdrawal leaves no stranded one-click item --- docs/features/auto-update.md | 154 +++++++++++++++--- .../UpdateServiceFeedTests.swift | 25 +++ 2 files changed, 152 insertions(+), 27 deletions(-) diff --git a/docs/features/auto-update.md b/docs/features/auto-update.md index 4e54afb3..120f9d27 100644 --- a/docs/features/auto-update.md +++ b/docs/features/auto-update.md @@ -16,6 +16,13 @@ for everything else. Related: [Version Updates](/features/version-updates) (how the *check* works), [Prerelease Builds](/prerelease-builds) (the RC channel). +:::warning macOS one-click is built but not switched on +The updater ships in every macOS build and does nothing until a maintainer +publishes the update feed. Until then the tray falls back to browser downloads. +See the [activation checklist](#activation-checklist) for the exact remaining +steps. +::: + ## Channel matrix | Install channel | `mcpproxy update` (CLI) | macOS tray | @@ -44,8 +51,15 @@ inside `MCPProxy.app`. 2. When something newer exists, the tray menu grows one gentle line: **"Update 0.55.0 — ready to restart?"**. No popup, no dock bounce, no interruption — the menu item *is* the notification. -3. Clicking it runs the whole thing: download → **verify** → stop the managed - core → replace the installed app → relaunch on the new version. +3. Clicking it runs the whole thing: **verify** → stop the managed core → + replace the installed app → relaunch on the new version. + +The download happens during step 1, not step 3: the background check downloads +and verifies the enclosure ahead of time, so activating the menu item has +nothing left to wait for. Sparkle then asks for one confirmation +("Install and Relaunch") before it replaces the bundle — its public API has no +way to install an already-downloaded update without it. So the honest count is +**two clicks, no waiting**: ours, and Sparkle's. ### Verification (two independent checks) @@ -64,7 +78,15 @@ running version keeps working. The updater stops the tray-managed core **before** the bundle is replaced, not after. A core still executing from a replaced (deleted-inode) bundle is exactly the failure reported in issue #957. In-flight tool calls fail visibly rather -than hanging: the stop is `SIGTERM`, a five-second grace period, then `SIGKILL`. +than hanging: the stop is `SIGTERM`, a five-second grace period, then `SIGKILL`, +and then a bounded wait to *confirm* the process is actually gone. + +If it is not gone — it outlived `SIGKILL`, or its pid can no longer be +identified as an mcpproxy process — the installation is **postponed**. The +downloaded update stays downloaded, the menu item stays where it was, the +running version keeps working, and the menu reports why. Replacing the bundle +under a live core is the bug, so "we could not confirm it stopped" is a reason +not to install, not a line in a log. The Phase-0 supersede check stays active permanently as the safety net for the paths the updater does not control — drag-installs, PKG runs, install-on-quit. @@ -84,14 +106,16 @@ there; updates work from then on. ### One item, one owner Two mechanisms know about releases: the feed, and the daily GitHub check that -predates it. They never both nudge: +predates it. There is never more than one item: -- feed offer present → the feed owns the item, and the GitHub check is silent - for the same or an older version; -- GitHub only (feed unreachable, or it does not carry that version) → the item - reads **"Update available: vX.Y.Z — Download"** and opens the browser, never - a one-click action it cannot perform; -- equal versions → exactly one item. +- feed offer present → the feed owns the item and the GitHub check is silent, + whatever version it found. Even when GitHub is a release ahead: what the feed + offers is real and installable in one click, and the next check picks the + newer version up. Two items would ask the user to choose between an install + the tray can perform and a download it cannot verify; +- feed has nothing (unreachable, 404, or genuinely up to date) → the GitHub + result renders as **"Update available: vX.Y.Z — Download"** and opens the + browser, never a one-click action it cannot perform. ## Kill switches @@ -120,6 +144,14 @@ yet — its absence cannot tell a client whether it is allowed to check. The policy is recomputed per request, so editing `update_check` in the config file takes effect on the tray's next connect with no restart. +**The tray performs no unattended check until this contract arrives.** Before +any core has answered, automatic checks are off, not on: a permissive default +would let the launch check fire under the previous policy, checking for updates +for someone who had just switched them off. A core too old to report +`update_policy` is a different case — the tray recognises it on connect and +keeps it on the pre-092 behaviour. "Check for Updates" from the menu works +throughout. + ## Release channels Stable users are never offered a release candidate. The mechanism is Sparkle @@ -143,12 +175,23 @@ Generated by the release pipeline, per stable release and per RC: | `appcast-arm64.xml` / `appcast-amd64.xml` | The stable feeds, EdDSA-signed. | | `appcast-beta-arm64.xml` / `appcast-beta-amd64.xml` | The RC feeds, additionally tagged `sparkle:channel = beta`. | -**One feed per architecture.** A Sparkle appcast has no architecture selector, -and both macOS bundles report the same version — a single merged feed would -hand an Intel user the Apple-Silicon build. The tray rewrites the configured -`SUFeedURL` (`…/appcast.xml` → `…/appcast-arm64.xml`) to request its own; a feed -URL with any other file name is used verbatim, so an operator serving a -universal feed is not second-guessed. +**One feed per architecture, and one per channel.** A Sparkle appcast has no +architecture selector, and both macOS bundles report the same version — a single +merged feed would hand an Intel user the Apple-Silicon build. The tray rewrites +the configured `SUFeedURL` to request its own: + +| Channel | arm64 | amd64 | +|---|---|---| +| stable | `…/appcast-arm64.xml` | `…/appcast-amd64.xml` | +| rc | `…/appcast-beta-arm64.xml` | `…/appcast-beta-amd64.xml` | + +Only the exact file name `appcast.xml` is rewritten; a feed URL with any other +name is used verbatim, so an operator serving a universal or merged feed is not +second-guessed. + +Each RC release also carries `checksums.txt` and its `checksums.txt.cosign.bundle`, +so `mcpproxy update` can verify a prerelease the same way it verifies a stable +one. ### Signing keys @@ -166,19 +209,64 @@ makes the updater refuse to start and the tray fall back to browser downloads. That is the correct failure direction: no key, no one-click, never an unverified install. -:::warning Feed hosting is not yet decided -The shipped `Info.plist` points at `https://mcpproxy.app/appcast.xml`, which the -**website** repository would have to serve — this repository cannot publish -there. Until that is wired, each release attaches its feeds as release assets -and exports them as the `sparkle-appcast` (and `sparkle-appcast-beta`) workflow -artifact for the website repo to consume. +## Activation checklist + +**One-click update is built but not switched on.** Everything below is in the +repository and runs on every release; what is missing is a server answering the +feed URL. Until step 3 is done, `SPUUpdater.start()` fails or the feed 404s, the +tray logs *"One-click updates unavailable"*, and every install falls back to the +browser-download item. That is the designed failure direction — no key and no +feed can never mean an unverified install — but it does mean nobody gets +one-click until a maintainer completes these steps. + +| # | Step | Where | Done? | +|---|---|---|---| +| 1 | Generate an EdDSA key pair with Sparkle's `generate_keys` | local, once | ☐ | +| 2 | Add `SPARKLE_ED_PRIVATE_KEY` and `SPARKLE_ED_PUBLIC_KEY` as repository **secrets** | repo settings | ☐ | +| 3 | Serve the four feed files at `https://mcpproxy.app/` (see below) | **website repo** | ☐ | +| 4 | Set `auto_updates true` in the Homebrew cask | `smart-mcp-proxy/homebrew-mcpproxy` | ☐ | +| 5 | Cut a release and confirm the tray logs `Sparkle updater started` | — | ☐ | -As an interim stable-channel feed URL, +Steps 1, 2 and 5 are ordinary release work. Step 3 is the blocker, and step 4 +must land **before** the first Sparkle-capable release or `brew` and the in-app +updater will fight over the same install. + +### What the website repository must serve + +The shipped `Info.plist` sets `SUFeedURL` to `https://mcpproxy.app/appcast.xml`, +and this repository cannot publish there. The release pipeline instead attaches +the feeds to the GitHub release **and** exports them as workflow artifacts for +the website repo to pick up: + +| Workflow artifact | Files | Must be served at | +|---|---|---| +| `sparkle-appcast` (from `release.yml`) | `appcast-arm64.xml`, `appcast-amd64.xml` | `https://mcpproxy.app/appcast-arm64.xml`, `https://mcpproxy.app/appcast-amd64.xml` | +| `sparkle-appcast-beta` (from `prerelease.yml`) | `appcast-beta-arm64.xml`, `appcast-beta-amd64.xml` | `https://mcpproxy.app/appcast-beta-arm64.xml`, `https://mcpproxy.app/appcast-beta-amd64.xml` | + +Three things the serving side must get right: + +- **The file names are the contract.** The tray derives them from `SUFeedURL` by + substitution (`SparkleFeedURL.archSpecific`); nothing at + `https://mcpproxy.app/appcast.xml` itself is ever fetched. Serving only + `appcast.xml` activates nothing. +- **The enclosure URLs inside the feeds already point at GitHub release assets** + (`generate_appcast --download-url-prefix`), so the website only serves four + small XML files — not the ~40 MB archives. +- **Serve as `application/xml` over HTTPS**, and do not rewrite the bodies: the + EdDSA signature covers the enclosure, and any proxy that alters the XML breaks + the feed parse. + +### Interim URL without the website + +For the **stable** channel only, `https://github.com/smart-mcp-proxy/mcpproxy-go/releases/latest/download/appcast-.xml` -works today (GitHub resolves `latest` to the newest non-prerelease release). It -cannot serve the beta channel, because RC releases are prereleases and are never -`latest`. -::: +works today — GitHub resolves `latest` to the newest non-prerelease release. +Point the `SPARKLE_FEED_URL` repository variable at +`https://github.com/smart-mcp-proxy/mcpproxy-go/releases/latest/download/appcast.xml` +and the per-architecture rewrite does the rest. + +This cannot serve the beta channel: RC releases are prereleases and are never +`latest`, so the RC feed needs a real host either way. ### Homebrew @@ -199,6 +287,18 @@ either the app is not running from a bundle (a `swift build` binary), or the bundle carries the placeholder public key. The browser-download path still works. +**No update item at all, and the log shows a feed error.** Expected until the +[activation checklist](#activation-checklist) is complete — nothing serves +`appcast-.xml` yet, so the feed 404s. A 404 reads as "the feed has +nothing", not as an error that hides the offer: the GitHub check still produces +the browser-download item. + +**"The update was not installed: the MCPProxy core is still running."** The +managed core did not die within the stop ladder, so the installation was +postponed rather than performed over a live process. Quit the core +(`mcpproxy upstream list` will show whether one is answering) and click the +update item again. + **The old core keeps answering after an upgrade.** That is the Phase-0 supersede path, not the updater — see the tray's "Old core vX running — Restart into vY" item, and `mcpproxy status` (`Launched by:`) to see who started it. diff --git a/native/macos/MCPProxy/MCPProxyTests/UpdateServiceFeedTests.swift b/native/macos/MCPProxy/MCPProxyTests/UpdateServiceFeedTests.swift index 1db4cf48..f8f084a4 100644 --- a/native/macos/MCPProxy/MCPProxyTests/UpdateServiceFeedTests.swift +++ b/native/macos/MCPProxy/MCPProxyTests/UpdateServiceFeedTests.swift @@ -200,6 +200,31 @@ final class UpdateServiceFeedTests: XCTestCase { XCTAssertEqual(service.lastErrorMessage, "the update is improperly signed") } + /// FR-016 / the "update feed unreachable" edge case. Nothing publishes the + /// appcast at its Info.plist URL yet (see the activation checklist in + /// docs/features/auto-update.md), so a 404 is the state most installs are + /// in: it must read as "no update from the feed" and hand the menu to the + /// legacy browser path, not as an error that eats the offer. + func testAnUnreachableFeedFallsBackToBrowserGuidance() { + let (service, _) = makeService() + service.setCoreReportedVersion("0.55.0") + service.feedUpdater(didFailWith: + "The update feed could not be loaded (404).") + + XCTAssertEqual(service.menuEntries, [.browserGuidance(version: "0.55.0")]) + XCTAssertNotNil(service.lastErrorMessage, "the reason stays available for the log") + } + + func testAFeedFailureDoesNotStrandAPreviousOffer() { + // A transient failure after a successful check must not leave a + // one-click item pointing at an update the feed can no longer serve. + let (service, _) = makeService() + service.feedUpdater(didFindVersion: "0.55.0") + service.feedUpdaterDidNotFindUpdate() + service.feedUpdater(didFailWith: "connection lost") + XCTAssertEqual(service.menuEntries, []) + } + // MARK: - FR-012 func testInstallHookStopsTheManagedCoreSynchronously() { From fdfc402e4f42a14099a6dac9cf1488aac589fa78 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Sat, 8 Aug 2026 09:07:40 +0300 Subject: [PATCH 31/37] fix(cli): tell an unverified swap apart from a finished one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Related #957 The recovery added in 20caffafa closed one crash window and left the other open. A crash AFTER the staged->target rename but BEFORE verification leaves an unverified binary at the target and the last known-good one at .old — and on disk that is indistinguishable from a completed update. Recovery saw a target and returned; the swap then deleted .old, and if that attempt's verification failed, the rollback restored the unverified binary. The known-good one was already gone. Nothing about the file contents can settle it, so the swap now records that it is in progress. A sentinel written next to the target before the first rename and removed only after verification passes makes the two states tell themselves apart, and no path removes the backup while it exists. With the sentinel present, target and backup between them say how far the interrupted swap got: neither -> nothing to recover from; refuse backup only -> crashed between the renames; restore it target only -> crashed before the first rename; the target is original target + backup -> crashed after the second; the target was never verified Only the last needs a judgement, and it is made by running the caller's verification against what is at the target. It proves itself or the known-good binary goes back. With no verifier available, the known-good binary wins. ## Changes - `swapSentinelSuffix` + `writeSwapSentinel` (fsynced content; the directory entry is not, which is the same guarantee the renames already had) - `recoverInterruptedSwap` replaces `restoreInterruptedSwap`, covering all four states plus the no-sentinel case left by a build that predates it - `restoreKnownGood` — os.Rename over the target is what discards the unverified binary - The sentinel is removed on every path the function returns by, so only a crash leaves one ## Testing - go test -race ./cmd/mcpproxy/ — verified interrupted swap cleans up, an unverifiable one is replaced, a retry that cannot install ends on the known-good binary, no verifier prefers the known-good, crash-before-first- rename is a no-op, and the sentinel is present while the new binary is unverified --- cmd/mcpproxy/update_apply.go | 178 ++++++++++++++++++++++---- cmd/mcpproxy/update_apply_test.go | 206 ++++++++++++++++++++++++++++++ 2 files changed, 356 insertions(+), 28 deletions(-) diff --git a/cmd/mcpproxy/update_apply.go b/cmd/mcpproxy/update_apply.go index 66f6ebd5..9826b758 100644 --- a/cmd/mcpproxy/update_apply.go +++ b/cmd/mcpproxy/update_apply.go @@ -31,6 +31,12 @@ const ( // verifyExecTimeout bounds the post-swap ` --version` probe // (FR-021: success means the new binary actually runs). verifyExecTimeout = 30 * time.Second + + // swapSentinelSuffix names the file that marks a binary swap as in + // progress. Present only between "about to move the current binary aside" + // and "the replacement verified", so finding one means a swap died + // halfway. See applyNewBinary. + swapSentinelSuffix = ".updating" ) // parseChecksums parses a sha256sum-format manifest (" " or @@ -205,15 +211,24 @@ func ensureTargetWritable(target string) error { // once verification passed. Callers must pass an already-resolved (symlink // free) target so a symlinked launcher keeps pointing at the file we replace. // -// The two renames are individually atomic but the pair is not, so there is a -// window in which the target path is empty and target.old holds the ONLY copy -// of the binary. A crash in that window must be survivable: the first thing -// this function does is recover from it (restoreInterruptedSwap), and a -// leftover backup is never removed while the target is absent. +// The two renames are individually atomic but the sequence is not, so a crash +// can land in one of two windows, and the two look different on disk: +// +// between the renames → target absent, backup holds the only copy; +// after the renames → target holds an UNVERIFIED binary, backup holds the +// last known-good one. +// +// The second is the dangerous one, because on disk it is indistinguishable +// from a finished update — and a retry that mistakes it for one deletes the +// backup, which is the last known-good binary there is. So the swap writes a +// sentinel next to the target for exactly as long as it is in progress, and +// recovery reads it to tell the two states apart. Nothing removes the backup +// while the sentinel exists. func applyNewBinary(target, staged string, verify func(path string) error) (err error) { backup := target + ".old" + sentinel := target + swapSentinelSuffix - if recoverErr := restoreInterruptedSwap(target, backup); recoverErr != nil { + if recoverErr := recoverInterruptedSwap(target, backup, sentinel, verify); recoverErr != nil { return recoverErr } @@ -225,10 +240,20 @@ func applyNewBinary(target, staged string, verify func(path string) error) (err return fmt.Errorf("preserve file mode %o: %w", mode, chmodErr) } - // The target is present (restoreInterruptedSwap guarantees it), so a - // leftover .old is genuinely expendable and must not block the rename. + // The target is present and known-good (recoverInterruptedSwap guarantees + // both), so a leftover .old is genuinely expendable. Done BEFORE the + // sentinel goes down, so "sentinel exists" never coincides with a backup + // being deleted. _ = os.Remove(backup) + if sentinelErr := writeSwapSentinel(sentinel, staged); sentinelErr != nil { + return sentinelErr + } + // Removed on every path this function RETURNS by — success or handled + // failure — because each of those ends with the filesystem consistent. Only + // a crash leaves it behind, which is exactly what it is for. + defer func() { _ = os.Remove(sentinel) }() + if renameErr := os.Rename(target, backup); renameErr != nil { return fmt.Errorf("move current binary aside: %w", renameErr) } @@ -264,39 +289,136 @@ func applyNewBinary(target, staged string, verify func(path string) error) (err return nil } -// restoreInterruptedSwap puts the world back together after a crash between -// the two renames in applyNewBinary. +// writeSwapSentinel marks the target as mid-swap. // -// The dangerous state is "target missing, backup present": the backup is then -// the only copy of the binary, and removing it — which the swap used to do -// before it had even looked at the target — destroys the install. So the -// backup is moved back FIRST, and a missing target with no backup is an error -// rather than something to plough on through. -func restoreInterruptedSwap(target, backup string) error { - targetExists, err := regularFileExists(target) +// Best-effort durability: the content is fsynced, but the directory entry is +// not, so a power cut can still lose the file. That is the same guarantee the +// renames themselves have, and the failure it would reintroduce is the one +// that existed before the sentinel — not a new one. +func writeSwapSentinel(sentinel, staged string) error { + f, err := os.OpenFile(sentinel, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) // #nosec G304 -- derived from the caller-resolved target path if err != nil { - return fmt.Errorf("inspect %s: %w", target, err) + return fmt.Errorf("mark the update in progress (%s): %w", sentinel, err) } - if targetExists { - return nil + fmt.Fprintf(f, "mcpproxy is replacing this binary with %s (pid %d, %s).\n"+ + "If this file is still here, the update did not finish; mcpproxy will\n"+ + "repair it on the next run. Do not delete the .old file by hand.\n", + staged, os.Getpid(), time.Now().UTC().Format(time.RFC3339)) + if syncErr := f.Sync(); syncErr != nil { + f.Close() + return fmt.Errorf("flush %s: %w", sentinel, syncErr) } + return f.Close() +} +// recoverInterruptedSwap puts the world back together after a crash inside +// applyNewBinary, and is the reason the sentinel exists. +// +// With the sentinel present, target and backup between them say exactly how +// far the interrupted swap got: +// +// neither → nothing to recover from and nothing to install onto +// backup only → crashed between the renames; the backup is the only copy +// target only → crashed before the first rename; the target is original +// target + backup → crashed after the second rename; the target was NEVER +// verified and the backup is the last known-good binary +// +// Only the last case needs a judgement, and it is made by running the caller's +// verification against what is at the target: it either proves itself or the +// known-good binary goes back. Without a verifier there is no way to prove it, +// so the known-good binary wins by default. +func recoverInterruptedSwap(target, backup, sentinel string, verify func(path string) error) error { + interrupted, err := regularFileExists(sentinel) + if err != nil { + return fmt.Errorf("inspect %s: %w", sentinel, err) + } + targetExists, err := regularFileExists(target) + if err != nil { + return fmt.Errorf("inspect %s: %w", target, err) + } backupExists, err := regularFileExists(backup) if err != nil { return fmt.Errorf("inspect %s: %w", backup, err) } - if !backupExists { - return fmt.Errorf("%s does not exist and there is no %s to restore from; "+ - "reinstall mcpproxy rather than letting an update invent a binary", target, backup) + + if !interrupted { + switch { + case targetExists: + return nil + case backupExists: + // No sentinel, no target: a swap interrupted by a build that + // predates the sentinel. The backup is still the only copy. + return restoreKnownGood(target, backup, "a previous update was interrupted") + default: + return fmt.Errorf("%s does not exist and there is no %s to restore from; "+ + "reinstall mcpproxy rather than letting an update invent a binary", target, backup) + } } - if renameErr := os.Rename(backup, target); renameErr != nil { - return fmt.Errorf("a previous update was interrupted: %s is the only copy of the binary "+ - "and it could not be moved back to %s: %w", backup, target, renameErr) + clearSentinel := func() { + if rmErr := os.Remove(sentinel); rmErr != nil { + fmt.Fprintf(os.Stderr, "note: could not remove %s: %v\n", sentinel, rmErr) + } } + + switch { + case !targetExists && !backupExists: + return fmt.Errorf("an update of %s was interrupted and neither the binary nor %s is there; "+ + "reinstall mcpproxy", target, backup) + + case !targetExists: + if restoreErr := restoreKnownGood(target, backup, + "an update was interrupted between replacing the binary and putting the new one in place"); restoreErr != nil { + return restoreErr + } + clearSentinel() + return nil + + case !backupExists: + // The swap never got as far as moving anything: what is at the target + // is the binary that was there all along. + clearSentinel() + return nil + } + + // Both present. What is at the target came from an update that never + // finished proving itself. + if verify == nil { + if restoreErr := restoreKnownGood(target, backup, + "an update was interrupted before the new binary could be verified, and this run cannot verify it either"); restoreErr != nil { + return restoreErr + } + clearSentinel() + return nil + } + if verifyErr := verify(target); verifyErr != nil { + if restoreErr := restoreKnownGood(target, backup, fmt.Sprintf( + "an update was interrupted and left an unusable binary at %s (%v)", target, verifyErr)); restoreErr != nil { + return restoreErr + } + clearSentinel() + return nil + } + + // It verifies: the interrupted run had in fact succeeded and only died + // before tidying up. The backup has served its purpose. fmt.Fprintf(os.Stderr, - "note: a previous update left %s missing; restored it from %s before continuing\n", - target, backup) + "note: a previous update of %s had completed; cleaning up after it\n", target) + if rmErr := os.Remove(backup); rmErr != nil { + fmt.Fprintf(os.Stderr, "note: could not remove %s: %v\n", backup, rmErr) + } + clearSentinel() + return nil +} + +// restoreKnownGood moves backup back over target. os.Rename replaces an +// existing target, which is what discards an unverified binary. +func restoreKnownGood(target, backup, why string) error { + if renameErr := os.Rename(backup, target); renameErr != nil { + return fmt.Errorf("%s: %s holds the last known-good binary and it could not be moved "+ + "back to %s: %w", why, backup, target, renameErr) + } + fmt.Fprintf(os.Stderr, "note: %s; restored %s from %s\n", why, target, backup) return nil } diff --git a/cmd/mcpproxy/update_apply_test.go b/cmd/mcpproxy/update_apply_test.go index b2696108..29c80106 100644 --- a/cmd/mcpproxy/update_apply_test.go +++ b/cmd/mcpproxy/update_apply_test.go @@ -3,6 +3,7 @@ package main import ( "archive/zip" "bytes" + "errors" "os" "path/filepath" "strings" @@ -301,6 +302,211 @@ func TestApplyNewBinary_InterruptedSwapKeepsTheOnlyBinary(t *testing.T) { } } +// The other crash window: staged->target succeeded but verification never +// ran, so the target holds an UNVERIFIED binary and .old holds the last +// known-good one. On disk that is indistinguishable from a finished update +// unless the sentinel says otherwise — and mistaking it for one deletes the +// only known-good binary there is. +// +// Here the interrupted binary proves itself, so the retry may clean up. +func TestApplyNewBinary_RecoveryKeepsAVerifiedInterruptedSwap(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "mcpproxy") + staged := filepath.Join(dir, ".mcpproxy.new") + + writeAll(t, map[string]string{ + target: "interrupted-new", + target + ".old": "old", + target + swapSentinelSuffix: "swap in progress", + staged: "new", + }) + + var verified []string + if err := applyNewBinary(target, staged, func(path string) error { + content, readErr := os.ReadFile(path) + if readErr != nil { + return readErr + } + verified = append(verified, string(content)) + return nil + }); err != nil { + t.Fatalf("applyNewBinary: %v", err) + } + + if len(verified) == 0 || verified[0] != "interrupted-new" { + t.Errorf("recovery must verify what the interrupted swap left behind, saw %v", verified) + } + if got, _ := os.ReadFile(target); string(got) != "new" { + t.Errorf("target content = %q, want the freshly staged binary", string(got)) + } + if _, err := os.Stat(target + ".old"); !os.IsNotExist(err) { + t.Error("the backup must be gone once the update completed") + } + assertNoSentinel(t, target) +} + +// Same interrupted state, but what the crash left at the target does not run. +// The known-good binary must come back and the unverified one must go. +func TestApplyNewBinary_RecoveryRestoresOverAnUnverifiableInterruptedSwap(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "mcpproxy") + staged := filepath.Join(dir, ".mcpproxy.new") + + writeAll(t, map[string]string{ + target: "unverified", + target + ".old": "old", + target + swapSentinelSuffix: "swap in progress", + staged: "new", + }) + + // Rejects only what the interrupted run left behind. + verify := func(path string) error { + content, readErr := os.ReadFile(path) + if readErr != nil { + return readErr + } + if string(content) == "unverified" { + return errors.New("does not run") + } + return nil + } + if err := applyNewBinary(target, staged, verify); err != nil { + t.Fatalf("applyNewBinary: %v", err) + } + + if got, _ := os.ReadFile(target); string(got) != "new" { + t.Errorf("target content = %q, want the freshly staged binary", string(got)) + } + if _, err := os.Stat(target + ".old"); !os.IsNotExist(err) { + t.Error("the backup must be gone once the update completed") + } + assertNoSentinel(t, target) +} + +// The case the regression was really about: the retry cannot install anything, +// so the run must end on the last known-good binary rather than on the +// unverified one the crash left behind. +func TestApplyNewBinary_RecoveryNeverLeavesTheUnverifiedBinaryInstalled(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "mcpproxy") + staged := filepath.Join(dir, ".mcpproxy.new") // deliberately never created + + writeAll(t, map[string]string{ + target: "unverified", + target + ".old": "old", + target + swapSentinelSuffix: "swap in progress", + }) + + err := applyNewBinary(target, staged, func(path string) error { + content, readErr := os.ReadFile(path) + if readErr != nil { + return readErr + } + if string(content) == "unverified" { + return errors.New("does not run") + } + return nil + }) + if err == nil { + t.Fatal("expected an error: there is no staged binary to install") + } + + got, readErr := os.ReadFile(target) + if readErr != nil { + t.Fatalf("the known-good binary must have been restored: %v", readErr) + } + if string(got) != "old" { + t.Errorf("target content = %q, want the restored known-good binary", string(got)) + } + assertNoSentinel(t, target) +} + +// Without a verifier there is no way to prove the interrupted binary, so the +// known-good one wins by default. +func TestApplyNewBinary_RecoveryPrefersTheKnownGoodWhenItCannotVerify(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "mcpproxy") + staged := filepath.Join(dir, ".mcpproxy.new") + + writeAll(t, map[string]string{ + target: "unverified", + target + ".old": "old", + target + swapSentinelSuffix: "swap in progress", + staged: "new", + }) + + if err := applyNewBinary(target, staged, nil); err != nil { + t.Fatalf("applyNewBinary: %v", err) + } + if got, _ := os.ReadFile(target); string(got) != "new" { + t.Errorf("target content = %q, want the freshly staged binary", string(got)) + } + assertNoSentinel(t, target) +} + +// A crash before the first rename: the target is the binary that was always +// there, and there is nothing to undo. +func TestApplyNewBinary_RecoveryAcceptsATargetWithNoBackup(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "mcpproxy") + staged := filepath.Join(dir, ".mcpproxy.new") + + writeAll(t, map[string]string{ + target: "old", + target + swapSentinelSuffix: "swap in progress", + staged: "new", + }) + + if err := applyNewBinary(target, staged, nil); err != nil { + t.Fatalf("applyNewBinary: %v", err) + } + if got, _ := os.ReadFile(target); string(got) != "new" { + t.Errorf("target content = %q, want new", string(got)) + } + assertNoSentinel(t, target) +} + +// The sentinel has to be on disk for the whole window it describes, or the +// recovery above can never fire. +func TestApplyNewBinary_SentinelExistsForTheDurationOfTheSwap(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "mcpproxy") + staged := filepath.Join(dir, ".mcpproxy.new") + + writeAll(t, map[string]string{target: "old", staged: "new"}) + + sawSentinel := false + if err := applyNewBinary(target, staged, func(string) error { + // Verification runs after both renames — the exact moment a crash + // would leave an unverified binary installed. + _, statErr := os.Stat(target + swapSentinelSuffix) + sawSentinel = statErr == nil + return nil + }); err != nil { + t.Fatalf("applyNewBinary: %v", err) + } + if !sawSentinel { + t.Error("the swap must be marked in progress while the new binary is unverified") + } + assertNoSentinel(t, target) +} + +func writeAll(t *testing.T, files map[string]string) { + t.Helper() + for path, content := range files { + if err := os.WriteFile(path, []byte(content), 0o755); err != nil { // #nosec G306 -- test fixture standing in for an executable + t.Fatalf("write %s: %v", path, err) + } + } +} + +func assertNoSentinel(t *testing.T, target string) { + t.Helper() + if _, err := os.Stat(target + swapSentinelSuffix); !os.IsNotExist(err) { + t.Errorf("%s must not survive a completed run", target+swapSentinelSuffix) + } +} + func TestApplyNewBinary_MissingTargetAndBackupIsAnError(t *testing.T) { dir := t.TempDir() target := filepath.Join(dir, "mcpproxy") From 7c581e06a83bed1827a3c25e195f35953daf50d3 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Sat, 8 Aug 2026 09:07:57 +0300 Subject: [PATCH 32/37] fix(tray): never arm the install-on-quit the core stop cannot veto MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Related #957 Enabling automaticallyDownloadsUpdates for the pre-download in 6914bb664 also switched Sparkle from SPUScheduledUpdateDriver to SPUAutomaticUpdateDriver — the driver is chosen on that flag alone (SPUUpdater.m:621) — and that driver arms a silent install-on-quit. It hands the update to the external installer tool, which replaces the bundle once this process exits, on a path that never calls installWithToolAndRelaunch: and therefore never reaches the postpone hook that refuses an unconfirmed core stop. The fallback the on-quit path does reach is `void`; it called the stop and dropped the answer. `willInstallUpdateOnQuit:` is NOT a veto, so intercepting it does not fix this. Its own documentation: "In either case Sparkle will always attempt to install the update when the app terminates." Nor does the terminate path make it safe — applicationWillTerminate sends the core a SIGTERM and does not wait, so the installer can still run against a live core. What is provably safe is not arming it: with automaticallyDownloadsUpdates false, SPUAutomaticUpdateDriver is never constructed and every install goes through the postpone hook, which can refuse. The cost is that FR-010's click downloads before it installs — a slower click, in exchange for an install that can always be called off. ## Changes - `EffectiveUpdatePolicy.automaticDownloadsAllowed` — always false, given a name and a test because SparkleFeedUpdater cannot be instantiated outside a real .app bundle. Assigned unconditionally: the flag is backed by a user default Sparkle's own permission prompt can write, so leaving it alone is not the same as it being off - `willInstallUpdateOnQuit:` implemented as a tripwire — it cannot refuse, so it logs and surfaces the situation instead of pretending to have handled it - The two `void` hooks report a failed stop instead of discarding it - Docs: honest click count and why the pre-download is refused ## Testing - swift test — every policy (permissive, awaiting-core, legacy, rc, disabled, CI, kill switch) refuses pre-downloading --- docs/features/auto-update.md | 22 +++-- .../MCPProxy/Services/FeedUpdater.swift | 96 +++++++++++++++---- .../MCPProxy/Services/UpdatePolicy.swift | 15 +++ .../MCPProxyTests/UpdatePolicyTests.swift | 25 +++++ 4 files changed, 128 insertions(+), 30 deletions(-) diff --git a/docs/features/auto-update.md b/docs/features/auto-update.md index 120f9d27..8c8ed177 100644 --- a/docs/features/auto-update.md +++ b/docs/features/auto-update.md @@ -51,15 +51,19 @@ inside `MCPProxy.app`. 2. When something newer exists, the tray menu grows one gentle line: **"Update 0.55.0 — ready to restart?"**. No popup, no dock bounce, no interruption — the menu item *is* the notification. -3. Clicking it runs the whole thing: **verify** → stop the managed core → - replace the installed app → relaunch on the new version. - -The download happens during step 1, not step 3: the background check downloads -and verifies the enclosure ahead of time, so activating the menu item has -nothing left to wait for. Sparkle then asks for one confirmation -("Install and Relaunch") before it replaces the bundle — its public API has no -way to install an already-downloaded update without it. So the honest count is -**two clicks, no waiting**: ours, and Sparkle's. +3. Clicking it runs the whole thing: download → **verify** → stop the managed + core → replace the installed app → relaunch on the new version. + +Honest click count: **two**. Ours, and Sparkle's own "Install and Relaunch" +confirmation, which its public API gives no way to skip. + +Background checks deliberately do **not** pre-download, even though that would +make the second click instant. In Sparkle the pre-download flag also selects +the update driver, and the pre-downloading one arms a silent *install-on-quit*: +the bundle gets replaced by an external tool once MCPProxy exits, on a path +where no delegate can call it off. That would replace the app underneath a core +nobody had confirmed was stopped — the bug this feature exists to fix. A slower +click is the better trade. ### Verification (two independent checks) diff --git a/native/macos/MCPProxy/MCPProxy/Services/FeedUpdater.swift b/native/macos/MCPProxy/MCPProxy/Services/FeedUpdater.swift index a270c4c1..b6ff4aec 100644 --- a/native/macos/MCPProxy/MCPProxy/Services/FeedUpdater.swift +++ b/native/macos/MCPProxy/MCPProxy/Services/FeedUpdater.swift @@ -115,21 +115,21 @@ final class SparkleFeedUpdater: NSObject, FeedUpdating { /// Whether Sparkle has the update downloaded and is only waiting to be told /// to install it. /// + /// True only for a RESUMED session — the user started a check, then + /// dismissed Sparkle's window while the download stayed on disk. Scheduled + /// checks never pre-download; see `apply(policy:)` for why that is off. + /// /// FR-010 asks for one click doing download → verify → swap → relaunch. /// Sparkle's PUBLIC API cannot deliver a literal single click on top of /// `SPUStandardUserDriver`: there is no "install the update you are holding" /// method on `SPUUpdater` (see SPUUpdater.h — the only entry points are the /// three check methods), so the menu item has to resume the session with - /// `checkForUpdates()`, and the standard driver then shows its confirmation. - /// - /// What we can do is make sure that confirmation is the LAST step rather - /// than the first: with `automaticallyDownloadsUpdates` on, the scheduled - /// check downloads and verifies in the background, so activating the menu - /// item lands directly on "Install and Relaunch" with nothing left to wait - /// for. Honest click count: ONE on our menu item, plus ONE on Sparkle's - /// install confirmation. Removing the second one needs a custom - /// `SPUUserDriver` implementation, which replaces every piece of update UI - /// Sparkle ships and is out of scope here. + /// `checkForUpdates()`, and the standard driver then shows its + /// confirmation. Honest click count: ONE on our menu item, plus ONE on + /// Sparkle's install confirmation, with the download in between. Removing + /// the second click needs a custom `SPUUserDriver` implementation, which + /// replaces every piece of update UI Sparkle ships and is out of scope + /// here; removing the download needs the on-quit installer we refuse to arm. private(set) var updateIsReadyToInstall: Bool = false var isAvailable: Bool { controller != nil } @@ -188,12 +188,32 @@ final class SparkleFeedUpdater: NSObject, FeedUpdating { // FR-015: the kill switch governs the SCHEDULED cycle. `check(userInitiated:)` // deliberately does not consult it. updater.automaticallyChecksForUpdates = policy.automaticChecksAllowed - // FR-010: pre-download so the one click that follows is an install and - // not a download. Tied to the same switch — an install that downloads - // ~40 MB unasked is exactly what the kill switch is for. Sparkle only - // honours this where the host allows automatic updates; when it does - // not, the flow degrades to download-on-click, which is what it was. - updater.automaticallyDownloadsUpdates = policy.automaticChecksAllowed && updater.allowsAutomaticUpdates + + // ALWAYS false, and not a preference we are willing to expose. + // + // Turning it on looks like a free win — the scheduled check + // pre-downloads, so FR-010's click has nothing left to wait for — but + // it also switches Sparkle from SPUScheduledUpdateDriver to + // SPUAutomaticUpdateDriver (SPUUpdater.m: the driver is chosen on this + // flag alone), and that driver ARMS a silent install-on-quit: it hands + // the update to the external installer tool, which replaces the bundle + // once this process exits. + // + // Nothing can call that off afterwards. `willInstallUpdateOnQuit:` is + // not a veto — its own documentation says "In either case Sparkle will + // always attempt to install the update when the app terminates" — and + // `shouldPostponeRelaunchForUpdate:`, the hook that CAN refuse, lives + // in SPUInstallerDriver.installWithToolAndRelaunch:, which the on-quit + // path never calls. So the bundle would be replaced without anyone + // having confirmed the managed core is down, which is issue #957. + // + // The price is that FR-010's click downloads before it installs. That + // is a slower click; the alternative is an install we cannot refuse. + // + // Assigned unconditionally rather than left alone: the flag is backed + // by a user default Sparkle's own permission prompt can write, so not + // setting it is not the same as it being off. + updater.automaticallyDownloadsUpdates = policy.automaticDownloadsAllowed // Changing the channel set mid-session needs a cycle reset for the next // scheduled check to use it (`allowedChannelsForUpdater:` is consulted // per check, but the schedule is not). @@ -281,15 +301,49 @@ extension SparkleFeedUpdater: SPUUpdaterDelegate { return true } - /// Belt and braces for the paths that never reach the postpone hook - /// (install-on-quit, a resumed session that already postponed once). The - /// stop is idempotent, and neither of these can refuse anything. + /// Tripwire. This is only ever called by `SPUAutomaticUpdateDriver`, which + /// `apply(policy:)` makes sure is never constructed — so reaching it means + /// something turned `automaticallyDownloadsUpdates` back on and a silent + /// install-on-quit has ALREADY been armed by the external installer tool. + /// + /// Neither return value can call that off ("In either case Sparkle will + /// always attempt to install the update when the app terminates"), so this + /// declines to take control and says so loudly rather than pretending to + /// have handled it. `false` at least leaves Sparkle's normal cycle running, + /// so the update can still be presented through the path that can be + /// refused. + func updater( + _ updater: SPUUpdater, + willInstallUpdateOnQuit item: SUAppcastItem, + immediateInstallationBlock immediateInstallHandler: @escaping () -> Void + ) -> Bool { + NSLog("[MCPProxy] WARNING: Sparkle armed a silent install-on-quit for %@. " + + "This path cannot be vetoed and bypasses the managed-core stop.", + item.displayVersionString) + observer?.feedUpdater(didFailWith: + "An update was staged to install when MCPProxy quits, bypassing the usual " + + "shutdown of the core. Quit the core before quitting MCPProxy.") + return false + } + + /// Belt and braces for the paths that never reach the postpone hook. Both + /// of these are `void` — Sparkle is telling us, not asking — so a stop that + /// fails here cannot stop anything; the most it can do is be visible + /// instead of silent. The hook that CAN refuse is the postpone one above, + /// and with the automatic driver disabled every install goes through it. func updater(_ updater: SPUUpdater, willInstallUpdate item: SUAppcastItem) { - observer?.feedUpdaterWillInstallUpdate() + reportUnvetoableStop(from: "willInstallUpdate") } func updaterWillRelaunchApplication(_ updater: SPUUpdater) { - observer?.feedUpdaterWillInstallUpdate() + reportUnvetoableStop(from: "updaterWillRelaunchApplication") + } + + private func reportUnvetoableStop(from hook: String) { + guard let observer else { return } + guard !observer.feedUpdaterWillInstallUpdate() else { return } + NSLog("[MCPProxy] WARNING: the managed core is still running at %@, which cannot " + + "refuse the install. The bundle may be replaced under it.", hook) } func updater(_ updater: SPUUpdater, didAbortWithError error: Error) { diff --git a/native/macos/MCPProxy/MCPProxy/Services/UpdatePolicy.swift b/native/macos/MCPProxy/MCPProxy/Services/UpdatePolicy.swift index 6d9501e9..5584cd89 100644 --- a/native/macos/MCPProxy/MCPProxy/Services/UpdatePolicy.swift +++ b/native/macos/MCPProxy/MCPProxy/Services/UpdatePolicy.swift @@ -97,6 +97,21 @@ struct EffectiveUpdatePolicy: Equatable { /// they are on. let disabledReason: String + /// Whether the feed updater may download an update before the user asks + /// for it. + /// + /// Always false, for every policy — deliberately not a setting. In Sparkle + /// this single flag also selects the update driver, and the pre-downloading + /// one arms a silent install-on-quit that no delegate can refuse, so the + /// bundle would be replaced without anyone having confirmed the managed + /// core is down. The full reasoning, with the Sparkle source references, is + /// on `SparkleFeedUpdater.apply(policy:)`. + /// + /// It lives here, as a property rather than a literal at the call site, so + /// the decision has one name and one test — the Sparkle class itself cannot + /// be instantiated outside a real .app bundle. + var automaticDownloadsAllowed: Bool { false } + /// Automatic checks on, nothing suppressed, stable channel. static let permissive = EffectiveUpdatePolicy( automaticChecksAllowed: true, diff --git a/native/macos/MCPProxy/MCPProxyTests/UpdatePolicyTests.swift b/native/macos/MCPProxy/MCPProxyTests/UpdatePolicyTests.swift index e7be4af7..b1fc8caf 100644 --- a/native/macos/MCPProxy/MCPProxyTests/UpdatePolicyTests.swift +++ b/native/macos/MCPProxy/MCPProxyTests/UpdatePolicyTests.swift @@ -115,6 +115,31 @@ final class UpdatePolicyTests: XCTestCase { XCTAssertTrue(policy.nudgesSuppressed) } + // MARK: - Never pre-download + + func testNoPolicyEverAllowsSparkleToPreDownload() { + // Enabling background downloads switches Sparkle to the driver that + // arms a silent install-on-quit, and NOTHING can refuse that install + // once armed — not `willInstallUpdateOnQuit:` ("Sparkle will always + // attempt to install the update when the app terminates") and not the + // postpone hook, which that path never reaches. The bundle would be + // replaced without anyone confirming the managed core is down. + let policies: [EffectiveUpdatePolicy] = [ + .permissive, + .awaitingCore, + resolve(core: .legacyDefault), + resolve(core: CoreUpdatePolicy(enabled: true, channel: "rc", nudgesSuppressed: false)), + resolve(core: CoreUpdatePolicy(enabled: false, channel: "stable", nudgesSuppressed: false)), + resolve(core: .legacyDefault, env: ["CI": "true"]), + resolve(core: .legacyDefault, env: ["MCPPROXY_DISABLE_AUTO_UPDATE": "true"]) + ] + for policy in policies { + XCTAssertFalse(policy.automaticDownloadsAllowed, + "pre-downloading buys a faster click and costs the ability to " + + "refuse an install: \(policy)") + } + } + // MARK: - Channels (FR-014) func testStableChannelAcceptsOnlyTheDefaultSparkleChannel() { From 026f268d27aedf17cd808dbe9c43ae8b54418d67 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Sat, 8 Aug 2026 09:15:27 +0300 Subject: [PATCH 33/37] =?UTF-8?q?docs(update):=20link=20Prerelease=20Build?= =?UTF-8?q?s=20via=20GitHub=20=E2=80=94=20the=20doc=20is=20not=20on=20the?= =?UTF-8?q?=20website?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Related #957 The docs site's docusaurus include-whitelist serves only configuration/**, development/**, etc.; root-level docs/prerelease-builds.md has no site route, so /prerelease-builds broke the site build (onBrokenLinks: throw). --- docs/features/auto-update.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/features/auto-update.md b/docs/features/auto-update.md index 8c8ed177..132f4d4c 100644 --- a/docs/features/auto-update.md +++ b/docs/features/auto-update.md @@ -14,7 +14,7 @@ over, so MCPProxy only ever replaces what it owns and prints the exact command for everything else. Related: [Version Updates](/features/version-updates) (how the *check* works), -[Prerelease Builds](/prerelease-builds) (the RC channel). +[Prerelease Builds](https://github.com/smart-mcp-proxy/mcpproxy-go/blob/main/docs/prerelease-builds.md) (the RC channel). :::warning macOS one-click is built but not switched on The updater ships in every macOS build and does nothing until a maintainer @@ -167,7 +167,7 @@ channels: offers a tagged item only to clients that ask for that channel. The tray asks for `beta` only when the core reports `update_policy.channel == "rc"`. -See [Prerelease Builds](/prerelease-builds) for how to get on the RC channel. +See [Prerelease Builds](https://github.com/smart-mcp-proxy/mcpproxy-go/blob/main/docs/prerelease-builds.md) for how to get on the RC channel. ## Release infrastructure From bf2f6e3104094691e6886115b64c2cf1c9c2c16b Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Sat, 8 Aug 2026 09:20:59 +0300 Subject: [PATCH 34/37] fix(cli): serialize self-update per target with an OS-level advisory lock Related #957 Two concurrent 'mcpproxy update' runs could read each other's mid-swap sentinel state, misclassify it, and delete the only known-good backup. The whole recover-and-swap sequence now runs under an exclusive non-blocking flock (LockFileEx on Windows) on .update-lock; the second invocation fails fast with 'another update is already in progress'. The lock file is never unlinked (unlink+relock races two holders onto different inodes); the kernel drops the lock with the process, so a crash cannot wedge future updates. --- cmd/mcpproxy/update_apply.go | 10 ++++++ cmd/mcpproxy/update_apply_test.go | 51 +++++++++++++++++++++++++++++ cmd/mcpproxy/update_lock.go | 40 ++++++++++++++++++++++ cmd/mcpproxy/update_lock_unix.go | 25 ++++++++++++++ cmd/mcpproxy/update_lock_windows.go | 28 ++++++++++++++++ 5 files changed, 154 insertions(+) create mode 100644 cmd/mcpproxy/update_lock.go create mode 100644 cmd/mcpproxy/update_lock_unix.go create mode 100644 cmd/mcpproxy/update_lock_windows.go diff --git a/cmd/mcpproxy/update_apply.go b/cmd/mcpproxy/update_apply.go index 9826b758..b55adf53 100644 --- a/cmd/mcpproxy/update_apply.go +++ b/cmd/mcpproxy/update_apply.go @@ -228,6 +228,16 @@ func applyNewBinary(target, staged string, verify func(path string) error) (err backup := target + ".old" sentinel := target + swapSentinelSuffix + // The whole recover-and-swap sequence runs under an exclusive per-target + // lock: a concurrent invocation reading a sibling's sentinel/backup state + // mid-swap would misclassify it and could delete the only known-good + // backup. Crash-safe (the kernel releases it with the process). + release, lockErr := acquireUpdateLock(target) + if lockErr != nil { + return lockErr + } + defer release() + if recoverErr := recoverInterruptedSwap(target, backup, sentinel, verify); recoverErr != nil { return recoverErr } diff --git a/cmd/mcpproxy/update_apply_test.go b/cmd/mcpproxy/update_apply_test.go index 29c80106..ce113403 100644 --- a/cmd/mcpproxy/update_apply_test.go +++ b/cmd/mcpproxy/update_apply_test.go @@ -611,3 +611,54 @@ func TestReportsVersion(t *testing.T) { } } } + +func TestAcquireUpdateLock_SecondHolderIsRefused(t *testing.T) { + target := filepath.Join(t.TempDir(), "mcpproxy") + + release, err := acquireUpdateLock(target) + if err != nil { + t.Fatalf("first acquire: %v", err) + } + // flock/LockFileEx conflict even between two opens in the same process, + // so this stands in for a concurrent `mcpproxy update` invocation. + if _, err := acquireUpdateLock(target); !errors.Is(err, errUpdateInProgress) { + t.Fatalf("second acquire: want errUpdateInProgress, got %v", err) + } + + release() + release2, err := acquireUpdateLock(target) + if err != nil { + t.Fatalf("re-acquire after release: %v", err) + } + release2() +} + +func TestApplyNewBinary_RefusedWhileAnotherSwapHoldsTheLock(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "mcpproxy") + staged := filepath.Join(dir, "staged") + if err := os.WriteFile(target, []byte("current"), 0o755); err != nil { + t.Fatalf("write target: %v", err) + } + if err := os.WriteFile(staged, []byte("new"), 0o600); err != nil { + t.Fatalf("write staged: %v", err) + } + + release, err := acquireUpdateLock(target) + if err != nil { + t.Fatalf("acquire: %v", err) + } + defer release() + + if err := applyNewBinary(target, staged, nil); !errors.Is(err, errUpdateInProgress) { + t.Fatalf("applyNewBinary under a held lock: want errUpdateInProgress, got %v", err) + } + // The refused attempt must not have touched anything. + got, readErr := os.ReadFile(target) + if readErr != nil { + t.Fatalf("read target: %v", readErr) + } + if string(got) != "current" { + t.Fatalf("target changed by refused swap: %q", got) + } +} diff --git a/cmd/mcpproxy/update_lock.go b/cmd/mcpproxy/update_lock.go new file mode 100644 index 00000000..361700fb --- /dev/null +++ b/cmd/mcpproxy/update_lock.go @@ -0,0 +1,40 @@ +package main + +import ( + "errors" + "fmt" + "os" +) + +// errUpdateInProgress reports that another mcpproxy process holds the +// self-update lock for the same target binary. +var errUpdateInProgress = errors.New("another mcpproxy update is already updating this binary") + +// updateLockSuffix names the advisory lock file that serializes the whole +// recover-and-swap sequence per target. The file is deliberately NEVER +// unlinked: removing a held lock file lets a third process create-and-lock a +// fresh inode while a second still holds the old one, which is two "exclusive" +// holders. A leftover zero-byte .update-lock is the documented cost. +const updateLockSuffix = ".update-lock" + +// acquireUpdateLock takes an exclusive, non-blocking, OS-level advisory lock +// scoped to the target binary. It guards recoverInterruptedSwap AND the swap +// itself: without it, a concurrent `mcpproxy update` can read a half-finished +// sibling's sentinel/backup state, misclassify it, and delete the only +// known-good backup. The kernel releases the lock if the process dies, so a +// crash can never wedge future updates. +func acquireUpdateLock(target string) (release func(), err error) { + path := target + updateLockSuffix + f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600) // #nosec G304 -- derived from the caller-resolved target path + if err != nil { + return nil, fmt.Errorf("open update lock %s: %w", path, err) + } + if lockErr := lockFileExclusiveNB(f); lockErr != nil { + _ = f.Close() + if errors.Is(lockErr, errWouldBlock) { + return nil, fmt.Errorf("%w (lock: %s)", errUpdateInProgress, path) + } + return nil, fmt.Errorf("lock %s: %w", path, lockErr) + } + return func() { _ = f.Close() }, nil // closing the fd releases the lock +} diff --git a/cmd/mcpproxy/update_lock_unix.go b/cmd/mcpproxy/update_lock_unix.go new file mode 100644 index 00000000..595a45a3 --- /dev/null +++ b/cmd/mcpproxy/update_lock_unix.go @@ -0,0 +1,25 @@ +//go:build !windows + +package main + +import ( + "errors" + "os" + + "golang.org/x/sys/unix" +) + +// errWouldBlock is what lockFileExclusiveNB returns when another process +// already holds the lock. +var errWouldBlock = unix.EWOULDBLOCK + +// lockFileExclusiveNB takes a non-blocking exclusive flock on f. flock locks +// belong to the open file description, so they conflict even between two +// opens in the same process, and the kernel drops them when the process exits. +func lockFileExclusiveNB(f *os.File) error { + err := unix.Flock(int(f.Fd()), unix.LOCK_EX|unix.LOCK_NB) + if errors.Is(err, unix.EAGAIN) { + return errWouldBlock + } + return err +} diff --git a/cmd/mcpproxy/update_lock_windows.go b/cmd/mcpproxy/update_lock_windows.go new file mode 100644 index 00000000..256e980f --- /dev/null +++ b/cmd/mcpproxy/update_lock_windows.go @@ -0,0 +1,28 @@ +//go:build windows + +package main + +import ( + "errors" + "os" + + "golang.org/x/sys/windows" +) + +// errWouldBlock is what lockFileExclusiveNB returns when another process +// already holds the lock. +var errWouldBlock = errors.New("update lock held by another process") + +// lockFileExclusiveNB takes a non-blocking exclusive LockFileEx on the first +// byte of f. Windows releases the region lock when the handle is closed or +// the process exits. +func lockFileExclusiveNB(f *os.File) error { + ol := new(windows.Overlapped) + err := windows.LockFileEx(windows.Handle(f.Fd()), + windows.LOCKFILE_EXCLUSIVE_LOCK|windows.LOCKFILE_FAIL_IMMEDIATELY, + 0, 1, 0, ol) + if errors.Is(err, windows.ERROR_LOCK_VIOLATION) { + return errWouldBlock + } + return err +} From 6d615e3012862c16963dda6594f102d5100a70d7 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Sat, 8 Aug 2026 09:38:28 +0300 Subject: [PATCH 35/37] fix(cli): key the update lock on the binary, not on the name we resolved MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Related #957 The per-target lock did not serialize what it was supposed to. Its key was the resolved executable path, and on Linux os.Executable() reads /proc/self/exe, which names the running INODE rather than the path it was launched from. A second `mcpproxy update` that resolves its own path AFTER the first has renamed mcpproxy to mcpproxy.old therefore sees itself as mcpproxy.old: it locks mcpproxy.old.update-lock, contends with nobody, and proceeds to swap mcpproxy.old — moving the first process's only known-good backup aside. Two "exclusive" holders on two different files. macOS and Windows report the launch path and do not alias this way, so the fix is pure path arithmetic with no /proc reads and behaves identically everywhere. Both layers are addressed, because they answer different questions. The LOCK KEY is canonicalized: mcpproxy.old, mcpproxy.updating, mcpproxy.update-lock and the staged .mcpproxy.new- all resolve to mcpproxy, so however each process spells the path they contend on one file. Mapping an unrelated user file named foo.old onto foo's lock costs nothing worse than two updates taking turns. Deciding which file to REPLACE gets the opposite treatment: it refuses. Two things produce a target called mcpproxy.old — a concurrent update that just renamed the binary aside, where we must touch nothing, and a user who kept a copy under that name and ran it, where rewriting mcpproxy instead would silently update the wrong binary. Nothing on disk reliably separates them and one of the answers is destructive, so neither is guessed at; the error names both readings and says what to do about each. ## Changes - `canonicalUpdateTarget` / `stripUpdateArtifactSuffix`, applied repeatedly (a swap of an aliased path yields names like mcpproxy.old.old) and leaving non-aliased paths untouched rather than silently Clean-ing them - `acquireUpdateLock` keys on the canonical path - `refuseAliasedUpdateTarget` gates `applyNewBinary`, and `selfUpdate` too so a concurrent update is caught before ~90 MB is downloaded on its behalf - `backupSuffix` named, so the suffix list cannot drift from the swap ## Testing - go test -race ./cmd/mcpproxy/ — canonicalization table (incl. directories, double suffixes, staged names, near-misses), x and x.old contending on one lock with no aliased lock file created, and an aliased swap refused with the other update's backup left untouched - go build ./... ; GOOS=linux and GOOS=windows builds of ./cmd/mcpproxy --- cmd/mcpproxy/update_apply.go | 16 ++++- cmd/mcpproxy/update_apply_test.go | 110 ++++++++++++++++++++++++++++++ cmd/mcpproxy/update_cmd.go | 5 ++ cmd/mcpproxy/update_lock.go | 105 +++++++++++++++++++++++++++- 4 files changed, 234 insertions(+), 2 deletions(-) diff --git a/cmd/mcpproxy/update_apply.go b/cmd/mcpproxy/update_apply.go index b55adf53..2a83ca49 100644 --- a/cmd/mcpproxy/update_apply.go +++ b/cmd/mcpproxy/update_apply.go @@ -37,6 +37,11 @@ const ( // and "the replacement verified", so finding one means a swap died // halfway. See applyNewBinary. swapSentinelSuffix = ".updating" + + // backupSuffix names the copy of the previous binary kept until the new + // one has proved itself. Named rather than inlined because + // canonicalUpdateTarget has to recognise it. + backupSuffix = ".old" ) // parseChecksums parses a sha256sum-format manifest (" " or @@ -225,7 +230,16 @@ func ensureTargetWritable(target string) error { // recovery reads it to tell the two states apart. Nothing removes the backup // while the sentinel exists. func applyNewBinary(target, staged string, verify func(path string) error) (err error) { - backup := target + ".old" + // Before anything else, and before the lock: a target that is itself one of + // our working files means the path we were handed is an alias for a binary + // some other update owns. Swapping it would move that update's backup out + // from under it — the lock cannot help, because the two processes would be + // naming different files. + if aliasErr := refuseAliasedUpdateTarget(target); aliasErr != nil { + return aliasErr + } + + backup := target + backupSuffix sentinel := target + swapSentinelSuffix // The whole recover-and-swap sequence runs under an exclusive per-target diff --git a/cmd/mcpproxy/update_apply_test.go b/cmd/mcpproxy/update_apply_test.go index ce113403..75e42f68 100644 --- a/cmd/mcpproxy/update_apply_test.go +++ b/cmd/mcpproxy/update_apply_test.go @@ -662,3 +662,113 @@ func TestApplyNewBinary_RefusedWhileAnotherSwapHoldsTheLock(t *testing.T) { t.Fatalf("target changed by refused swap: %q", got) } } + +// On Linux os.Executable() reads /proc/self/exe, which names the running +// INODE — so a process whose binary a concurrent update has just renamed to +// .old resolves its own path to .old. Keying the lock on that name +// gives two "exclusive" holders on two different files, and the second one's +// swap then moves the first one's backup aside. +func TestCanonicalUpdateTarget(t *testing.T) { + for _, tc := range []struct{ in, want string }{ + {"mcpproxy", "mcpproxy"}, + {"mcpproxy" + backupSuffix, "mcpproxy"}, + {"mcpproxy" + swapSentinelSuffix, "mcpproxy"}, + {"mcpproxy" + updateLockSuffix, "mcpproxy"}, + // A swap of an already-aliased path would have produced this. + {"mcpproxy" + backupSuffix + backupSuffix, "mcpproxy"}, + {".mcpproxy.new-4242", "mcpproxy"}, + // Directories are preserved, and only the base name is examined. + {filepath.Join("/usr", "local", "bin", "mcpproxy"+backupSuffix), filepath.Join("/usr", "local", "bin", "mcpproxy")}, + {filepath.Join("/opt", "old", "mcpproxy"), filepath.Join("/opt", "old", "mcpproxy")}, + // Not ours: no suffix to strip, and nothing that only looks like one. + {"mcpproxy.new-notdigits", "mcpproxy.new-notdigits"}, + {backupSuffix, backupSuffix}, + } { + if got := canonicalUpdateTarget(tc.in); got != tc.want { + t.Errorf("canonicalUpdateTarget(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} + +// The two processes must contend on ONE lock however each of them spells the +// path — this is the aliasing bug reduced to its mechanism. +func TestAcquireUpdateLock_AliasedPathsContendOnOneLock(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "mcpproxy") + + release, err := acquireUpdateLock(target) + if err != nil { + t.Fatalf("first acquire: %v", err) + } + + // What the second process sees for its own executable mid-swap. + if _, err := acquireUpdateLock(target + backupSuffix); !errors.Is(err, errUpdateInProgress) { + t.Fatalf("acquire via the .old alias: want errUpdateInProgress, got %v", err) + } + + release() + release2, err := acquireUpdateLock(target + backupSuffix) + if err != nil { + t.Fatalf("re-acquire via the alias after release: %v", err) + } + release2() + + // One lock file, under the canonical name — not two. + if _, err := os.Stat(target + updateLockSuffix); err != nil { + t.Errorf("canonical lock file missing: %v", err) + } + if _, err := os.Stat(target + backupSuffix + updateLockSuffix); !os.IsNotExist(err) { + t.Error("an aliased lock file was created; the two processes did not contend") + } +} + +// Canonicalizing the lock key is not enough on its own: the swap must also +// refuse to REPLACE a file named like our own working files, because it cannot +// tell a concurrent update's backup from a copy the user renamed themselves. +func TestApplyNewBinary_RefusesAnAliasedTarget(t *testing.T) { + dir := t.TempDir() + canonical := filepath.Join(dir, "mcpproxy") + aliased := canonical + backupSuffix + staged := filepath.Join(dir, "staged") + + writeAll(t, map[string]string{ + canonical: "the binary another update is replacing", + aliased: "that update's only backup", + staged: "new", + }) + + err := applyNewBinary(aliased, staged, nil) + if !errors.Is(err, errUpdateTargetIsArtifact) { + t.Fatalf("want errUpdateTargetIsArtifact, got %v", err) + } + + // Nothing may have moved: the backup above is the only copy of a working + // binary that the other process still expects to find. + got, readErr := os.ReadFile(aliased) + if readErr != nil { + t.Fatalf("the other update's backup must survive: %v", readErr) + } + if string(got) != "that update's only backup" { + t.Errorf("backup content = %q", got) + } + if _, statErr := os.Stat(aliased + backupSuffix); !os.IsNotExist(statErr) { + t.Error("the refused swap moved the backup aside") + } + if _, statErr := os.Stat(aliased + swapSentinelSuffix); !os.IsNotExist(statErr) { + t.Error("the refused swap marked itself in progress") + } +} + +func TestRefuseAliasedUpdateTarget(t *testing.T) { + for _, name := range []string{"mcpproxy" + backupSuffix, "mcpproxy" + swapSentinelSuffix, + "mcpproxy" + updateLockSuffix, ".mcpproxy.new-17"} { + if err := refuseAliasedUpdateTarget(filepath.Join("/usr/local/bin", name)); err == nil { + t.Errorf("%s must be refused", name) + } + } + for _, name := range []string{"mcpproxy", "mcpproxy-server", "mcpproxy.exe", "oldmcpproxy"} { + if err := refuseAliasedUpdateTarget(filepath.Join("/usr/local/bin", name)); err != nil { + t.Errorf("%s must be accepted: %v", name, err) + } + } +} diff --git a/cmd/mcpproxy/update_cmd.go b/cmd/mcpproxy/update_cmd.go index 738ca6a7..0b842f50 100644 --- a/cmd/mcpproxy/update_cmd.go +++ b/cmd/mcpproxy/update_cmd.go @@ -430,6 +430,11 @@ func (r *updateRunner) selfUpdate(release *updatecheck.GitHubRelease) error { // Defence in depth: decideAction already routes bundles to the tray. return fmt.Errorf("refusing to modify %s: it is inside a macOS app bundle", target) } + // Checked here as well as inside applyNewBinary so a concurrent update is + // caught before ~90 MB is downloaded on its behalf. + if aliasErr := refuseAliasedUpdateTarget(target); aliasErr != nil { + return aliasErr + } // Fail before spending a ~90 MB download on an install we cannot write. if err := ensureTargetWritable(target); err != nil { diff --git a/cmd/mcpproxy/update_lock.go b/cmd/mcpproxy/update_lock.go index 361700fb..0ec86f45 100644 --- a/cmd/mcpproxy/update_lock.go +++ b/cmd/mcpproxy/update_lock.go @@ -4,12 +4,18 @@ import ( "errors" "fmt" "os" + "path/filepath" + "strings" ) // errUpdateInProgress reports that another mcpproxy process holds the // self-update lock for the same target binary. var errUpdateInProgress = errors.New("another mcpproxy update is already updating this binary") +// errUpdateTargetIsArtifact reports that the binary we were asked to replace is +// named like one of the files an update creates — see canonicalUpdateTarget. +var errUpdateTargetIsArtifact = errors.New("an update of this binary is already in progress") + // updateLockSuffix names the advisory lock file that serializes the whole // recover-and-swap sequence per target. The file is deliberately NEVER // unlinked: removing a held lock file lets a third process create-and-lock a @@ -24,7 +30,13 @@ const updateLockSuffix = ".update-lock" // known-good backup. The kernel releases the lock if the process dies, so a // crash can never wedge future updates. func acquireUpdateLock(target string) (release func(), err error) { - path := target + updateLockSuffix + // Keyed on the CANONICAL binary, never on whatever name the caller happens + // to hold. On Linux os.Executable() reads /proc/self/exe, which names the + // running inode — so a process whose binary was renamed to .old by a + // concurrent update resolves its own path to .old and would otherwise + // take out a second, entirely separate "exclusive" lock. Both processes + // have to contend on one file or the lock guarantees nothing. + path := canonicalUpdateTarget(target) + updateLockSuffix f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600) // #nosec G304 -- derived from the caller-resolved target path if err != nil { return nil, fmt.Errorf("open update lock %s: %w", path, err) @@ -38,3 +50,94 @@ func acquireUpdateLock(target string) (release func(), err error) { } return func() { _ = f.Close() }, nil // closing the fd releases the lock } + +// canonicalUpdateTarget maps any of the files an update creates next to a +// binary back to the binary itself: mcpproxy.old, mcpproxy.updating, +// mcpproxy.update-lock and the staged .mcpproxy.new- all canonicalize to +// mcpproxy. +// +// Pure path arithmetic — no /proc, no stat — so it behaves identically on every +// platform even though only Linux can produce the aliasing it defends against. +// Applied repeatedly, because a swap of an already-aliased path would have +// produced names like mcpproxy.old.old. +// +// Used for the LOCK KEY, where mapping an unrelated user file named foo.old +// onto foo's lock costs nothing worse than two updates taking turns. Deciding +// which file to REPLACE is a different question with a different answer — see +// refuseAliasedUpdateTarget. +func canonicalUpdateTarget(target string) string { + base := filepath.Base(target) + canonical := base + for range 4 { // bounded: each pass must shorten base, so this always settles + stripped, ok := stripUpdateArtifactSuffix(canonical) + if !ok { + break + } + canonical = stripped + } + if canonical == base { + // Nothing to canonicalize. Returned untouched rather than round-tripped + // through filepath.Join, which would also Clean it — quietly rewriting + // a path we were given no reason to rewrite. + return target + } + return filepath.Join(filepath.Dir(target), canonical) +} + +// stripUpdateArtifactSuffix removes one layer of update-artifact naming from a +// base name, reporting whether it removed anything. +func stripUpdateArtifactSuffix(base string) (string, bool) { + for _, suffix := range []string{backupSuffix, swapSentinelSuffix, updateLockSuffix} { + if len(base) > len(suffix) && strings.HasSuffix(base, suffix) { + return strings.TrimSuffix(base, suffix), true + } + } + // The staged binary: "..new-". It is never executed, so it + // cannot alias the way .old does, but a name we generate is a name we + // should recognise. + if strings.HasPrefix(base, ".") { + if idx := strings.LastIndex(base, ".new-"); idx > 1 { + if digits := base[idx+len(".new-"):]; digits != "" && isAllDigits(digits) { + return base[1:idx], true + } + } + } + return base, false +} + +func isAllDigits(s string) bool { + for _, r := range s { + if r < '0' || r > '9' { + return false + } + } + return true +} + +// refuseAliasedUpdateTarget rejects a target that is named like one of our own +// update artifacts, instead of quietly canonicalizing it. +// +// Stripping the suffix and updating the canonical binary would be wrong at +// least as often as it was right. Two things produce a target called +// mcpproxy.old: +// +// - a concurrent update that has just renamed the binary aside, and this +// process (on Linux) resolved /proc/self/exe afterwards — here the caller +// must not touch anything; +// - a user who kept a copy under that name and ran it — here rewriting +// mcpproxy instead of the file they invoked is silently updating the wrong +// binary. +// +// Nothing on disk reliably separates the two, and one of the answers is +// destructive, so neither gets guessed at. +func refuseAliasedUpdateTarget(target string) error { + base := filepath.Base(target) + if _, aliased := stripUpdateArtifactSuffix(base); !aliased { + return nil + } + return fmt.Errorf("%w: %s is named like the working files mcpproxy creates while replacing a "+ + "binary, so updating it could overwrite another update's only backup.\n"+ + "If an update is running, wait for it to finish. If this is a copy you renamed yourself, "+ + "give it a name that does not end in %q, %q or %q and run the update again", + errUpdateTargetIsArtifact, target, backupSuffix, swapSentinelSuffix, updateLockSuffix) +} From ada3edd4f0738f4da3c74334061316cd24e173ff Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Sat, 8 Aug 2026 15:51:34 +0300 Subject: [PATCH 36/37] ci(release): auto-publish the Sparkle feeds to mcpproxy.app Related #957 After uploading the signed appcasts as release assets, release.yml and prerelease.yml dispatch publish-appcast at the website repo (same MARKETING_SITE_DISPATCH_TOKEN pattern as the marketing version bump); its receiving workflow downloads the feeds from the public release, verifies signatures, and commits them into public/ for Cloudflare Pages. Non-blocking and skipped when no appcast was generated; manual backfill stays possible via the site workflow's workflow_dispatch. Website side: smart-mcp-proxy/mcpproxy.app-website#4. --- .github/workflows/prerelease.yml | 14 ++++++++++++++ .github/workflows/release.yml | 17 +++++++++++++++++ docs/features/auto-update.md | 17 ++++++++++++----- 3 files changed, 43 insertions(+), 5 deletions(-) diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml index 3da0b293..16ceaea2 100644 --- a/.github/workflows/prerelease.yml +++ b/.github/workflows/prerelease.yml @@ -1111,6 +1111,7 @@ jobs: path: enclosures - name: Generate and publish the beta appcast + id: appcast env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} # No actions/checkout in this job, so `gh` cannot infer the repository @@ -1183,6 +1184,7 @@ jobs: done gh release upload "${GITHUB_REF_NAME}" appcast-out/*.xml --clobber + echo "generated=true" >> "$GITHUB_OUTPUT" - name: Upload beta appcast artifact for the website repo uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -1190,3 +1192,15 @@ jobs: name: sparkle-appcast-beta path: appcast-out/* if-no-files-found: ignore + + # Same site hand-off as release.yml, beta channel. Non-blocking; the + # site workflow is manually re-runnable (workflow_dispatch). + - name: Publish beta feeds to mcpproxy.app + if: steps.appcast.outputs.generated == 'true' + continue-on-error: true + uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4.0.1 + with: + token: ${{ secrets.MARKETING_SITE_DISPATCH_TOKEN }} + repository: smart-mcp-proxy/mcpproxy.app-website + event-type: publish-appcast + client-payload: '{"version": "${{ github.ref_name }}", "channel": "beta"}' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 30090cf6..a0c00d4b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1586,6 +1586,7 @@ jobs: path: enclosures - name: Generate and publish the appcast + id: appcast env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} # This job deliberately does not check the repo out (it only needs the @@ -1663,6 +1664,7 @@ jobs: done gh release upload "${GITHUB_REF_NAME}" appcast-out/*.xml --clobber + echo "generated=true" >> "$GITHUB_OUTPUT" - name: Upload appcast artifact for the website repo uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -1671,6 +1673,21 @@ jobs: path: appcast-out/* if-no-files-found: ignore + # Tell mcpproxy.app to serve the freshly uploaded feeds (they are public + # release assets by now — the site's publish-appcast workflow downloads + # them from the release, so no artifact plumbing crosses repos). + # Non-blocking: the feeds stay downloadable from the release either way, + # and the site workflow can be re-run by hand (workflow_dispatch). + - name: Publish feeds to mcpproxy.app + if: steps.appcast.outputs.generated == 'true' + continue-on-error: true + uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4.0.1 + with: + token: ${{ secrets.MARKETING_SITE_DISPATCH_TOKEN }} + repository: smart-mcp-proxy/mcpproxy.app-website + event-type: publish-appcast + client-payload: '{"version": "${{ github.ref_name }}", "channel": "stable"}' + provenance: needs: [release] permissions: diff --git a/docs/features/auto-update.md b/docs/features/auto-update.md index 132f4d4c..210ab41e 100644 --- a/docs/features/auto-update.md +++ b/docs/features/auto-update.md @@ -238,11 +238,18 @@ updater will fight over the same install. ### What the website repository must serve The shipped `Info.plist` sets `SUFeedURL` to `https://mcpproxy.app/appcast.xml`, -and this repository cannot publish there. The release pipeline instead attaches -the feeds to the GitHub release **and** exports them as workflow artifacts for -the website repo to pick up: - -| Workflow artifact | Files | Must be served at | +and this repository cannot publish there directly. Publishing is automated as a +hand-off: after uploading the signed feeds as release assets, `release.yml` +(stable) and `prerelease.yml` (beta) fire a `publish-appcast` +repository dispatch (via `MARKETING_SITE_DISPATCH_TOKEN`, same pattern as the +existing marketing version bump) at the website repo, whose +`publish-appcast.yml` workflow downloads the feeds from the public release, +verifies them (XML + RSS + `sparkle:edSignature`), and commits them into +`public/` for Cloudflare Pages. Manual backfill: run that workflow via +`workflow_dispatch` with a tag + channel. The feeds are also exported as +workflow artifacts for inspection: + +| Workflow artifact | Files | Served at | |---|---|---| | `sparkle-appcast` (from `release.yml`) | `appcast-arm64.xml`, `appcast-amd64.xml` | `https://mcpproxy.app/appcast-arm64.xml`, `https://mcpproxy.app/appcast-amd64.xml` | | `sparkle-appcast-beta` (from `prerelease.yml`) | `appcast-beta-arm64.xml`, `appcast-beta-amd64.xml` | `https://mcpproxy.app/appcast-beta-arm64.xml`, `https://mcpproxy.app/appcast-beta-amd64.xml` | From 663d52c6b1f7543cd7ecd140cab53b6b421904ea Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Sat, 8 Aug 2026 15:55:20 +0300 Subject: [PATCH 37/37] =?UTF-8?q?docs(092):=20rollout=20&=20first-test=20p?= =?UTF-8?q?lan=20=E2=80=94=20failure=20modes,=20RC=20dress=20rehearsal,=20?= =?UTF-8?q?N+1=20canary?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Related #957 Detection failures degrade to the legacy browser nudge (structurally never 'stuck forever'); install failures are the class to guard. First real test is an rc.1->rc.2 one-click on the beta channel with the full production pipeline before any stable ships; the concurrency release follows within days as the first OTA payload to minimize the exposure window if release N's updater is broken. --- specs/092-auto-updater/rollout-plan.md | 143 +++++++++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 specs/092-auto-updater/rollout-plan.md diff --git a/specs/092-auto-updater/rollout-plan.md b/specs/092-auto-updater/rollout-plan.md new file mode 100644 index 00000000..7a78eaaa --- /dev/null +++ b/specs/092-auto-updater/rollout-plan.md @@ -0,0 +1,143 @@ +# Auto-Updater Rollout & First-Test Plan + +**Feature**: 092-auto-updater · **Created**: 2026-08-08 · **Status**: Active + +The governing risk: **release N ships a broken updater, and N's users can never +be reached over the air again.** Every decision below is shaped by that +asymmetry — detection failures are recoverable nuisances, but only because we +keep independent fallback channels alive; a broken *install* path would be +worse than no updater at all. + +## Why "stuck forever" is already structurally impossible + +Three update channels exist and fail independently. The Sparkle feed is the +*best* one, not the *only* one: + +| Channel | Detects via | Survives a broken… | +|---|---|---| +| Sparkle one-click | signed appcast at mcpproxy.app | — (the thing under test) | +| Legacy nudge → browser download | GitHub Releases API, polled by core (24h) **and** tray — no appcast, no EdDSA involved | broken feed, wrong key, dead site | +| Manual DMG over-install | user action | everything above | + +Two invariants make the fallbacks real, both already tested on this branch: + +1. **Feed empty/404/unverifiable → the GitHub-check result renders as a + browser-download menu item** (FR-017 state machine; feed-404 tests). A user + whose feed never works still gets nudged — through a channel with no shared + failure mode. +2. **Manual DMG over-install now works cleanly** (Phase 0: bundle-replacement + detection + stale-core supersede + postinstall quit — the #957 fix). The + escape hatch is no longer booby-trapped. + +So "fails to detect new version" degrades to "user updates the old way, like +every release before this one." The plan's job is to make sure we *notice* +quickly and never regress the fallbacks. + +## Failure modes & mitigations + +### Detection (user stays on old version — annoying, recoverable) + +| # | Failure | Mitigation in place | Residual action | +|---|---|---|---| +| D1 | Appcast not served (site PR unmerged, CF outage, stale deploy) | GitHub-check fallback nudges; site workflow verifies live URLs after deploy (below) | Merge website PR before release N | +| D2 | Wrong/placeholder `SUPublicEDKey` in shipped app, or wrong private key in CI | Sparkle refuses items *silently* → fallback nudge still fires; RC dress rehearsal (below) catches it before stable | Never rotate the key casually | +| D3 | Feed malformed / missing `edSignature` | CI refuses to publish such a feed (generate + site workflows both grep for the signature); fallback | — | +| D4 | Arch mix-up (arm feed serving amd enclosure) | Per-arch feeds generated from per-arch enclosure dirs; RC rehearsal runs on arm64 at minimum | Verify amd64 once via the rehearsal checklist | +| D5 | Update policy stuck restrictive (core never reports policy) | `awaitingCore` default is deliberate; `legacyDefault` stamps once a core actually answered; manual "Check for Updates" always allowed | — | +| D6 | SemVer comparison bug hides an update | SemVer 2.0 suite (rc.2/rc.10, build metadata, malformed) | — | +| D7 | Stable user offered an RC (or vice versa) | Channel-tagged beta feeds at separate URLs; policy-gated | Negative check in rehearsal | + +### Install (worse class — must never strand a broken app) + +| # | Failure | Mitigation | +|---|---|---| +| I1 | Crash mid-swap | Sparkle's install is atomic; CLI path is sentinel-journaled + advisory-locked with `.old` rollback and post-swap `--version` proof | +| I2 | Old core survives the update | Phase 0 supersede runs permanently at attach + on every version report — cleans up after *any* install path, including Sparkle's | +| I3 | Updated app killed by Gatekeeper (not stapled) | Enclosure is the notarized+stapled zip; **rehearsal must run on a real signed build, not a dev build** (Sparkle's own documented requirement) | +| I4 | App translocated / read-only volume | Surfaced error + browser fallback (FR-016), not a silent no-op | +| I5 | Install-on-quit replacing bundle under a live core | Path disabled by construction (`automaticallyDownloadsUpdates` forced false; tripwire logs if ever re-enabled) | + +### Systemic (the "broken N" scenario) + +| # | Failure | Mitigation | +|---|---|---| +| S1 | Release N's updater is broken; N+1 can't reach users OTA | Fallback channels (above); **minimize the N→N+1 gap** — see sequencing; release notes for N state the manual path | +| S2 | A *bad* update is being offered (worse than none) | Feed pull = `git revert` on the website repo (feeds live in `public/` under version control) — offering stops within one Pages deploy, ~1 min; users stay on current version | +| S3 | Site serves stale feeds silently | Post-deploy live verification in the site workflow (hash-compare against the release assets) | +| S4 | Adoption is broken and nobody notices | Telemetry heartbeats carry version — watch the version mix on the dashboard; success = N→N+1 migration visibly faster than the historical manual-update curve | + +## The test plan + +### Stage 0 — local rehearsal (no releases; can run today on this branch) + +Build the rig once, keep it as the pre-release smoke test: + +1. Build two app bundles locally (`build-swift-app.sh` at fake versions + `v0.99.0-test` → `v0.99.1-test`), signed with the dev identity, **test** + EdDSA keypair (never the production key). +2. `generate_appcast` over the v0.99.1 zip; serve feed + zip from + `python3 -m http.server`; point the installed v0.99.0 at it + (`defaults write com.smartmcpproxy.mcpproxy SUFeedURL http://localhost:8000/appcast.xml` + — any non-`appcast.xml`-default URL is honored verbatim). +3. **Green path**: menu shows "Update 0.99.1 — ready to restart?" → click → + download → core stopped (verify by pid) → bundle swapped → relaunch → app + *and* core report 0.99.1; old core did not survive. +4. **Negative paths**, same rig: + - tamper one byte of the zip → Sparkle refuses, current version keeps running; + - serve a feed signed with a *different* key → refused; + - kill the HTTP server → menu falls back to the browser-download item; + - `MCPPROXY_DISABLE_AUTO_UPDATE=1` → no nudge, manual check still works. + +### Stage 1 — RC dress rehearsal (the real "first test", full production pipeline) + +The RC channel is the staging environment: `prerelease.yml` now has full parity +(beta feeds, checksums, cosign, site dispatch). **No stable user is exposed.** + +1. Merge PR #958 and website PR #4. Cut `vNEXT-rc.1` per the prerelease flow. +2. Verify the pipeline did its job with no hands: + `appcast-beta-*.xml` on the release **and** live at mcpproxy.app (site + workflow green incl. live verification), enclosure downloads, stapler + validates it. +3. Install rc.1 from the DMG on the laptop (manual — this is everyone's last + manual install), opt into the RC channel. +4. Cut `vNEXT-rc.2` (trivial diff). Wait for the scheduled check or use + "Check for Updates": **one click must land rc.2** with the full + stop-core → swap → relaunch → supersede chain on a real notarized build. +5. Negative: a stable-channel install must not see rc.2; `mcpproxy update + --check` on the RC channel reports it; on a brew install prints the brew + line. +6. Only a clean rc.1→rc.2 one-click unlocks the stable release. + +### Stage 2 — stable N (auto-updater release) + +Cut stable per the usual process (tag main; update site links). Release notes +say explicitly: *this release installs manually; from the next release, updates +are one click* — and name the fallback (menu → browser) in case the feed is +ever unreachable. + +### Stage 3 — N+1 canary: the concurrency release (minimize the gap) + +Ship 093 (request concurrency) as `v(N+1)` **within days, not weeks** — it is +merged, Codex-clean, and deliberately independent, so it is the ideal +first-OTA payload: + +- If the updater works: users get a real feature via one click, and the + dashboard shows the migration curve. +- If the updater is broken: the exposure window is days, the population is one + release deep, and every user still has two working fallback channels. + +Watch after shipping N+1: version mix in telemetry heartbeats (expect N to +drain measurably faster than historical releases), GitHub issues mentioning +updates, and the site workflow staying green. + +## Standing rules distilled from this plan + +- The GitHub-check fallback and the Phase 0 supersede are **permanent safety + rails** — never removed as "redundant with Sparkle". +- The production EdDSA key never rotates casually (shipped apps pin it; a + rotation orphans every install back to manual). +- Feeds are pulled (git revert on the site), never edited by hand — an edited + body fails signature verification anyway. +- Every future release is implicitly an updater test: N's pipeline publishes + the feed that N−1's users consume. A failed `sparkle-appcast` job or a red + site workflow is a release blocker, not a warning.