diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml index 19b7c1197..16ceaea22 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 @@ -217,6 +224,15 @@ 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. 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 @@ -690,6 +706,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: | @@ -772,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.')) @@ -850,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:" @@ -931,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: | @@ -943,3 +1080,127 @@ 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. + # + # 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 + 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 + id: 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" + 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 + echo "generated=true" >> "$GITHUB_OUTPUT" + + - 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 + + # 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 f35c8670c..a0c00d4b6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -390,14 +390,25 @@ 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): 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 +423,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 +556,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,39 +692,66 @@ 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. + # 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' uses: ./.github/actions/build-linux-packages @@ -899,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: | @@ -1363,6 +1550,144 @@ 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 + id: 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" + 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 + echo "generated=true" >> "$GITHUB_OUTPUT" + + - 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 + + # 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/CLAUDE.md b/CLAUDE.md index 12157195c..456f47ff6 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/generate-types/main.go b/cmd/generate-types/main.go index 8c78cd3c9..743516d67 100644 --- a/cmd/generate-types/main.go +++ b/cmd/generate-types/main.go @@ -446,6 +446,27 @@ 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; + // 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/cmd/mcpproxy/main.go b/cmd/mcpproxy/main.go index 17759f2eb..34d597bd4 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/status_cmd.go b/cmd/mcpproxy/status_cmd.go index 07ea457d8..75be2917a 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 000000000..d57253018 --- /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/cmd/mcpproxy/update_apply.go b/cmd/mcpproxy/update_apply.go new file mode 100644 index 000000000..2a83ca49c --- /dev/null +++ b/cmd/mcpproxy/update_apply.go @@ -0,0 +1,498 @@ +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 + + // 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" + + // 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 +// " *") 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. +// +// 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) { + // 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 + // 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 + } + + 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) + } + + // 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) + } + + 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 +} + +// writeSwapSentinel marks the target as mid-swap. +// +// 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("mark the update in progress (%s): %w", sentinel, err) + } + 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 !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) + } + } + + 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 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 +} + +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. +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 !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 new file mode 100644 index 000000000..75e42f68b --- /dev/null +++ b/cmd/mcpproxy/update_apply_test.go @@ -0,0 +1,774 @@ +package main + +import ( + "archive/zip" + "bytes" + "errors" + "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)) + } +} + +// 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)) + } +} + +// 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") + 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") + 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") + } +} + +// 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) + } + } +} + +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) + } +} + +// 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 new file mode 100644 index 000000000..0b842f506 --- /dev/null +++ b/cmd/mcpproxy/update_cmd.go @@ -0,0 +1,733 @@ +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 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|prerelease)\.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: + // 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 + } + 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) + } + // 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 { + 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 000000000..c1dc29c7f --- /dev/null +++ b/cmd/mcpproxy/update_cmd_test.go @@ -0,0 +1,575 @@ +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) + } + }) + } +} + +// 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) { + 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_lock.go b/cmd/mcpproxy/update_lock.go new file mode 100644 index 000000000..0ec86f453 --- /dev/null +++ b/cmd/mcpproxy/update_lock.go @@ -0,0 +1,143 @@ +package main + +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 +// 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) { + // 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) + } + 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 +} + +// 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) +} diff --git a/cmd/mcpproxy/update_lock_unix.go b/cmd/mcpproxy/update_lock_unix.go new file mode 100644 index 000000000..595a45a37 --- /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 000000000..256e980ff --- /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 +} diff --git a/cmd/mcpproxy/update_owner_unix.go b/cmd/mcpproxy/update_owner_unix.go new file mode 100644 index 000000000..5436c4e47 --- /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 000000000..58a58a4df --- /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 000000000..3a54b43ef --- /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/api/rest-api.md b/docs/api/rest-api.md index f229691f8..fdc7c613c 100644 --- a/docs/api/rest-api.md +++ b/docs/api/rest-api.md @@ -892,6 +892,13 @@ Get application info, version, and update availability. "http": "127.0.0.1:8080", "socket": "/Users/user/.mcpproxy/mcpproxy.sock" }, + "launched_by": "tray", + "pid": 4711, + "update_policy": { + "enabled": true, + "channel": "stable", + "nudges_suppressed": false + }, "update": { "available": true, "latest_version": "v1.3.0", @@ -914,6 +921,12 @@ 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. | +| `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/docs/configuration.md b/docs/configuration.md index 7f5ad5352..204f0dafb 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/configuration/environment-variables.md b/docs/configuration/environment-variables.md index 64622722a..6eece1208 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/docs/features/auto-update.md b/docs/features/auto-update.md new file mode 100644 index 000000000..210ab41e1 --- /dev/null +++ b/docs/features/auto-update.md @@ -0,0 +1,319 @@ +--- +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](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 +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 | +|---|---|---| +| **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. + +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) + +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`, +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. + +### 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. There is never more than 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 + +| 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. + +**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 +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](https://github.com/smart-mcp-proxy/mcpproxy-go/blob/main/docs/prerelease-builds.md) 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, 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 + +| 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. + +## 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` | — | ☐ | + +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 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` | + +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. +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 + +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. + +**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. + +**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 ba7fb87b4..35fa11c74 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: @@ -171,10 +177,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 +214,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 +241,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,12 +252,55 @@ 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 +### `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/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 000000000..7b695a07b --- /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:

+
    +
  • Core: internal/updatecheck (Spec 079) polls GitHub daily, detects the install channel (dmg / homebrew / deb / rpm / docker / go-install / windows-installer / tarball) and surfaces guidance via /api/v1/info, status, doctor, Web UI, and tray. The DMG channel deliberately gets no command — only “Download the latest DMG from…”. + internal/updatecheck/channel.go:129-217 · guidance.go:12-25, 58-59 — CONFIRMED
  • +
  • Tray: Sparkle 2.9.3 is declared, linked (@rpath/Sparkle.framework per otool), bundled and signed by both build scripts — but no Swift file ever import Sparkles. checkWithSparkle() is a stub that falls through to a raw GitHub API check. The menu already has the exact slots: “Check for Updates” and a conditional “Update available: vX” that just opens the browser. + Package.swift:8 · UpdateService.swift:49,132-139 · MCPProxyApp.swift:1112-1115, 1132-1136 — CONFIRMED
  • +
  • Sparkle infra is half-plumbed but dead: Info.plist has SUFeedURL=https://mcpproxy.app/appcast.xml, SUPublicEDKey=SPARKLE_PUBLIC_KEY_PLACEHOLDER, SUEnableAutomaticChecks=true. No appcast.xml exists in the repo, the release workflow, or the website repo. No EdDSA keys were ever generated. + native/macos/MCPProxy/MCPProxy/Info.plist:35-42 — CONFIRMED
  • +
  • Release pipeline already publishes trust artifacts: checksums.txt + keyless cosign bundle + SLSA v1 provenance. Gotcha: the bare per-arch DMG is signed but not notarized; only the PKG-wrapping -installer.dmg is notarized + stapled. A Sparkle enclosure needs a notarized, stapled, symlink-preserving .app zip — a new CI asset. + .github/workflows/release.yml — CONFIRMED
  • +
  • No mcpproxy update subcommand exists. The legacy Go tray (tarball-only now) has a real binary self-updater (inconshreveable/go-update) that refuses to run inside .app bundles. + cmd/mcpproxy/ · internal/tray/tray.go — CONFIRMED
  • +
+ +

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/frontend/src/components/UpdateBanner.vue b/frontend/src/components/UpdateBanner.vue index 1d451da6f..94f488820 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/frontend/src/types/contracts.ts b/frontend/src/types/contracts.ts index 28a69fcb2..2d54819d3 100644 --- a/frontend/src/types/contracts.ts +++ b/frontend/src/types/contracts.ts @@ -380,4 +380,25 @@ 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; + // 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 4f8eb7cb0..ae4c36354 100644 --- a/internal/contracts/types.go +++ b/internal/contracts/types.go @@ -1166,4 +1166,39 @@ 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"` + // 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"` + // 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 d55a50a6b..1451435e6 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_launched_by_test.go b/internal/httpapi/info_launched_by_test.go new file mode 100644 index 000000000..558e5a4b7 --- /dev/null +++ b/internal/httpapi/info_launched_by_test.go @@ -0,0 +1,101 @@ +package httpapi + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "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") +} + +// 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/info_update_policy_test.go b/internal/httpapi/info_update_policy_test.go new file mode 100644 index 000000000..f28dd761c --- /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 e2f0945d6..887bf48bc 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 5a5986af2..0121aa337 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" @@ -23,6 +24,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" @@ -157,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) @@ -1105,6 +1113,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 +1156,23 @@ 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(), + // 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(), + // 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() @@ -1195,6 +1221,17 @@ 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 + +// 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/internal/launch/provenance.go b/internal/launch/provenance.go new file mode 100644 index 000000000..c194b911a --- /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 000000000..42a8ee3be --- /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/internal/runtime/runtime.go b/internal/runtime/runtime.go index 726db5f68..61d847995 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 eaba679b1..062d3894d 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/channel.go b/internal/updatecheck/channel.go index a3925bf4e..12b4beb4c 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 000000000..4073b961f --- /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/github.go b/internal/updatecheck/github.go index 762519b53..c96d06004 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 +} diff --git a/internal/updatecheck/guidance.go b/internal/updatecheck/guidance.go index a727528eb..702765efc 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 0c17151e5..845835efb 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) { diff --git a/internal/updatecheck/policy.go b/internal/updatecheck/policy.go new file mode 100644 index 000000000..a83f6bdae --- /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 000000000..79db97239 --- /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/native/macos/MCPProxy/MCPProxy/API/Models.swift b/native/macos/MCPProxy/MCPProxy/API/Models.swift index 41dfac8e0..f3f36e982 100644 --- a/native/macos/MCPProxy/MCPProxy/API/Models.swift +++ b/native/macos/MCPProxy/MCPProxy/API/Models.swift @@ -794,12 +794,33 @@ 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? + + /// 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" case listenAddr = "listen_addr" case endpoints case update + case launchedBy = "launched_by" + case pid + case updatePolicy = "update_policy" } } diff --git a/native/macos/MCPProxy/MCPProxy/Core/BundledCore.swift b/native/macos/MCPProxy/MCPProxy/Core/BundledCore.swift new file mode 100644 index 000000000..3ec932a9d --- /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 5ef656398..e564afc73 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,238 @@ 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 { + // 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 { + 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() + } + + /// 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 + /// `/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 { + // 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 { + 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. @@ -1022,7 +1294,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) { @@ -1245,6 +1526,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" }), @@ -1267,6 +1558,21 @@ actor CoreProcessManager { } await MainActor.run { + // 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. + // + // 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 { @@ -1852,6 +2158,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 000000000..1a0a7732e --- /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/Core/SemanticVersion.swift b/native/macos/MCPProxy/MCPProxy/Core/SemanticVersion.swift new file mode 100644 index 000000000..401234241 --- /dev/null +++ b/native/macos/MCPProxy/MCPProxy/Core/SemanticVersion.swift @@ -0,0 +1,214 @@ +// 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. + 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 (false, true): + return 1 + case (false, false): + // §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 + } + + /// 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`, + /// 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/MCPProxyApp.swift b/native/macos/MCPProxy/MCPProxy/MCPProxyApp.swift index ecf1eeba3..19868717e 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,39 @@ 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. + // 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 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 + // 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 +309,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 +318,20 @@ 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) + + // 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) @@ -572,6 +623,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. @@ -705,6 +760,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, @@ -1114,25 +1178,71 @@ 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 + // 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()) @@ -1243,6 +1353,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 ?? "" @@ -1392,6 +1597,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/BundleUpdateWatcher.swift b/native/macos/MCPProxy/MCPProxy/Services/BundleUpdateWatcher.swift new file mode 100644 index 000000000..2a7dbdcb6 --- /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/MCPProxy/Services/FeedUpdater.swift b/native/macos/MCPProxy/MCPProxy/Services/FeedUpdater.swift new file mode 100644 index 000000000..b6ff4aecb --- /dev/null +++ b/native/macos/MCPProxy/MCPProxy/Services/FeedUpdater.swift @@ -0,0 +1,409 @@ +// 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) + + /// 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. +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. + /// + /// 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 + +#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 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 + /// FR-010's menu item is supposed to stay there, gently, until it is + /// installed. + private(set) var offeredVersion: String? + + /// 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. 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 } + + 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 + 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 + // 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 + + // 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). + 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-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 } + // 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(), channel: policy.channel + ) + 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. + 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 + updateIsReadyToInstall = false + onMain { [weak self] in self?.observer?.feedUpdaterDidNotFindUpdate() } + } + + /// 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. + /// + /// 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 + } + + /// 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) { + reportUnvetoableStop(from: "willInstallUpdate") + } + + func updaterWillRelaunchApplication(_ updater: SPUUpdater) { + 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) { + 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, 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 + 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 000000000..6b0dfd48b --- /dev/null +++ b/native/macos/MCPProxy/MCPProxy/Services/ManagedCoreStop.swift @@ -0,0 +1,135 @@ +// 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 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 { + + /// 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 + + /// 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 + + /// 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, + killGracePeriod: TimeInterval = defaultKillGracePeriod, + 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) + + if waitForExit(pid: pid, budget: gracePeriod, isRunning: isRunning, wait: wait) { + 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 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 new file mode 100644 index 000000000..77520ea07 --- /dev/null +++ b/native/macos/MCPProxy/MCPProxy/Services/SparkleFeedURL.swift @@ -0,0 +1,63 @@ +// 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`, and on the RC channel + /// `appcast-beta-arm64.xml` / `appcast-beta-amd64.xml`. + static let defaultFeedFileName = "appcast.xml" + + /// 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 + /// mangling their URL would be worse than fetching it. + /// + /// - Parameters: + /// - feedURL: the URL from Info.plist (`SUFeedURL`). + /// - arch: `"arm64"` or `"amd64"` (see `UpdateService.hostArchToken`). + /// - 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( + fileName(channel: channel, arch: arch) + ) + return components.string ?? feedURL + } +} diff --git a/native/macos/MCPProxy/MCPProxy/Services/StagedCoreBinary.swift b/native/macos/MCPProxy/MCPProxy/Services/StagedCoreBinary.swift new file mode 100644 index 000000000..1fbb3700c --- /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/MCPProxy/Services/UpdateInstallability.swift b/native/macos/MCPProxy/MCPProxy/Services/UpdateInstallability.swift new file mode 100644 index 000000000..e100a90a8 --- /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 000000000..ecdf55d4e --- /dev/null +++ b/native/macos/MCPProxy/MCPProxy/Services/UpdateMenuState.swift @@ -0,0 +1,100 @@ +// 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 — "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. + +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 + + 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)) + } + + 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 000000000..5584cd894 --- /dev/null +++ b/native/macos/MCPProxy/MCPProxy/Services/UpdatePolicy.swift @@ -0,0 +1,217 @@ +// 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" + } + + /// 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 + +/// 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 + + /// 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, + 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 + +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 { + // 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: false, + nudgesSuppressed: false, + channel: channel, + disabledReason: EffectiveUpdatePolicy.awaitingCore.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 5f3afa2b9..728e81a63 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,47 @@ 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), 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" @@ -43,23 +71,163 @@ 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. + /// + /// `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) + } + + /// 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 @@ -87,36 +255,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.. Void) { + if Thread.isMainThread { + work() + } else { + DispatchQueue.main.async(execute: work) + } + } + + /// 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") + 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/MCPProxy/State/AppState.swift b/native/macos/MCPProxy/MCPProxy/State/AppState.swift index 51e04c612..b71c38516 100644 --- a/native/macos/MCPProxy/MCPProxy/State/AppState.swift +++ b/native/macos/MCPProxy/MCPProxy/State/AppState.swift @@ -217,6 +217,25 @@ 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 + /// 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/BundleUpdateWatcherTests.swift b/native/macos/MCPProxy/MCPProxyTests/BundleUpdateWatcherTests.swift new file mode 100644 index 000000000..09a31cf3a --- /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/CoreSupersedeAttachTests.swift b/native/macos/MCPProxy/MCPProxyTests/CoreSupersedeAttachTests.swift new file mode 100644 index 000000000..09ce5b453 --- /dev/null +++ b/native/macos/MCPProxy/MCPProxyTests/CoreSupersedeAttachTests.swift @@ -0,0 +1,177 @@ +// 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, 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( + 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 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") + } + + // 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 000000000..753d01e5b --- /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/ManagedCoreStopTests.swift b/native/macos/MCPProxy/MCPProxyTests/ManagedCoreStopTests.swift new file mode 100644 index 000000000..5e0085261 --- /dev/null +++ b/native/macos/MCPProxy/MCPProxyTests/ManagedCoreStopTests.swift @@ -0,0 +1,148 @@ +// 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, + identityChecks: (() -> Bool)? = nil, + survivesSIGKILL: Bool = false, + gracePeriod: TimeInterval = ManagedCoreStop.defaultGracePeriod + ) -> ManagedCoreStopOutcome { + ManagedCoreStop.stop( + pid: pid, + gracePeriod: gracePeriod, + isCore: { _ in identityChecks?() ?? 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 && !survivesSIGKILL { 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) + } + + // 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/ModelsTests.swift b/native/macos/MCPProxy/MCPProxyTests/ModelsTests.swift index 51013bc85..c11487d3b 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/SemanticVersionTests.swift b/native/macos/MCPProxy/MCPProxyTests/SemanticVersionTests.swift new file mode 100644 index 000000000..1a543633a --- /dev/null +++ b/native/macos/MCPProxy/MCPProxyTests/SemanticVersionTests.swift @@ -0,0 +1,177 @@ +// SemanticVersionTests.swift +// MCPProxyTests +// +// Spec 092 FR-006. Every version comparison that can stop a process or offer +// an update goes through `SemanticVersion`, so the ordering it implements is +// pinned here rather than left to the two call sites to get right. +// +// The headline case is `rc.10` vs `rc.2`: the comparison this type replaced +// sorted prerelease identifiers as strings, which made 0.54.0-rc.10 look +// OLDER than 0.54.0-rc.2. On the update path that offers a downgrade as an +// "update"; on the supersede path it kills a newer core to start an older one. + +import XCTest +@testable import MCPProxy + +final class SemanticVersionTests: XCTestCase { + + // MARK: - The bug this type exists for + + func testNumericPrereleaseIdentifiersCompareNumerically() { + XCTAssertEqual(SemanticVersion.compare("1.0.0-rc.10", "1.0.0-rc.2"), 1, + "rc.10 must outrank rc.2 — as strings it does not") + XCTAssertEqual(SemanticVersion.compare("1.0.0-rc.2", "1.0.0-rc.10"), -1) + XCTAssertEqual(SemanticVersion.compare("0.54.0-rc.9", "0.54.0-rc.11"), -1) + } + + /// The public adapter the menu uses must inherit the fix, not just the + /// type behind it. + func testUpdateServiceComparisonInheritsNumericOrdering() { + XCTAssertGreaterThan(UpdateService.compareSemver("1.0.0-rc.10", "1.0.0-rc.2"), 0) + XCTAssertLessThan(UpdateService.compareSemver("1.0.0-rc.2", "1.0.0-rc.10"), 0) + } + + // MARK: - §11.3 prerelease < release + + func testPrereleaseRanksBelowTheMatchingRelease() { + XCTAssertEqual(SemanticVersion.compare("1.0.0-rc.1", "1.0.0"), -1) + XCTAssertEqual(SemanticVersion.compare("1.0.0", "1.0.0-rc.1"), 1) + XCTAssertEqual(SemanticVersion.compare("1.0.0-rc.1", "1.0.1-rc.1"), -1, + "core precedence is decided before prerelease is even looked at") + } + + // MARK: - Core ordering + + func testCoreOrdering() { + XCTAssertEqual(SemanticVersion.compare("1.2.3", "1.2.3"), 0) + XCTAssertEqual(SemanticVersion.compare("1.2.4", "1.2.3"), 1) + XCTAssertEqual(SemanticVersion.compare("1.3.0", "1.2.9"), -1 * -1) // 1.3.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: - §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() { + 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) + } +} diff --git a/native/macos/MCPProxy/MCPProxyTests/SparkleFeedURLTests.swift b/native/macos/MCPProxy/MCPProxyTests/SparkleFeedURLTests.swift new file mode 100644 index 000000000..fb5e8813b --- /dev/null +++ b/native/macos/MCPProxy/MCPProxyTests/SparkleFeedURLTests.swift @@ -0,0 +1,87 @@ +// 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" + ) + } + + /// 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( + "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") + } +} diff --git a/native/macos/MCPProxy/MCPProxyTests/StagedCoreBinaryTests.swift b/native/macos/MCPProxy/MCPProxyTests/StagedCoreBinaryTests.swift new file mode 100644 index 000000000..469449fa4 --- /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") + } +} diff --git a/native/macos/MCPProxy/MCPProxyTests/SupersedeMenuTests.swift b/native/macos/MCPProxy/MCPProxyTests/SupersedeMenuTests.swift new file mode 100644 index 000000000..ad8fd3106 --- /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")) + } +} diff --git a/native/macos/MCPProxy/MCPProxyTests/Support/UnixSocketHTTPStub.swift b/native/macos/MCPProxy/MCPProxyTests/Support/UnixSocketHTTPStub.swift index 908eb5f0c..e3325e22c 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: diff --git a/native/macos/MCPProxy/MCPProxyTests/UpdateInstallabilityTests.swift b/native/macos/MCPProxy/MCPProxyTests/UpdateInstallabilityTests.swift new file mode 100644 index 000000000..fb13c48a1 --- /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 000000000..fdaae483b --- /dev/null +++ b/native/macos/MCPProxy/MCPProxyTests/UpdateMenuStateTests.swift @@ -0,0 +1,134 @@ +// 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 testANewerLegacyVersionStillDoesNotEarnASecondItem() { + // The feed lags behind GitHub (the appcast job has not run yet). Both + // 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")]) + } + + 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() { + // 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 000000000..b1fc8caf5 --- /dev/null +++ b/native/macos/MCPProxy/MCPProxyTests/UpdatePolicyTests.swift @@ -0,0 +1,173 @@ +// 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 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, + "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(core: .legacyDefault, + 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(core: .legacyDefault, 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: - 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() { + 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 000000000..f8f084a41 --- /dev/null +++ b/native/macos/MCPProxy/MCPProxyTests/UpdateServiceFeedTests.swift @@ -0,0 +1,307 @@ +// 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) } + + /// Forget the setup traffic so a test asserts only what it provoked. + func resetRecordings() { + appliedPolicies.removeAll() + checks.removeAll() + } +} + +@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() + } + + /// 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(), + corePolicy: CoreUpdatePolicy? = .legacyDefault + ) -> (UpdateService, StubFeedUpdater?) { + let counter = legacyCounter + let service = UpdateService( + environment: env, + hostBundleURL: URL(fileURLWithPath: bundlePath), + 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() { + 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") + } + + /// 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() { + let (service, _) = makeService() + var stopped = 0 + service.stopManagedCore = { stopped += 1; return true } + XCTAssertTrue(service.feedUpdaterWillInstallUpdate()) + XCTAssertEqual(stopped, 1, + "the core must be down BEFORE the delegate call returns — Sparkle " + + "proceeds with the install the moment it does") + } + + func testInstallHookIsSafeWithoutACore() { + let (service, _) = makeService() + 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 + + 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/native/macos/MCPProxy/scripts/build-macos-tray.sh b/native/macos/MCPProxy/scripts/build-macos-tray.sh index d35e3332f..cf2107300 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 diff --git a/oas/docs.go b/oas/docs.go index 73701fc23..fdd3e7b62 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"},"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","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 c0300059b..08d51a5ec 100644 --- a/oas/swagger.yaml +++ b/oas/swagger.yaml @@ -1665,11 +1665,30 @@ 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 + 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' + update_policy: + $ref: '#/components/schemas/contracts.UpdatePolicy' version: description: Current MCPProxy version type: string @@ -2486,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: @@ -4253,6 +4295,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 diff --git a/packaging/macos/postinstall.sh b/packaging/macos/postinstall.sh index 02ed83034..4db6e0341 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,96 @@ 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" +# 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 +} + +# 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 - # 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 + PGREP_SCOPE="-U $REAL_UID" fi +# shellcheck disable=SC2086 # PGREP_SCOPE is empty or "-U " +app_is_running() { + /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 +# 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 + # 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 + # 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. + 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 diff --git a/scripts/build-swift-app.sh b/scripts/build-swift-app.sh index 08a0b5e1f..3d196709c 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 diff --git a/specs/092-auto-updater/checklists/requirements.md b/specs/092-auto-updater/checklists/requirements.md new file mode 100644 index 000000000..09bbf4054 --- /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/rollout-plan.md b/specs/092-auto-updater/rollout-plan.md new file mode 100644 index 000000000..7a78eaaac --- /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. diff --git a/specs/092-auto-updater/spec.md b/specs/092-auto-updater/spec.md new file mode 100644 index 000000000..c17a6902f --- /dev/null +++ b/specs/092-auto-updater/spec.md @@ -0,0 +1,182 @@ +# 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). +> +> 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)* + +### 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 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. + +--- + +### 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 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**: + +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"; 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. + +--- + +### 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 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: 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. 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**: 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. 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 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 + +- **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), 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. +- **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 +``` diff --git a/website/sidebars.js b/website/sidebars.js index 774e4d4c4..b571fedd9 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', ], }, {