From eceae789c7c3b3e8ef1f6f6b1a24c7ac3f5da634 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Tue, 20 Oct 2026 18:50:56 +0300 Subject: [PATCH 1/6] docs(specs): Spec 121 install surfaces (E7) spec, plan, tasks Co-Authored-By: Claude Sonnet 5.5 --- ROADMAP.md | 1 + .../checklists/requirements.md | 11 + .../contracts/install-channel.schema.json | 6 + .../contracts/server-package.json | 12 + specs/121-install-surfaces/data-model.md | 17 ++ specs/121-install-surfaces/followup-mcpb.md | 17 ++ specs/121-install-surfaces/plan.md | 94 ++++++++ specs/121-install-surfaces/quickstart.md | 13 + specs/121-install-surfaces/research.md | 18 ++ specs/121-install-surfaces/review-log.md | 17 ++ specs/121-install-surfaces/spec.md | 225 ++++++++++++++++++ specs/121-install-surfaces/tasks.md | 52 ++++ 12 files changed, 483 insertions(+) create mode 100644 specs/121-install-surfaces/checklists/requirements.md create mode 100644 specs/121-install-surfaces/contracts/install-channel.schema.json create mode 100644 specs/121-install-surfaces/contracts/server-package.json create mode 100644 specs/121-install-surfaces/data-model.md create mode 100644 specs/121-install-surfaces/followup-mcpb.md create mode 100644 specs/121-install-surfaces/plan.md create mode 100644 specs/121-install-surfaces/quickstart.md create mode 100644 specs/121-install-surfaces/research.md create mode 100644 specs/121-install-surfaces/review-log.md create mode 100644 specs/121-install-surfaces/spec.md create mode 100644 specs/121-install-surfaces/tasks.md diff --git a/ROADMAP.md b/ROADMAP.md index 38ca672ac..4f906f0f8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1172,3 +1172,4 @@ Legend: `shipped` ≥95% checked · `in-flight` 1–94% · `drafted` 0% · `—` | [115-mcp-credential-lifecycle](./specs/115-mcp-credential-lifecycle/) | `shipped` | 86/87 (99%) | | [116-config-commit-consistency](./specs/116-config-commit-consistency/) | `in-flight` | 81/124 (65%) | | [117-review-bound-approvals](./specs/117-review-bound-approvals/) | `in-flight` | 46/50 (92%) | +| [121-install-surfaces](./specs/121-install-surfaces/) | `drafted` | 0/34 (0%) | diff --git a/specs/121-install-surfaces/checklists/requirements.md b/specs/121-install-surfaces/checklists/requirements.md new file mode 100644 index 000000000..29327b8e3 --- /dev/null +++ b/specs/121-install-surfaces/checklists/requirements.md @@ -0,0 +1,11 @@ +# Specification Quality Checklist: Zero-Friction Install Surfaces (E7) + +**Created**: 2026-10-20 | **Feature**: [spec.md](../spec.md) + +- [x] Focused on user value; mandatory sections complete +- [x] No [NEEDS CLARIFICATION] markers (all self-answered in Clarifications) +- [x] Requirements testable; success criteria measurable +- [x] Edge cases and scope bounds identified (Out of Scope section) +- [x] Dependencies and assumptions identified + +Note: this is a distribution spec, so FR text names package ecosystems (registry, winget, scoop) by necessity — those are the user-visible surfaces, not internal implementation choices. diff --git a/specs/121-install-surfaces/contracts/install-channel.schema.json b/specs/121-install-surfaces/contracts/install-channel.schema.json new file mode 100644 index 000000000..906c4cd91 --- /dev/null +++ b/specs/121-install-surfaces/contracts/install-channel.schema.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Heartbeat install_channel field (Spec 121)", + "type": "string", + "enum": ["brew","dmg","winget","scoop","windows-installer","deb","rpm","docker","registry","curl","tarball","go-install","unknown"] +} diff --git a/specs/121-install-surfaces/contracts/server-package.json b/specs/121-install-surfaces/contracts/server-package.json new file mode 100644 index 000000000..9d832a335 --- /dev/null +++ b/specs/121-install-surfaces/contracts/server-package.json @@ -0,0 +1,12 @@ +{ + "registryType": "oci", + "identifier": "ghcr.io/smart-mcp-proxy/mcpproxy-server:v0.0.0", + "runtimeHint": "docker", + "runtimeArguments": [ + {"type": "named", "name": "-p", "value": "127.0.0.1:8080:8080"} + ], + "transport": {"type": "streamable-http", "url": "http://localhost:8080/mcp"}, + "environmentVariables": [ + {"name": "MCPPROXY_INSTALL_CHANNEL", "value": "registry"} + ] +} diff --git a/specs/121-install-surfaces/data-model.md b/specs/121-install-surfaces/data-model.md new file mode 100644 index 000000000..b72e682b4 --- /dev/null +++ b/specs/121-install-surfaces/data-model.md @@ -0,0 +1,17 @@ +# Data model — Spec 121 + +**InstallChannel** (closed enum, string): `brew | dmg | winget | scoop | windows-installer | deb | rpm | docker | registry | curl | tarball | go-install | unknown`. + +**Detector ID → InstallChannel** (total): homebrew→brew; dmg→dmg; deb→deb; rpm→rpm; docker→docker; go-install→go-install; windows-installer→windows-installer; tarball→tarball; scoop→scoop; unknown/anything else→unknown. + +**Declaration sources** (in precedence order): +1. `MCPPROXY_INSTALL_CHANNEL` env — any enum member except `unknown`. +2. `/install-channel` — installer-owned; one token, ≤64 bytes, trimmed; allowed: `winget`. +3. Detector (mapped). +4. `/install-channel` — weak; honoured only if (3) ∈ {tarball, go-install, unknown}; allowed: `curl`. + +Invalid/oversized/unreadable → ignored (fall through). Never transmitted raw. + +**Payload field**: `install_channel` (string, enum above, omitted only when telemetry builds a zero payload). + +**Distribution manifests**: scoop JSON (version, architecture.{64bit,arm64}.{url,hash}, bin, shortcuts, checkver, autoupdate); winget 3-file set (version/installer/defaultLocale) owned by winget-releaser after bootstrap. diff --git a/specs/121-install-surfaces/followup-mcpb.md b/specs/121-install-surfaces/followup-mcpb.md new file mode 100644 index 000000000..9644d3f1d --- /dev/null +++ b/specs/121-install-surfaces/followup-mcpb.md @@ -0,0 +1,17 @@ +# Follow-up note: Claude Desktop `.mcpb` bundle (NOT part of Spec 121) + +Status: open questions only. Nothing here is implemented in the E7 PR. + +## Why it matters +Claude Desktop is 18% of actives, today reached through an `mcp-remote` bridge. An `.mcpb` bundle gives one-click install in Claude Desktop, and the registry's `mcpb` package type (identifier = release-asset URL, mandatory `fileSha256`, URL must contain "mcp") would give registry-fed catalogs a second install action. + +## Open questions +1. **What does the bundle wrap?** (a) A stdio bridge binary that connects to an already-running local MCPProxy (needs it running: how does a one-click install start the core?), (b) the full core as a stdio server (Claude Desktop owns its lifecycle; conflicts with the tray-owned core, the DB lock on `~/.mcpproxy` and port 8080), (c) a launcher that starts/ensures the tray core then bridges. Recommended starting point: (c), reusing the existing `mcp-remote`-equivalent bridge logic in the Connect wizard. +2. **Platforms**: mcpb bundles are per-OS/arch (or multi-binary with a manifest platform map). macOS arm64/amd64, Windows amd64/arm64 at minimum; Linux unsupported by Claude Desktop. +3. **Signing / notarization**: bundle contains executables; macOS needs the same notarized binary as the DMG. Can the release job reuse the already signed artifacts instead of signing again? +4. **Auth**: the bridge must send `X-API-Key` (or use the Unix socket); where does it read the key — config file at runtime — and how does that interact with quarantine/agent tokens (a scoped agent token per bundle?). +5. **user_config** in the manifest: expose port and optional token vs read local config; privacy of values. +6. **Updates**: the bundle is versioned independently of the core; does the bundle auto-update with Claude Desktop's mcpb update flow, and what is the version-skew contract with the running core? +7. **Registry**: adding an `mcpb` entry to `packages` requires the release asset URL and SHA-256 to be known before publish; ordering with the `release` job. +8. **Telemetry**: add `mcpb` to the install-channel enum (schema bump) and have the bundle declare it through the channel marker. +9. **Interaction with Connect wizard**: the wizard currently writes the `mcp-remote` entry for Claude Desktop; decide whether the bundle replaces or complements it. diff --git a/specs/121-install-surfaces/plan.md b/specs/121-install-surfaces/plan.md new file mode 100644 index 000000000..14d4c6cf4 --- /dev/null +++ b/specs/121-install-surfaces/plan.md @@ -0,0 +1,94 @@ +# Implementation Plan: Zero-Friction Install Surfaces (E7) + +**Branch**: `121-install-surfaces` | **Date**: 2026-10-20 | **Spec**: [spec.md](spec.md) + +## Summary + +Five small, mostly non-Go changes plus one narrow Go change: +(A) `server.json` OCI package + image ownership label + one shared stamp/preflight script used by both registry publishers; +(B) a reusable Windows-packages workflow (winget via a SHA-pinned winget-releaser action, scoop via a generated manifest pushed to a bucket repo) fed by `checksums.txt`, an Inno installer that records a `winget` channel marker and is silent-safe, and a `scoop` channel in the update-channel detector; +(C) install-first tuning of the existing docusaurus-plugin-llms output, a post-build check, and a single-source "Paste this into your agent" prompt (README + docs page, drift-tested); +(D) `install_channel` in the heartbeat via a pure resolver (env > exe marker > detector > data-dir marker) that maps detector IDs to a closed telemetry enum; +(E) follow-up note only (already written: `followup-mcpb.md`). + +## Technical Context + +**Language/Version**: Go 1.26 (telemetry, updatecheck), Bash + jq (stamp/manifest scripts), GitHub Actions YAML, Inno Setup Pascal script, Docusaurus 3 (Node 22) +**Primary Dependencies**: none new in Go. CI: `mcp-publisher` (already used), a SHA-pinned winget-releaser action (new, CI-only), `yq`/`jq` (runner-provided). Docs: existing `docusaurus-plugin-llms`. +**Storage**: marker files `install-channel` (one token, ≤64 bytes); no DB change. +**Testing**: Go table tests (`internal/telemetry`, `internal/updatecheck`), shell tests for `scripts/*` using fixtures (checksums, server.json), a Go test that parses `.github/workflows/*.yml` ordering/guard invariants (existing `cmd/release-gate` pattern), docs post-build check script, prompt-drift test. +**Target Platform**: macOS/Linux/Windows runners; Windows installer; docs site on Cloudflare Pages. +**Project Type**: single repo, mixed (Go + CI + docs). +**Performance Goals**: telemetry resolver runs once per heartbeat; marker read ≤ 64 bytes; zero startup cost. +**Constraints**: no new Go dependencies; privacy contract (closed enum only); release workflow must never fail because an optional distribution surface is unconfigured. +**Scale/Scope**: ~600 LoC Go+tests, ~300 LoC scripts, ~250 LoC workflow YAML, 3 docs pages. + +## Constitution Check + +| Principle | Status | +|---|---| +| I. Performance | N/A (no hot path); resolver is once-per-heartbeat. | +| II. Actor concurrency | N/A; pure function, no shared state. | +| III. Config-driven | No new config field (env var + marker only, deliberately not in `Config` — avoids the 5-point config checklist and keeps the value out of user-editable config). | +| IV. Security by default | Closed-enum validation; size-capped marker read; checksum-verified install prompt; no credentials in prompt/evidence; third-party action SHA-pinned; tokens only in repo secrets, jobs skip when absent. | +| V. TDD | Tests first for resolver, mapper, scoop detection, stamp script, manifest generator, drift/post-build checks. | +| VI. Documentation hygiene | Installation docs, telemetry.md, registry-publishing.md, release-runbook.md, new agent-install page + sidebar + include allowlist. | + +No violations. + +## Design decisions + +### A. Registry +- `server.json.packages = [{registryType:"oci", identifier:"ghcr.io/smart-mcp-proxy/mcpproxy-server:v0.0.0", transport:{type:"streamable-http", url:"http://localhost:8080/mcp"}, runtimeHint:"docker", runtimeArguments:[-p 127.0.0.1:8080:8080], environmentVariables:[{name:"MCPPROXY_INSTALL_CHANNEL", value:"registry"}]}]`. Identifier tag is a placeholder pin stamped by `scripts/stamp-server-json.sh ` (also sets `.version`). +- Dockerfile gains `LABEL io.modelcontextprotocol.server.name="io.github.smart-mcp-proxy/mcpproxy-go"`; `build-docker` `labels:` gets the same line (belt and braces; build-push labels override). Consistency guarded by a Go test (`cmd/release-gate` style) comparing server.json name, Dockerfile label and workflow label. +- `scripts/registry-preflight.sh `: anonymous token → manifest → config blob → label check; `docker run` smoke (declared env, loopback port) → MCP `initialize`; prints cause and exits non-zero. Both `release.yml` and `retry-sign-release.yml` mcp-registry jobs: stamp → preflight → `mcp-publisher validate` → publish; job stays `continue-on-error`. +- Ordering: `mcp-registry` already needs `release` which needs `build-docker` (verified `release.yml:1340`); a Go invariant test pins it. Retry workflow: add the same preflight; image already exists for recovered releases. +- Existing versions (≤0.71.x) stay package-less (registry immutability); first stable release after merge publishes packages. + +### B. Windows +- New reusable workflow `.github/workflows/publish-windows-packages.yml` (`workflow_call` + `workflow_dispatch` with `tag`): jobs `scoop` and `winget`, both `if: !contains(tag,'-')`, each first checking its secret/repo is configured (else `::notice::` and exit 0). +- `scripts/gen-scoop-manifest.sh ` → `mcpproxy.json` (version, per-arch `architecture.{64bit,arm64}.{url,hash}` from `mcpproxy--windows-.zip`, `bin: [mcpproxy.exe]`, `shortcuts` for the tray, `checkver: github`, `autoupdate` with `$version` templates and a `hash` url to `checksums.txt`). Job commits it to `smart-mcp-proxy/scoop-bucket/bucket/mcpproxy.json` using `SCOOP_BUCKET_TOKEN`; idempotent (no diff → no commit). +- winget: SHA-pinned `vedantmgoyal9/winget-releaser` (wingetcreate wrapper), `identifier: smart-mcp-proxy.mcpproxy`, `installers-regex: 'mcpproxy-setup-v.*-(amd64|arm64)\.exe$'`, token `WINGET_TOKEN`; a checked-in template `packaging/windows/winget/` (YAML 3-file skeleton incl. `InstallerType: inno`, `Scope: machine`, `InstallerSwitches.Custom: /CHANNEL=winget`, `Silent: /VERYSILENT /SUPPRESSMSGBOXES /NORESTART`) is what the human uses for the one-time first submission (`wingetcreate new` flags in docs). Job only runs if the package exists in winget-pkgs (checked via the GitHub API), else notice. +- `release.yml` calls the reusable workflow after `release`; `retry-sign-release.yml` calls it too (post-release-only path). +- Inno (`scripts/installer.iss`): `{param:CHANNEL|}`-style handling in `[Code]`: write `{app}\install-channel` (value validated to `winget`) in `CurStepChanged(ssPostInstall)` if the param is present, else delete a stale one; `[UninstallDelete]` removes it; `NeedsAddPath`'s `MsgBox` guarded by `not WizardSilent`. +- Detector: `ChannelScoop="scoop"` in `knownChannels`; path heuristic (`\scoop\apps\mcpproxy\` or `\scoop\persist\`, case-insensitive, slash-normalized) before the tarball fallback; `UpdateCommand` → `scoop update mcpproxy`; `decideAction` → command; `UpdateBanner.vue` + generated types doc strings updated. + +### C. Agent-readable install +- Single source `docs/getting-started/agent-install.mdx`? MDX import would complicate drift check; instead single source = `docs/getting-started/agent-install-prompt.txt`? Decision: prompt lives in `docs/getting-started/agent-install.md` fenced block (published page); README block is copied by `scripts/sync-agent-prompt.sh` (writes between markers) and a Go/shell test fails on drift (`scripts/sync-agent-prompt.sh --check`, wired into the unit-tests job). +- Page added to docusaurus `include` allowlist (it matches `getting-started/**` already) and `sidebars.js` (first item under Getting Started). +- llms: plugin options `includeOrder` puts `getting-started/agent-install` first; `ignoreFiles` for generated reference pages (`errors/**`, big API reference) to bound `llms-full.txt`; `website/scripts/check-llms.mjs` (post-build): files exist, agent-install first, excluded docs absent, size ≤ budget (set after measuring). Hooked into `npm run build` via a `postbuild` script and runs in `docs.yml`. +- Prompt content: detect OS; check `command -v mcpproxy` / existing instance first and stop to ask if present; macOS/Linux → `brew install smart-mcp-proxy/mcpproxy/mcpproxy` (cask on macOS desktop), Windows → `winget install smart-mcp-proxy.mcpproxy` else `scoop`, else release archive with `checksums.txt` verification and a data-dir marker `curl`; `mcpproxy serve` (background); verify with `mcpproxy doctor` / `mcpproxy upstream list`; `mcpproxy connect `; tell user to reload the client; never read/print config or keys. + +### D. Telemetry +- `internal/telemetry/install_channel.go`: `type InstallChannel string` + `AllInstallChannels()`; `ResolveInstallChannel(deps)` where deps injects env getter, exe path, file reader, data dir, detector func — pure and table-testable. `MapDetectorChannel(string) InstallChannel` is total (homebrew→brew, dmg, deb, rpm, docker, go-install, windows-installer, tarball, scoop, unknown→unknown, anything else→unknown). +- `HeartbeatPayload.InstallChannel string json:"install_channel,omitempty"`; filled in `BuildPayload` only on the enabled path; resolved once per process (cached via `sync.Once`, since channel cannot change while running) — preview path uses the same cached resolver but only when telemetry is enabled. No `SchemaVersion` bump (FR-030). +- Anonymity scanner (`anonymity.go`) allow-lists the field's closed values; `payload_privacy_test.go` gains a canary (marker content `C:\Users\secret` and an out-of-set token must not appear). +- `MCPPROXY_INSTALL_CHANNEL` documented in `docs/features/telemetry.md` and `docs/configuration.md` env table (env var only; no config field → config checklist N/A). + +### E. Follow-up +`followup-mcpb.md` (done). + +## Project Structure + +```text +specs/121-install-surfaces/{spec,plan,research,data-model,quickstart,tasks,review-log,followup-mcpb}.md, contracts/, checklists/ +server.json # + packages +Dockerfile # + LABEL +scripts/{stamp-server-json,registry-preflight,gen-scoop-manifest,sync-agent-prompt}.sh (+ tests) +scripts/installer.iss # channel switch, silent-safe +packaging/windows/winget/ # one-time bootstrap templates +.github/workflows/{release,retry-sign-release,publish-windows-packages,docs}.yml +internal/updatecheck/{channel,guidance}.go (+tests) +internal/telemetry/{install_channel,telemetry,anonymity}.go (+tests) +cmd/mcpproxy/update_cmd.go (+test) # scoop action +frontend/src/components/UpdateBanner.vue, frontend/src/types/contracts.ts, cmd/generate-types +website/{docusaurus.config.js,sidebars.js,scripts/check-llms.mjs,package.json} +docs/getting-started/{installation,agent-install}.md, docs/features/telemetry.md, docs/mcp-registry-publishing.md, docs/release-runbook.md, docs/operations/windows-package-managers.md, README.md +``` + +## Risks + +- Winget-releaser/first-submission delays are human/Microsoft-bound → job skips cleanly; docs checklist. +- Inno changes cannot be executed on macOS CI; verified by installer.iss review + a Windows runner dry compile is out of reach locally → noted in PR as "needs first-release verification"; the `[Code]` change is minimal. +- Registry immutability: preflight is the only guard; smoke test uses the exact declared args. +- Merge collision with concurrent specs on `telemetry.go`/`release.yml`: kept additive and localized. diff --git a/specs/121-install-surfaces/quickstart.md b/specs/121-install-surfaces/quickstart.md new file mode 100644 index 000000000..18c7a52ca --- /dev/null +++ b/specs/121-install-surfaces/quickstart.md @@ -0,0 +1,13 @@ +# Quickstart — verifying Spec 121 locally + +```bash +# A. registry +scripts/stamp-server-json.sh v0.71.1 > /tmp/server.json && mcp-publisher validate /tmp/server.json +scripts/registry-preflight.sh v0.71.1 # needs docker; fails until a labelled image exists +# B. scoop manifest from a real release +gh release download v0.71.1 -p checksums.txt -D /tmp/rel && scripts/gen-scoop-manifest.sh v0.71.1 /tmp/rel/checksums.txt | jq . +# C. prompt drift + llms check +scripts/sync-agent-prompt.sh --check && (cd website && npm ci && npm run build) +# D. telemetry +go test ./internal/telemetry/... ./internal/updatecheck/... -run 'InstallChannel|Scoop' -race +``` diff --git a/specs/121-install-surfaces/research.md b/specs/121-install-surfaces/research.md new file mode 100644 index 000000000..5c2891e04 --- /dev/null +++ b/specs/121-install-surfaces/research.md @@ -0,0 +1,18 @@ +# Research — Spec 121 + +| Topic | Decision | Evidence / alternatives | +|---|---|---| +| Registry package type | `oci` → `ghcr.io/smart-mcp-proxy/mcpproxy-server:vX.Y.Z` | Registry package types: npm, pypi, nuget, cargo, oci, mcpb (modelcontextprotocol.io/registry/package-types). No binary type. `mcpb` deferred (followup-mcpb.md). | +| OCI ownership | Image label `io.modelcontextprotocol.server.name` = server name | Same doc. Current image `v0.71.1` labels checked via `docker inspect`: only OCI-standard labels present → label is required before publishing. Image is anonymously pullable (token endpoint returns a token; `docker pull` unauthenticated works). | +| Image behaviour | Starts, serves `/mcp` on 8080, MCP session registers with no config | Ran `v0.71.1` locally on 127.0.0.1:18391 (see log). Distroless, so it cannot launch stdio upstreams → catalog description must say so; Personal image (Spec 111) is the follow-up. | +| Transport entry | `streamable-http`, url `http://localhost:8080/mcp` | LocalTransport schema allows streamable-http with URL. | +| Registry publishers | Two: release.yml and retry-sign-release.yml | Both only `jq .version`; shared script required. | +| Image tag spelling | tag includes `v`; registry version does not | `release.yml` build-docker `tags:` uses `github.ref_name`. | +| Winget installer type | `inno` signed installer | `scripts/installer.iss` is Inno; assets `mcpproxy-setup-v-.exe`; sha256 in `checksums.txt`. Portable zip rejected (loses tray/Start-menu/PATH). | +| Winget automation | SHA-pinned winget-releaser on ubuntu | Alternative `wingetcreate update --submit` needs Windows runner; rejected for cost. First PR must be manual. | +| Scoop source | Release zip `mcpproxy--windows-.zip` (flat: mcpproxy.exe, mcpproxy-tray.exe) | Verified by downloading v0.71.1 zip. Hashes from `checksums.txt` (covers all assets). | +| Scoop self-update risk | New `scoop` channel; zip is stamped `tarball` which would otherwise allow self-update | `release.yml:432-449`, `update_cmd.go decideAction`. | +| Install channel telemetry | Reuse `updatecheck` detector + resolver with declarations | Detector exists since Spec 079/092 and is already in `/api/v1/info`. | +| Schema bump | None | Additive, always populated; siblings (118/119 specs) may bump concurrently → collision. | +| llms.txt | Already generated | `curl https://docs.mcpproxy.app/llms.txt` → 200, 39 KB; llms-full 2.9 MB; config `docusaurus-plugin-llms` at `website/docusaurus.config.js:92`. Tune, don't replace. | +| Connect CLI | `mcpproxy connect `; clients: claude-code, cursor, windsurf, vscode, codex, gemini, opencode, zcode; no dry-run; Claude Desktop only via wizard | `mcpproxy connect --help`. | diff --git a/specs/121-install-surfaces/review-log.md b/specs/121-install-surfaces/review-log.md new file mode 100644 index 000000000..30e9d879f --- /dev/null +++ b/specs/121-install-surfaces/review-log.md @@ -0,0 +1,17 @@ +# Cross-model review log (opencode `github-copilot/gpt-6.1-sol`, variant high, read-only) + +## Round 1 — spec.md — verdict: CHANGES REQUIRED → all 11 findings verified and applied + +| # | Sev | Finding | Disposition | +|---|-----|---------|-------------| +| 1 | P1 | Scoop installs would be self-updatable (zip stamped `tarball`) | Fixed: new `scoop` channel, guidance `scoop update mcpproxy`, no self-update (FR-016, US2.6) | +| 2 | P1 | `retry-sign-release.yml` has its own registry publisher | Fixed: shared stamping/check script, reusable Windows workflow with tag dispatch (FR-004, FR-017) | +| 3 | P1 | Prompt would read raw config / admin key | Fixed: never read config; `connect` masks credential; redacted evidence (Edge Cases, FR-020/024) | +| 4 | P2 | `llms.txt` already generated by docusaurus-plugin-llms | Fixed: tune existing plugin, one generator (FR-022/023); live files verified 200 | +| 5 | P2 | Publication eligibility ignored `exclude` | Fixed: post-build check with negative cases (FR-023) | +| 6 | P2 | Preview can't prove "connected" | Fixed: "configured + truthful reload hint"; handshake proven by harness (FR-024, SC-003) | +| 7 | P2 | `curl` attribution vs package-manager preference | Fixed: documented limit, SC-006 wording, new-install counting | +| 8 | P2 | Marker lifecycle undefined | Fixed: authoritative vs weak markers, removal on reinstall/uninstall (US4.3-5, FR-014/031) | +| 9 | P2 | Inno `MsgBox` blocks silent winget install | Fixed: silent-safe (FR-014, US2.8) | +| 10 | P2 | Registry acceptance didn't run the package | Fixed: smoke test with declared env/port (FR-005, US1.7); container smoke-tested manually | +| 11 | P3 | "Nothing is sent" vs opt-out beacon; preview path | Fixed: wording + preview gating (US4.6) | diff --git a/specs/121-install-surfaces/spec.md b/specs/121-install-surfaces/spec.md new file mode 100644 index 000000000..937f66fbe --- /dev/null +++ b/specs/121-install-surfaces/spec.md @@ -0,0 +1,225 @@ +# Feature Specification: Zero-Friction Install Surfaces (Experiment E7) + +**Feature Branch**: `121-install-surfaces` (worktree branch; spec number 121 chosen because 118 and 119 are already taken by sibling branches `118-auto-rescan-on-change`, `118-pin-visibility-boundary`, `119-day-two-activation`) +**Created**: 2026-10-20 +**Status**: Draft. All clarifications self-answered (see Clarifications). Cross-model review verdicts are recorded in [review-log.md](review-log.md). +**Input**: Experiment E7 from the Q4 2026 growth research pass (approved by the maintainer): competitors win on distribution — Docker MCP Toolkit is zero-install, 1MCP has `cli-setup --codex/--claude`, mcpm.sh ships a curl one-liner and `llm.txt`. MCPProxy's official-registry entry has `packages: []`, so registry-fed catalogs show no install action; there is no winget, scoop or chocolatey package; there is no agent-readable install prompt (`llms.txt` and `llms-full.txt` are already generated by the docs build, but neither leads with install guidance and `llms-full.txt` is ~3 MB); and no one can tell, from telemetry, which install channel a running install came from. +**Related**: Spec 079/092 (install-channel detection and update guidance, `internal/updatecheck`), Spec 111 (Personal-edition Docker image — spec only, nothing published), `docs/mcp-registry-publishing.md`, `docs/release-runbook.md`, `docs/features/telemetry.md`. +**Success measure (E7)**: at least 10 attributable installs per month for each new surface (registry, winget, scoop, agent prompt), read from the install-channel telemetry this spec adds. + +## Context & Motivation + +Windows is 14% of active installs and rising; Claude Desktop (reached through an `mcp-remote` bridge) is 18%. A Windows user today must find the GitHub release page, pick the right `.exe`, and click through an installer. A user browsing the official MCP registry, or a catalog fed by it, sees MCPProxy with no install action at all. An agent (Claude Code, Codex) asked "install mcpproxy" has no single page that tells it how. + +Four surfaces fix this, and a fifth (telemetry) makes them measurable: + +| Surface | What a person or agent gets | +|---|---| +| A. Registry packages | The official MCP registry entry carries a runnable package, so registry-fed catalogs show an install action | +| B. Windows package managers | `winget install` and `scoop install` work and stay current on every release without a person in the loop | +| C. Agent-readable install | A paste-into-your-agent prompt, plus the existing `llms.txt` / `llms-full.txt` hardened (install-first ordering, bounded size, no unpublished pages), so an agent can install, start and connect MCPProxy on its own | +| D. Install-channel telemetry | The heartbeat says which channel the install came from, as a closed bucket, never free text | +| E. `.mcpb` bundle | **Out of scope** — only a follow-up note with the open questions | + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 — Registry entry shows an install action (Priority: P1) + +A person (or a catalog that mirrors the official MCP registry) opens the `io.github.smart-mcp-proxy/mcpproxy-go` entry and sees a package they can run, not an empty list. + +**Why this priority**: it is the cheapest surface (data, no new code path), it unblocks every registry-fed catalog at once, and the current empty list is an outright defect in how MCPProxy presents itself. + +**Independent Test**: validate `server.json` with the registry's own publisher validator (offline) and publish dry-run output; after a real stable release, `GET /v0/servers?search=mcpproxy&version=latest` returns a non-empty `packages` array. + +**Acceptance Scenarios**: + +1. **Given** the committed `server.json`, **When** it is validated against the registry's schema and semantic rules, **Then** validation passes with a non-empty `packages` array. +2. **Given** a stable release tag `vX.Y.Z`, **When** the registry publish step runs, **Then** the package identifier references the image tag published for that same release, and the step runs only after that image is pushed. +3. **Given** the published container image, **When** its labels are inspected, **Then** it carries the registry's ownership label whose value equals the `server.json` name (the registry rejects the publish otherwise). +4. **Given** the image tag for the release is missing or lacks the label, **When** the publish step runs, **Then** it fails fast with a message naming the cause instead of publishing a broken entry (the step stays non-blocking for the release, as today). +5. **Given** a release candidate tag, **When** the release workflow runs, **Then** nothing is published to the registry (unchanged). +6. **Given** the recovery workflow (`retry-sign-release.yml`) republishes a release, **When** its registry step runs, **Then** it uses the same stamping and pre-publish checks as the main release (one shared script), so a stale committed identifier can never reach the registry. +7. **Given** the declared package, **When** it is started with exactly the environment, arguments and port mapping that `server.json` declares, **Then** a client can complete an MCP `initialize` handshake against it (smoke test run before publishing). + +### User Story 2 — Windows user installs with one command (Priority: P1) + +A Windows user runs `winget install smart-mcp-proxy.mcpproxy` or `scoop install mcpproxy` (after adding the bucket) and gets a working install; it updates with the package manager's normal upgrade command. + +**Why this priority**: Windows is the fastest-growing platform and the only major platform without a package-manager install. + +**Independent Test**: render both manifests from a real past release's assets in a scratch directory and validate them (schema validation for winget, JSON validity plus hash/URL checks for scoop); run the release jobs in dry-run mode on a non-release ref. + +**Acceptance Scenarios**: + +1. **Given** a stable release, **When** the release workflow completes, **Then** a scoop manifest for that version (zip archive URL and SHA-256 taken from the release's checksum file) is pushed to the bucket repository, with no human step after the one-time setup. +2. **Given** a stable release, **When** the release workflow completes, **Then** a winget manifest update for that version is submitted to the community winget repository as a pull request, with no human step after the one-time bootstrap. +3. **Given** the one-time setup has not been done (bucket repository or token missing), **When** the release workflow runs, **Then** the Windows jobs skip with a visible notice and the release is not failed. +4. **Given** a release candidate tag, **When** the workflow runs, **Then** neither manifest is published. +5. **Given** a scoop install, **When** the user upgrades with `scoop update mcpproxy`, **Then** the bucket's own autoupdate metadata resolves the next version without edits. +6. **Given** a scoop-installed binary, **When** `mcpproxy update` runs, **Then** it does NOT self-replace the scoop-owned executable and instead tells the user to run `scoop update mcpproxy` (the zip's weak `tarball` stamp must not make a package-manager-owned file self-updatable). +7. **Given** the recovery workflow re-runs a release, **When** it completes, **Then** the Windows package jobs can be re-run for that tag via the same reusable workflow (manual dispatch with a tag input). +8. **Given** the installer runs silently (as winget runs it), **When** any warning dialog would normally appear (e.g. PATH length), **Then** none blocks the install. + +### User Story 3 — An agent installs MCPProxy from one prompt (Priority: P1) + +A person pastes a self-contained prompt from the README (or docs page) into Claude Code or Codex. The agent installs MCPProxy for the current OS, starts it, and runs the connect flow for the client it is running in. + +**Why this priority**: this is the "zero-friction" demo and the surface competitors already have. It also costs no product code. + +**Independent Test**: run the prompt verbatim in a fresh Claude Code session in a scratch directory with `HOME` and the listen port isolated; the agent ends with MCPProxy running on the isolated port and a connect dry-run/preview for its own client. + +**Acceptance Scenarios**: + +1. **Given** the README, **When** a reader looks for an install shortcut, **Then** a clearly marked "Paste this into your agent" block is present with the prompt in a copyable code fence. +2. **Given** the prompt, **When** an agent follows it on macOS, Linux or Windows, **Then** it selects the right installer (Homebrew/winget/scoop/release archive), verifies the install, starts the service, runs `mcpproxy connect ` for the client it is running in, and tells the user the truthful follow-up (reload/restart that client) — it never claims a live connection it has not observed. +3. **Given** the prompt, **When** it is read, **Then** it never asks the agent to run something that overwrites an existing running install without confirming, and states the verification commands and expected outputs. +4. **Given** the docs site, **When** `/llms.txt` and `/llms-full.txt` are requested, **Then** they list the install guidance first, cover the published pages only (never the explicitly excluded internal documents), and are produced by exactly one generator (the existing plugin, tuned — no second generator). +5. **Given** the docs build, **When** the install page links are checked, **Then** the build still passes (no broken links) and the new page(s) are reachable from the sidebar. + +### User Story 4 — The maintainer can see which channel installs come from (Priority: P2) + +The heartbeat carries `install_channel`, a closed enum, so the E7 success measure (≥10 installs/month per surface) can be read from telemetry. + +**Why this priority**: it makes the experiment measurable, but is only useful once surfaces ship; it is also the only Go change. + +**Independent Test**: table-driven unit tests over every enum value and every override source; a payload-privacy test proving no free text or path can reach the payload. + +**Acceptance Scenarios**: + +1. **Given** any running install, **When** the heartbeat is built, **Then** `install_channel` is exactly one value of the closed set: `brew`, `dmg`, `winget`, `scoop`, `windows-installer`, `deb`, `rpm`, `docker`, `registry`, `curl`, `tarball`, `go-install`, `unknown`. +2. **Given** an install whose channel the existing detector already knows (Homebrew, DMG, deb, rpm, docker, Windows installer, tarball, go install, and the new scoop), **When** no explicit declaration exists, **Then** the detector's answer is reported, mapped onto the closed set. +3. **Given** an *authoritative* declaration (environment variable, or an installer-owned `install-channel` marker file next to the executable) whose value is in the closed set, **When** the heartbeat is built, **Then** the declared value wins; **Given** an out-of-set value, **Then** it is ignored and never transmitted. +4. **Given** a *weak* declaration (a marker file in the data directory, written by the agent prompt for archive installs), **When** the detector's answer is `tarball`, `go-install` or `unknown`, **Then** the declared value is reported; **When** the detector positively identified a package-manager/installer channel, **Then** the weak declaration is ignored (a stale `curl` marker cannot mask a later Homebrew or winget install). +5. **Given** a winget install, **When** the installer ran with the winget switch, **Then** the marker file written beside the installed executable makes the heartbeat say `winget`; **When** the installer later runs without the switch (direct reinstall/upgrade), **Then** the stale marker is removed; **When** the app is uninstalled, **Then** the marker is removed with it. +6. **Given** telemetry is disabled, **When** the process runs or a payload preview is requested, **Then** no usage heartbeat is sent and no marker is read by the telemetry path (the existing one-time opt-out beacon is unchanged). +7. **Given** a user-facing surface that already shows the install channel for update guidance (`/api/v1/info`, `mcpproxy status`, `mcpproxy doctor`, `mcpproxy update`), **When** this feature ships, **Then** their behavior is unchanged for every existing channel; the only change is the new scoop channel (guidance `scoop update mcpproxy`, no self-update). + +The field means the *current* install channel as last declared or detected, not the original acquisition channel. Counting "new installs per month" uses the dashboard's first-seen date per machine, not recurring heartbeats. + +### User Story 5 — `.mcpb` bundle is scoped for later (Priority: P3) + +The maintainer has a written follow-up note listing the open design questions for a Claude Desktop `.mcpb` bundle, so the next spec starts from decisions rather than discovery. **No bundle is built or published by this feature.** + +**Acceptance Scenarios**: + +1. **Given** the spec directory, **Then** `followup-mcpb.md` exists and lists the open questions and a recommended starting position. + +### Edge Cases + +- **Immutability**: the registry stores each version once; a failed or partial publish for version X.Y.Z cannot be corrected by republishing X.Y.Z. The pre-publish checks (US1-4) exist to prevent shipping a bad entry that can never be replaced. +- **Image tag spelling**: the container image is tagged with the git tag including the leading `v` (`vX.Y.Z`), while the registry `version` has none. The identifier must use the image's real tag. +- **Private image**: a package first created by the CI token starts private; an anonymous pull must be verified, because a private image makes the registry entry unusable. +- **Server-edition image**: the only published image today is the Server edition (distroless, no shell, no Node/uv). The registry package describes what it is; a Personal-edition image (Spec 111) will supersede it for most users and is a follow-up, not a dependency. +- **Winget bootstrap**: the winget automation can only update a package that already exists in the community repository; the first submission is manual and moderated by Microsoft (review latency days to weeks). +- **Windows installer needs admin and a fixed silent-install contract**: winget runs the installer silently; the installer must accept the winget channel switch without prompting. +- **Same artifact, several channels**: winget and a direct download use the same installer; the Docker image and the registry package are the same image. Only an explicit declaration can tell them apart, which is why declarations are first-class. +- **Marker file tampering / garbage**: unreadable, oversized, or out-of-set marker content degrades to detection; it must never be transmitted raw. +- **Idempotent regeneration**: `llms.txt` generation run twice on the same input yields byte-identical output (no timestamps). +- **Secrets in prompt/docs**: the install prompt never embeds an API key and never asks the agent to read or print the config file, the instance admin key, or a client credential. `mcpproxy connect` writes a per-client credential itself and returns it masked; verification uses CLI commands that return sanitized output. The PR evidence is redacted the same way. +- **Existing running install**: the prompt and docs must not reinstall over a running local MCPProxy without telling the user; the verification step checks for an existing install first. + +## Requirements *(mandatory)* + +### Functional Requirements + +**Registry (A)** + +- **FR-001**: `server.json` MUST contain at least one package of a registry-supported type, validated by the registry's own publisher validator. +- **FR-002**: The package MUST reference the published Server-edition container image by an explicit tag equal to that release's image tag, with the correct transport and the port/ listen-address settings needed to reach it. +- **FR-003**: The published image MUST carry the registry ownership label with value equal to the `server.json` `name`, set in the image build so it holds for every future build. +- **FR-004**: One shared script MUST stamp `version` and the package identifier from the release tag at publish time; both the main release workflow and the recovery workflow MUST use it, and the publish MUST remain ordered after the image push (already transitively true in the main workflow; MUST be preserved). +- **FR-005**: Before publishing, the workflow MUST verify the target image tag exists, is anonymously pullable, and carries the label, and MUST run the package's smoke test (start with the declared environment/port, complete an MCP `initialize`); on failure it MUST print the cause and skip the publish without failing the release. +- **FR-006**: RC tags MUST NOT publish to the registry (unchanged). +- **FR-007**: A repository check (test or script run in CI) MUST fail if `server.json` loses its `packages`, if its `name` differs from the label in the image build, or if its schema validation fails. + +**Windows package managers (B)** + +- **FR-010**: A scoop manifest MUST be generated from each stable release's archive asset and its published SHA-256, include `checkver`/`autoupdate` metadata, expose the core binary on PATH, and be pushed to a bucket repository without human action. +- **FR-011**: A winget manifest update MUST be submitted to the community winget repository for each stable release, using the signed installer asset, without human action after one-time bootstrap. +- **FR-012**: Both jobs MUST run only for stable tags, MUST depend on the release having published its assets, and MUST skip (not fail) with a visible notice when their credentials or target repositories are not configured. +- **FR-016**: A new `scoop` channel MUST be recognized by the existing detector from the scoop apps directory layout of the resolved executable path, MUST take precedence over the weak `tarball` stamp, and MUST map to update guidance `scoop update mcpproxy` with no self-update. +- **FR-017**: The Windows package jobs MUST live in one reusable workflow invoked by the release workflow and manually dispatchable with a tag input, so a recovered or re-run release can republish them idempotently (re-submitting an existing version is a no-op or a clearly reported skip). +- **FR-013**: The manifest generation logic MUST be a script runnable locally against any released tag, with a test that renders manifests from fixture data and validates them; the release job calls the same script. +- **FR-014**: The Windows installer MUST accept a channel switch that records the winget channel as a marker file beside the installed executable, MUST remove a stale marker when run without the switch and on uninstall, and MUST NOT show any blocking dialog in silent mode (including the existing PATH-length warning). +- **FR-015**: Documentation MUST list the exact one-time human steps (create the bucket repository, create and store the tokens, perform the first winget submission) and the install/upgrade/uninstall commands for each Windows channel. + +**Agent-readable install (C)** + +- **FR-020**: README and a docs page MUST contain a "Paste this into your agent" prompt that is self-contained: OS detection, install via Homebrew / winget / scoop / release archive (checksum-verified), existing-install check, start, verification with sanitized CLI output only, and `mcpproxy connect ` for the agent's own client, ending with the truthful reload/restart instruction. Claude Desktop (not in the CLI client list) is directed to the Connect wizard in the Web UI. +- **FR-021**: The prompt MUST be a single source of truth: the README block and the docs page are generated from, or checked against, one file so they cannot drift (a test fails if they differ). +- **FR-022**: The existing docs-build generator (the Docusaurus llms plugin) MUST be tuned, not duplicated: install guidance first, internal/excluded pages absent, and `llms-full.txt` bounded in size by excluding generated reference pages that are not useful to an agent. Exactly one generator MUST own these files. +- **FR-023**: A post-build check MUST fail the docs build if either file is missing, if the install page is not first, if an explicitly excluded document appears, or if `llms-full.txt` exceeds the documented size budget. +- **FR-024**: The prompt MUST be verified end-to-end by running it in a fresh agent session against an isolated instance (separate port, scratch data directory, scratch config, sandboxed HOME) without touching the maintainer's live install; the evidence (redacted of any credential) is recorded in the PR. "Connected" in this spec means: the client's config entry is written and `connect --list` shows it; a live handshake is proven by the isolated harness, not claimed by the prompt. + +**Install-channel telemetry (D)** + +- **FR-030**: The heartbeat MUST include `install_channel`, a member of the closed set listed in US4, with the payload documentation updated. The field is purely additive and always populated (worst case `unknown`), so the schema version is NOT bumped (avoids a merge collision with concurrent specs that bump it; consumers detect the field by presence). +- **FR-031**: Resolution order MUST be: (1) environment declaration, (2) installer-owned marker beside the executable, (3) the existing detector, (4) the data-directory marker only when (3) answered `tarball`, `go-install` or `unknown`; each explicit source MUST be validated against the closed set, an invalid value falls through, and the marker read MUST be size-capped and happen only on the telemetry-enabled path. +- **FR-032**: The mapping from the existing detector's identifiers to the telemetry set MUST be total and tested for every detector identifier (Homebrew→`brew`, Windows installer→`windows-installer`, etc.), and an unrecognized identifier MUST map to `unknown`. +- **FR-033**: The scoop channel MUST be recognized without any declaration (FR-016). +- **FR-034**: The registry package MUST declare the registry channel through the package's environment variable list so a container started from the registry entry reports `registry`. +- **FR-035**: No path, file content beyond the enum token, hostname or user name MAY reach the payload; the existing payload privacy test MUST cover the new field. +- **FR-036**: Existing install-channel behavior used for update guidance (`/api/v1/info`, `status`, `doctor`, `update`) MUST remain unchanged for every existing channel; declarations and the weak data-directory marker affect only the telemetry mapping, which is a separate function. The only guidance change is the new scoop channel (FR-016). +- **FR-037**: The receiver and dashboard repositories are outside this repository; the PR MUST list the receiver/dashboard change needed to store and chart the new field and MUST confirm the receiver accepts unknown fields in the meantime. + +**Follow-up note (E)** + +- **FR-040**: `followup-mcpb.md` MUST capture the open questions for the `.mcpb` bundle; no bundle code, workflow or manifest is added. + +### Key Entities + +- **Install channel**: a closed, low-cardinality label for how a running install was obtained. Attributes: token (enum), source of the answer (declaration vs detection — internal, not transmitted). +- **Channel declaration**: an explicit statement of the channel — environment variable or `install-channel` marker file — containing exactly one token. +- **Package entry**: one element of `server.json` `packages`, describing a runnable distribution of the server for registry consumers. +- **Distribution manifest**: a generated, version-specific description of a release for a package manager (winget manifest set, scoop manifest). +- **Agent install prompt**: the single-source text that instructs an agent how to install, start and connect MCPProxy. +- **llms index / llms full text**: generated, deterministic documents describing the published docs for language models. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: After the first stable release containing this feature, the official registry returns a non-empty `packages` array for the latest MCPProxy version. +- **SC-002**: A Windows user can install MCPProxy with one winget command and one scoop command (after one `bucket add`), and upgrade with the package manager's upgrade command, with zero maintainer actions per release after one-time setup. +- **SC-003**: Pasting the README prompt into a fresh Claude Code session in a scratch environment results in a running isolated MCPProxy with the client's entry configured and the correct reload instruction reported, with no further human input beyond approving tool permissions, and with no credential in the transcript. +- **SC-004**: `llms.txt` and `llms-full.txt` are served from the docs site, list install guidance first, contain 0 explicitly excluded documents, and `llms-full.txt` stays under the documented size budget. +- **SC-005**: 100% of heartbeats carry an `install_channel` that is in the closed set; 0 values outside it in the payload tests; the `unknown` bucket is below 40% of installs one month after release (read from telemetry — the measure of whether detection and declarations are adequate). +- **SC-006**: E7 experiment target: at least 10 attributable *new* installs (first-seen machines) per month for each of registry, winget, scoop and agent-driven archive installs (`curl`), evaluated one month after the first release that ships them. Agent-driven installs through Homebrew/winget/scoop are attributed to those channels, not to the prompt; this is a known limit of a single-valued enum. +- **SC-007**: Lint (both tag sets), unit tests with race detection, the API end-to-end script and the docs build all pass; no existing install-channel test changes. + +## Assumptions + +- The Server-edition image is the only published image at the time of writing (verified: only `mcpproxy-server` is pushed by the release workflow; Spec 111's Personal image is spec-only). The registry entry therefore points at it; swapping to the Personal image is a one-line follow-up once Spec 111 ships. +- The registry has no raw-binary package type; the only archive-style type is `mcpb` (a Claude Desktop bundle), which is out of scope. Therefore binary release assets are not listed in `packages`. +- The registry validates the OCI ownership label at publish time against the exact tag in the identifier; therefore the first release carrying the label is the first that can publish packages (already-published versions are immutable and stay package-less). +- Winget automation uses an established, SHA-pinned community action that wraps the official manifest tooling; its first submission for a new package id must be manual. Winget's installer type for the existing signed installer is `inno`. +- Scoop installs the release zip archive (core + tray executables), not the installer, so scoop installs do not need admin rights and are path-identifiable. +- `llms.txt` / `llms-full.txt` are already live (verified 2026-10-20: HTTP 200, 39 KB and ~3 MB) via `docusaurus-plugin-llms` configured in `website/docusaurus.config.js`; this spec tunes that generator rather than adding another. +- The recovery workflow `retry-sign-release.yml` carries its own registry-publish job and must share the new stamping script. +- The Server-edition image was smoke-tested for this spec: it starts, serves `/mcp` on 8080 and registers an MCP session with no extra configuration. +- Telemetry receiver and dashboard live in other repositories (`mcpproxy-telemetry`, `mcpproxy-dash`); they tolerate unknown payload fields, and extending them is a tracked follow-up, not part of this PR. +- Chocolatey is deliberately not included (no demand signal; moderation overhead); revisit if winget+scoop prove out. +- The `curl` channel means "installed from the release archive by an agent or script following the documented prompt"; it is declared by the prompt writing a marker file into the data directory, so it is attributable but self-reported. + +## Out of Scope + +- The `.mcpb` Claude Desktop bundle (follow-up note only). +- A Personal-edition Docker image (Spec 111). +- Chocolatey, apt/dnf repository changes, Nix, AUR. +- Receiver / dashboard changes (listed in the PR as follow-ups). +- Any change to update-guidance behavior or to the existing channel detector's public results. + +## Clarifications + +### Session 2026-10-20 (self-answered, per the zero-interruption policy) + +- Q: Which image does the registry package point at? → A: `ghcr.io/smart-mcp-proxy/mcpproxy-server` (the only published image), tag = the release's image tag with its leading `v`. The Personal image from Spec 111 replaces it later. +- Q: Does the registry support a binary-release package type? → A: No (`npm`, `pypi`, `nuget`, `cargo`, `oci`, `mcpb`). So binaries are not listed; `mcpb` is the follow-up. +- Q: How is the OCI ownership proven? → A: An image label/annotation `io.modelcontextprotocol.server.name` equal to the `server.json` name, set in the Dockerfile and the build step's labels; checked by the registry at publish time. +- Q: Winget: installer exe or zip-portable? → A: The signed installer (`inno`), because it carries the tray app, Start-menu entry and PATH registration users expect; portable would lose the tray. The installer therefore needs a channel switch for attribution. +- Q: Scoop: bucket repo or manifest in this repo? → A: Bucket repository `smart-mcp-proxy/scoop-bucket` fed by the release workflow (this repo cannot commit manifests after tagging without a write-back), plus docs; the one-time human step is creating the repository and token. +- Q: Which winget automation? → A: A SHA-pinned winget-releaser action on an Ubuntu runner (no Windows runner minutes); needs a classic PAT with public-repo scope and a fork of `microsoft/winget-pkgs`. +- Q: How are `curl`/`registry`/`winget` distinguished from `tarball`/`docker`/`windows-installer`? → A: Explicit declarations (env var, marker file) take precedence over detection, validated against the closed set; detection still answers when nothing is declared. +- Q: Should the install prompt hit a hosted install script? → A: No new hosted script; the prompt tells the agent to use the package manager or the release archive and verifies checksums from `checksums.txt`. Avoids a new trusted-remote-code surface. +- Q: Does `llms.txt` come from the Docusaurus build or a script? → A: A deterministic generation step in the docs build (script reading the docs include allowlist and sidebar), output into the static directory before `docusaurus build`; failing on missing pages. +- Q: Telemetry privacy risk of the marker? → A: Only the enum token is ever read and transmitted; file size capped; non-members discarded. diff --git a/specs/121-install-surfaces/tasks.md b/specs/121-install-surfaces/tasks.md new file mode 100644 index 000000000..34ab38368 --- /dev/null +++ b/specs/121-install-surfaces/tasks.md @@ -0,0 +1,52 @@ +# Tasks: Zero-Friction Install Surfaces (Spec 121) + +Order = dependency order. `[T]` = write the failing test first. `[P]` = parallelizable (different files). + +## Phase 1 — Detector + telemetry (Go, US2.6, US4) + +- [ ] T001 [T] `internal/updatecheck/channel_scoop_test.go`: scoop paths (`C:\Users\u\scoop\apps\mcpproxy\current\mcpproxy.exe`, `\scoop\persist\`, forward-slash and case variants) → `scoop`; tarball marker + scoop path → `scoop`; non-scoop windows path stays `tarball`. +- [ ] T002 `internal/updatecheck/channel.go`: add `ChannelScoop`, `knownChannels`, path heuristic before the tarball fallback (normalize `\`→`/`, lowercase compare). +- [ ] T003 [T] `guidance_test.go` + `cmd/mcpproxy/update_cmd_test.go`: `UpdateCommand("scoop")=="scoop update mcpproxy"`, `decideAction(scoop)==actionCommand`, existing channels' outputs unchanged (table of all old channels). +- [ ] T004 `guidance.go`, `update_cmd.go` (+help text line 141 area): scoop command/action. +- [ ] T005 [P] `frontend/src/components/UpdateBanner.vue`, `cmd/generate-types/main.go` + `frontend/src/types/contracts.ts`, `internal/contracts/types.go` doc comments: add scoop to the channel lists; run `make swagger`/type generation if comments feed it; frontend unit test if banner has one. +- [ ] T006 [T] `internal/telemetry/install_channel_test.go`: mapper totality (every `updatecheck` channel const + garbage → enum), precedence table (env > exe marker > detector > weak data-dir marker), weak marker ignored for brew/dmg/winget/deb/rpm/docker/windows-installer/scoop, accepted for tarball/go-install/unknown, invalid/oversized/unreadable marker falls through, env `unknown` ignored, env `registry` over detector `docker`, scoop with no declaration. +- [ ] T007 `internal/telemetry/install_channel.go`: types, `AllInstallChannels`, `MapDetectorChannel`, `ResolveInstallChannel` with injectable deps, 64-byte capped reader; `sync.Once` cache wrapper for production. +- [ ] T008 [T] payload tests: `install_channel` present and in enum for enabled telemetry; absent/unread when disabled (incl. preview path); privacy canary (marker content `C:\Users\secret`, `../../etc`, 10 KB garbage never appear in the serialized payload); anonymity scanner accepts every enum value. +- [ ] T009 `telemetry.go` (+ `anonymity.go`): field + wiring in `BuildPayload` on the enabled path only. No `SchemaVersion` change; assert existing schema pins untouched. +- [ ] T010 `docs/features/telemetry.md` (field, enum, precedence, privacy, E7 query hint), `docs/configuration.md` env var `MCPPROXY_INSTALL_CHANNEL`. + +## Phase 2 — Registry (US1) + +- [ ] T020 [T] `scripts/stamp-server-json.test.sh` (fixtures): stamps `.version` (no `v`) and `.packages[0].identifier` tag (with `v`); refuses an input with no `packages`; idempotent. +- [ ] T021 `scripts/stamp-server-json.sh`. +- [ ] T022 `server.json`: add package from `contracts/server-package.json`; `Dockerfile` + `release.yml` build-docker `labels:`: `io.modelcontextprotocol.server.name`. +- [ ] T023 [T] `cmd/release-gate/distribution_workflow_test.go`: (a) server.json name == Dockerfile label == release.yml label; (b) `mcp-registry` transitively needs `build-docker` and `release`, has the stable-only guard and `continue-on-error`; (c) both registry jobs call `stamp-server-json.sh` and `registry-preflight.sh`; (d) no registry/winget/scoop job runs for `-` tags; (e) server.json passes `mcp-publisher validate` when the binary is present (skip otherwise). +- [ ] T024 `scripts/registry-preflight.sh ` (anonymous pull token, config label check, `docker run` smoke with declared env/port, MCP `initialize` via curl, cleanup, clear failure causes; `--skip-smoke` for environments without docker). +- [ ] T025 `release.yml` + `retry-sign-release.yml` mcp-registry jobs: stamp → preflight → validate → publish; drop inline `jq`. +- [ ] T026 `docs/mcp-registry-publishing.md`, `docs/release-runbook.md`: packages, label, preflight, immutability. + +## Phase 3 — Windows (US2) + +- [ ] T030 [T] `scripts/gen-scoop-manifest.test.sh` with a fixture `checksums.txt` (v0.71.1 real hashes): output valid JSON, correct URLs/hashes for both arches, `bin`, `checkver`, `autoupdate`, fails on missing asset hash. +- [ ] T031 `scripts/gen-scoop-manifest.sh`. +- [ ] T032 `scripts/installer.iss`: `/CHANNEL=` handling (accept only `winget`), marker write/stale delete, `[UninstallDelete]`, `NeedsAddPath` MsgBox guarded by `WizardSilent`; keep file ASCII; document manual compile check. +- [ ] T033 `.github/workflows/publish-windows-packages.yml` (reusable + dispatch): scoop job (token-gated, idempotent commit), winget job (SHA-pinned action, package-exists + token gate); both stable-only; extend T023 to assert these. +- [ ] T034 `release.yml` and `retry-sign-release.yml`: call the reusable workflow after `release` (non-blocking: `continue-on-error`). +- [ ] T035 `packaging/windows/winget/` templates (3 YAML files) + `docs/operations/windows-package-managers.md` (install/upgrade/uninstall commands; one-time human checklist: create `smart-mcp-proxy/scoop-bucket`, create fine-grained PAT → `SCOOP_BUCKET_TOKEN`, fork `microsoft/winget-pkgs`, classic PAT → `WINGET_TOKEN`, first `wingetcreate new` submission). +- [ ] T036 `docs/getting-started/installation.md`: winget/scoop sections. + +## Phase 4 — Agent install + llms (US3) + +- [ ] T040 [T] `scripts/sync-agent-prompt.test.sh`: `--check` fails on drift between README block and docs page; passes after sync. +- [ ] T041 `docs/getting-started/agent-install.md` (prompt source of truth; no secrets; verification; Claude Desktop → wizard), `scripts/sync-agent-prompt.sh`, README block between markers; wire `--check` into `unit-tests.yml`/lint job. +- [ ] T042 `website/sidebars.js` entry (first under Getting Started); confirm `include` covers it; run `npm run build` (broken-link guard). +- [ ] T043 [T] `website/scripts/check-llms.mjs` (exists, install page first, excluded docs absent, size ≤ budget) with a negative self-test. +- [ ] T044 `website/docusaurus.config.js`: `includeOrder`/`ignoreFiles` for the llms plugin; measure size; set budget; `package.json` `postbuild`. +- [ ] T045 Verify prompt end-to-end in an isolated environment (scratch HOME, port 18xxx, scratch data/config); record redacted evidence in `quickstart.md`/PR. + +## Phase 5 — Wrap-up + +- [ ] T050 `CLAUDE.md` Recent Changes entry (keep size < 40k). +- [ ] T051 Lint bare + `--build-tags server`; `go test -race ./internal/...` (CI skip regex for server pkg); `./scripts/test-api-e2e.sh` (check no foreign cores first); docs build; frontend tests if touched. +- [ ] T052 Sol review of the final diff (chunked); verify findings; record in `review-log.md`. +- [ ] T053 PR with title "feat(distribution): registry packages, winget/scoop, agent install prompt, install-channel telemetry (E7)", reviewer verdicts, human-steps checklist, receiver/dashboard follow-up note. From 3f4b6384cdaa4bca4e120a248d7b527c65b3b92a Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Tue, 20 Oct 2026 18:56:36 +0300 Subject: [PATCH 2/6] feat(distribution): scoop channel, install_channel telemetry, registry OCI package + preflight Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/release.yml | 20 +- .github/workflows/retry-sign-release.yml | 21 +- Dockerfile | 4 + cmd/generate-types/main.go | 2 +- cmd/mcpproxy/update_cmd.go | 4 +- cmd/mcpproxy/update_cmd_test.go | 8 +- .../distribution_workflow_test.go | 120 ++++++++++ frontend/src/types/contracts.ts | 2 +- internal/contracts/types.go | 2 +- internal/telemetry/anonymity.go | 17 ++ internal/telemetry/install_channel.go | 206 ++++++++++++++++++ internal/telemetry/install_channel_test.go | 140 ++++++++++++ .../telemetry/payload_install_channel_test.go | 92 ++++++++ internal/telemetry/telemetry.go | 19 ++ internal/updatecheck/channel.go | 21 ++ internal/updatecheck/channel_scoop_test.go | 55 +++++ internal/updatecheck/guidance.go | 4 + internal/updatecheck/guidance_test.go | 2 + oas/docs.go | 2 +- oas/swagger.yaml | 2 +- scripts/registry-preflight.sh | 74 +++++++ scripts/stamp-server-json.sh | 35 +++ scripts/stamp-server-json.test.sh | 28 +++ server.json | 25 ++- 24 files changed, 882 insertions(+), 23 deletions(-) create mode 100644 cmd/release-gate/distribution_workflow_test.go create mode 100644 internal/telemetry/install_channel.go create mode 100644 internal/telemetry/install_channel_test.go create mode 100644 internal/telemetry/payload_install_channel_test.go create mode 100644 internal/updatecheck/channel_scoop_test.go create mode 100755 scripts/registry-preflight.sh create mode 100755 scripts/stamp-server-json.sh create mode 100755 scripts/stamp-server-json.test.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9ab45668e..81914056a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1322,6 +1322,7 @@ jobs: org.opencontainers.image.licenses=MIT org.opencontainers.image.revision=${{ github.sha }} org.opencontainers.image.version=${{ github.ref_name }} + io.modelcontextprotocol.server.name=io.github.smart-mcp-proxy/mcpproxy-go # Spec 081: release qualification gate. Runs the server-type matrix + # invariants + assembled suites and produces one pass/fail verdict. The @@ -2221,10 +2222,6 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Extract version from tag - id: version - run: echo "VERSION=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" - - name: Install mcp-publisher run: | curl -L "https://github.com/modelcontextprotocol/registry/releases/latest/download/mcp-publisher_linux_amd64.tar.gz" | tar xz mcp-publisher @@ -2232,9 +2229,20 @@ jobs: - name: Authenticate to MCP Registry run: ./mcp-publisher login github-oidc - - name: Update version in server.json + # Spec 121: stamp version AND the OCI package identifier from the tag + # (shared with retry-sign-release.yml), then prove the image the entry + # points at exists, is anonymously pullable, carries the registry + # ownership label and answers an MCP initialize. The registry is + # immutable per version, so a bad publish cannot be corrected later. + - name: Stamp server.json for the tag run: | - jq --arg v "${{ steps.version.outputs.VERSION }}" '.version = $v' server.json > server.tmp && mv server.tmp server.json + scripts/stamp-server-json.sh "${GITHUB_REF_NAME}" server.json > server.tmp && mv server.tmp server.json + + - name: Pre-publish checks (image, label, MCP smoke test) + run: scripts/registry-preflight.sh "${GITHUB_REF_NAME}" + + - name: Validate server.json + run: ./mcp-publisher validate server.json - name: Publish to MCP Registry run: ./mcp-publisher publish diff --git a/.github/workflows/retry-sign-release.yml b/.github/workflows/retry-sign-release.yml index 8d4c40740..18fea59ad 100644 --- a/.github/workflows/retry-sign-release.yml +++ b/.github/workflows/retry-sign-release.yml @@ -1012,12 +1012,6 @@ jobs: with: ref: ${{ inputs.tag }} - - name: Extract version from tag - id: version - run: echo "VERSION=${TAG#v}" >> "$GITHUB_OUTPUT" - env: - TAG: ${{ inputs.tag }} - - name: Install mcp-publisher run: | curl -L "https://github.com/modelcontextprotocol/registry/releases/latest/download/mcp-publisher_linux_amd64.tar.gz" | tar xz mcp-publisher @@ -1025,9 +1019,20 @@ jobs: - name: Authenticate to MCP Registry run: ./mcp-publisher login github-oidc - - name: Update version in server.json + # Spec 121: same stamping + pre-publish checks as release.yml. + - name: Stamp server.json for the tag run: | - jq --arg v "${{ steps.version.outputs.VERSION }}" '.version = $v' server.json > server.tmp && mv server.tmp server.json + scripts/stamp-server-json.sh "${TAG}" server.json > server.tmp && mv server.tmp server.json + env: + TAG: ${{ inputs.tag }} + + - name: Pre-publish checks (image, label, MCP smoke test) + run: scripts/registry-preflight.sh "${TAG}" + env: + TAG: ${{ inputs.tag }} + + - name: Validate server.json + run: ./mcp-publisher validate server.json - name: Publish to MCP Registry run: ./mcp-publisher publish diff --git a/Dockerfile b/Dockerfile index 6b06c3ef3..770b401f6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -29,6 +29,10 @@ RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH:-amd64} go build \ # Runtime stage FROM gcr.io/distroless/static-debian12 +# Official MCP registry ownership proof (Spec 121 FR-003): the registry rejects +# a package whose image does not carry this label equal to server.json "name". +LABEL io.modelcontextprotocol.server.name="io.github.smart-mcp-proxy/mcpproxy-go" + COPY --from=builder /mcpproxy /usr/local/bin/mcpproxy EXPOSE 8080 diff --git a/cmd/generate-types/main.go b/cmd/generate-types/main.go index a75ae174f..92fd9aade 100644 --- a/cmd/generate-types/main.go +++ b/cmd/generate-types/main.go @@ -716,7 +716,7 @@ export interface UpdateInfo { is_prerelease?: boolean; check_error?: string; // Spec 079 US2 (additive per FR-021): detected install channel (homebrew, - // dmg, deb, rpm, docker, go-install, windows-installer, tarball, unknown). + // dmg, deb, rpm, docker, go-install, windows-installer, scoop, tarball, unknown). install_channel?: string; // One-line update command for the channel; only present when an update is // available and the channel has a safe command (FR-009). diff --git a/cmd/mcpproxy/update_cmd.go b/cmd/mcpproxy/update_cmd.go index 54d49c40d..6d9bc13cc 100644 --- a/cmd/mcpproxy/update_cmd.go +++ b/cmd/mcpproxy/update_cmd.go @@ -137,7 +137,7 @@ func GetUpdateCommand() *cobra.Command { 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 + homebrew / deb / rpm / go-install / scoop 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 @@ -264,7 +264,7 @@ func (r *updateRunner) effectiveChannel() string { // 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: + case updatecheck.ChannelHomebrew, updatecheck.ChannelDeb, updatecheck.ChannelRPM, updatecheck.ChannelGoInstall, updatecheck.ChannelScoop: return actionCommand case updatecheck.ChannelDocker, updatecheck.ChannelWindowsInstaller: return actionGuidance diff --git a/cmd/mcpproxy/update_cmd_test.go b/cmd/mcpproxy/update_cmd_test.go index c1dc29c7f..f52776cc6 100644 --- a/cmd/mcpproxy/update_cmd_test.go +++ b/cmd/mcpproxy/update_cmd_test.go @@ -123,6 +123,12 @@ func TestUpdateCommand_ChannelBranches(t *testing.T) { wantAction: actionCommand, wantCommand: "go install github.com/smart-mcp-proxy/mcpproxy-go/cmd/mcpproxy@latest", }, + { + name: "scoop prints the scoop command and never self-updates", + channel: updatecheck.ChannelScoop, + wantAction: actionCommand, + wantCommand: "scoop update mcpproxy", + }, { name: "docker gets guidance only", channel: updatecheck.ChannelDocker, @@ -199,7 +205,7 @@ func TestUpdateCommand_ChannelBranches(t *testing.T) { func TestUpdateCommand_GuidanceChannelsNeverSelfUpdate(t *testing.T) { for _, channel := range []string{ updatecheck.ChannelHomebrew, updatecheck.ChannelDeb, updatecheck.ChannelRPM, - updatecheck.ChannelGoInstall, updatecheck.ChannelDocker, + updatecheck.ChannelGoInstall, updatecheck.ChannelDocker, updatecheck.ChannelScoop, updatecheck.ChannelWindowsInstaller, updatecheck.ChannelDMG, updatecheck.ChannelUnknown, } { t.Run(channel, func(t *testing.T) { diff --git a/cmd/release-gate/distribution_workflow_test.go b/cmd/release-gate/distribution_workflow_test.go new file mode 100644 index 000000000..18c8941d4 --- /dev/null +++ b/cmd/release-gate/distribution_workflow_test.go @@ -0,0 +1,120 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "regexp" + "strings" + "testing" +) + +// Spec 121 workflow/packaging invariants for the install surfaces. These are +// audits of files, not behavior: they fail the build when a future edit breaks +// a contract the official MCP registry or a package manager relies on. + +func repoFile(t *testing.T, rel ...string) string { + t.Helper() + b, err := os.ReadFile(filepath.Join(append([]string{"..", ".."}, rel...)...)) + if err != nil { + t.Fatalf("read %v: %v", rel, err) + } + return string(b) +} + +func jobRunText(j job) string { + var sb strings.Builder + for _, s := range j.Steps { + sb.WriteString(s.Run) + sb.WriteString("\n") + } + return sb.String() +} + +// The registry verifies image ownership by a label equal to server.json "name". +// Three places must agree or the publish is rejected (immutably per version). +func TestRegistryOwnershipLabelAgreesEverywhere(t *testing.T) { + var sj struct { + Name string `json:"name"` + Packages []struct { + RegistryType string `json:"registryType"` + Identifier string `json:"identifier"` + } `json:"packages"` + } + if err := json.Unmarshal([]byte(repoFile(t, "server.json")), &sj); err != nil { + t.Fatal(err) + } + if sj.Name == "" { + t.Fatal("server.json has no name") + } + if len(sj.Packages) == 0 { + t.Fatal("server.json has no packages: registry-fed catalogs show no install action (Spec 121 FR-001)") + } + var oci bool + for _, p := range sj.Packages { + if p.RegistryType == "oci" { + oci = true + if !strings.HasPrefix(p.Identifier, "ghcr.io/smart-mcp-proxy/mcpproxy-server:") { + t.Errorf("oci identifier %q must name the published image with an explicit tag", p.Identifier) + } + } + } + if !oci { + t.Error("server.json must keep an oci package") + } + + want := `io.modelcontextprotocol.server.name` + docker := repoFile(t, "Dockerfile") + if !regexp.MustCompile(`(?m)^LABEL\s+` + regexp.QuoteMeta(want) + `="` + regexp.QuoteMeta(sj.Name) + `"`).MatchString(docker) { + t.Errorf("Dockerfile must carry LABEL %s=%q", want, sj.Name) + } + rel := repoFile(t, ".github", "workflows", "release.yml") + if !strings.Contains(rel, want+"="+sj.Name) { + t.Errorf("release.yml build-docker labels must include %s=%s", want, sj.Name) + } +} + +// Both registry publishers must stamp via the shared script and run the +// preflight before publishing, stay stable-only, and never block the release. +func TestRegistryPublishersShareStampAndPreflight(t *testing.T) { + for _, name := range []string{"release.yml", "retry-sign-release.yml"} { + name := name + t.Run(name, func(t *testing.T) { + wf := parseWorkflow(t, filepath.Join("..", "..", ".github", "workflows", name)) + j, ok := wf.Jobs["mcp-registry"] + if !ok { + t.Fatalf("%s has no mcp-registry job", name) + } + if strings.TrimSpace(j.ContinueOnError.Value) != "true" { + t.Errorf("%s mcp-registry must be continue-on-error: a registry hiccup must not fail a release", name) + } + run := jobRunText(j) + stamp := strings.Index(run, "scripts/stamp-server-json.sh") + pre := strings.Index(run, "scripts/registry-preflight.sh") + pub := strings.Index(run, "mcp-publisher publish") + if stamp < 0 || pre < 0 || pub < 0 { + t.Fatalf("%s mcp-registry must stamp (%d), preflight (%d) and publish (%d)", name, stamp, pre, pub) + } + if !(stamp < pre && pre < pub) { + t.Errorf("%s: order must be stamp < preflight < publish (got %d, %d, %d)", name, stamp, pre, pub) + } + if strings.Contains(run, `'.version = $v'`) { + t.Errorf("%s still stamps only .version inline; the OCI identifier would go stale", name) + } + if !strings.Contains(strings.TrimSpace(j.If.Value), "'-'") && !strings.Contains(j.If.Value, `'-'`) { + t.Errorf("%s mcp-registry must exclude prerelease tags (if: %q)", name, j.If.Value) + } + }) + } +} + +// release.yml: the registry job must only run after the image is pushed. +func TestRegistryPublishAfterImagePush(t *testing.T) { + wf := parseWorkflow(t, filepath.Join("..", "..", ".github", "workflows", "release.yml")) + closure := wf.transitiveNeeds("mcp-registry") + for _, need := range []string{"release", "build-docker"} { + if !closure[need] { + t.Errorf("mcp-registry must transitively need %q (transitive needs: %v)", need, sortedKeys(closure)) + } + } +} diff --git a/frontend/src/types/contracts.ts b/frontend/src/types/contracts.ts index 530a8d56a..59a26e273 100644 --- a/frontend/src/types/contracts.ts +++ b/frontend/src/types/contracts.ts @@ -657,7 +657,7 @@ export interface UpdateInfo { is_prerelease?: boolean; check_error?: string; // Spec 079 US2 (additive per FR-021): detected install channel (homebrew, - // dmg, deb, rpm, docker, go-install, windows-installer, tarball, unknown). + // dmg, deb, rpm, docker, go-install, windows-installer, scoop, tarball, unknown). install_channel?: string; // One-line update command for the channel; only present when an update is // available and the channel has a safe command (FR-009). diff --git a/internal/contracts/types.go b/internal/contracts/types.go index b64f76ce9..aa77bd718 100644 --- a/internal/contracts/types.go +++ b/internal/contracts/types.go @@ -1384,7 +1384,7 @@ type UpdateInfo struct { CheckedAt *time.Time `json:"checked_at,omitempty"` // When the update check was performed IsPrerelease bool `json:"is_prerelease,omitempty"` // Whether the latest version is a prerelease CheckError string `json:"check_error,omitempty"` // Error message if update check failed - InstallChannel string `json:"install_channel,omitempty"` // Detected install channel (homebrew, dmg, deb, rpm, docker, go-install, windows-installer, tarball, unknown) — Spec 079 FR-008 + InstallChannel string `json:"install_channel,omitempty"` // Detected install channel (homebrew, dmg, deb, rpm, docker, go-install, windows-installer, scoop, tarball, unknown) — Spec 079 FR-008 UpdateCommand string `json:"update_command,omitempty"` // One-line update command for the channel; only set when an update is available and the channel has one — Spec 079 FR-009 NudgesSuppressed bool `json:"nudges_suppressed,omitempty"` // UI surfaces must stay quiet (CI / non-interactive context); machine-readable fields still report the facts — Spec 079 FR-019 diff --git a/internal/telemetry/anonymity.go b/internal/telemetry/anonymity.go index 21daf5a7f..bb24a0a39 100644 --- a/internal/telemetry/anonymity.go +++ b/internal/telemetry/anonymity.go @@ -86,6 +86,9 @@ type anonymityScanEnvelope struct { PreviousShutdown *json.RawMessage `json:"previous_shutdown"` LastErrorCode *json.RawMessage `json:"last_error_code"` + // Spec 121: install_channel must be a member of the closed InstallChannel set. + InstallChannel *json.RawMessage `json:"install_channel"` + // Schema v8 structural check: the security-scanner sub-object must be // counts-and-fixed-enum-keys only. Deliberately NOT a pointer: JSON null // sets a *RawMessage pointer to nil, which is indistinguishable from an @@ -616,6 +619,9 @@ func ScanForPII(payloadJSON []byte) error { if v := scanV7Fields(&env); v != nil { return v } + if v := scanInstallChannel(env.InstallChannel); v != nil { + return v + } // Rule 5: schema-v8 tpa_scanner must be counts + fixed severity keys only. if v := scanV8TPAScanner(env.TPAScanner); v != nil { @@ -727,3 +733,14 @@ func populateBlockedValuesFrom( BlockedValues = out } + +// scanInstallChannel enforces Spec 121 FR-035: install_channel, when present, +// is a string drawn from the closed InstallChannel set. A path, free text or a +// widened type is a privacy violation. +func scanInstallChannel(raw *json.RawMessage) *AnonymityViolation { + allowed := make([]string, 0, len(AllInstallChannels())) + for _, c := range AllInstallChannels() { + allowed = append(allowed, string(c)) + } + return scanV7Enum(raw, "install_channel", allowed...) +} diff --git a/internal/telemetry/install_channel.go b/internal/telemetry/install_channel.go new file mode 100644 index 000000000..a858fef60 --- /dev/null +++ b/internal/telemetry/install_channel.go @@ -0,0 +1,206 @@ +package telemetry + +import ( + "io" + "os" + "path/filepath" + "strings" + "sync" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/updatecheck" +) + +// InstallChannel is the closed, low-cardinality label for how a running +// install was obtained (Spec 121). It is the ONLY install-provenance value +// that may reach the heartbeat: never a path, a file's contents beyond one +// validated token, a hostname or a user name. +type InstallChannel string + +const ( + InstallChannelBrew InstallChannel = "brew" + InstallChannelDMG InstallChannel = "dmg" + InstallChannelWinget InstallChannel = "winget" + InstallChannelScoop InstallChannel = "scoop" + InstallChannelWindowsInstaller InstallChannel = "windows-installer" + InstallChannelDeb InstallChannel = "deb" + InstallChannelRPM InstallChannel = "rpm" + InstallChannelDocker InstallChannel = "docker" + InstallChannelRegistry InstallChannel = "registry" + InstallChannelCurl InstallChannel = "curl" + InstallChannelTarball InstallChannel = "tarball" + InstallChannelGoInstall InstallChannel = "go-install" + InstallChannelUnknown InstallChannel = "unknown" +) + +const ( + // InstallChannelEnvVar lets any wrapper (a container run from the registry + // entry, a script) declare the channel explicitly. + InstallChannelEnvVar = "MCPPROXY_INSTALL_CHANNEL" + // installChannelMarkerName is the one-token marker file. Beside the + // executable it is installer-owned (authoritative); in the data directory + // it is a weak hint written by the agent install prompt. + installChannelMarkerName = "install-channel" + // markerMaxBytes caps what is ever read from a marker. + markerMaxBytes = 64 +) + +// AllInstallChannels returns the canonical, ordered closed set. +func AllInstallChannels() []InstallChannel { + return []InstallChannel{ + InstallChannelBrew, InstallChannelDMG, InstallChannelWinget, InstallChannelScoop, + InstallChannelWindowsInstaller, InstallChannelDeb, InstallChannelRPM, InstallChannelDocker, + InstallChannelRegistry, InstallChannelCurl, InstallChannelTarball, InstallChannelGoInstall, + InstallChannelUnknown, + } +} + +// Valid reports membership in the closed set. +func (c InstallChannel) Valid() bool { + for _, v := range AllInstallChannels() { + if c == v { + return true + } + } + return false +} + +// MapDetectorChannel maps an internal/updatecheck channel identifier onto the +// telemetry set. It is total: anything unrecognised (including "") is +// "unknown", so a future detector value can never leak into the payload. +func MapDetectorChannel(detected string) InstallChannel { + switch detected { + case updatecheck.ChannelHomebrew: + return InstallChannelBrew + case updatecheck.ChannelDMG: + return InstallChannelDMG + case updatecheck.ChannelDeb: + return InstallChannelDeb + case updatecheck.ChannelRPM: + return InstallChannelRPM + case updatecheck.ChannelDocker: + return InstallChannelDocker + case updatecheck.ChannelGoInstall: + return InstallChannelGoInstall + case updatecheck.ChannelWindowsInstaller: + return InstallChannelWindowsInstaller + case updatecheck.ChannelScoop: + return InstallChannelScoop + case updatecheck.ChannelTarball: + return InstallChannelTarball + default: + return InstallChannelUnknown + } +} + +// installChannelDeps injects every OS probe so the resolver is a pure, +// table-testable function. +type installChannelDeps struct { + getenv func(string) string + exeDir func() string + dataDir string + detect func() string + readFile func(path string, max int64) ([]byte, error) +} + +// resolveInstallChannel applies the precedence of Spec 121 FR-031: +// +// 1. MCPPROXY_INSTALL_CHANNEL (any member except "unknown") +// 2. installer-owned marker beside the executable (only "winget") +// 3. the update-channel detector, mapped +// 4. weak data-directory marker (only "curl"), and only when (3) answered +// tarball / go-install / unknown, so a stale hint can never mask a later +// Homebrew, winget or installer install. +// +// Every explicit source is validated against the closed set; an invalid value +// falls through to the next source and is never transmitted. +func resolveInstallChannel(d installChannelDeps) InstallChannel { + if d.getenv != nil { + if c := InstallChannel(strings.ToLower(strings.TrimSpace(d.getenv(InstallChannelEnvVar)))); c.Valid() && c != InstallChannelUnknown { + return c + } + } + if d.exeDir != nil { + if dir := d.exeDir(); dir != "" { + if InstallChannel(readChannelMarker(filepath.Join(dir, installChannelMarkerName), d.readFile)) == InstallChannelWinget { + return InstallChannelWinget + } + } + } + detected := InstallChannelUnknown + if d.detect != nil { + detected = MapDetectorChannel(d.detect()) + } + switch detected { + case InstallChannelTarball, InstallChannelGoInstall, InstallChannelUnknown: + if d.dataDir != "" && + InstallChannel(readChannelMarker(filepath.Join(d.dataDir, installChannelMarkerName), d.readFile)) == InstallChannelCurl { + return InstallChannelCurl + } + } + return detected +} + +// readCapped reads at most max bytes of a regular file. +func readCapped(path string, max int64) ([]byte, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + return io.ReadAll(io.LimitReader(f, max)) +} + +// readChannelMarker returns the trimmed, lower-cased, size-capped marker +// token, or "" when it is absent, unreadable or empty. Validation against the +// closed set is the caller's job. +func readChannelMarker(path string, read func(string, int64) ([]byte, error)) string { + if read == nil { + return "" + } + b, err := read(path, markerMaxBytes) + if err != nil { + return "" + } + return strings.ToLower(strings.TrimSpace(string(b))) +} + +var ( + installChannelOnce sync.Once + installChannelVal InstallChannel +) + +// CurrentInstallChannel resolves the channel for the running process once; +// provenance cannot change while the process lives, and caching keeps the +// marker reads off the heartbeat hot path. version is the build version the +// update checker also hands to DetectChannel; dataDir is the instance's data +// directory ("" means the default ~/.mcpproxy). +func CurrentInstallChannel(version, dataDir string) InstallChannel { + installChannelOnce.Do(func() { + if dataDir == "" { + if home, err := os.UserHomeDir(); err == nil && home != "" { + dataDir = filepath.Join(home, ".mcpproxy") + } + } + installChannelVal = resolveInstallChannel(installChannelDeps{ + getenv: os.Getenv, + exeDir: func() string { + p, err := os.Executable() + if err != nil { + return "" + } + if r, err := filepath.EvalSymlinks(p); err == nil { + p = r + } + return filepath.Dir(p) + }, + dataDir: dataDir, + detect: func() string { return updatecheck.DetectChannel(version) }, + readFile: readCapped, + }) + }) + return installChannelVal +} + +// installChannelResolver is indirected so payload tests can substitute the +// once-per-process resolver without touching the real executable path. +var installChannelResolver = CurrentInstallChannel diff --git a/internal/telemetry/install_channel_test.go b/internal/telemetry/install_channel_test.go new file mode 100644 index 000000000..7e4d806a2 --- /dev/null +++ b/internal/telemetry/install_channel_test.go @@ -0,0 +1,140 @@ +package telemetry + +import ( + "os" + "strings" + "testing" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/updatecheck" +) + +// fakeChannelDeps builds resolver dependencies from literal values so each +// table row flips exactly one signal (Spec 121 FR-031). +type fakeChannelDeps struct { + env string + exeDir string + dataDir string + detected string + files map[string]string +} + +func (f fakeChannelDeps) deps() installChannelDeps { + return installChannelDeps{ + getenv: func(string) string { return f.env }, + exeDir: func() string { return f.exeDir }, + dataDir: f.dataDir, + detect: func() string { return f.detected }, + readFile: func(path string, max int64) ([]byte, error) { + v, ok := f.files[path] + if !ok { + return nil, os.ErrNotExist + } + if int64(len(v)) > max { + v = v[:max] + } + return []byte(v), nil + }, + } +} + +func TestMapDetectorChannel_Total(t *testing.T) { + want := map[string]InstallChannel{ + updatecheck.ChannelHomebrew: InstallChannelBrew, + updatecheck.ChannelDMG: InstallChannelDMG, + updatecheck.ChannelDeb: InstallChannelDeb, + updatecheck.ChannelRPM: InstallChannelRPM, + updatecheck.ChannelDocker: InstallChannelDocker, + updatecheck.ChannelGoInstall: InstallChannelGoInstall, + updatecheck.ChannelWindowsInstaller: InstallChannelWindowsInstaller, + updatecheck.ChannelScoop: InstallChannelScoop, + updatecheck.ChannelTarball: InstallChannelTarball, + updatecheck.ChannelUnknown: InstallChannelUnknown, + "": InstallChannelUnknown, + "flatpak": InstallChannelUnknown, + "C:\\Users\\secret": InstallChannelUnknown, + } + for in, w := range want { + if got := MapDetectorChannel(in); got != w { + t.Errorf("MapDetectorChannel(%q) = %q, want %q", in, got, w) + } + } +} + +func TestInstallChannelEnum_IsClosedAndComplete(t *testing.T) { + all := AllInstallChannels() + seen := map[InstallChannel]bool{} + for _, c := range all { + if seen[c] { + t.Errorf("duplicate channel %q", c) + } + seen[c] = true + if !c.Valid() { + t.Errorf("%q must be Valid()", c) + } + } + for _, must := range []InstallChannel{"brew", "dmg", "winget", "scoop", "windows-installer", "deb", "rpm", "docker", "registry", "curl", "tarball", "go-install", "unknown"} { + if !seen[must] { + t.Errorf("enum missing %q", must) + } + } + if InstallChannel("flatpak").Valid() || InstallChannel("").Valid() { + t.Error("out-of-set values must not be Valid()") + } +} + +func TestResolveInstallChannel_Precedence(t *testing.T) { + const exeMarker = "/opt/app/install-channel" + const dataMarker = "/data/install-channel" + tests := []struct { + name string + f fakeChannelDeps + want InstallChannel + }{ + {"detector only", fakeChannelDeps{detected: updatecheck.ChannelHomebrew}, InstallChannelBrew}, + {"env beats detector (registry over docker)", fakeChannelDeps{env: "registry", detected: updatecheck.ChannelDocker}, InstallChannelRegistry}, + {"env beats exe marker", fakeChannelDeps{env: "curl", exeDir: "/opt/app", files: map[string]string{exeMarker: "winget"}, detected: updatecheck.ChannelWindowsInstaller}, InstallChannelCurl}, + {"env unknown ignored", fakeChannelDeps{env: "unknown", detected: updatecheck.ChannelDeb}, InstallChannelDeb}, + {"env garbage ignored", fakeChannelDeps{env: "C:\\Users\\secret", detected: updatecheck.ChannelDeb}, InstallChannelDeb}, + {"exe marker winget beats windows-installer detector", fakeChannelDeps{exeDir: "/opt/app", files: map[string]string{exeMarker: "winget\n"}, detected: updatecheck.ChannelWindowsInstaller}, InstallChannelWinget}, + {"exe marker only accepts winget", fakeChannelDeps{exeDir: "/opt/app", files: map[string]string{exeMarker: "curl"}, detected: updatecheck.ChannelWindowsInstaller}, InstallChannelWindowsInstaller}, + {"exe marker garbage falls through", fakeChannelDeps{exeDir: "/opt/app", files: map[string]string{exeMarker: strings.Repeat("x", 5000)}, detected: updatecheck.ChannelDMG}, InstallChannelDMG}, + {"weak marker curl applies to tarball", fakeChannelDeps{dataDir: "/data", files: map[string]string{dataMarker: "curl"}, detected: updatecheck.ChannelTarball}, InstallChannelCurl}, + {"weak marker curl applies to unknown", fakeChannelDeps{dataDir: "/data", files: map[string]string{dataMarker: "curl"}, detected: updatecheck.ChannelUnknown}, InstallChannelCurl}, + {"weak marker curl applies to go-install", fakeChannelDeps{dataDir: "/data", files: map[string]string{dataMarker: "curl"}, detected: updatecheck.ChannelGoInstall}, InstallChannelCurl}, + {"weak marker ignored for brew", fakeChannelDeps{dataDir: "/data", files: map[string]string{dataMarker: "curl"}, detected: updatecheck.ChannelHomebrew}, InstallChannelBrew}, + {"weak marker ignored for scoop", fakeChannelDeps{dataDir: "/data", files: map[string]string{dataMarker: "curl"}, detected: updatecheck.ChannelScoop}, InstallChannelScoop}, + {"weak marker ignored for windows-installer", fakeChannelDeps{dataDir: "/data", files: map[string]string{dataMarker: "curl"}, detected: updatecheck.ChannelWindowsInstaller}, InstallChannelWindowsInstaller}, + {"weak marker ignored for docker", fakeChannelDeps{dataDir: "/data", files: map[string]string{dataMarker: "curl"}, detected: updatecheck.ChannelDocker}, InstallChannelDocker}, + {"weak marker only accepts curl", fakeChannelDeps{dataDir: "/data", files: map[string]string{dataMarker: "winget"}, detected: updatecheck.ChannelTarball}, InstallChannelTarball}, + {"missing everything is unknown", fakeChannelDeps{detected: ""}, InstallChannelUnknown}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := resolveInstallChannel(tt.f.deps()); got != tt.want { + t.Errorf("resolve = %q, want %q", got, tt.want) + } + }) + } +} + +// The marker reader must cap what it reads so a hostile or corrupt file can +// never allocate or leak beyond a single short token. +func TestReadChannelMarker_CapsAndTrims(t *testing.T) { + dir := t.TempDir() + path := dir + "/install-channel" + if err := os.WriteFile(path, []byte(" winget \r\n"), 0o600); err != nil { + t.Fatal(err) + } + if got := readChannelMarker(path, readCapped); got != "winget" { + t.Errorf("readChannelMarker = %q, want winget", got) + } + if err := os.WriteFile(path, []byte(strings.Repeat("a", 1<<20)), 0o600); err != nil { + t.Fatal(err) + } + if got := readChannelMarker(path, readCapped); len(got) > markerMaxBytes { + t.Errorf("marker read not capped: %d bytes", len(got)) + } + if got := readChannelMarker(dir+"/absent", readCapped); got != "" { + t.Errorf("absent marker = %q, want empty", got) + } +} diff --git a/internal/telemetry/payload_install_channel_test.go b/internal/telemetry/payload_install_channel_test.go new file mode 100644 index 000000000..4847b370e --- /dev/null +++ b/internal/telemetry/payload_install_channel_test.go @@ -0,0 +1,92 @@ +package telemetry + +import ( + "encoding/json" + "strings" + "testing" +) + +func stubInstallChannel(t *testing.T, ch InstallChannel) *int { + t.Helper() + calls := 0 + prev := installChannelResolver + installChannelResolver = func(version, dataDir string) InstallChannel { + calls++ + return ch + } + t.Cleanup(func() { installChannelResolver = prev }) + return &calls +} + +// Spec 121 US4.1: an enabled heartbeat carries install_channel, drawn from the +// closed set, and still passes the anonymity scanner. +func TestPayload_InstallChannelPresentAndScannable(t *testing.T) { + stubInstallChannel(t, InstallChannelWinget) + svc := newFunnelPayloadTestService(t) + payload := svc.BuildPayload() + if payload.InstallChannel != "winget" { + t.Fatalf("InstallChannel = %q, want winget", payload.InstallChannel) + } + b, err := json.Marshal(payload) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(b), `"install_channel":"winget"`) { + t.Errorf("serialized payload missing install_channel: %s", b) + } + if err := ScanForPII(b); err != nil { + t.Errorf("ScanForPII rejected a legitimate install_channel: %v", err) + } +} + +// Every member of the enum is accepted; anything else is rejected before send. +func TestAnonymityScanner_InstallChannelEnum(t *testing.T) { + for _, c := range AllInstallChannels() { + raw := `{"install_channel":"` + string(c) + `"}` + if err := ScanForPII([]byte(raw)); err != nil { + t.Errorf("enum member %q rejected: %v", c, err) + } + } + for _, bad := range []string{`"flatpak"`, `"C:\\Users\\secret"`, `"/etc/passwd"`, `""`, `42`, `true`, `{"a":1}`} { + if err := ScanForPII([]byte(`{"install_channel":` + bad + `}`)); err == nil { + t.Errorf("install_channel=%s must be rejected by the scanner", bad) + } + } +} + +// Spec 121 US4.6/FR-031: when telemetry is disabled neither a heartbeat nor a +// payload preview may read a channel marker. +func TestPayload_InstallChannelNotResolvedWhenTelemetryDisabled(t *testing.T) { + calls := stubInstallChannel(t, InstallChannelBrew) + svc := newFunnelPayloadTestService(t) + off := false + svc.config.Telemetry.Enabled = &off + payload := svc.BuildPayload() + if payload.InstallChannel != "" { + t.Errorf("InstallChannel = %q while telemetry disabled, want empty", payload.InstallChannel) + } + if *calls != 0 { + t.Errorf("resolver called %d times while telemetry disabled, want 0", *calls) + } +} + +// Privacy canary: a hostile marker or env value never reaches the payload. +func TestResolveInstallChannel_HostileValuesNeverEscape(t *testing.T) { + f := fakeChannelDeps{ + env: `C:\Users\secret\.ssh`, + exeDir: "/opt/app", + dataDir: "/data", + detected: "../../etc/passwd", + files: map[string]string{ + "/opt/app/install-channel": `C:\Users\secret`, + "/data/install-channel": strings.Repeat("A", 4096), + }, + } + got := resolveInstallChannel(f.deps()) + if got != InstallChannelUnknown { + t.Fatalf("hostile inputs resolved to %q, want unknown", got) + } + if !got.Valid() { + t.Fatal("result must always be a member of the closed set") + } +} diff --git a/internal/telemetry/telemetry.go b/internal/telemetry/telemetry.go index 064314d76..3b931e587 100644 --- a/internal/telemetry/telemetry.go +++ b/internal/telemetry/telemetry.go @@ -261,6 +261,14 @@ type HeartbeatPayload struct { // the installer_heartbeat_pending BBolt flag (cleared after first heartbeat). LaunchSource string `json:"launch_source,omitempty"` + // InstallChannel (Spec 121): how this install was obtained, as a member of + // the closed InstallChannel set (brew, dmg, winget, scoop, windows-installer, + // deb, rpm, docker, registry, curl, tarball, go-install, unknown). Additive + // and always populated on an enabled heartbeat, so SchemaVersion is NOT + // bumped; consumers detect it by presence. Never a path or free text: the + // anonymity scanner re-validates it against the closed set before send. + InstallChannel string `json:"install_channel,omitempty"` + // AutostartEnabled: tri-state login-item status. // - *true : tray reports the app IS registered as a login item // - *false : tray reports the app is NOT registered @@ -1565,6 +1573,17 @@ func (s *Service) buildHeartbeatWithOneShots(consumeOneShots bool) HeartbeatPayl // detector result (tray/login_item/cli/unknown) is emitted. payload.LaunchSource = string(s.resolveLaunchSource(consumeOneShots)) + // Spec 121: install channel. Resolved only when telemetry is effectively + // enabled so a disabled install (or a payload preview of one) never reads + // a channel marker from disk. + if s.envDisabledReason == EnvDisabledNone && EffectiveTelemetryEnabled(cfg) { + dataDir := "" + if cfg != nil { + dataDir = cfg.DataDir + } + payload.InstallChannel = string(installChannelResolver(s.version, dataDir)) + } + // Spec 044 (T051): AutostartEnabled. Tri-state; nil when the tray sidecar // is absent/unreachable/malformed (Linux always falls here). if s.autostartReader != nil { diff --git a/internal/updatecheck/channel.go b/internal/updatecheck/channel.go index d0b5e27a5..00272c419 100644 --- a/internal/updatecheck/channel.go +++ b/internal/updatecheck/channel.go @@ -47,6 +47,7 @@ const ( ChannelDocker = "docker" ChannelGoInstall = "go-install" ChannelWindowsInstaller = "windows-installer" + ChannelScoop = "scoop" ChannelTarball = "tarball" ChannelUnknown = "unknown" ) @@ -61,6 +62,7 @@ var knownChannels = map[string]bool{ ChannelDocker: true, ChannelGoInstall: true, ChannelWindowsInstaller: true, + ChannelScoop: true, ChannelTarball: true, ChannelUnknown: true, } @@ -221,6 +223,17 @@ func (d *channelDetector) detect() string { return ChannelDMG } + // (4b) Scoop (Spec 121 FR-016). Scoop installs the very same release zip + // that carries the weak "tarball" stamp, so without this branch a + // scoop-owned executable would be classified as a self-updatable tarball + // install and `mcpproxy update` would overwrite a file scoop tracks + // (version, hash). The apps// layout is scoop's own and is checked + // on a slash-normalised, lower-cased path so it holds for both the + // per-user (~\scoop) and global (C:\ProgramData\scoop) roots. + if isScoopPath(path) { + return ChannelScoop + } + // (5) go install: the Go toolchain stamps the module version into build // info for `go install module@version` builds, while such builds carry no // -X ldflags version (the checker then receives the "development" @@ -312,3 +325,11 @@ func (d *channelDetector) isGoInstall() bool { // ("v0.0.0-20260701123456-abcdef123456") are both valid semver. return semver.IsValid(v) } + +// isScoopPath reports whether path lies inside scoop's package layout for +// mcpproxy (/scoop/apps/mcpproxy/...). Windows paths are normalised so +// the check also works when the detector is exercised on another OS. +func isScoopPath(path string) bool { + p := strings.ToLower(strings.ReplaceAll(path, `\`, "/")) + return strings.Contains(p, "/scoop/apps/mcpproxy/") +} diff --git a/internal/updatecheck/channel_scoop_test.go b/internal/updatecheck/channel_scoop_test.go new file mode 100644 index 000000000..ad583bb92 --- /dev/null +++ b/internal/updatecheck/channel_scoop_test.go @@ -0,0 +1,55 @@ +package updatecheck + +import "testing" + +// Spec 121 FR-016: a scoop-owned binary must be recognised as its own channel. +// The Windows release zip scoop installs is stamped "tarball" (weak), which +// would otherwise let `mcpproxy update` self-replace a package-manager-owned +// file and desynchronise scoop's version/hash bookkeeping. +func TestDetectChannel_Scoop(t *testing.T) { + tests := []struct { + name string + marker string + path string + want string + }{ + {"scoop current shim target", ChannelTarball, `C:\Users\u\scoop\apps\mcpproxy\current\mcpproxy.exe`, ChannelScoop}, + {"scoop versioned dir", ChannelTarball, `C:\Users\u\scoop\apps\mcpproxy\0.71.1\mcpproxy.exe`, ChannelScoop}, + {"scoop global install", ChannelTarball, `C:\ProgramData\scoop\apps\mcpproxy\current\mcpproxy.exe`, ChannelScoop}, + {"mixed case and slashes", ChannelTarball, `c:/Users/U/Scoop/Apps/MCPProxy/current/mcpproxy.exe`, ChannelScoop}, + {"unstamped build still scoop", "", `C:\Users\u\scoop\apps\mcpproxy\current\mcpproxy.exe`, ChannelScoop}, + {"other scoop app is not mcpproxy", ChannelTarball, `C:\Users\u\scoop\apps\othertool\current\mcpproxy.exe`, ChannelTarball}, + {"plain extracted zip stays tarball", ChannelTarball, `C:\tools\mcpproxy\mcpproxy.exe`, ChannelTarball}, + {"program files installer stays unknown without marker", "", `C:\Program Files\MCPProxy\mcpproxy.exe`, ChannelUnknown}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + d := testDetector() + d.goos = "windows" + d.marker = tt.marker + path := tt.path + d.execPath = func() (string, error) { return path, nil } + if got := d.detect(); got != tt.want { + t.Errorf("detect() = %q, want %q", got, tt.want) + } + }) + } +} + +// A scoop path must not override the absolute markers: a windows-installer +// binary relocated under a scoop-looking directory is still the installer. +func TestDetectChannel_ScoopDoesNotBeatAbsoluteMarker(t *testing.T) { + d := testDetector() + d.goos = "windows" + d.marker = ChannelWindowsInstaller + d.execPath = func() (string, error) { return `C:\Users\u\scoop\apps\mcpproxy\current\mcpproxy.exe`, nil } + if got := d.detect(); got != ChannelWindowsInstaller { + t.Errorf("detect() = %q, want %q", got, ChannelWindowsInstaller) + } +} + +func TestScoopKnownChannel(t *testing.T) { + if !knownChannels[ChannelScoop] { + t.Fatal("scoop must be a recognised build marker/channel") + } +} diff --git a/internal/updatecheck/guidance.go b/internal/updatecheck/guidance.go index 702765efc..630591a63 100644 --- a/internal/updatecheck/guidance.go +++ b/internal/updatecheck/guidance.go @@ -19,6 +19,10 @@ 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 ChannelScoop: + // Spec 121 FR-016: scoop owns the executable and its version/hash + // bookkeeping; point at scoop, never at self-update. + return "scoop update mcpproxy" 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 diff --git a/internal/updatecheck/guidance_test.go b/internal/updatecheck/guidance_test.go index 845835efb..e0f726a04 100644 --- a/internal/updatecheck/guidance_test.go +++ b/internal/updatecheck/guidance_test.go @@ -14,6 +14,8 @@ 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 121 FR-016: scoop owns its executable; never self-update it. + {ChannelScoop, "scoop update mcpproxy"}, // Spec 092 FR-020/FR-024: a positively identified tarball install is // self-managed, so its "command" is mcpproxy's own verified // self-update. diff --git a/oas/docs.go b/oas/docs.go index bd4fa99c0..fb05e73e1 100644 --- a/oas/docs.go +++ b/oas/docs.go @@ -6,7 +6,7 @@ import "github.com/swaggo/swag/v2" const docTemplate = `{ "schemes": {{ marshal .Schemes }}, - "components": {"schemas":{"auth.CredentialIssuer":{"properties":{"actor_kind":{"type":"string"},"actor_name":{"type":"string"},"surface":{"type":"string"}},"type":"object"},"config.AuditLogConfig":{"description":"AuditLog configures the Spec 107 edition-neutral audit sink\n(internal/audit). nil means \"use the per-edition/per-transport\ndefault\" (EffectiveAuditLog); restart-pinned (bound at sink\nconstruction). See audit_log.go.","properties":{"compress":{"type":"boolean"},"enabled":{"type":"boolean"},"max_age_days":{"type":"integer"},"max_backups":{"type":"integer"},"max_size_mb":{"type":"integer"},"path":{"type":"string"},"stdout":{"type":"boolean"}},"type":"object"},"config.ConcurrencyDefaults":{"description":"ServerConcurrencyDefaults is scope (b) of FR-020: the blanket per-server\ndefault set inherited by every server that does not override a setting.\nAbsent (the default) = no per-server limiting unless a server configures\nit explicitly. File/API-configured only — no env scheme (FR-022).","properties":{"max_concurrent_requests":{"type":"integer"},"queue_size":{"type":"integer"},"queue_timeout":{"type":"string"}},"type":"object"},"config.Config":{"properties":{"activity_cleanup_interval_min":{"description":"Background cleanup interval in minutes (default: 60)","type":"integer"},"activity_max_records":{"description":"Max records before pruning (default: 100000)","type":"integer"},"activity_max_response_size":{"description":"Response truncation limit in bytes (default: 65536)","type":"integer"},"activity_max_size_mb":{"description":"ActivityMaxSizeMB caps the total activity-log size in MB before the\noldest records are pruned. Omit the key for the 256MB default; set it to\n0 to disable the size cap.","type":"integer"},"activity_retention_days":{"description":"Activity logging settings (RFC-003)","type":"integer"},"advertise_upstream_servers":{"description":"AdvertiseUpstreamServers controls whether MCP clients are told the\nnames of the upstream servers they can reach — in the initialize\ninstructions and the retrieve_tools description — so agents use the\nproxied tools instead of falling back to shell CLIs. Names are always\nfiltered to the caller's profile and agent-token scope. Default true;\nset false to keep server names out of client context. Read live.","type":"boolean"},"aggregate_upstream_prompts":{"description":"AggregateUpstreamPrompts, when true, aggregates every connected upstream\nserver's advertised MCP prompts into mcpproxy's own prompts/list\n(exposed as \"\u003cserver\u003e__\u003cprompt\u003e\"). OFF by default: users are safe by\ndefault and opt in deliberately. EnablePrompts still governs the built-in\nprompts + the prompts capability; this flag gates ONLY the upstream\naggregation performed by RefreshPrompts. Hot-reloadable.","type":"boolean"},"allow_private_registry_fetch":{"description":"AllowPrivateRegistryFetch opts out of the registry SSRF guard (MCP-1076,\nCWE-918). By default (false) registry fetches refuse any host that is — or\nresolves to — a non-routable address (loopback, RFC1918/CGNAT private,\nlink-local incl. the 169.254.169.254 cloud-metadata endpoint), so a\nmalicious or typo'd registry source cannot turn the daemon into a\nrequest-forgery vector against internal services.\n\nThis opt-out is BLANKET (all-or-nothing): setting it true disables the\nguard for EVERY non-routable range at once — loopback, RFC1918/CGNAT\nprivate, link-local AND the 169.254.169.254 cloud-metadata endpoint. There\nis no way to allow only loopback; enabling it for a localhost dev registry\nalso re-opens the cloud-metadata SSRF vector. Set true ONLY when you\nintentionally run a trusted registry mirror on an internal/private address,\nideally on a host with no cloud-metadata exposure. The change takes effect\nonly on daemon (re)start or config reload.","type":"boolean"},"allow_server_add":{"type":"boolean"},"allow_server_remove":{"type":"boolean"},"anonymous_profile":{"description":"AnonymousProfile confines every caller whose request authenticates as\ncredential kind \"anonymous\" (no credential, or an unrecognised\nnon-agent token accepted by the require_mcp_auth:false back-compat\nbranch) to the named profile (Spec 108 FR-008). Empty (default) means\nunconfined, legacy anonymous behaviour. A name that does not match any\nconfigured profile resolves anonymous callers to deny-all and is\nreported as a validation warning (data-model.md §1).","type":"string"},"api_key":{"description":"Security settings","type":"string"},"audit_log":{"$ref":"#/components/schemas/config.AuditLogConfig"},"call_tool_timeout":{"type":"string"},"check_server_repo":{"description":"Repository detection settings","type":"boolean"},"code_execution_max_parallel":{"description":"Default concurrency for call_tools() batches (1-32, default: 8)","type":"integer"},"code_execution_max_tool_calls":{"description":"Max tool calls per execution (0 = unlimited, default: 0)","type":"integer"},"code_execution_pool_size":{"description":"JavaScript runtime pool size (default: 10)","type":"integer"},"code_execution_timeout_ms":{"description":"Timeout in milliseconds (default: 120000, max: 600000)","type":"integer"},"data_dir":{"type":"string"},"debug_search":{"type":"boolean"},"direct_tool_response_mode":{"description":"DirectToolResponseMode selects the serialization of the DIRECT\nenumeration surface (Spec 102). Valid values: \"\" (= full), \"full\"\n(default: today's schema-bearing entries), \"deferred\" (description +\ncompact signature, with a minimal permissive input schema; upstream\ninputSchema and outputSchema are stripped and recovered on demand via\ndescribe_tool).\n\nDeliberately NOT an extension of tool_response_mode: reusing that axis\nwould silently change /mcp/all output for every deployment already\nrunning compact, which FR-015 forbids. Serialization-only — it never\nchanges WHICH tools are listed, only how (FR-008). Hot-reloadable.","type":"string"},"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_client_headers":{"description":"ForwardClientHeaders is the global switch for client header forwarding\n(Spec 112): copying allowlisted headers from the inbound /mcp request into\nthe upstream tools/call request. nil (absent) means enabled; because every\nper-server forward_headers allowlist is empty by default, the default\nforwards nothing. false disables forwarding for every server. Read via\nIsClientHeaderForwardingEnabled(); MCPPROXY_FORWARD_CLIENT_HEADERS\noverrides it for the process. Distinct from ForwardedHeaders/trusted_proxies\n(Spec 107), which trusts inbound X-Forwarded-*.","type":"boolean"},"forward_proxy_env":{"description":"ForwardProxyEnv opts in to forwarding the ambient HTTP(S)/ALL/NO/FTP proxy\nenvironment variables to spawned stdio upstream servers (MCP-2769). OFF by\ndefault: proxy URLs commonly embed credentials (http://user:pass@proxy), so\nforwarding them to every upstream is a credential-leak risk. When enabled,\nvalues are forwarded with their userinfo (credentials) redacted.","type":"boolean"},"health_check_interval":{"description":"Discovery \u0026 health-check cadence (spec 074, #608). Both are *Duration\ntri-state pointers: nil = inherit the built-in default; a pointer to 0s =\nthe loop is disabled; a positive value = that interval. Defaults live only\nin the resolvers (ResolveHealthCheckInterval / ResolveToolDiscoveryInterval)\nso an unset key behaves exactly as before this feature (SC-005). Validated\nin Validate(): health-check ∈ {0} ∪ [5s,1h]; tool-discovery ∈ {0} ∪ [30s,24h].","type":"string"},"http_idle_timeout":{"description":"HTTPIdleTimeout caps how long an idle keep-alive connection is kept open.\nUnset = 180s. \"0s\" removes the dedicated idle deadline, but net/http then\nfalls back to ReadTimeout — idle is fully unbounded only when\nhttp_read_timeout is also \"0s\". Requires a restart.","type":"string"},"http_read_timeout":{"description":"HTTPReadTimeout caps how long reading a whole request (headers + body)\nmay take. Unset = 120s; \"0s\" disables it. Requires a restart.","type":"string"},"http_write_timeout":{"description":"HTTPWriteTimeout caps how long producing a whole response may take on\nnon-streaming endpoints (REST, Web UI, health). Unset = 120s; \"0s\"\ndisables it globally. MCP and SSE /events routes are exempt by design.","type":"string"},"init_timeout":{"description":"InitTimeout is the global default deadline for an upstream's MCP\n` + "`" + `initialize` + "`" + ` handshake (MCP-3322 / GH #760). *Duration tri-state: nil =\ninherit the built-in 30s default; a positive value = that deadline. A\nper-server InitTimeout overrides this. Resolved by ResolveInitTimeout;\nvalidated to {0} ∪ [1s, 30m] in Validate(). Servers doing legitimate\nfirst-run warmup (cache/index build) before answering ` + "`" + `initialize` + "`" + ` can\nraise this so they are not killed mid-startup.","type":"string"},"instructions":{"description":"Instructions text returned in the MCP initialize response to guide AI agents.\nWhen empty, a built-in default is used that explains retrieve_tools workflow.","type":"string"},"intent_declaration":{"$ref":"#/components/schemas/config.IntentDeclarationConfig"},"listen":{"type":"string"},"logging":{"$ref":"#/components/schemas/config.LogConfig"},"max_concurrent_requests":{"description":"Concurrency limits (spec 093, GH #955). Scope (a) of FR-020: the GLOBAL\nAGGREGATE limiter — one proxy-wide cap on concurrently running upstream\ntool calls, with its own bounded wait queue. Tri-state pointers: absent =\nthe limiter does not exist (default, zero behavior change); an explicit 0\nmax also disables it; positive = that cap. This scope is NEVER a\nper-server inheritance source — per-server values come from\nServerConcurrencyDefaults / the per-server overrides — but a server's\neffective concurrency is bounded by BOTH its own limiter and this one.\nResolved by ResolveGlobalConcurrency; hot-reloadable; overridable via\nMCPPROXY_MAX_CONCURRENT_REQUESTS / _QUEUE_SIZE / _QUEUE_TIMEOUT (FR-022).","type":"integer"},"max_result_size_chars":{"description":"MaxResultSizeChars is advertised on every tool as\n` + "`" + `_meta.anthropic/maxResultSizeChars` + "`" + `; it raises Claude Code's\ninline-response ceiling from 50k to up to 500k chars. Omit the key for\nthe 500000 default; set it to 0 to disable the annotation.","type":"integer"},"mcpServers":{"items":{"$ref":"#/components/schemas/config.ServerConfig"},"type":"array","uniqueItems":false},"oauth_expiry_warning_hours":{"description":"Health status settings","type":"number"},"observability":{"$ref":"#/components/schemas/config.ObservabilityConfig"},"output_sanitisation":{"$ref":"#/components/schemas/config.OutputSanitisationConfig"},"output_validation":{"$ref":"#/components/schemas/config.OutputValidationConfig"},"profiles":{"description":"Profiles are optional named, server-scoped views exposed at /mcp/p/\u003cname\u003e\n(Spec 057). Absent/empty is fully supported — /mcp is unchanged and configs\nwithout this key serialize byte-identically (SC-004).","items":{"$ref":"#/components/schemas/config.ProfileConfig"},"type":"array","uniqueItems":false},"quarantine_enabled":{"description":"QuarantineEnabled controls whether quarantine is active. It gates two\nthings together:\n 1. Server-level auto-quarantine for newly added servers (issue #370).\n When true, servers added via the upstream_servers MCP tool or the\n REST API default to quarantined=true; when false, they default to\n quarantined=false. Explicit per-request values always win.\n 2. Tool-level quarantine (Spec 032): per-tool SHA-256 approval of\n tool descriptions/schemas.\nWhen nil (default), quarantine is enabled (secure by default). Set to\nexplicit false to opt out of both. Per-server SkipQuarantine still\napplies for the tool-level check on individual servers.","type":"boolean"},"queue_size":{"type":"integer"},"queue_timeout":{"type":"string"},"read_only_mode":{"type":"boolean"},"registries":{"description":"Registries configuration for MCP server discovery","items":{"$ref":"#/components/schemas/config.RegistryEntry"},"type":"array","uniqueItems":false},"registries_locked":{"description":"RegistriesLocked is an enterprise stub knob (MCP-866): when true, runtime\nadditions of custom registries (e.g. ` + "`" + `registry add-source` + "`" + `, the REST/MCP\nadd-source surface) are rejected so an administrator can pin the discovery\nsources. Built-in defaults are unaffected. Documented but otherwise inert\nbeyond the add-source rejection.","type":"boolean"},"require_mcp_auth":{"description":"Require authentication on /mcp endpoint (default: false)","type":"boolean"},"reveal_secret_headers":{"description":"RevealSecretHeaders, when true, disables the redaction of the\nsecret-bearing server fields — sensitive header values (Authorization,\nX-API-Key, Cookie, …), env-var secrets, and URL query credentials — in\nresponses from the ` + "`" + `upstream_servers` + "`" + ` MCP tool, the ` + "`" + `/api/v1/servers` + "`" + `\nREST API, and the SSE event stream. It also lets URL secrets echoed\ninto last_error / health.detail through unscrubbed.\n\nDefault false — sensitive values are surfaced masked as\n` + "`" + `••••\u003clast2\u003e (\u003cN\u003e chars)` + "`" + ` (error strings use ` + "`" + `***REDACTED***` + "`" + `) so an\nMCP agent cannot read Bearer tokens / API keys / URL secrets out of\nanother upstream's config (PR #425, issue #872). ${env:…}/${keyring:…}\nreferences are labels, not secrets, and pass through unchanged.\n\nThe Web UI / macOS tray edit forms work without seeing the real\nvalues: PATCH /api/v1/servers/{id} deep-merges (omitted keys are\npreserved, see ` + "`" + `headers_remove` + "`" + ` / ` + "`" + `env_remove` + "`" + ` for explicit\ndeletes), so clients compute a diff and only send the keys that\nactually changed. Redacted-but-unchanged values never round-trip\n— the backend keeps the real string. Set this to true if a\ndownstream tool genuinely needs raw values in the response.","type":"boolean"},"routing_mode":{"description":"Routing mode (Spec 031): how MCP tools are exposed to clients\nValid values: \"retrieve_tools\" (default), \"direct\", \"code_execution\"","type":"string"},"security":{"$ref":"#/components/schemas/config.SecurityConfig"},"sensitive_data_detection":{"$ref":"#/components/schemas/config.SensitiveDataDetectionConfig"},"server_concurrency_defaults":{"$ref":"#/components/schemas/config.ConcurrencyDefaults"},"telemetry":{"$ref":"#/components/schemas/config.TelemetryConfig"},"tls":{"$ref":"#/components/schemas/config.TLSConfig"},"tokenizer":{"$ref":"#/components/schemas/config.TokenizerConfig"},"tool_call_max_records_per_server":{"description":"Calls retained per server (default: 1000)","type":"integer"},"tool_call_max_response_size":{"description":"Bounds for the per-server tool-call history behind GET /api/v1/tool-calls\n(#1176). It is a recent-debugging window, not an audit log — the activity\nlog is the durable record — and it kept every upstream response whole,\nper server, forever. A non-positive value means \"use the default\", not\n\"disable\": this store must never be unbounded again, so there is\ndeliberately no off switch.","type":"integer"},"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},"trusted_proxies":{"description":"TrustedProxies lists the CIDRs or IP addresses whose X-Forwarded-For /\nX-Real-IP / X-Forwarded-Proto / X-Forwarded-Host headers are believed\n(Spec 107 FR-027). Empty (default) trusts nobody. Edition-neutral, live\n(hot-reloadable). Env override: MCPPROXY_TRUSTED_PROXIES (comma-separated).\nThe one reader is ForwardedHeaders; validation is validateTrustedProxies.","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.\nThe endpoint is admin-authenticated (SEC-07): scrapers must present the\nglobal API key, via X-API-Key or an Authorization: Bearer header.","type":"boolean"}},"type":"object"},"config.OAuthConfig":{"description":"OAuth configuration (keep even when empty to signal OAuth requirement)","properties":{"auth_server_metadata_url":{"type":"string"},"authorization_endpoint":{"description":"Spec 113-b: optional per-server discovery overrides. Each is an absolute\nhttps URL (http only on a loopback host) with no query, fragment or\nuserinfo; validated at write time only (ValidateOAuthEndpointOverrides).\nThe endpoint overrides rewrite the matching fields of the authorization\nserver metadata mcp-go reads; AuthServerMetadataURL replaces discovery.","type":"string"},"callback_base_url":{"type":"string"},"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"},"registration_endpoint":{"type":"string"},"scopes":{"items":{"type":"string"},"type":"array","uniqueItems":false},"token_endpoint":{"type":"string"}},"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":{"code_execution":{"description":"CodeExecution: nil = inherit the global enable_code_execution gate;\nnon-nil narrows it (a profile can only narrow, never widen, FR-006).","type":"boolean"},"description":{"description":"\u003c= 500 chars (FR-001)","type":"string"},"management_tools":{"description":"ManagementTools: nil = legacy/inherit (FR-016); non-nil sets the\nprofile-level visibility of upstream_servers/quarantine_security.","type":"boolean"},"max_tier":{"description":"MaxTier caps the tier a tool may run at under this profile: \"\" (no\ncap) | \"read\" | \"write\" | \"destructive\" (FR-001).","type":"string"},"name":{"description":"slug (Spec 057 rules unchanged)","type":"string"},"servers":{"description":"references to mcpServers[].name","items":{"type":"string"},"type":"array","uniqueItems":false},"switchable_to":{"description":"SwitchableTo is the list of profile names a session under this\nprofile may ` + "`" + `set_profile` + "`" + ` into (FR-022). nil = legacy/none (research\nD6); a non-nil EMPTY list is an explicit \"none\" and must round-trip as\n` + "`" + `[]` + "`" + `, never be dropped by omitempty — hence the pointer (see the\nMarshalJSON note below, and profiles_v3_test.go's switchable_to round\ntrip case).","items":{"type":"string"},"type":"array","uniqueItems":false},"title":{"description":"display only, \u003c= 80 chars (FR-001)","type":"string"},"tools":{"$ref":"#/components/schemas/config.ProfileToolRules"},"unannotated":{"description":"Unannotated is this profile's handling of a tool whose effective\nannotations carry no tier hint: \"\" (unset, see EffectiveUnannotated) |\n\"deny\" | \"as_write\" | \"as_read\" (FR-001, FR-003).","type":"string"}},"type":"object"},"config.ProfileToolRules":{"description":"Tools holds the allow/deny/classify rule set (FR-001, FR-004, FR-005).","properties":{"allow":{"items":{"type":"string"},"type":"array","uniqueItems":false},"classify":{"additionalProperties":{"type":"string"},"type":"object"},"deny":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"config.RegistryEntry":{"properties":{"count":{"description":"number or string","type":"string"},"description":{"type":"string"},"id":{"type":"string"},"name":{"type":"string"},"protocol":{"type":"string"},"provenance":{"description":"Provenance is the trust tag for this registry (MCP-866):\nRegistryProvenanceOfficial for built-in defaults, RegistryProvenanceCustom\nfor user-added registries. It is authoritatively (re)computed by the\nregistries merge from whether the ID is a shipped default — a user cannot\nclaim \"official\" by writing it into their config.","type":"string"},"requires_key":{"description":"RequiresKey marks a registry that needs an API key to be queried. When\ntrue and no key is configured, the registry is skipped/marked unavailable\nrather than failing the whole search (FR-008).","type":"boolean"},"servers_url":{"type":"string"},"tags":{"items":{"type":"string"},"type":"array","uniqueItems":false},"url":{"type":"string"}},"type":"object"},"config.SecurityConfig":{"description":"Security scanner settings (Spec 039)","properties":{"auto_baseline_scan":{"description":"AutoBaselineScan is the kill-switch for the AUTOMATIC, informational\nPass-1 baseline scan: the free in-process TPA scan mcpproxy runs for every\nnewly admitted server (any trust mode) and, once per installation, over\npre-existing servers that have never been scanned.\n\nInformational ONLY: the resulting verdict populates the security badge and\nthe scan summary, and NEVER gates quarantine or approval. The\ntrust_mode:\"scan\" admission gate is a separate path and is unaffected by\nthis flag.\n\nDefault (nil) is ENABLED. Set to false to suppress every automatic scan\n(manual scans keep working). Env override: MCPPROXY_AUTO_BASELINE_SCAN,\nwhich wins over this field on every path.","type":"boolean"},"deep_scan":{"$ref":"#/components/schemas/config.DeepScanConfig"},"integrity_check_interval":{"type":"string"},"integrity_check_on_restart":{"type":"boolean"},"runtime_read_only":{"type":"boolean"},"runtime_tmpfs_size":{"type":"string"},"scan_timeout_default":{"type":"string"},"scanner_disable_no_new_privileges":{"description":"Deprecated (Spec 077 US3): migrated on load into DeepScan.DisableNoNewPrivileges\n(see migrateDeepScanConfig). Retained only so existing configs that still carry\nthe top-level key parse; consumers MUST read the effective value via\nSecurityConfig.IsDisableNoNewPrivileges. Cleared after migration.\n\nScannerDisableNoNewPrivileges, when true, omits the\n` + "`" + `--security-opt no-new-privileges` + "`" + ` flag from scanner container runs.\n\nBackground: snap-installed Docker on Ubuntu confines dockerd under the\n` + "`" + `snap.docker.dockerd` + "`" + ` AppArmor profile. When runc tries to transition\nthe container into the inner ` + "`" + `docker-default` + "`" + ` profile to exec the\nentrypoint, AppArmor refuses the transition because NO_NEW_PRIVS\nforbids privilege/profile changes on exec — the result is EPERM\n(\"operation not permitted\") and every scanner fails immediately.\n\nSet this to true ONLY on hosts hitting that incompatibility. Scanner\ncontainers still run with read-only rootfs, tmpfs /tmp, no-network by\ndefault, and read-only source mounts, so the marginal isolation loss\nis small. The preferred fix remains replacing snap docker with a\ndistro-packaged docker.","type":"boolean"},"scanner_fetch_package_source":{"description":"Deprecated (Spec 077 US3): migrated on load into DeepScan.FetchPackageSource\n(see migrateDeepScanConfig). Retained only so existing configs that still carry\nthe top-level key parse; consumers MUST read the effective value via\nSecurityConfig.EffectiveFetchPackageSource. Cleared after migration.\n\nScannerFetchPackageSource controls whether the scanner fetches the\nPUBLISHED source of package-runner servers (npx/uvx) — without executing\nit — when no local source is available (no Docker container, no local\npackage cache, no working_dir). This is the primary quarantine/scan\ntarget: a quarantined-on-add server is never run locally, so without this\nthe scan degrades to tool-definitions-only (no real source-level\nanalysis). See MCP-2206.\n\nFetching uses ` + "`" + `npm pack --ignore-scripts` + "`" + ` (npm) and ` + "`" + `uv pip download` + "`" + ` /\n` + "`" + `pip download` + "`" + ` with ` + "`" + `--only-binary=:all:` + "`" + ` (Python), which only download +\nunpack archives and NEVER run install, build, or setup.py — a scanner must\nnot execute the untrusted code it is scanning. The Python\n` + "`" + `--only-binary=:all:` + "`" + ` flag is required because downloading an sdist would\ninvoke its build backend (setup.py); packages with no wheel fall back to\ntool-definitions-only instead. Extraction is hardened against path\ntraversal and decompression bombs.\n\nDefault (nil) is ENABLED. Set to false on air-gapped deployments to\nforbid the scanner's network egress; such servers then fall back to the\ntool-definitions-only scan with no regression.","type":"boolean"},"scanner_registry_url":{"type":"string"},"tpa_bundle_path":{"description":"TPABundlePath is the filesystem path to the tpa-db scanner-bundle.json\nthe offline TPA scanner runs (spec 086 FR-019: the signature-DB location\nMUST be configuration-driven, not hardcoded). Empty (the default) runs the\ncorpus embedded in this build.\n\nEnv override: MCPPROXY_TPA_BUNDLE_PATH. Hot-reloadable — the path is\nre-read on every config.reloaded event via\nscanner.Service.ApplySecurityConfig, so a corpus refresh needs no restart.\nA configured bundle that fails to read/parse/version-check/compile is\nREFUSED and the previously active corpus stays live (fail-closed, never\nfail-empty); the reason is logged and surfaced in the security overview's\nsignature_bundle.load_error.","type":"string"}},"type":"object"},"config.SensitiveDataDetectionConfig":{"description":"Sensitive data detection settings (Spec 026)","properties":{"categories":{"additionalProperties":{"type":"boolean"},"description":"Enable/disable specific detection categories","type":"object"},"custom_patterns":{"description":"User-defined detection patterns","items":{"$ref":"#/components/schemas/config.CustomPattern"},"type":"array","uniqueItems":false},"enabled":{"description":"Enable sensitive data detection (default: true)","type":"boolean"},"entropy_threshold":{"description":"Shannon entropy threshold for high-entropy detection (default: 4.5)","type":"number"},"max_payload_size_kb":{"description":"Max size to scan before truncating (default: 1024)","type":"integer"},"scan_requests":{"description":"Scan tool call arguments (default: true)","type":"boolean"},"scan_responses":{"description":"Scan tool responses (default: true)","type":"boolean"},"sensitive_keywords":{"description":"Keywords to flag","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"config.ServerConfig":{"properties":{"args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"auto_approve_tool_changes":{"description":"AutoApproveToolChanges is the per-server intent to auto-approve tool\nchanges/additions (disabling per-server rug-pull protection). Supersedes\nskip_quarantine. MCP-2930 only ACCEPTS, persists, and migrates this flag — it\nis NOT yet consulted at runtime; auto-approval is still governed by\nSkipQuarantine until the trust-baseline behavior change (MCP-2931) migrates the\nruntime consumers onto it.\nTri-state pointer (mirrors QuarantineEnabled): nil = unset (inherit/migrate\nfrom legacy skip_quarantine), explicit true/false = honored as-is so an\nexplicit auto_approve_tool_changes:false overrides a legacy skip_quarantine:true.\nRead via IsAutoApproveToolChanges().","type":"boolean"},"command":{"type":"string"},"created":{"type":"string"},"disabled_tools":{"description":"Denylist: these tools are hidden; mutually exclusive with enabled_tools","items":{"type":"string"},"type":"array","uniqueItems":false},"enabled":{"type":"boolean"},"enabled_tools":{"description":"Allowlist: only these tools are exposed; mutually exclusive with disabled_tools","items":{"type":"string"},"type":"array","uniqueItems":false},"env":{"additionalProperties":{"type":"string"},"type":"object"},"expose_prompts":{"description":"ExposePrompts overrides whether this server's advertised MCP prompts are\naggregated into mcpproxy's prompts/list. nil (default) inherits the\ndefault-aggregate behavior (included if the server advertises\nCapabilities.Prompts); false excludes it regardless of capability.","type":"boolean"},"forward_headers":{"description":"ForwardHeaders lists inbound MCP-client header NAMES (never values) that\nare copied into this server's upstream tools/call requests (Spec 112).\nExact names, case-insensitive, at most 32, no wildcards. Empty forwards\nnothing. Only HTTP-based transports (http, streamable-http) forward; the\ndeny list (Authorization, Host, hop-by-hop, ...) is enforced at runtime\nregardless of what is configured here.","items":{"type":"string"},"type":"array","uniqueItems":false},"headers":{"additionalProperties":{"type":"string"},"description":"For HTTP servers","type":"object"},"health_check_interval":{"description":"Per-server discovery \u0026 health-check overrides (spec 074). Same *Duration\ntri-state as the global keys: nil = inherit the global value (or default),\npointer to 0s = disabled for this server, positive = that interval.\nHealthCheckInterval is fully wired into the per-server health loop;\nToolDiscoveryInterval is accepted/validated and round-trips for\nforward-compat, but the periodic index sweep is governed by the global\ncadence in this iteration (see spec 074 plan §C).","type":"string"},"init_timeout":{"description":"InitTimeout overrides the global init_timeout for this server's MCP\n` + "`" + `initialize` + "`" + ` handshake deadline (MCP-3322 / GH #760). *Duration tri-state:\nnil = inherit the global value (or 30s default), positive = that deadline.\nResolved by Config.ResolveInitTimeout; validated to {0} ∪ [1s, 30m]. Raise\nthis for upstreams that do legitimate first-run warmup (e.g. caching many\nchannels/users) before responding to ` + "`" + `initialize` + "`" + `.","type":"string"},"isolation":{"$ref":"#/components/schemas/config.IsolationConfig"},"launcher_wait_timeout":{"description":"LauncherWaitTimeout caps how long mcpproxy will wait for a locally-launched\nHTTP/SSE upstream's URL to become reachable after Spawn(). Only consulted\nwhen the server is configured with both Command and an HTTP/SSE URL — i.e.,\nmcpproxy starts the process AND connects via network. Stdio servers ignore\nthis field. Zero or unset → 30s default.","type":"string"},"max_concurrent_requests":{"description":"Per-server concurrency overrides — scope (c) of FR-020 (spec 093, #955).\nTri-state per setting, exactly like HealthCheckInterval: absent = inherit\nthe per-server default set (server_concurrency_defaults), explicit 0 =\ndisable that setting for this server (0 max = no per-server limiter at\nall; 0 queue_size = no pending capacity, shed immediately at the cap),\npositive = override. The global aggregate limiter is never inherited from\nhere — it applies on top, so effective concurrency is min(per-server,\nglobal). Resolved by Config.ResolveServerConcurrency.","type":"integer"},"name":{"type":"string"},"oauth":{"$ref":"#/components/schemas/config.OAuthConfig"},"protocol":{"description":"stdio, http, sse, streamable-http, auto","type":"string"},"quarantined":{"description":"Security quarantine status","type":"boolean"},"queue_size":{"type":"integer"},"queue_timeout":{"type":"string"},"reconnect_on_use":{"description":"Attempt reconnection when a tool call targets a disconnected server","type":"boolean"},"shared":{"description":"Server edition: shared with all users","type":"boolean"},"skip_quarantine":{"description":"SkipQuarantine is DEPRECATED (MCP-2930): use AutoApproveToolChanges instead.\nKept for back-compat parsing; on config load a legacy skip_quarantine:true is\nmigrated to auto_approve_tool_changes:true only when the new field is unset\n(see normalizeServerQuarantineFlags).","type":"boolean"},"source_registry_id":{"description":"SourceRegistryID records which registry this server was added from (empty\nfor manually-configured servers). MCP-866: surfaced in the approval /\nquarantine view so a reviewer can see a server's origin.","type":"string"},"source_registry_provenance":{"description":"SourceRegistryProvenance records the source registry's provenance at add\ntime (RegistryProvenanceOfficial / RegistryProvenanceCustom). It is purely\ninformational (MCP-1072) — surfaced so a reviewer can see a server's origin\n— and no longer gates quarantine or skip_quarantine.","type":"string"},"tool_discovery_interval":{"type":"string"},"toon_output":{"description":"ToonOutput overrides the global toon_output mode for this server's\ntools (spec 084, FR-001). Plain string, not a pointer: \"\"/absent =\ninherit the global value; \"off\"|\"adaptive\"|\"always\" = override (\"off\"\nis the explicit force-off). Resolved by Config.ResolveToonOutput.","type":"string"},"trust_mode":{"description":"TrustMode is the per-server trust tier: auto|scan|manual. Supersedes\nauto_approve_tool_changes (spec 086). An empty value is derived from the\nlegacy fields at load via normalizeServerQuarantineFlags; the single\nresolution point is EffectiveTrustMode(), which treats an empty or\nunrecognized value as manual (secure by default). Read via\nEffectiveTrustMode(), never the raw string.","type":"string"},"updated":{"type":"string"},"url":{"type":"string"},"working_dir":{"description":"Working directory for stdio servers","type":"string"}},"type":"object"},"config.TLSConfig":{"description":"TLS configuration","properties":{"certs_dir":{"description":"Directory for certificates","type":"string"},"enabled":{"description":"Enable HTTPS","type":"boolean"},"hsts":{"description":"Enable HTTP Strict Transport Security","type":"boolean"},"require_client_cert":{"description":"Enable mTLS","type":"boolean"}},"type":"object"},"config.TelemetryConfig":{"description":"Telemetry settings (Spec 036)","properties":{"anonymous_id":{"description":"Auto-generated UUIDv4","type":"string"},"anonymous_id_created_at":{"description":"Spec 042 (Tier 2) additions — all default-zero, all backwards-compatible.","type":"string"},"enabled":{"description":"Default: true (opt-out)","type":"boolean"},"endpoint":{"description":"Override for testing","type":"string"},"last_reported_version":{"description":"Upgrade funnel","type":"string"},"last_startup_outcome":{"description":"success|port_conflict|db_locked|...","type":"string"},"notice_shown":{"description":"First-run notice flag","type":"boolean"}},"type":"object"},"config.TokenizerConfig":{"description":"Tokenizer configuration for token counting","properties":{"default_model":{"description":"Default model for tokenization (e.g., \"gpt-4\")","type":"string"},"enabled":{"description":"Enable token counting","type":"boolean"},"encoding":{"description":"Default encoding (e.g., \"cl100k_base\")","type":"string"}},"type":"object"},"config.TracingExporterConfig":{"description":"Tracing gates the OpenTelemetry OTLP trace exporter (MCP-32). Disabled by\ndefault.","properties":{"enabled":{"description":"Enabled turns on OTLP trace export for tool calls and upstream hops.","type":"boolean"},"endpoint":{"description":"Endpoint is the collector address as host:port (no scheme), e.g.\n\"localhost:4318\" for http or \"localhost:4317\" for grpc.","type":"string"},"protocol":{"description":"Protocol selects the OTLP transport: \"http\" or \"grpc\".","type":"string"},"sample_rate":{"description":"SampleRate is the head-based trace sampling ratio in [0,1]. Default 0.1.\nOmit the key for the 0.1 default; set it to 0 to sample nothing.","type":"number"}},"type":"object"},"config.UpdateCheckConfig":{"description":"Update-check settings (Spec 079 FR-012): config-file control of the\nbackground upgrade-awareness checker (internal/updatecheck). nil =\nenabled on the stable channel (existing default behavior). The existing\nenvironment switches keep working and WIN over these keys (FR-014):\nMCPPROXY_DISABLE_AUTO_UPDATE=true force-disables even when\nenabled=true, and MCPPROXY_ALLOW_PRERELEASE_UPDATES=true force-selects\nthe rc channel even when channel=stable.","properties":{"channel":{"description":"Channel selects which releases are offered as updates: \"stable\"\n(default; prereleases never offered) or \"rc\" (prereleases included).\nEmpty resolves to stable. Validated in ValidateDetailed.\n\nNOTE: for a RELEASED build the running binary's own version is\nauthoritative and overrides this field — a stable build is never\noffered an RC (even with channel=rc), and an RC build always tracks the\nrc channel. This field only takes effect on dev/unstamped builds. See\ninternal/updatecheck.Checker.IncludePrereleases.","type":"string"},"enabled":{"description":"Enabled gates all update checking. Tri-state: nil/absent = enabled\n(default true, matching pre-079 behavior). When false, no network\ncheck is performed and no upgrade nudge appears on any surface\n(FR-015) — /api/v1/info omits the update object entirely.","type":"boolean"}},"type":"object"},"configimport.FailedServer":{"properties":{"details":{"type":"string"},"error":{"type":"string"},"name":{"type":"string"}},"type":"object"},"configimport.ImportSummary":{"properties":{"failed":{"type":"integer"},"imported":{"type":"integer"},"skipped":{"type":"integer"},"total":{"type":"integer"}},"type":"object"},"configimport.SkippedServer":{"properties":{"name":{"type":"string"},"reason":{"description":"\"already_exists\", \"filtered_out\", \"invalid_name\", \"self_reference\"","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"},"credential":{"description":"Credential is the masked client credential the write embedded\n(` + "`" + `mcp_cli_••••` + "`" + `); the real secret is never returned (FR-024). Empty for a\nkeyless entry, a disconnect and every refusal.","type":"string"},"credential_revoke_error":{"description":"CredentialRevokeError is set when a disconnect removed the entry but\nrevoking the client credential failed. The disconnect still succeeded;\nthe credential is still active and ` + "`" + `mcpproxy client forget \u003cid\u003e` + "`" + `\nretries the revoke.","type":"string"},"credential_revoked":{"description":"CredentialRevoked names the credential that was revoked: by an undo,\nbecause the restored config no longer holds it (plan D16), or by a\ndisconnect, which cuts the client off (a later undo of that disconnect\ndoes not bring the credential back).","type":"string"},"display_path":{"description":"DisplayPath is ConfigPath with the home directory shortened to \"~\"\n(FR-037). Populated for every result whose ConfigPath is known.","type":"string"},"keyless":{"description":"Keyless is true when the entry was written with no credential.","type":"boolean"},"message":{"type":"string"},"mode":{"type":"string"},"profile":{"description":"Profile and Mode are the binding of the credential this write minted or\nkept. Profile \"\" means the built-in All servers scope.","type":"string"},"reload_hint":{"description":"ReloadHint is this client's instruction for making the write take\neffect (FR-037/FR-042), e.g. \"Restart Cursor to load MCPProxy\". Empty\nfor an unknown client.","type":"string"},"rotation":{"description":"Rotation is \"finalized\" when the write replaced an active credential's\nsecret (staged rotation, FR-021a), empty otherwise.","type":"string"},"server_name":{"type":"string"},"success":{"type":"boolean"},"token_name":{"description":"TokenName is the client credential's token name (` + "`" + `client-\u003cid\u003e` + "`" + `).","type":"string"}},"type":"object"},"contracts.APIResponse":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"},"contracts.ActivityDetailResponse":{"properties":{"activity":{"$ref":"#/components/schemas/contracts.ActivityRecord"}},"type":"object"},"contracts.ActivityListResponse":{"properties":{"activities":{"items":{"$ref":"#/components/schemas/contracts.ActivityRecord"},"type":"array","uniqueItems":false},"limit":{"type":"integer"},"offset":{"type":"integer"},"total":{"type":"integer"}},"type":"object"},"contracts.ActivityPerServer":{"properties":{"calls":{"description":"Calls counted per storage.CountsAsCall","type":"integer"},"errors":{"description":"Of those calls, how many failed","type":"integer"},"last_call_at":{"description":"LastCallAt is RFC3339, or \"\" if the server had no call in the period\n(PerServer only lists servers that did, so this is always set).","type":"string"},"name":{"description":"Server name","type":"string"}},"type":"object"},"contracts.ActivityRecord":{"properties":{"agent_name":{"description":"Agent token name when auth_type is \"agent\"","type":"string"},"arguments":{"description":"Tool call arguments","type":"object"},"auth_type":{"description":"\"admin\", \"agent\", \"user\" or \"admin_user\"; empty without an auth context or on another caller's row for a scoped caller","type":"string"},"block_reason":{"description":"Typed cause of a profile policy refusal","type":"string"},"client_id":{"description":"Client id from the client-credential binding","type":"string"},"client_name":{"description":"Self-reported clientInfo.name (advisory)","type":"string"},"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_class":{"description":"Call-error taxonomy (Spec 113-c FR-045): what failed and whose fault it\nis. Absent on success and on records written before Spec 113.","type":"string"},"error_message":{"description":"Error details if status is \"error\"","type":"string"},"fault_domain":{"description":"upstream|proxy|client","type":"string"},"has_sensitive_data":{"description":"Sensitive data detection fields (Spec 026)","type":"boolean"},"id":{"description":"Unique identifier (ULID format)","type":"string"},"max_severity":{"description":"Highest severity level detected (critical, high, medium, low)","type":"string"},"metadata":{"description":"Additional context-specific data","type":"object"},"parent_id":{"description":"Correlation id of the parent call (the code_execution whose sandbox issued this sub-call)","type":"string"},"profile":{"description":"Scope attribution (Spec 108 FR-029): the profile, client and token in\neffect when the call ran. Stamped at write time, never rewritten. For a\nscoped (non-admin) caller the profile/profile_source/client_id/token_name\nof a row it did not make are blanked (binding disclosure is admin-only);\nclient_name is self-reported and stays. Absent on records written before\nSpec 108 and on records with no MCP/REST request context.","type":"string"},"profile_source":{"description":"pin|binding|url|session|anonymous|none","type":"string"},"request_bytes":{"description":"Byte sizes measured pre-truncation, mirroring storage.ActivityRecord\n(Spec 069 A1). They are the only cost signal a bodies-off export carries:\nwith payloads suppressed there is no text left to measure, so a consumer\naccounting for a record it cannot read has nothing else to go on. They are\nbyte LENGTHS, not token counts — the basis for an explicit estimate, never\na measured figure (spec 103, contracts/replay-input.md).\n\nZero means UNKNOWN, not free: legacy records predate the measurement and\ncode-execution sub-calls record both as zero. Hence omitempty — an absent\nkey tells a consumer to fall to exclusion accounting, whereas a present\nzero would read as a costless call and silently understate the workload.","type":"integer"},"request_id":{"description":"HTTP request ID for correlation","type":"string"},"response":{"description":"Tool response (potentially truncated)","type":"string"},"response_bytes":{"description":"Raw upstream response size in bytes before truncation","type":"integer"},"response_truncated":{"description":"True if response was truncated","type":"boolean"},"server_name":{"description":"Name of upstream MCP server","type":"string"},"session_id":{"description":"MCP transport session ID (regenerated on every reconnect)","type":"string"},"source":{"$ref":"#/components/schemas/contracts.ActivitySource"},"status":{"description":"Result status: \"success\", \"error\", \"blocked\", \"rejected\"","type":"string"},"timestamp":{"description":"When activity occurred","type":"string"},"token_name":{"description":"Agent/client token name","type":"string"},"tool_name":{"description":"Name of tool called","type":"string"},"type":{"$ref":"#/components/schemas/contracts.ActivityType"},"upstream_http_status":{"description":"Upstream HTTP status when known","type":"integer"},"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"},"call_count":{"description":"CallCount is how many of those records are CALLS THE USER MADE, as\ndefined once in storage.CountsAsCall and shared with the usage aggregate\nbehind the Usage tab (audit finding F1, #1046). TotalCount answers \"how\nmany rows does the Activity Log have\"; CallCount answers \"how many calls\nwere there\". They are different questions — quarantine auto-approvals,\nsystem start, security scans and management chatter are events, not calls\n— and printing either one under the other's label is how the same instance\ncame to report 51 calls on one screen and 19 on another.","type":"integer"},"call_error_count":{"description":"CallErrorCount is the failures within CallCount, so an error RATE computed\nfrom this response has one denominator. It is not ErrorCount: a policy\nblock is a failed call but carries status \"blocked\", and a shed call is an\nerror in neither sense because it never ran.","type":"integer"},"end_time":{"description":"End of the period (RFC3339)","type":"string"},"error_count":{"description":"Count of error activities","type":"integer"},"other_count":{"description":"OtherCount is every record whose status is outside the four-value\nvocabulary above, so that\n\n\tsuccess + error + blocked + rejected + other == total\n\nholds by construction. The status field is a CLOSED vocabulary for tool\ncalls, but the activity log is wider than tool calls: a quarantine change\nstores its ACTION there (\"approved\", \"auto_approved\"), a policy decision\nstores its DECISION (\"allow\"). Those rows were counted in the total and in\nnone of the four buckets, so the Activity Log's own status tiles summed to\nless than the denominator printed beside them — 15+4+0+0 under a \"42\"\n(audit finding F2, #1046). The residual now has a name and a tile.","type":"integer"},"per_server":{"description":"PerServer covers EVERY server with at least one call in the period\n(unlike TopServers, which is capped at 5 and carries no error counts).\nSpec 109 FR-013: the server-card stats line and the macOS Servers rows\nread this — one ` + "`" + `GET /activity/summary` + "`" + ` response per page load — rather\nthan issuing a per-server activity query each. Computed in the same\ncounting pass as the totals above, from the same CountsAsCall/\nIsManagementBuiltin definitions TopServers already uses.","items":{"$ref":"#/components/schemas/contracts.ActivityPerServer"},"type":"array","uniqueItems":false},"period":{"description":"Time period (1h, 24h, 7d, 30d)","type":"string"},"rejected_count":{"description":"RejectedCount is the number of calls shed by a concurrency limiter before\nthey reached an upstream (spec 093). Counted separately from errors: it is\nproxy backpressure, not an upstream fault, and it is the signal an\noperator right-sizes max_concurrent_requests against.","type":"integer"},"start_time":{"description":"Start of the period (RFC3339)","type":"string"},"success_count":{"description":"Count of successful activities","type":"integer"},"top_servers":{"description":"Top servers by activity count","items":{"$ref":"#/components/schemas/contracts.ActivityTopServer"},"type":"array","uniqueItems":false},"top_tools":{"description":"Top tools by activity count","items":{"$ref":"#/components/schemas/contracts.ActivityTopTool"},"type":"array","uniqueItems":false},"total_count":{"description":"Total activity count","type":"integer"}},"type":"object"},"contracts.ActivityTopServer":{"properties":{"count":{"description":"Activity count","type":"integer"},"name":{"description":"Server name","type":"string"}},"type":"object"},"contracts.ActivityTopTool":{"properties":{"count":{"description":"Activity count","type":"integer"},"server":{"description":"Server name","type":"string"},"tool":{"description":"Tool name","type":"string"}},"type":"object"},"contracts.ActivityType":{"description":"Type of activity","type":"string","x-enum-varnames":["ActivityTypeToolCall","ActivityTypePolicyDecision","ActivityTypeQuarantineChange","ActivityTypeServerChange"]},"contracts.AddFromRegistryRequest":{"properties":{"enabled":{"description":"defaults to true when nil","type":"boolean"},"env":{"additionalProperties":{"type":"string"},"description":"overrides + required-input values","type":"object"},"name":{"description":"optional name override","type":"string"}},"type":"object"},"contracts.AddRegistrySourceRequest":{"properties":{"id":{"description":"derived from the host when empty","type":"string"},"name":{"description":"defaults to the id","type":"string"},"protocol":{"description":"defaults to modelcontextprotocol/registry","type":"string"},"url":{"description":"required https registry URL","type":"string"}},"type":"object"},"contracts.AttentionFix":{"properties":{"label":{"type":"string"},"target":{"type":"string"},"verb":{"type":"string"}},"type":"object"},"contracts.AttentionItem":{"properties":{"detail":{"type":"string"},"fix":{"$ref":"#/components/schemas/contracts.AttentionFix"},"id":{"description":"kind:type:subject[:state]","type":"string"},"kind":{"type":"string"},"rank":{"type":"integer"},"since":{"type":"string"},"subject":{"$ref":"#/components/schemas/contracts.AttentionSubject"},"summary":{"type":"string"}},"type":"object"},"contracts.AttentionSubject":{"properties":{"id":{"type":"string"},"name":{"type":"string"},"type":{"description":"server|tool|client|setting","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"},"incarnation":{"description":"Incarnation is the committed incarnation of the server the tools were\nread for (Spec 116 FR-017), captured before they were fetched; omitted\nwhen unknown.","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":{"counts":{"$ref":"#/components/schemas/contracts.ViewAsCounts"},"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\", \"edit_url\", or \"\" (none)\nInvariant: Action always equals Actions[0], or \"\" when Actions is empty.","type":"string"},"actions":{"description":"Actions lists every applicable next step in priority order (FR-012):\nlogin \u003e set_secret \u003e configure \u003e edit_url \u003e approve \u003e restart \u003e\nview_logs \u003e enable. Always non-nil (empty slice, never null).","items":{"type":"string"},"type":"array","uniqueItems":false},"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"},"status":{"description":"Status is the ONE status vocabulary rendered as text on every surface\n(Web UI, macOS, tray, CLI) — Spec 109 FR-010/FR-011. Values: \"ready\",\n\"connecting\", \"sign_in_required\", \"needs_review\", \"needs_secret\",\n\"needs_config\", \"error\", \"disabled\". Unlike Level (a severity signal for\nbadge/tray coloring only), no renderer may print Level as text.","type":"string"},"summary":{"description":"Summary is a human-readable status message (e.g., \"Connected (5 tools)\")","type":"string"},"usable":{"description":"Usable reports whether the server can currently serve tool calls. True\nonly when Status == \"ready\".","type":"boolean"}},"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":{"description":"Enabled is the EFFECTIVE isolation state for this server: whether its\nprocess is actually CONFINED, after the global setting, the per-server\noverride, the structural gates and the host's capabilities. It is NOT the\nraw per-server override — read EnabledOverride for that (GH #1142).\n\nREAD-ONLY. The write surfaces reject an ` + "`" + `enabled` + "`" + ` key precisely because\nit is derived: echoing it back would convert \"inherits the global\nsetting\" into a permanent explicit override. Write EnabledOverride.\n\nIt stays a non-pointer bool that is always present on the wire: the macOS\ntray decodes it as a non-optional Swift Bool, so omitting or nulling the\nkey would fail Codable for the whole server payload. Older clients that\nread this field now simply get a true answer.","type":"boolean"},"enabled_override":{"description":"EnabledOverride is the RAW per-server ` + "`" + `isolation.enabled` + "`" + ` override, as\npersisted. Absent means \"inherit the global setting\" — which is a\ndistinct state from an explicit false, and the distinction the reporting\nbug used to destroy.","type":"boolean"},"extra_args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"image":{"type":"string"},"memory_limit":{"type":"string"},"mode_override":{"description":"ModeOverride is the RAW per-server ` + "`" + `isolation.mode` + "`" + ` override\n(\"docker\" | \"sandbox\" | \"none\"). Absent means \"inherit\".","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.IsolationEffective":{"description":"IsolationEffective exposes the resolved isolation state (and the rule\nthat decided it) so clients can distinguish \"inherits global\" from an\nexplicit per-server choice. Read-only; never consumed on PATCH.","properties":{"global_mode":{"description":"GlobalMode is what \"inherit\" resolves to right now.","type":"string"},"inherited":{"description":"Inherited is true when the server sets neither ` + "`" + `isolation.enabled` + "`" + ` nor\n` + "`" + `isolation.mode` + "`" + `, so its state tracks the global setting.","type":"boolean"},"isolated":{"description":"Isolated reports whether the process is actually CONFINED. It is NOT\nsimply Mode != \"none\": \"sandbox\" on a host that cannot enforce Landlock\n(any non-Linux OS, or a kernel without the LSM) runs the server\nunconfined, and Source then says \"sandbox-unavailable\" (GH #1142).","type":"boolean"},"mode":{"description":"Mode is the effective isolation mode: \"docker\" | \"sandbox\" | \"none\" —\nexactly what the spawn path branches on.","type":"string"},"source":{"description":"Source names the deciding rule: \"global\", \"server-mode\",\n\"server-opt-out\", \"server-opt-in-ignored\", \"not-stdio\",\n\"already-docker\", \"sandbox-unavailable\" or \"unsupported-mode\".\nTreat an unrecognized value as \"global\".","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_id":{"description":"Scope attribution (Spec 108 FR-033). ClientID and TokenName are the\ncredential the session initialized with; Profile and ProfileSource are\nthe session's latest effective resolution. Empty on legacy sessions.","type":"string"},"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"},"profile":{"type":"string"},"profile_source":{"type":"string"},"start_time":{"type":"string"},"status":{"type":"string"},"token_name":{"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_server_metadata_url":{"type":"string"},"auth_url":{"type":"string"},"authorization_endpoint":{"description":"Spec 113-b operator overrides of OAuth discovery (https-or-loopback URLs,\nnever credentials). Empty means \"use discovery\".","type":"string"},"client_id":{"type":"string"},"extra_params":{"additionalProperties":{"type":"string"},"type":"object"},"pkce_enabled":{"type":"boolean"},"redirect_port":{"type":"integer"},"registration_endpoint":{"type":"string"},"scopes":{"items":{"type":"string"},"type":"array","uniqueItems":false},"token_endpoint":{"type":"string"},"token_expires_at":{"description":"When the OAuth token expires","type":"string"},"token_url":{"type":"string"},"token_valid":{"description":"Whether token is currently valid","type":"boolean"}},"type":"object"},"contracts.OAuthErrorDetails":{"description":"Structured discovery/failure details","properties":{"authorization_server_metadata":{"$ref":"#/components/schemas/contracts.MetadataStatus"},"dcr_status":{"$ref":"#/components/schemas/contracts.DCRStatus"},"protected_resource_metadata":{"$ref":"#/components/schemas/contracts.MetadataStatus"},"server_url":{"type":"string"}},"type":"object"},"contracts.OAuthFlowError":{"properties":{"correlation_id":{"description":"Flow tracking ID for log correlation","type":"string"},"debug_hint":{"description":"CLI command for log lookup","type":"string"},"details":{"$ref":"#/components/schemas/contracts.OAuthErrorDetails"},"error_code":{"description":"Machine-readable error code (e.g., OAUTH_NO_METADATA)","type":"string"},"error_type":{"description":"Category of OAuth runtime failure","type":"string"},"message":{"description":"Human-readable error description","type":"string"},"request_id":{"description":"HTTP request ID (from PR #237)","type":"string"},"server_name":{"description":"Server that failed OAuth","type":"string"},"success":{"description":"Always false","type":"boolean"},"suggestion":{"description":"Actionable remediation hint","type":"string"}},"type":"object"},"contracts.OAuthIssue":{"properties":{"documentation_url":{"type":"string"},"error":{"type":"string"},"issue":{"type":"string"},"missing_params":{"items":{"type":"string"},"type":"array","uniqueItems":false},"resolution":{"type":"string"},"server_name":{"type":"string"}},"type":"object"},"contracts.OAuthRequirement":{"properties":{"expires_at":{"type":"string"},"message":{"type":"string"},"server_name":{"type":"string"},"state":{"type":"string"}},"type":"object"},"contracts.OAuthStartResponse":{"properties":{"auth_url":{"description":"Authorization URL (always included for manual use)","type":"string"},"browser_error":{"description":"Error message if browser launch failed","type":"string"},"browser_opened":{"description":"Whether browser launch succeeded","type":"boolean"},"correlation_id":{"description":"UUID for tracking this flow","type":"string"},"message":{"description":"Human-readable status message","type":"string"},"server_name":{"description":"Name of the server being authenticated","type":"string"},"success":{"description":"Always true for successful start","type":"boolean"}},"type":"object"},"contracts.PreflightPolicy":{"properties":{"exclude_destructive":{"type":"boolean"},"exclude_open_world":{"type":"boolean"},"read_only_only":{"type":"boolean"}},"type":"object"},"contracts.PreflightReason":{"type":"string","x-enum-varnames":["PreflightReasonServerInitializing","PreflightReasonServerUnhealthy","PreflightReasonServerDisabled","PreflightReasonServerQuarantined","PreflightReasonToolPendingApproval","PreflightReasonToolChanged","PreflightReasonToolBlockedByUser","PreflightReasonOAuthRequired","PreflightReasonHashMismatch","PreflightReasonServerNotInScope","PreflightReasonToolDeniedByConfig","PreflightReasonMissingAnnotation","PreflightReasonPolicyFiltered","PreflightReasonNotFound","PreflightReasonServerNotConfigured","PreflightReasonToolBlockedByProfile","PreflightReasonTokenPermissionDenied"]},"contracts.PreflightRequest":{"properties":{"policy":{"$ref":"#/components/schemas/contracts.PreflightPolicy"},"profile":{"description":"Profile evaluates under a named profile's server scope and tool policy;\nit can only narrow a credential's own pin. Unknown: 400.","type":"string"},"tools":{"description":"Tools is 1..100 entries BEFORE dedup; duplicates are collapsed, and\nduplicate ids carrying different pins are a validation error.","items":{"$ref":"#/components/schemas/contracts.PreflightToolRef"},"type":"array","uniqueItems":false},"wait_ms":{"description":"WaitMS polls local state for up to this many milliseconds (cap 10000)\nwhile every failure is retryable-class.","type":"integer"}},"type":"object"},"contracts.PreflightResponse":{"properties":{"checked_at":{"type":"string"},"tools":{"description":"Tools are ordered by first occurrence of each unique id in the request.","items":{"$ref":"#/components/schemas/contracts.PreflightToolResult"},"type":"array","uniqueItems":false},"verdict":{"$ref":"#/components/schemas/contracts.PreflightVerdict"},"waited_ms":{"description":"WaitedMS is present when wait_ms was requested (0 when the wait\nsemaphore was exhausted and the request resolved immediately).","type":"integer"}},"type":"object"},"contracts.PreflightStatus":{"type":"string","x-enum-varnames":["PreflightStatusReady","PreflightStatusUnavailable"]},"contracts.PreflightToolRef":{"properties":{"id":{"description":"ID is a canonical \"\u003cserver\u003e:\u003ctool\u003e\" id. A malformed id is answered with a\nper-ID not_found carrying a format hint, never a request-level error.","type":"string"},"pin_hash":{"description":"PinHash is \"sha256/v{N}:{hex}\" — the schema version is embedded so a\nproxy-side hash-algorithm bump is distinguishable from upstream drift.","type":"string"}},"type":"object"},"contracts.PreflightToolResult":{"properties":{"action":{"type":"string"},"detail":{"type":"string"},"did_you_mean":{"description":"DidYouMean carries up to 3 nearest caller-visible ids on not_found. It\nnever crosses a scope boundary and never names a quarantined server's\ntools.","items":{"type":"string"},"type":"array","uniqueItems":false},"hash":{"description":"Hash is the tool's current pin (\"sha256/v{N}:{hex}\") — operator tier,\nready results only. Never disclosed to an agent token.","type":"string"},"id":{"type":"string"},"reason":{"$ref":"#/components/schemas/contracts.PreflightReason"},"remediation":{"type":"string"},"retryable":{"type":"boolean"},"status":{"$ref":"#/components/schemas/contracts.PreflightStatus"}},"type":"object"},"contracts.PreflightVerdict":{"type":"string","x-enum-varnames":["PreflightVerdictReady","PreflightVerdictDegradedRetryable","PreflightVerdictBlocked","PreflightVerdictUnknownIDs"]},"contracts.QuarantineStats":{"description":"Tool quarantine metrics for this server","properties":{"blocked_count":{"description":"Number of disabled (blocked) tools","type":"integer"},"changed_count":{"description":"Number of tools whose description/schema changed since approval","type":"integer"},"pending_count":{"description":"Number of newly discovered tools awaiting approval","type":"integer"}},"type":"object"},"contracts.RefreshRegistryResponse":{"properties":{"cleared":{"description":"number of cached entries dropped","type":"integer"},"registry_id":{"type":"string"}},"type":"object"},"contracts.Registry":{"properties":{"count":{"description":"number or string","type":"string"},"description":{"type":"string"},"id":{"type":"string"},"name":{"type":"string"},"protocol":{"type":"string"},"provenance":{"description":"Provenance is the trust tag (MCP-866): \"official/trusted\" for built-in\ndefaults, \"custom/unverified\" for user-added registries.","type":"string"},"servers_url":{"type":"string"},"tags":{"items":{"type":"string"},"type":"array","uniqueItems":false},"trusted":{"description":"Trusted indicates whether this is an official, shipped-by-default\nregistry. Trust is derived from membership in the default set, never\nfrom self-assertion in config.","type":"boolean"},"url":{"type":"string"}},"type":"object"},"contracts.RegistryCacheInfo":{"properties":{"age_seconds":{"type":"number"},"stale":{"type":"boolean"}},"type":"object"},"contracts.RegistryUnavailable":{"properties":{"reason":{"type":"string"}},"type":"object"},"contracts.ReplayToolCallRequest":{"properties":{"arguments":{"description":"Modified arguments for replay","type":"object"}},"type":"object"},"contracts.ReplayToolCallResponse":{"properties":{"error":{"description":"Error if replay failed","type":"string"},"new_call_id":{"description":"ID of the newly created call","type":"string"},"new_tool_call":{"$ref":"#/components/schemas/contracts.ToolCallRecord"},"replayed_from":{"description":"Original call ID","type":"string"},"success":{"type":"boolean"}},"type":"object"},"contracts.RepositoryInfo":{"description":"Detected package info","properties":{"npm":{"$ref":"#/components/schemas/contracts.NPMPackageInfo"}},"type":"object"},"contracts.RepositoryServer":{"properties":{"connect_url":{"description":"Alternative connection URL","type":"string"},"created_at":{"type":"string"},"description":{"type":"string"},"id":{"type":"string"},"install_cmd":{"description":"Installation command","type":"string"},"name":{"type":"string"},"registry":{"description":"Which registry this came from","type":"string"},"repository_info":{"$ref":"#/components/schemas/contracts.RepositoryInfo"},"source_code_url":{"description":"Source repository URL","type":"string"},"updated_at":{"type":"string"},"url":{"description":"MCP endpoint for remote servers only","type":"string"}},"type":"object"},"contracts.SearchRegistryServersResponse":{"properties":{"cache":{"$ref":"#/components/schemas/contracts.RegistryCacheInfo"},"query":{"type":"string"},"registry_id":{"type":"string"},"servers":{"items":{"$ref":"#/components/schemas/contracts.RepositoryServer"},"type":"array","uniqueItems":false},"tag":{"type":"string"},"total":{"type":"integer"},"unavailable":{"$ref":"#/components/schemas/contracts.RegistryUnavailable"}},"type":"object"},"contracts.SearchResult":{"properties":{"matches":{"type":"integer"},"score":{"type":"number"},"snippet":{"type":"string"},"tool":{"$ref":"#/components/schemas/contracts.Tool"}},"type":"object"},"contracts.SearchToolsResponse":{"properties":{"query":{"type":"string"},"results":{"items":{"$ref":"#/components/schemas/contracts.SearchResult"},"type":"array","uniqueItems":false},"took":{"type":"string"},"total":{"type":"integer"}},"type":"object"},"contracts.SecurityScanSummary":{"description":"Latest security scan results summary","properties":{"deep_scan":{"$ref":"#/components/schemas/contracts.DeepScanDescriptor"},"finding_counts":{"$ref":"#/components/schemas/contracts.FindingCounts"},"last_scan_at":{"type":"string"},"risk_score":{"description":"0-100","type":"integer"},"scanners_failed":{"type":"integer"},"scanners_run":{"description":"Scanner coverage for the primary (baseline) scan pass — informational only.\nSpec 077 US3 (FR-008/FR-014): Status is derived SOLELY from the\ndeterministic baseline findings; a failed Docker deep scanner no longer\ndowngrades a clean verdict. That failure is surfaced via DeepScan instead.","type":"integer"},"scanners_total":{"type":"integer"},"status":{"description":"\"clean\", \"warnings\", \"dangerous\", \"failed\", \"not_scanned\", \"scanning\"","type":"string"}},"type":"object"},"contracts.Server":{"properties":{"args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"authenticated":{"description":"OAuth authentication status","type":"boolean"},"auto_approve_tool_changes":{"description":"AutoApproveToolChanges mirrors config.ServerConfig.AutoApproveToolChanges\n(MCP-2930): the per-server intent to auto-approve new/changed tools past\nthe trust baseline. Tri-state *bool — nil means \"never set\" (omitted from\nthe payload), so the Web UI toggle (MCP-2932) can distinguish unset from\nan explicit false. Read-only on the GET path; PATCH/POST accept it via\nAddServerRequest.","type":"boolean"},"command":{"type":"string"},"connected":{"type":"boolean"},"connected_at":{"type":"string"},"connecting":{"type":"boolean"},"created":{"type":"string"},"diagnostic":{"$ref":"#/components/schemas/contracts.Diagnostic"},"enabled":{"type":"boolean"},"env":{"additionalProperties":{"type":"string"},"type":"object"},"error_code":{"type":"string"},"expose_prompts":{"description":"ExposePrompts mirrors config.ServerConfig.ExposePrompts (F9): the per-server\nprompt-aggregation override. Tri-state *bool — nil/omitted means \"inherit\ndefault aggregation\". Surfaced on GET so a caller that PATCHed the override\ncan read it back; PATCH/POST accept it via AddServerRequest.","type":"boolean"},"forward_headers":{"description":"ForwardHeaders mirrors config.ServerConfig.ForwardHeaders (Spec 112): the\nallowlist of inbound MCP client header NAMES forwarded to this server on\ntools/call. Names only, never values. Omitted when empty.","items":{"type":"string"},"type":"array","uniqueItems":false},"generation":{"description":"Generation is the server's committed config generation (Spec 116\nFR-017): it changes whenever the server's configuration changes.","type":"integer"},"headers":{"additionalProperties":{"type":"string"},"type":"object"},"health":{"$ref":"#/components/schemas/contracts.HealthStatus"},"id":{"type":"string"},"incarnation":{"description":"Incarnation changes only when the name is removed and created again\n(Spec 116 FR-017). Approval endpoints accept it back as\nexpected_incarnation and answer 409 server_replaced on a mismatch.","type":"integer"},"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"},"isolation_effective":{"$ref":"#/components/schemas/contracts.IsolationEffective"},"last_error":{"type":"string"},"last_reconnect_at":{"type":"string"},"last_retry_time":{"type":"string"},"max_concurrent_requests":{"description":"Spec 093 (GH #955) — per-server concurrency overrides, scope (c) of\nFR-020. Each setting is tri-state: nil (omitted) means \"inherit\nserver_concurrency_defaults\", 0 disables that setting for this server,\npositive overrides it. Surfaced on the GET path so a caller can read back\nwhat it set; PATCH/POST accept them via AddServerRequest. The effective\nconcurrency for a server is additionally bounded by the global aggregate\nlimiter, which is NOT an inheritance source for these fields.","type":"integer"},"name":{"type":"string"},"oauth":{"$ref":"#/components/schemas/contracts.OAuthConfig"},"oauth_status":{"description":"OAuth status: \"authenticated\", \"expired\", \"error\", \"none\"","type":"string"},"protocol":{"type":"string"},"quarantine":{"$ref":"#/components/schemas/contracts.QuarantineStats"},"quarantined":{"type":"boolean"},"queue_size":{"type":"integer"},"queue_timeout":{"type":"string"},"reconnect_count":{"type":"integer"},"reconnect_on_use":{"description":"Attempt reconnection when a tool call targets this disconnected server","type":"boolean"},"retry_count":{"type":"integer"},"retry_stopped":{"description":"RetryStopped reports that automatic reconnection has been given up for\ngood because the failure is deterministic and unrecoverable — a missing\nbinary, an image without the interpreter, an unparseable config (GH\n#1145). It is NOT ordinary exponential backoff, which keeps retrying;\nnothing will happen until the user fixes the config or restarts the\nserver. RetryStoppedCode is the stable MCPX_* code that proved it and\nRetryStoppedReason the catalog message explaining how to fix it. All three\nare omitted for servers that are healthy or still retrying.","type":"boolean"},"retry_stopped_code":{"type":"string"},"retry_stopped_reason":{"type":"string"},"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"},"estimated":{"description":"Estimated (Spec 109-k FR-070-ish, url-filter-contract.md / audit F-Token):\ntrue while AverageQueryResultSize is a synthetic simulation (a sample of\nthe first ` + "`" + `tools_limit` + "`" + ` tools' schemas — no real retrieve_tools call has\ncompleted yet in this runtime's usage aggregate); false once at least one\nreal retrieve_tools call has, at which point AverageQueryResultSize is\nderived from the real observed average response size instead. The Web\nUI and macOS render an \"estimate\" label while this is true.","type":"boolean"},"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.Tier":{"description":"Tier is computed by AnnotationTier (Spec 109 FR-028/X11) from\nAnnotations — read|write|destructive|unannotated. Set by every producer\nof a Tool (enrichServerTools, the global tools handler); never left for\na consuming surface to compute.","type":"string","x-enum-varnames":["TierRead","TierWrite","TierDestructive","TierUnannotated","TierUnknown"]},"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":{"access":{"$ref":"#/components/schemas/contracts.ToolAccess"},"annotations":{"$ref":"#/components/schemas/contracts.ToolAnnotation"},"approval_status":{"type":"string"},"config_denied":{"description":"ConfigDenied is true when the tool is denied by the server's static\nenabled_tools / disabled_tools config. The user cannot override this toggle.","type":"boolean"},"description":{"type":"string"},"disabled":{"description":"Disabled mirrors ToolApprovalRecord.Disabled so per-tool enable state is\navailable without a second round-trip to the approvals endpoint. Absent\nin the JSON when false (default) to keep responses compact.","type":"boolean"},"hash":{"description":"Hash is the tool's current stored hash rendered in the preflight pin\nformat \"sha256/v{N}:{hex}\" (Spec 098 FR-011), where N is the approval\nrecord's HashSchemaVersion. It is the authoring surface for\n` + "`" + `POST /api/v1/preflight` + "`" + ` pins and ` + "`" + `mcpproxy tools preflight --pin` + "`" + `:\ncopy the value straight into a pin.\n\nDisclosure is OPERATOR TIER ONLY — same rule as the preflight per-tool\nresult. The field is omitted for agent-token callers and for tools with\nno stored hash (no approval record yet, or a record written before\nhashes existed).","type":"string"},"held_reason":{"description":"HeldReason, HeldVerdict and HeldSignals mirror the same-named fields on\nstorage.ToolApprovalRecord: the offline-scan evidence that made\ntrust_mode: scan hold this tool for review (spec 086 FR-018). HeldSignals\nnames the matched deterministic check ids, e.g.\n\"tpa.TPA-2026-0001.hidden_instruction\", so a reviewer can see WHY the tool\nis held. All three are omitted for tools that are not held by the scan gate\n(including every record written before the field existed).","type":"string"},"held_signals":{"items":{"type":"string"},"type":"array","uniqueItems":false},"held_verdict":{"type":"string"},"last_used":{"type":"string"},"name":{"type":"string"},"profile_tier":{"description":"ProfileTier and Access are present only in a view-as listing (a tools\nlisting with ?client= or ?profile=, Spec 108 FR-032). ProfileTier is the\ntool's tier under the viewed subject's profile (Tier above stays the\nintrinsic tier); Access is the subject's verdict for the tool. Absent\notherwise, so an ordinary listing is byte-identical to before.","type":"string","x-enum-varnames":["TierRead","TierWrite","TierDestructive","TierUnannotated","TierUnknown"]},"schema":{"type":"object"},"server_incarnation":{"description":"ServerIncarnation is the committed incarnation of the server this row\nwas read for (Spec 116 FR-017), captured before the tools were fetched.\nA client echoes it as expected_incarnation on a per-tool decision, so a\nstale row is refused (409 server_replaced) after a same-name re-add.\nOmitted when the server has no committed incarnation.","type":"integer"},"server_name":{"type":"string"},"tier":{"$ref":"#/components/schemas/contracts.Tier"},"usage":{"type":"integer"}},"type":"object"},"contracts.ToolAccess":{"properties":{"callable":{"type":"boolean"},"reason":{"type":"string"},"visible":{"type":"boolean"}},"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"},"arguments_truncated":{"description":"ArgumentsTruncated marks Arguments as a placeholder rather than the\narguments the tool was called with. Replaying such a record without\nsupplying arguments explicitly is refused.","type":"boolean"},"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"},"response_bytes":{"description":"Marshalled response size before truncation","type":"integer"},"response_truncated":{"description":"ResponseTruncated and ResponseBytes describe a STORAGE-side cut (#1176):\nthe caller received the response whole, and only the persisted copy was\nshortened to tool_call_max_response_size. When ResponseTruncated is true\nthe Response object carries {truncated, original_bytes, preview, note}\ninstead of the upstream result, and ResponseBytes is its size before the\ncut.","type":"boolean"},"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"},"behind_summary":{"description":"Spec 079 FR-002 — how far behind the running build is. All four are\nadditive (FR-021) and absent when the delta could not be resolved, in\nwhich case every surface renders its pre-delta wording.","type":"string"},"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"},"releases_behind":{"description":"Releases on the offered channel between the running and offered versions","type":"integer"},"releases_behind_saturated":{"description":"ReleasesBehind is a lower bound: the running build predates the scanned release window","type":"boolean"},"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"},"weeks_behind":{"description":"Whole weeks between the two releases' publish dates; 0 is a real value, absent means unknown","type":"integer"}},"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_estimated":{"description":"TokensSavedEstimated echoes ServerTokenMetrics.Estimated (Spec 109-k):\ntrue while TokensSaved is a synthetic simulation rather than derived\nfrom a real retrieve_tools call. Dropped (false, the zero value) for a\nscoped caller along with TokensSaved itself, above.","type":"boolean"},"tokens_saved_percentage":{"type":"number"},"tools":{"items":{"$ref":"#/components/schemas/contracts.UsageToolStat"},"type":"array","uniqueItems":false},"total_calls":{"description":"TotalCalls and TotalErrors are the headline counts for the window: the sum\nof the timeline this same response carries, so the tiles and the histogram\nunder them cannot disagree. They are NOT the sum of Tools — that list is\nlifetime-cumulative, upstream-only and truncated to top-N, and summing it\nclient-side is what made the Usage tab print a third number for the same\n24 hours (audit finding F1, #1046). The population is\nstorage.CountsAsCall, shared with ActivitySummaryResponse.CallCount.\n\nTwo bounds on how exactly this matches the Activity Log's own count.\nBoth are bounded and disclosed, unlike the population mismatch they\nreplace, which was unbounded and silent:\n\n - Window granularity is the timeline's: whole hour buckets, so the span\n is the requested window rounded up to a bucket edge.\n - This response is served from a snapshot behind a short read cache\n (observability.usage_cache_ttl, 5s by default) so the endpoint never\n scans the activity log per request, while the summary endpoint counts\n live. Calls that land inside that window appear on the Activity Log\n first. FreshnessMs and GeneratedAt say how old the figures are, and\n the Usage tab prints it (\"Updated 3s ago\").","type":"integer"},"total_errors":{"type":"integer"},"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_exceeds":{"type":"boolean"},"p50_ms":{"description":"P50Ms and P95Ms are read off a fixed latency histogram, so they are BUCKET\nBOUNDS, not measured durations: the true percentile is at or below the\nvalue, and a client must render it as a bound (\"≤ 5 ms\"). P50Exceeds /\nP95Exceeds flip that reading for the unbounded overflow bucket, where the\nvalue is the last bound and the truth is above it (\"\u003e 10 s\").","type":"integer"},"p95_exceeds":{"type":"boolean"},"p95_ms":{"type":"integer"},"rejected":{"description":"spec 093: shed by a concurrency limit; never executed, so excluded from calls/latency","type":"integer"},"server":{"type":"string"},"sized_calls":{"description":"calls with known response size (basis for avg_resp_bytes)","type":"integer"},"tool":{"type":"string"},"total_req_bytes":{"type":"integer"},"total_resp_bytes":{"type":"integer"}},"type":"object"},"contracts.ValidateConfigResponse":{"properties":{"errors":{"items":{"$ref":"#/components/schemas/contracts.ValidationError"},"type":"array","uniqueItems":false},"valid":{"type":"boolean"}},"type":"object"},"contracts.ValidationError":{"properties":{"field":{"type":"string"},"message":{"type":"string"}},"type":"object"},"contracts.ViewAsCounts":{"description":"Counts is present only for a NON-administrator profile view-as (Spec 108\nFR-032): the response then lists only the visible rows, and this is the\nonly trace of the rest, with no per-server, per-tier or per-reason\nbreakdown.","properties":{"hidden":{"type":"integer"},"visible":{"type":"integer"}},"type":"object"},"data":{"properties":{"data":{"$ref":"#/components/schemas/contracts.InfoResponse"}},"type":"object"},"httpapi.AccessExplanationData":{"properties":{"first_failure":{"type":"string","x-enum-varnames":["StepCredential","StepProfile","StepServerInScope","StepToolRule","StepTierCap","StepTokenPermission","StepGlobalGate","StepServerState","StepToolApproval"]},"fixes":{"items":{"$ref":"#/components/schemas/runtime.Fix"},"type":"array","uniqueItems":false},"profile":{"$ref":"#/components/schemas/runtime.ExplainProfileView"},"steps":{"items":{"$ref":"#/components/schemas/runtime.ExplainStepView"},"type":"array","uniqueItems":false},"subject":{"$ref":"#/components/schemas/runtime.ExplainSubjectView"},"tool":{"type":"string"},"verdict":{"$ref":"#/components/schemas/profile.ExplainVerdict"}},"type":"object"},"httpapi.AddServerRequest":{"properties":{"args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"auto_approve_tool_changes":{"description":"AutoApproveToolChanges is the per-server intent to auto-approve\nnew/changed tools past the trust baseline (MCP-2930). Tri-state *bool:\na nil pointer means \"leave unchanged\" on PATCH; a present value\n(including false) is applied. Mirrors config.ServerConfig's *bool\nsemantics — do NOT collapse to a plain bool, or an omitted field would\nsilently reset a previously-set value.","type":"boolean"},"command":{"type":"string"},"enabled":{"type":"boolean"},"env":{"additionalProperties":{"type":"string"},"type":"object"},"expose_prompts":{"description":"ExposePrompts is the per-server override for prompt aggregation (F9):\nwhether this server's advertised MCP prompts are merged into mcpproxy's\nprompts/list. Tri-state *bool mirroring config.ServerConfig.ExposePrompts —\na nil pointer means \"leave unchanged\" on PATCH (and \"inherit the default\naggregate behavior\" on create); a present value (including false) is applied.","type":"boolean"},"forward_headers":{"description":"ForwardHeaders is the per-server allowlist of inbound MCP client header\nNAMES forwarded to this server on tools/call (Spec 112). Names only, never\nvalues. On PATCH a nil slice (field omitted) leaves the stored allowlist\nunchanged and an empty array ([]) clears it. Invalid, denied, duplicate\nand static-header-colliding names are rejected with 400.","items":{"type":"string"},"type":"array","uniqueItems":false},"headers":{"additionalProperties":{"type":"string"},"type":"object"},"init_timeout":{"description":"InitTimeout is the per-server MCP ` + "`" + `initialize` + "`" + ` handshake deadline override\n(MCP-3322 / GH #760), serialized as a duration string (e.g. \"120s\"). A nil\npointer means \"leave unchanged\" on PATCH; a present value is applied.\nMirrors config.ServerConfig.InitTimeout's *Duration tri-state.","type":"string"},"isolation":{"$ref":"#/components/schemas/httpapi.IsolationRequest"},"max_concurrent_requests":{"description":"MaxConcurrentRequests / QueueSize / QueueTimeout are the per-server\nconcurrency overrides (spec 093 / GH #955, FR-020 scope (c)). Each is\ntri-state: a nil pointer means \"leave unchanged\" on PATCH and \"inherit\nserver_concurrency_defaults\" on create; an explicit 0 disables that\nsetting for this server; a positive value overrides it. Do NOT collapse\nthem to plain values — an omitted field would then silently reset a\nconfigured limit.","type":"integer"},"name":{"type":"string"},"protocol":{"type":"string"},"quarantined":{"type":"boolean"},"queue_size":{"type":"integer"},"queue_timeout":{"type":"string"},"reconnect_on_use":{"type":"boolean"},"trust_mode":{"description":"TrustMode is the per-server trust tier (spec 086): \"auto\", \"scan\", or\n\"manual\". Empty means \"leave unchanged\" on PATCH (and inherit the migrated\ndefault on create). A non-empty value is applied to ServerConfig.TrustMode\nand resolved by EffectiveTrustMode (an unrecognized value fails closed to\nmanual). This is the REST seam for changing the trust tier via\nPOST/PATCH /api/v1/servers.","type":"string"},"url":{"type":"string"},"working_dir":{"type":"string"}},"type":"object"},"httpapi.BindingGuardResponse":{"properties":{"bindings":{"items":{"$ref":"#/components/schemas/httpapi.GuardBinding"},"type":"array","uniqueItems":false},"code":{"description":"binding_bypassable_without_auth","type":"string"},"error":{"description":"Human-readable, byte-stable refusal text","type":"string"},"fixes":{"items":{"$ref":"#/components/schemas/httpapi.GuardFixOption"},"type":"array","uniqueItems":false},"request_id":{"type":"string"},"success":{"description":"Always false","type":"boolean"}},"type":"object"},"httpapi.BulkAssignRequest":{"properties":{"from_profile":{"type":"string"},"mode":{"type":"string"},"to_profile":{"type":"string"}},"type":"object"},"httpapi.BulkAssignResponse":{"properties":{"moved":{"items":{"type":"string"},"type":"array","uniqueItems":false},"skipped":{"items":{"$ref":"#/components/schemas/httpapi.BulkAssignSkipped"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.BulkAssignSkipped":{"properties":{"client_id":{"type":"string"},"code":{"type":"string"},"error":{"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.ClientBindingErrorResponse":{"properties":{"code":{"type":"string"},"error":{"type":"string"},"field":{"type":"string"},"request_id":{"type":"string"},"success":{"description":"Always false","type":"boolean"}},"type":"object"},"httpapi.ClientBindingResponse":{"properties":{"client":{"$ref":"#/components/schemas/httpapi.clientPresence"},"warnings":{"items":{"$ref":"#/components/schemas/httpapi.ClientWarning"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.ClientSnippet":{"properties":{"generic_http":{"type":"string"},"header_name":{"type":"string"}},"type":"object"},"httpapi.ClientWarning":{"properties":{"action":{"$ref":"#/components/schemas/runtime.WarningAction"},"bindings":{"items":{"$ref":"#/components/schemas/runtime.BindingRef"},"type":"array","uniqueItems":false},"client_id":{"type":"string"},"code":{"$ref":"#/components/schemas/profile.WarningCode"},"fixes":{"items":{"$ref":"#/components/schemas/runtime.GuardFix"},"type":"array","uniqueItems":false},"message":{"type":"string"},"severity":{"$ref":"#/components/schemas/profile.WarningSeverity"}},"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"},"keyless":{"type":"boolean"},"mode":{"type":"string"},"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"},"profile":{"description":"Profile, Mode and Keyless are the client-credential intent (Spec 108\nFR-024). Profile names the profile the client's credential binds to;\n\"\" is the built-in All servers scope. Omitted means \"not specified\": a\nfresh credential defaults to All servers and a reconnect keeps the\nclient's existing binding (a reconnect never silently widens a locked\nclient). Mode is locked|switchable (default locked for a named profile,\nswitchable for All servers). Keyless writes a credential-less entry and\nis only possible while require_mcp_auth is off; it cannot carry a\nprofile or mode.","type":"string"},"server_name":{"description":"Defaults to \"mcpproxy\"","type":"string"}},"type":"object"},"httpapi.CreateClientRequest":{"properties":{"display_name":{"type":"string"},"expires_in":{"description":"ExpiresIn is a duration like 30d or 720h; default and cap are 365 days.","type":"string"},"id":{"type":"string"},"mode":{"type":"string"},"profile":{"type":"string"},"purpose":{"description":"Purpose is the stated, unenforced task brief (Spec 115), at most 500\ncharacters; screened for secrets before anything is stored.","type":"string"}},"type":"object"},"httpapi.CreateClientResponse":{"properties":{"client":{"$ref":"#/components/schemas/httpapi.clientPresence"},"credential":{"type":"string"},"snippet":{"$ref":"#/components/schemas/httpapi.ClientSnippet"}},"type":"object"},"httpapi.EffectiveToolsData":{"properties":{"counts":{"$ref":"#/components/schemas/runtime.EffectiveCounts"},"profile":{"type":"string"},"stale_classification_reasons":{"additionalProperties":{"type":"string"},"description":"StaleClassificationReasons maps each stale classify entry to why it no\nlonger applies: profile.StaleClassificationAnnotated or\nprofile.StaleClassificationMissing. It is computed over the UNFILTERED\ntool set, so a server or reason filter never changes it. Administrators\nonly.","type":"object"},"stale_classifications":{"description":"StaleClassifications lists classify entries for tools that are now\nannotated or no longer exist (FR-005). Administrators only.","items":{"type":"string"},"type":"array","uniqueItems":false},"tools":{"items":{"$ref":"#/components/schemas/runtime.EffectiveTool"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.EnvFieldPreview":{"properties":{"empty_or_placeholder":{"type":"boolean"},"name":{"type":"string"},"secret_like":{"type":"boolean"},"value_present":{"type":"boolean"}},"type":"object"},"httpapi.FinalizeClientResponse":{"properties":{"client":{"$ref":"#/components/schemas/httpapi.clientPresence"},"rotation":{"$ref":"#/components/schemas/httpapi.RotationState"}},"type":"object"},"httpapi.ForgetClientResponse":{"properties":{"disconnect_error":{"type":"string"},"disconnected":{"type":"boolean"},"revoked":{"type":"string"}},"type":"object"},"httpapi.GuardBinding":{"properties":{"client_id":{"type":"string"},"mode":{"type":"string"},"profile":{"type":"string"},"token_name":{"type":"string"}},"type":"object"},"httpapi.GuardFixOption":{"properties":{"kind":{"type":"string"},"target":{"type":"string"}},"type":"object"},"httpapi.HeaderFieldPreview":{"properties":{"empty_or_placeholder":{"type":"boolean"},"name":{"type":"string"},"secret_like":{"type":"boolean"},"value_present":{"type":"boolean"}},"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":{"allow_paste_fallback":{"description":"AllowPasteFallback opts into detecting a bare URL or a single command\nline (FR-064) when Format/format detection would otherwise fail — see\nconfigimport.ImportOptions.AllowPasteFallback for why this must stay\nopt-in (review round 4 F-E). Only the interactive Paste tab sets this;\nevery other caller of this endpoint (the general \"Import config\"\npanel, or a direct API call) leaves it false and gets a clear\n\"unable to detect configuration format\" error for a plain one-liner\ninstead of it being silently guessed at and, on apply, added as a\nreal server with no confirmation step.","type":"boolean"},"content":{"description":"Raw JSON or TOML content","type":"string"},"env_override":{"additionalProperties":{"type":"string"},"description":"EnvOverride/HeaderOverride (Spec 109 FR-064/065, PR review round 4\nF-A/F-D fix): the Paste tab's per-field Value/Secret edits — a plain\nvalue the user typed, or a keyring ref path if they chose Secret —\nkeyed by field name. Applied to the matching imported server's\nEnv/Headers ONLY when preview=false, directly on the server this\nrequest's own raw Content parses to server-side. This is what lets\nthe apply call carry the user's edited env/header values without\never round-tripping the redacted preview: url/command/args on apply\nalways come from re-parsing Content here, never from a client-held\npreview response, so a credential embedded in a URL query param or\nargv flag (which the preview necessarily redacted for display) is\nnever overwritten with the masked placeholder.","type":"object"},"format":{"description":"Optional format hint","type":"string"},"header_override":{"additionalProperties":{"type":"string"},"type":"object"},"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"},"env":{"items":{"$ref":"#/components/schemas/httpapi.EnvFieldPreview"},"type":"array","uniqueItems":false},"fields_skipped":{"items":{"type":"string"},"type":"array","uniqueItems":false},"headers":{"items":{"$ref":"#/components/schemas/httpapi.HeaderFieldPreview"},"type":"array","uniqueItems":false},"name":{"type":"string"},"original_name":{"type":"string"},"protocol":{"type":"string"},"source_format":{"type":"string"},"summary":{"description":"Summary, Tags, Env and Headers are the Spec 109 FR-064 preview\nenrichment (contracts/rest-api.md \"Import preview\"). Summary and Tags\nare built from the already-redacted URL/Command/Args above, so a\nsecret embedded in argv or a URL query never reaches Summary either.\nEnv/Headers never carry the raw value — only its presence and two\nbooleans a surface uses to default the Value/Secret toggle (FR-065).","type":"string"},"tags":{"items":{"type":"string"},"type":"array","uniqueItems":false},"url":{"type":"string"},"warnings":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.IsolationRequest":{"description":"Isolation carries per-server Docker isolation overrides (enabled,\nmode_override, image, network_mode, extra_args, working_dir). A nil\npointer means \"do not touch isolation config\". A present object is\napplied field-by-field ON TOP of the persisted overrides, so omitting a\nfield leaves it alone; clear an individual override by sending it\nexplicitly (` + "`" + `\"enabled\": null` + "`" + `, ` + "`" + `\"image\": \"\"` + "`" + `).","properties":{"enabled":{"description":"Enabled exists ONLY to detect and reject an echoed-back read. It is the\neffective state on the read surface and is never writable; see validate().","type":"boolean"},"enabled_override":{"description":"EnabledOverride is the tri-state per-server override — the RAW value, the\nsame one reads return as ` + "`" + `enabled_override` + "`" + `. It has THREE meaningful wire\nstates, and collapsing them is what silently un-isolated servers\n(GH #1142):\n - absent → leave the persisted override untouched\n - null → clear the override, back to inheriting the global\n - true / false → set an explicit opt-in / opt-out","type":"boolean"},"extra_args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"image":{"type":"string"},"mode_override":{"description":"ModeOverride sets ` + "`" + `isolation.mode` + "`" + ` (\"docker\" | \"sandbox\" | \"none\").\nnil leaves the persisted value alone; an empty string clears it. An\nunrecognized value is rejected with a 400 rather than persisted.","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"},"connected_client_id":{"description":"ConnectedClientID records a successful connect write for this client id\n(Spec 109-b FR-042, review round 6). The REST connect endpoint\n(POST /api/v1/connect/{client}) already records this itself on success;\nthis field exists so ` + "`" + `mcpproxy connect` + "`" + ` — which writes the client's\nconfig file directly, without going through that endpoint, so the\ncommand still works when no daemon is running — can relay the same\nevent to a daemon that IS running, keeping ClientConnectedAt in sync\nacross both surfaces. Must be a known id from the fixed connect client\nregistry (internal/connect.GetAllClients); any other value is rejected,\nmatching the field's \"bounded by the registry\" invariant\n(data-model.md §7).","type":"string"},"disconnected_client_id":{"description":"DisconnectedClientID relays a successful local CLI disconnect to a running\ndaemon, which cannot observe the CLI's direct config-file edit itself.\nLike ConnectedClientID, it is bounded to the fixed client registry.","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.OnboardingStateResponse":{"properties":{"configured_server_count":{"description":"ConfiguredServerCount is the number of upstream MCP servers configured\nin mcpproxy (counts both enabled and disabled).","type":"integer"},"connected_client_count":{"description":"ConnectedClientCount is the number of supported clients currently\npointing at mcpproxy.","type":"integer"},"connected_client_ids":{"description":"ConnectedClientIDs are the identifiers of supported clients currently\npointing at mcpproxy. Drawn exclusively from the fixed adapter table —\nuser-entered values never appear here.","items":{"type":"string"},"type":"array","uniqueItems":false},"first_mcp_client_ever":{"description":"FirstMCPClientEver is true once any MCP client has successfully completed\nan ` + "`" + `initialize` + "`" + ` round-trip with this mcpproxy. Sourced from the Spec 044\nactivation bucket. Drives the Verify tab's \"green check\" state.","type":"boolean"},"has_configured_server":{"description":"HasConfiguredServer is true if at least one upstream MCP server is\nconfigured (regardless of current connection health).","type":"boolean"},"has_connected_client":{"description":"HasConnectedClient is true if at least one supported AI client currently\nhas mcpproxy registered in its config.","type":"boolean"},"has_usable_server":{"description":"HasUsableServer is true once at least one enabled, non-quarantined\nserver has usable health and at least one approved (non-disabled) tool.\nThis is the real \"the wizard has something to try\" signal:\nHasConfiguredServer only means a server entry exists, even while every\none of them sits quarantined, requires sign-in, or has zero approved\ntools. The Servers step and Setup badge use this instead of\nHasConfiguredServer, which is kept above for compatibility.\nHealthStatus.Usable is the shared\nreadiness contract used by every UI surface (Spec 109-c, tasks.md T051).","type":"boolean"},"incomplete_tab_count":{"description":"IncompleteTabCount is the number of wizard tabs whose state is incomplete.\nDrives the sidebar Setup entry's badge. Formula:\n +1 if HasConnectedClient == false\n +1 if HasUsableServer == false\n +1 if FirstMCPClientEver == false","type":"integer"},"mcp_clients_seen_ever":{"description":"MCPClientsSeenEver is the capped list of recognized client names that\nhave ever called this mcpproxy. Names come from the MCP ` + "`" + `initialize` + "`" + `\npayload's ` + "`" + `clientInfo.name` + "`" + ` field, sanitized. Surfaces on the Verify tab\nso the user can see whether their real IDE — not a test client — has\nconnected.","items":{"type":"string"},"type":"array","uniqueItems":false},"should_show_wizard":{"description":"ShouldShowWizard is the derived flag the frontend uses to decide\nwhether to auto-show. True when not engaged and IncompleteTabCount \u003e 0\n(Spec 046 v2 — semantics widened to also count the Verify tab).","type":"boolean"},"state":{"$ref":"#/components/schemas/storage.OnboardingState"},"usable_servers":{"description":"UsableServers lists the names behind HasUsableServer, for the Verify\nstep's suggested-prompt generator (FR-042): prompts are only built from\ntools of servers in this list, never from a quarantined or toolless one.","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.ProfileConflictResponse":{"properties":{"code":{"type":"string"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"description":"Always false","type":"boolean"},"used_by":{"$ref":"#/components/schemas/httpapi.ProfileUsedByData"}},"type":"object"},"httpapi.ProfileDeleteData":{"properties":{"anonymous_profile_moved_to":{"type":"string"},"deleted":{"type":"string"},"moved":{"$ref":"#/components/schemas/runtime.MovedRefs"}},"type":"object"},"httpapi.ProfileListData":{"properties":{"anonymous_profile":{"type":"string"},"profiles":{"items":{"$ref":"#/components/schemas/runtime.ProfileView"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.ProfileRenameData":{"properties":{"moved":{"$ref":"#/components/schemas/runtime.MovedRefs"},"profile":{"$ref":"#/components/schemas/runtime.ProfileView"}},"type":"object"},"httpapi.ProfileTryData":{"properties":{"hidden":{"items":{"$ref":"#/components/schemas/runtime.TryHidden"},"type":"array","uniqueItems":false},"hidden_by_profile":{"type":"integer"},"hidden_truncated":{"type":"boolean"},"results":{"items":{"additionalProperties":{},"type":"object"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.ProfileUsedByData":{"properties":{"anonymous_profile":{"type":"boolean"},"clients":{"items":{"$ref":"#/components/schemas/runtime.UsedByClient"},"type":"array","uniqueItems":false},"tokens":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.ProfileViewData":{"properties":{"blocked_24h":{"type":"integer"},"calls_24h":{"type":"integer"},"code_execution":{"type":"boolean"},"description":{"type":"string"},"effective_code_execution":{"type":"boolean"},"effective_servers":{"items":{"type":"string"},"type":"array","uniqueItems":false},"effective_unannotated":{"type":"string"},"is_legacy":{"type":"boolean"},"management_tools":{"type":"boolean"},"max_tier":{"type":"string"},"name":{"type":"string"},"servers":{"items":{"type":"string"},"type":"array","uniqueItems":false},"switchable_to":{"items":{"type":"string"},"type":"array","uniqueItems":false},"title":{"type":"string"},"tool_count":{"description":"ToolCount is the v2 field: indexed tools on the effective servers.\nDeprecated: use tool_counts.","type":"integer"},"tool_counts":{"$ref":"#/components/schemas/runtime.ToolCounts"},"tools":{"$ref":"#/components/schemas/config.ProfileToolRules"},"unannotated":{"type":"string"},"used_by":{"$ref":"#/components/schemas/runtime.UsedBy"}},"type":"object"},"httpapi.ProfileWriteData":{"properties":{"profile":{"$ref":"#/components/schemas/runtime.ProfileView"},"warnings":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.PutClientBindingRequest":{"properties":{"mode":{"type":"string"},"profile":{"type":"string"}},"type":"object"},"httpapi.RenameProfileRequest":{"properties":{"new_name":{"type":"string"}},"type":"object"},"httpapi.RotateClientRequest":{"properties":{"precondition_token":{"description":"PreconditionToken is the token of GET /connect/{client}/preview; it\nbinds a supported client's rotation to what the preview showed.","type":"string"}},"type":"object"},"httpapi.RotateClientResponse":{"properties":{"client":{"$ref":"#/components/schemas/httpapi.clientPresence"},"connect":{"$ref":"#/components/schemas/connect.ConnectResult"},"credential":{"type":"string"},"rotation":{"$ref":"#/components/schemas/httpapi.RotationState"},"snippet":{"$ref":"#/components/schemas/httpapi.ClientSnippet"}},"type":"object"},"httpapi.RotationState":{"properties":{"state":{"type":"string"}},"type":"object"},"httpapi.SetActiveProfileRequest":{"properties":{"active_profile":{"type":"string"},"profile":{"type":"string"}},"type":"object"},"httpapi.StaleReviewErrorResponse":{"properties":{"error":{"type":"string"},"error_code":{"type":"string"},"request_id":{"type":"string"},"stale_tools":{"$ref":"#/components/schemas/httpapi.StaleToolsDetail"},"success":{"type":"boolean"}},"type":"object"},"httpapi.StaleToolsDetail":{"properties":{"changed":{"items":{"type":"string"},"type":"array","uniqueItems":false},"missing_blocks":{"items":{"type":"string"},"type":"array","uniqueItems":false},"unreviewed":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.TryProfileRequest":{"properties":{"limit":{"type":"integer"},"profile":{"$ref":"#/components/schemas/config.ProfileConfig"},"query":{"type":"string"}},"type":"object"},"httpapi.UndoConnectRequest":{"properties":{"backup_name":{"description":"BackupName is the bare filename (filepath.Base) of the backup returned as\nbackup_path by the preceding connect — a name, never a path. Undo resolves\nthe full path server-side by joining it with the client's own config\ndirectory, so a client-supplied value can never contribute a directory\ncomponent (traversal is impossible by construction). Empty means the\nconnect created the file (no prior file existed), so undo removes it.","type":"string"},"server_name":{"description":"Defaults to \"mcpproxy\"","type":"string"}},"type":"object"},"httpapi.UpdateFailureRequest":{"properties":{"stage":{"description":"Stage is the failure stage of the update session.","enum":["appcast","download","install","other"],"type":"string"}},"type":"object"},"httpapi.UpgradeAdminKeyHoldersRequest":{"properties":{"apply":{"type":"boolean"},"mode":{"type":"string"},"precondition_token":{"type":"string"},"profile":{"type":"string"}},"type":"object"},"httpapi.approveToolsRequest":{"properties":{"approve_all":{"type":"boolean"},"expected_hashes":{"additionalProperties":{"type":"string"},"description":"ExpectedHashes binds the approval to the reviewed definitions (Spec\n117 PR-2, #1566): tool name -\u003e the review's current_hash. Absent or\nnull is an unbound (legacy) request; {} is bound.","type":["object","null"]},"expected_incarnation":{"description":"ExpectedIncarnation binds the approval to the server the client\nreviewed (Spec 116 FR-017); 409 server_replaced on a mismatch. It is\nindependent of ExpectedHashes and the two may be sent together.","type":"integer"},"tools":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.attentionResponse":{"properties":{"count":{"type":"integer"},"generated_at":{"type":"string"},"items":{"items":{"$ref":"#/components/schemas/contracts.AttentionItem"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.clientPresence":{"properties":{"active_sessions":{"type":"integer"},"blocked_24h":{"type":"integer"},"calls_24h":{"type":"integer"},"config_path":{"type":"string"},"connected":{"type":"boolean"},"connection_unverified":{"type":"boolean"},"credential_checked_at":{"type":"string"},"credential_state":{"$ref":"#/components/schemas/profile.CredentialState"},"display_name":{"type":"string"},"display_path":{"type":"string"},"expires_at":{"type":"string"},"icon":{"type":"string"},"id":{"type":"string"},"installed":{"type":"boolean"},"issuer":{"$ref":"#/components/schemas/auth.CredentialIssuer"},"kind":{"type":"string"},"last_seen":{"type":"string"},"lease":{"type":"boolean"},"profile":{"type":"string"},"profile_missing":{"type":"boolean"},"profile_mode":{"type":"string"},"profile_source":{"description":"profile_source is pin (locked) or binding (switchable) and is empty when\ncredential_state is not client.","type":"string"},"profile_state":{"type":"string"},"profile_title":{"type":"string"},"purpose":{"type":"string"},"reload_hint":{"type":"string"},"revoked_at":{"description":"Spec 115 lifecycle fields of the client's credential record (additive):\nwhen it was revoked, who issued it, its stated (unenforced) purpose,\nwhether it is a task lease (lifetime at issue \u003c= 24h) and whether its\nprofile still exists (ok | dangling | none).","type":"string"},"rotation_pending":{"type":"boolean"},"sessions":{"items":{"$ref":"#/components/schemas/httpapi.clientSession"},"type":"array","uniqueItems":false},"state":{"type":"string"},"token_name":{"type":"string"}},"type":"object"},"httpapi.clientSession":{"properties":{"id":{"type":"string"},"last_activity":{"type":"string"},"profile":{"description":"Profile and ProfileSource are the session's latest effective resolution\n(Spec 108-e FR-033); empty on legacy sessions.","type":"string"},"profile_source":{"type":"string"},"started_at":{"type":"string"},"work_session_id":{"type":"string"}},"type":"object"},"httpapi.clientsResponse":{"properties":{"clients":{"items":{"$ref":"#/components/schemas/httpapi.clientPresence"},"type":"array","uniqueItems":false},"routing":{},"warnings":{"items":{"$ref":"#/components/schemas/httpapi.ClientWarning"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.expectedIncarnationBody":{"properties":{"expected_incarnation":{"type":"integer"}},"type":"object"},"httpapi.securityApproveRequest":{"properties":{"block":{"items":{"type":"string"},"type":"array","uniqueItems":false},"expected_hashes":{"additionalProperties":{"type":"string"},"description":"ExpectedHashes binds the approval to the reviewed definitions (Spec\n117 PR-2, #1566): tool name -\u003e the review's current_hash. Absent or\nnull is an unbound (legacy) request; {} binds to the empty snapshot.\nIndependent of ExpectedIncarnation: a request may carry both, and the\ntwo refusals stay distinct (review_out_of_date vs server_replaced).","type":["object","null"]},"expected_incarnation":{"description":"ExpectedIncarnation binds the approval to the server the client\nreviewed (Spec 116 FR-017): a remove and re-add since then is refused\nwith 409 server_replaced and the replacement stays quarantined.","type":"integer"},"force":{"type":"boolean"}},"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"},"profile.AccessStepStatus":{"type":"string","x-enum-varnames":["AccessStepPass","AccessStepFail","AccessStepSkip"]},"profile.AccessSubjectKind":{"type":"string","x-enum-varnames":["AccessSubjectClient","AccessSubjectProfile","AccessSubjectToken","AccessSubjectAnonymous"]},"profile.CredentialState":{"description":"Spec 108-f ClientView additions (data-model §7). The Spec 109 fields above\nare unchanged; everything below is additive.\n\ncredential_state is what the client's connection carries (client |\nadmin_key | none | revoked | expired | unknown). The stat-only list never\nreads a config: it reports the token store, else the last on-demand\nobservation (credential_checked_at says when), else unknown.","type":"string","x-enum-varnames":["CredentialStateClient","CredentialStateAdminKey","CredentialStateNone","CredentialStateRevoked","CredentialStateExpired","CredentialStateUnknown"]},"profile.ExplainStep":{"type":"string","x-enum-varnames":["StepCredential","StepProfile","StepServerInScope","StepToolRule","StepTierCap","StepTokenPermission","StepGlobalGate","StepServerState","StepToolApproval"]},"profile.ExplainVerdict":{"type":"string","x-enum-varnames":["ExplainVerdictAllowed","ExplainVerdictBlocked","ExplainVerdictHidden"]},"profile.FixAction":{"type":"string","x-enum-varnames":["FixAllowInProfile","FixClassifyInProfile","FixAddServerToProfile","FixMoveClient","FixEditToken","FixEnableServer","FixApproveTool","FixChangeSetting","FixReconnectClient"]},"profile.WarningCode":{"type":"string","x-enum-varnames":["WarningAnonymousDeniedByBindingGuard","WarningClientHoldsAdminKey","WarningClientCredentialExpiring","WarningClientRotationPending","WarningProfileMissing","WarningClientTokenNameConflict"]},"profile.WarningSeverity":{"type":"string","x-enum-varnames":["WarningSeverityWarn","WarningSeverityInfo"]},"runtime.BindingRef":{"properties":{"client_id":{"type":"string"},"mode":{"type":"string"},"profile":{"type":"string"},"token_name":{"type":"string"}},"type":"object"},"runtime.EffectiveCounts":{"properties":{"by_reason":{"additionalProperties":{"type":"integer"},"type":"object"},"callable":{"type":"integer"},"hidden":{"type":"integer"},"visible":{"type":"integer"}},"type":"object"},"runtime.EffectiveTool":{"properties":{"access":{"$ref":"#/components/schemas/runtime.ToolAccessView"},"classification_stale":{"type":"boolean"},"intrinsic_tier":{"type":"string"},"profile_tier":{"type":"string"},"server":{"type":"string"},"tool":{"type":"string"}},"type":"object"},"runtime.ExplainProfileView":{"properties":{"name":{"type":"string"},"source":{"type":"string"}},"type":"object"},"runtime.ExplainStepView":{"properties":{"detail":{"type":"string"},"status":{"$ref":"#/components/schemas/profile.AccessStepStatus"},"step":{"$ref":"#/components/schemas/profile.ExplainStep"}},"type":"object"},"runtime.ExplainSubjectView":{"properties":{"kind":{"$ref":"#/components/schemas/profile.AccessSubjectKind"},"name":{"type":"string"}},"type":"object"},"runtime.Fix":{"properties":{"action":{"$ref":"#/components/schemas/profile.FixAction"},"label":{"type":"string"},"profile":{"description":"Profile is set for move_client only: the destination profile slug the fix\nmoves the client to. Target stays the client id (UIs navigate by it) and\nLabel carries the title, so neither can name the slug.","type":"string"},"step":{"type":"string","x-enum-varnames":["StepCredential","StepProfile","StepServerInScope","StepToolRule","StepTierCap","StepTokenPermission","StepGlobalGate","StepServerState","StepToolApproval"]},"target":{"type":"string"}},"type":"object"},"runtime.GuardFix":{"properties":{"kind":{"type":"string"},"target":{"type":"string"}},"type":"object"},"runtime.MovedRefs":{"properties":{"clients":{"items":{"type":"string"},"type":"array","uniqueItems":false},"tokens":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"runtime.ProfileView":{"properties":{"blocked_24h":{"type":"integer"},"calls_24h":{"type":"integer"},"code_execution":{"type":"boolean"},"description":{"type":"string"},"effective_code_execution":{"type":"boolean"},"effective_servers":{"items":{"type":"string"},"type":"array","uniqueItems":false},"effective_unannotated":{"type":"string"},"is_legacy":{"type":"boolean"},"management_tools":{"type":"boolean"},"max_tier":{"type":"string"},"name":{"type":"string"},"servers":{"items":{"type":"string"},"type":"array","uniqueItems":false},"switchable_to":{"items":{"type":"string"},"type":"array","uniqueItems":false},"title":{"type":"string"},"tool_count":{"description":"ToolCount is the v2 field: indexed tools on the effective servers.\nDeprecated: use tool_counts.","type":"integer"},"tool_counts":{"$ref":"#/components/schemas/runtime.ToolCounts"},"tools":{"$ref":"#/components/schemas/config.ProfileToolRules"},"unannotated":{"type":"string"},"used_by":{"$ref":"#/components/schemas/runtime.UsedBy"}},"type":"object"},"runtime.ToolAccessView":{"properties":{"callable":{"type":"boolean"},"reason":{"type":"string"},"visible":{"type":"boolean"}},"type":"object"},"runtime.ToolCounts":{"properties":{"destructive":{"type":"integer"},"read":{"type":"integer"},"unannotated_hidden":{"type":"integer"},"write":{"type":"integer"}},"type":"object"},"runtime.TryHidden":{"properties":{"reason":{"type":"string"},"server":{"type":"string"},"tool":{"type":"string"}},"type":"object"},"runtime.UsedBy":{"properties":{"anonymous_profile":{"type":"boolean"},"clients":{"items":{"$ref":"#/components/schemas/runtime.UsedByClient"},"type":"array","uniqueItems":false},"tokens":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"runtime.UsedByClient":{"properties":{"id":{"type":"string"},"mode":{"type":"string"}},"type":"object"},"runtime.WarningAction":{"properties":{"kind":{"type":"string"},"target":{"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"},"storage.ClientCredentialObservation":{"properties":{"at":{"type":"string"},"state":{"type":"string"}},"type":"object"},"storage.OnboardingState":{"description":"State is the persisted wizard engagement record. Engaged is true once\nthe wizard was shown and the user completed or skipped it.","properties":{"client_connected_at":{"additionalProperties":{"type":"string"},"description":"ClientConnectedAt records, per client id from the fixed connect client\nregistry (internal/connect.GetAllClients — never user input), the last\ntime a connect write succeeded for that client (Spec 109-b FR-042).\nWritten by the connect success path through UpdateOnboardingState so a\nconcurrent onboarding/mark write can never drop it. Consumed by the\nVerify step / presence layer to tell \"connected, never seen\" apart from\n\"connected seconds ago, hasn't reconnected yet\".","type":"object"},"client_credential_observed":{"additionalProperties":{"$ref":"#/components/schemas/storage.ClientCredentialObservation"},"description":"ClientCredentialObserved records, per client id, the LAST credential\nclassification an on-demand read produced (Spec 108-f FR-025, F11): what\nthe client's config held (client | admin_key | none | revoked | expired)\nand when it was read. The stat-only GET /clients listing never reads a\nconfig (Spec 075: no macOS App-Data prompt from a list), so this is how\nit still reports a client that holds the admin key across restarts. It is\nwritten only when the classification CHANGES, by every on-demand read\n(GET /clients/{id}, GET /connect/{client}, the admin-key upgrade preview,\na connect write) and deleted on disconnect. Additive: an older binary\nignores it, and a record without it reads as \"unknown\".","type":"object"},"client_disconnected_at":{"additionalProperties":{"type":"string"},"type":"object"},"client_last_seen":{"additionalProperties":{"type":"string"},"description":"ClientLastSeen records the latest MCP initialize for a recognised client\nalias. It is intentionally kept with onboarding state: presence is a\nlocal UI concern and must still work when telemetry is disabled.","type":"object"},"connect_step_status":{"description":"ConnectStepStatus is one of: \"\", \"completed\", \"completed_external\",\n\"skipped\" (Spec 080 FR-001). \"completed_external\" records a dismissal\nwhere the connect step was untouched but the install was already\nconnected outside the wizard (CLI, ConnectModal, manual config).","type":"string"},"engaged":{"description":"Engaged is true once the wizard was shown and the user completed or\nskipped it. Once true, the wizard does not auto-show again, even if\nstate regresses (e.g. user disconnects all clients).","type":"boolean"},"engaged_at":{"description":"EngagedAt is the timestamp of completion or explicit skip.","type":"string"},"first_shown_at":{"description":"FirstShownAt is the timestamp of first wizard render.","type":"string"},"server_step_status":{"description":"ServerStepStatus is one of: \"\", \"completed\", \"skipped\".","type":"string"}},"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":{"auth.CredentialIssuer":{"properties":{"actor_kind":{"type":"string"},"actor_name":{"type":"string"},"surface":{"type":"string"}},"type":"object"},"config.AuditLogConfig":{"description":"AuditLog configures the Spec 107 edition-neutral audit sink\n(internal/audit). nil means \"use the per-edition/per-transport\ndefault\" (EffectiveAuditLog); restart-pinned (bound at sink\nconstruction). See audit_log.go.","properties":{"compress":{"type":"boolean"},"enabled":{"type":"boolean"},"max_age_days":{"type":"integer"},"max_backups":{"type":"integer"},"max_size_mb":{"type":"integer"},"path":{"type":"string"},"stdout":{"type":"boolean"}},"type":"object"},"config.ConcurrencyDefaults":{"description":"ServerConcurrencyDefaults is scope (b) of FR-020: the blanket per-server\ndefault set inherited by every server that does not override a setting.\nAbsent (the default) = no per-server limiting unless a server configures\nit explicitly. File/API-configured only — no env scheme (FR-022).","properties":{"max_concurrent_requests":{"type":"integer"},"queue_size":{"type":"integer"},"queue_timeout":{"type":"string"}},"type":"object"},"config.Config":{"properties":{"activity_cleanup_interval_min":{"description":"Background cleanup interval in minutes (default: 60)","type":"integer"},"activity_max_records":{"description":"Max records before pruning (default: 100000)","type":"integer"},"activity_max_response_size":{"description":"Response truncation limit in bytes (default: 65536)","type":"integer"},"activity_max_size_mb":{"description":"ActivityMaxSizeMB caps the total activity-log size in MB before the\noldest records are pruned. Omit the key for the 256MB default; set it to\n0 to disable the size cap.","type":"integer"},"activity_retention_days":{"description":"Activity logging settings (RFC-003)","type":"integer"},"advertise_upstream_servers":{"description":"AdvertiseUpstreamServers controls whether MCP clients are told the\nnames of the upstream servers they can reach — in the initialize\ninstructions and the retrieve_tools description — so agents use the\nproxied tools instead of falling back to shell CLIs. Names are always\nfiltered to the caller's profile and agent-token scope. Default true;\nset false to keep server names out of client context. Read live.","type":"boolean"},"aggregate_upstream_prompts":{"description":"AggregateUpstreamPrompts, when true, aggregates every connected upstream\nserver's advertised MCP prompts into mcpproxy's own prompts/list\n(exposed as \"\u003cserver\u003e__\u003cprompt\u003e\"). OFF by default: users are safe by\ndefault and opt in deliberately. EnablePrompts still governs the built-in\nprompts + the prompts capability; this flag gates ONLY the upstream\naggregation performed by RefreshPrompts. Hot-reloadable.","type":"boolean"},"allow_private_registry_fetch":{"description":"AllowPrivateRegistryFetch opts out of the registry SSRF guard (MCP-1076,\nCWE-918). By default (false) registry fetches refuse any host that is — or\nresolves to — a non-routable address (loopback, RFC1918/CGNAT private,\nlink-local incl. the 169.254.169.254 cloud-metadata endpoint), so a\nmalicious or typo'd registry source cannot turn the daemon into a\nrequest-forgery vector against internal services.\n\nThis opt-out is BLANKET (all-or-nothing): setting it true disables the\nguard for EVERY non-routable range at once — loopback, RFC1918/CGNAT\nprivate, link-local AND the 169.254.169.254 cloud-metadata endpoint. There\nis no way to allow only loopback; enabling it for a localhost dev registry\nalso re-opens the cloud-metadata SSRF vector. Set true ONLY when you\nintentionally run a trusted registry mirror on an internal/private address,\nideally on a host with no cloud-metadata exposure. The change takes effect\nonly on daemon (re)start or config reload.","type":"boolean"},"allow_server_add":{"type":"boolean"},"allow_server_remove":{"type":"boolean"},"anonymous_profile":{"description":"AnonymousProfile confines every caller whose request authenticates as\ncredential kind \"anonymous\" (no credential, or an unrecognised\nnon-agent token accepted by the require_mcp_auth:false back-compat\nbranch) to the named profile (Spec 108 FR-008). Empty (default) means\nunconfined, legacy anonymous behaviour. A name that does not match any\nconfigured profile resolves anonymous callers to deny-all and is\nreported as a validation warning (data-model.md §1).","type":"string"},"api_key":{"description":"Security settings","type":"string"},"audit_log":{"$ref":"#/components/schemas/config.AuditLogConfig"},"call_tool_timeout":{"type":"string"},"check_server_repo":{"description":"Repository detection settings","type":"boolean"},"code_execution_max_parallel":{"description":"Default concurrency for call_tools() batches (1-32, default: 8)","type":"integer"},"code_execution_max_tool_calls":{"description":"Max tool calls per execution (0 = unlimited, default: 0)","type":"integer"},"code_execution_pool_size":{"description":"JavaScript runtime pool size (default: 10)","type":"integer"},"code_execution_timeout_ms":{"description":"Timeout in milliseconds (default: 120000, max: 600000)","type":"integer"},"data_dir":{"type":"string"},"debug_search":{"type":"boolean"},"direct_tool_response_mode":{"description":"DirectToolResponseMode selects the serialization of the DIRECT\nenumeration surface (Spec 102). Valid values: \"\" (= full), \"full\"\n(default: today's schema-bearing entries), \"deferred\" (description +\ncompact signature, with a minimal permissive input schema; upstream\ninputSchema and outputSchema are stripped and recovered on demand via\ndescribe_tool).\n\nDeliberately NOT an extension of tool_response_mode: reusing that axis\nwould silently change /mcp/all output for every deployment already\nrunning compact, which FR-015 forbids. Serialization-only — it never\nchanges WHICH tools are listed, only how (FR-008). Hot-reloadable.","type":"string"},"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_client_headers":{"description":"ForwardClientHeaders is the global switch for client header forwarding\n(Spec 112): copying allowlisted headers from the inbound /mcp request into\nthe upstream tools/call request. nil (absent) means enabled; because every\nper-server forward_headers allowlist is empty by default, the default\nforwards nothing. false disables forwarding for every server. Read via\nIsClientHeaderForwardingEnabled(); MCPPROXY_FORWARD_CLIENT_HEADERS\noverrides it for the process. Distinct from ForwardedHeaders/trusted_proxies\n(Spec 107), which trusts inbound X-Forwarded-*.","type":"boolean"},"forward_proxy_env":{"description":"ForwardProxyEnv opts in to forwarding the ambient HTTP(S)/ALL/NO/FTP proxy\nenvironment variables to spawned stdio upstream servers (MCP-2769). OFF by\ndefault: proxy URLs commonly embed credentials (http://user:pass@proxy), so\nforwarding them to every upstream is a credential-leak risk. When enabled,\nvalues are forwarded with their userinfo (credentials) redacted.","type":"boolean"},"health_check_interval":{"description":"Discovery \u0026 health-check cadence (spec 074, #608). Both are *Duration\ntri-state pointers: nil = inherit the built-in default; a pointer to 0s =\nthe loop is disabled; a positive value = that interval. Defaults live only\nin the resolvers (ResolveHealthCheckInterval / ResolveToolDiscoveryInterval)\nso an unset key behaves exactly as before this feature (SC-005). Validated\nin Validate(): health-check ∈ {0} ∪ [5s,1h]; tool-discovery ∈ {0} ∪ [30s,24h].","type":"string"},"http_idle_timeout":{"description":"HTTPIdleTimeout caps how long an idle keep-alive connection is kept open.\nUnset = 180s. \"0s\" removes the dedicated idle deadline, but net/http then\nfalls back to ReadTimeout — idle is fully unbounded only when\nhttp_read_timeout is also \"0s\". Requires a restart.","type":"string"},"http_read_timeout":{"description":"HTTPReadTimeout caps how long reading a whole request (headers + body)\nmay take. Unset = 120s; \"0s\" disables it. Requires a restart.","type":"string"},"http_write_timeout":{"description":"HTTPWriteTimeout caps how long producing a whole response may take on\nnon-streaming endpoints (REST, Web UI, health). Unset = 120s; \"0s\"\ndisables it globally. MCP and SSE /events routes are exempt by design.","type":"string"},"init_timeout":{"description":"InitTimeout is the global default deadline for an upstream's MCP\n` + "`" + `initialize` + "`" + ` handshake (MCP-3322 / GH #760). *Duration tri-state: nil =\ninherit the built-in 30s default; a positive value = that deadline. A\nper-server InitTimeout overrides this. Resolved by ResolveInitTimeout;\nvalidated to {0} ∪ [1s, 30m] in Validate(). Servers doing legitimate\nfirst-run warmup (cache/index build) before answering ` + "`" + `initialize` + "`" + ` can\nraise this so they are not killed mid-startup.","type":"string"},"instructions":{"description":"Instructions text returned in the MCP initialize response to guide AI agents.\nWhen empty, a built-in default is used that explains retrieve_tools workflow.","type":"string"},"intent_declaration":{"$ref":"#/components/schemas/config.IntentDeclarationConfig"},"listen":{"type":"string"},"logging":{"$ref":"#/components/schemas/config.LogConfig"},"max_concurrent_requests":{"description":"Concurrency limits (spec 093, GH #955). Scope (a) of FR-020: the GLOBAL\nAGGREGATE limiter — one proxy-wide cap on concurrently running upstream\ntool calls, with its own bounded wait queue. Tri-state pointers: absent =\nthe limiter does not exist (default, zero behavior change); an explicit 0\nmax also disables it; positive = that cap. This scope is NEVER a\nper-server inheritance source — per-server values come from\nServerConcurrencyDefaults / the per-server overrides — but a server's\neffective concurrency is bounded by BOTH its own limiter and this one.\nResolved by ResolveGlobalConcurrency; hot-reloadable; overridable via\nMCPPROXY_MAX_CONCURRENT_REQUESTS / _QUEUE_SIZE / _QUEUE_TIMEOUT (FR-022).","type":"integer"},"max_result_size_chars":{"description":"MaxResultSizeChars is advertised on every tool as\n` + "`" + `_meta.anthropic/maxResultSizeChars` + "`" + `; it raises Claude Code's\ninline-response ceiling from 50k to up to 500k chars. Omit the key for\nthe 500000 default; set it to 0 to disable the annotation.","type":"integer"},"mcpServers":{"items":{"$ref":"#/components/schemas/config.ServerConfig"},"type":"array","uniqueItems":false},"oauth_expiry_warning_hours":{"description":"Health status settings","type":"number"},"observability":{"$ref":"#/components/schemas/config.ObservabilityConfig"},"output_sanitisation":{"$ref":"#/components/schemas/config.OutputSanitisationConfig"},"output_validation":{"$ref":"#/components/schemas/config.OutputValidationConfig"},"profiles":{"description":"Profiles are optional named, server-scoped views exposed at /mcp/p/\u003cname\u003e\n(Spec 057). Absent/empty is fully supported — /mcp is unchanged and configs\nwithout this key serialize byte-identically (SC-004).","items":{"$ref":"#/components/schemas/config.ProfileConfig"},"type":"array","uniqueItems":false},"quarantine_enabled":{"description":"QuarantineEnabled controls whether quarantine is active. It gates two\nthings together:\n 1. Server-level auto-quarantine for newly added servers (issue #370).\n When true, servers added via the upstream_servers MCP tool or the\n REST API default to quarantined=true; when false, they default to\n quarantined=false. Explicit per-request values always win.\n 2. Tool-level quarantine (Spec 032): per-tool SHA-256 approval of\n tool descriptions/schemas.\nWhen nil (default), quarantine is enabled (secure by default). Set to\nexplicit false to opt out of both. Per-server SkipQuarantine still\napplies for the tool-level check on individual servers.","type":"boolean"},"queue_size":{"type":"integer"},"queue_timeout":{"type":"string"},"read_only_mode":{"type":"boolean"},"registries":{"description":"Registries configuration for MCP server discovery","items":{"$ref":"#/components/schemas/config.RegistryEntry"},"type":"array","uniqueItems":false},"registries_locked":{"description":"RegistriesLocked is an enterprise stub knob (MCP-866): when true, runtime\nadditions of custom registries (e.g. ` + "`" + `registry add-source` + "`" + `, the REST/MCP\nadd-source surface) are rejected so an administrator can pin the discovery\nsources. Built-in defaults are unaffected. Documented but otherwise inert\nbeyond the add-source rejection.","type":"boolean"},"require_mcp_auth":{"description":"Require authentication on /mcp endpoint (default: false)","type":"boolean"},"reveal_secret_headers":{"description":"RevealSecretHeaders, when true, disables the redaction of the\nsecret-bearing server fields — sensitive header values (Authorization,\nX-API-Key, Cookie, …), env-var secrets, and URL query credentials — in\nresponses from the ` + "`" + `upstream_servers` + "`" + ` MCP tool, the ` + "`" + `/api/v1/servers` + "`" + `\nREST API, and the SSE event stream. It also lets URL secrets echoed\ninto last_error / health.detail through unscrubbed.\n\nDefault false — sensitive values are surfaced masked as\n` + "`" + `••••\u003clast2\u003e (\u003cN\u003e chars)` + "`" + ` (error strings use ` + "`" + `***REDACTED***` + "`" + `) so an\nMCP agent cannot read Bearer tokens / API keys / URL secrets out of\nanother upstream's config (PR #425, issue #872). ${env:…}/${keyring:…}\nreferences are labels, not secrets, and pass through unchanged.\n\nThe Web UI / macOS tray edit forms work without seeing the real\nvalues: PATCH /api/v1/servers/{id} deep-merges (omitted keys are\npreserved, see ` + "`" + `headers_remove` + "`" + ` / ` + "`" + `env_remove` + "`" + ` for explicit\ndeletes), so clients compute a diff and only send the keys that\nactually changed. Redacted-but-unchanged values never round-trip\n— the backend keeps the real string. Set this to true if a\ndownstream tool genuinely needs raw values in the response.","type":"boolean"},"routing_mode":{"description":"Routing mode (Spec 031): how MCP tools are exposed to clients\nValid values: \"retrieve_tools\" (default), \"direct\", \"code_execution\"","type":"string"},"security":{"$ref":"#/components/schemas/config.SecurityConfig"},"sensitive_data_detection":{"$ref":"#/components/schemas/config.SensitiveDataDetectionConfig"},"server_concurrency_defaults":{"$ref":"#/components/schemas/config.ConcurrencyDefaults"},"telemetry":{"$ref":"#/components/schemas/config.TelemetryConfig"},"tls":{"$ref":"#/components/schemas/config.TLSConfig"},"tokenizer":{"$ref":"#/components/schemas/config.TokenizerConfig"},"tool_call_max_records_per_server":{"description":"Calls retained per server (default: 1000)","type":"integer"},"tool_call_max_response_size":{"description":"Bounds for the per-server tool-call history behind GET /api/v1/tool-calls\n(#1176). It is a recent-debugging window, not an audit log — the activity\nlog is the durable record — and it kept every upstream response whole,\nper server, forever. A non-positive value means \"use the default\", not\n\"disable\": this store must never be unbounded again, so there is\ndeliberately no off switch.","type":"integer"},"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},"trusted_proxies":{"description":"TrustedProxies lists the CIDRs or IP addresses whose X-Forwarded-For /\nX-Real-IP / X-Forwarded-Proto / X-Forwarded-Host headers are believed\n(Spec 107 FR-027). Empty (default) trusts nobody. Edition-neutral, live\n(hot-reloadable). Env override: MCPPROXY_TRUSTED_PROXIES (comma-separated).\nThe one reader is ForwardedHeaders; validation is validateTrustedProxies.","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.\nThe endpoint is admin-authenticated (SEC-07): scrapers must present the\nglobal API key, via X-API-Key or an Authorization: Bearer header.","type":"boolean"}},"type":"object"},"config.OAuthConfig":{"description":"OAuth configuration (keep even when empty to signal OAuth requirement)","properties":{"auth_server_metadata_url":{"type":"string"},"authorization_endpoint":{"description":"Spec 113-b: optional per-server discovery overrides. Each is an absolute\nhttps URL (http only on a loopback host) with no query, fragment or\nuserinfo; validated at write time only (ValidateOAuthEndpointOverrides).\nThe endpoint overrides rewrite the matching fields of the authorization\nserver metadata mcp-go reads; AuthServerMetadataURL replaces discovery.","type":"string"},"callback_base_url":{"type":"string"},"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"},"registration_endpoint":{"type":"string"},"scopes":{"items":{"type":"string"},"type":"array","uniqueItems":false},"token_endpoint":{"type":"string"}},"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":{"code_execution":{"description":"CodeExecution: nil = inherit the global enable_code_execution gate;\nnon-nil narrows it (a profile can only narrow, never widen, FR-006).","type":"boolean"},"description":{"description":"\u003c= 500 chars (FR-001)","type":"string"},"management_tools":{"description":"ManagementTools: nil = legacy/inherit (FR-016); non-nil sets the\nprofile-level visibility of upstream_servers/quarantine_security.","type":"boolean"},"max_tier":{"description":"MaxTier caps the tier a tool may run at under this profile: \"\" (no\ncap) | \"read\" | \"write\" | \"destructive\" (FR-001).","type":"string"},"name":{"description":"slug (Spec 057 rules unchanged)","type":"string"},"servers":{"description":"references to mcpServers[].name","items":{"type":"string"},"type":"array","uniqueItems":false},"switchable_to":{"description":"SwitchableTo is the list of profile names a session under this\nprofile may ` + "`" + `set_profile` + "`" + ` into (FR-022). nil = legacy/none (research\nD6); a non-nil EMPTY list is an explicit \"none\" and must round-trip as\n` + "`" + `[]` + "`" + `, never be dropped by omitempty — hence the pointer (see the\nMarshalJSON note below, and profiles_v3_test.go's switchable_to round\ntrip case).","items":{"type":"string"},"type":"array","uniqueItems":false},"title":{"description":"display only, \u003c= 80 chars (FR-001)","type":"string"},"tools":{"$ref":"#/components/schemas/config.ProfileToolRules"},"unannotated":{"description":"Unannotated is this profile's handling of a tool whose effective\nannotations carry no tier hint: \"\" (unset, see EffectiveUnannotated) |\n\"deny\" | \"as_write\" | \"as_read\" (FR-001, FR-003).","type":"string"}},"type":"object"},"config.ProfileToolRules":{"description":"Tools holds the allow/deny/classify rule set (FR-001, FR-004, FR-005).","properties":{"allow":{"items":{"type":"string"},"type":"array","uniqueItems":false},"classify":{"additionalProperties":{"type":"string"},"type":"object"},"deny":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"config.RegistryEntry":{"properties":{"count":{"description":"number or string","type":"string"},"description":{"type":"string"},"id":{"type":"string"},"name":{"type":"string"},"protocol":{"type":"string"},"provenance":{"description":"Provenance is the trust tag for this registry (MCP-866):\nRegistryProvenanceOfficial for built-in defaults, RegistryProvenanceCustom\nfor user-added registries. It is authoritatively (re)computed by the\nregistries merge from whether the ID is a shipped default — a user cannot\nclaim \"official\" by writing it into their config.","type":"string"},"requires_key":{"description":"RequiresKey marks a registry that needs an API key to be queried. When\ntrue and no key is configured, the registry is skipped/marked unavailable\nrather than failing the whole search (FR-008).","type":"boolean"},"servers_url":{"type":"string"},"tags":{"items":{"type":"string"},"type":"array","uniqueItems":false},"url":{"type":"string"}},"type":"object"},"config.SecurityConfig":{"description":"Security scanner settings (Spec 039)","properties":{"auto_baseline_scan":{"description":"AutoBaselineScan is the kill-switch for the AUTOMATIC, informational\nPass-1 baseline scan: the free in-process TPA scan mcpproxy runs for every\nnewly admitted server (any trust mode) and, once per installation, over\npre-existing servers that have never been scanned.\n\nInformational ONLY: the resulting verdict populates the security badge and\nthe scan summary, and NEVER gates quarantine or approval. The\ntrust_mode:\"scan\" admission gate is a separate path and is unaffected by\nthis flag.\n\nDefault (nil) is ENABLED. Set to false to suppress every automatic scan\n(manual scans keep working). Env override: MCPPROXY_AUTO_BASELINE_SCAN,\nwhich wins over this field on every path.","type":"boolean"},"deep_scan":{"$ref":"#/components/schemas/config.DeepScanConfig"},"integrity_check_interval":{"type":"string"},"integrity_check_on_restart":{"type":"boolean"},"runtime_read_only":{"type":"boolean"},"runtime_tmpfs_size":{"type":"string"},"scan_timeout_default":{"type":"string"},"scanner_disable_no_new_privileges":{"description":"Deprecated (Spec 077 US3): migrated on load into DeepScan.DisableNoNewPrivileges\n(see migrateDeepScanConfig). Retained only so existing configs that still carry\nthe top-level key parse; consumers MUST read the effective value via\nSecurityConfig.IsDisableNoNewPrivileges. Cleared after migration.\n\nScannerDisableNoNewPrivileges, when true, omits the\n` + "`" + `--security-opt no-new-privileges` + "`" + ` flag from scanner container runs.\n\nBackground: snap-installed Docker on Ubuntu confines dockerd under the\n` + "`" + `snap.docker.dockerd` + "`" + ` AppArmor profile. When runc tries to transition\nthe container into the inner ` + "`" + `docker-default` + "`" + ` profile to exec the\nentrypoint, AppArmor refuses the transition because NO_NEW_PRIVS\nforbids privilege/profile changes on exec — the result is EPERM\n(\"operation not permitted\") and every scanner fails immediately.\n\nSet this to true ONLY on hosts hitting that incompatibility. Scanner\ncontainers still run with read-only rootfs, tmpfs /tmp, no-network by\ndefault, and read-only source mounts, so the marginal isolation loss\nis small. The preferred fix remains replacing snap docker with a\ndistro-packaged docker.","type":"boolean"},"scanner_fetch_package_source":{"description":"Deprecated (Spec 077 US3): migrated on load into DeepScan.FetchPackageSource\n(see migrateDeepScanConfig). Retained only so existing configs that still carry\nthe top-level key parse; consumers MUST read the effective value via\nSecurityConfig.EffectiveFetchPackageSource. Cleared after migration.\n\nScannerFetchPackageSource controls whether the scanner fetches the\nPUBLISHED source of package-runner servers (npx/uvx) — without executing\nit — when no local source is available (no Docker container, no local\npackage cache, no working_dir). This is the primary quarantine/scan\ntarget: a quarantined-on-add server is never run locally, so without this\nthe scan degrades to tool-definitions-only (no real source-level\nanalysis). See MCP-2206.\n\nFetching uses ` + "`" + `npm pack --ignore-scripts` + "`" + ` (npm) and ` + "`" + `uv pip download` + "`" + ` /\n` + "`" + `pip download` + "`" + ` with ` + "`" + `--only-binary=:all:` + "`" + ` (Python), which only download +\nunpack archives and NEVER run install, build, or setup.py — a scanner must\nnot execute the untrusted code it is scanning. The Python\n` + "`" + `--only-binary=:all:` + "`" + ` flag is required because downloading an sdist would\ninvoke its build backend (setup.py); packages with no wheel fall back to\ntool-definitions-only instead. Extraction is hardened against path\ntraversal and decompression bombs.\n\nDefault (nil) is ENABLED. Set to false on air-gapped deployments to\nforbid the scanner's network egress; such servers then fall back to the\ntool-definitions-only scan with no regression.","type":"boolean"},"scanner_registry_url":{"type":"string"},"tpa_bundle_path":{"description":"TPABundlePath is the filesystem path to the tpa-db scanner-bundle.json\nthe offline TPA scanner runs (spec 086 FR-019: the signature-DB location\nMUST be configuration-driven, not hardcoded). Empty (the default) runs the\ncorpus embedded in this build.\n\nEnv override: MCPPROXY_TPA_BUNDLE_PATH. Hot-reloadable — the path is\nre-read on every config.reloaded event via\nscanner.Service.ApplySecurityConfig, so a corpus refresh needs no restart.\nA configured bundle that fails to read/parse/version-check/compile is\nREFUSED and the previously active corpus stays live (fail-closed, never\nfail-empty); the reason is logged and surfaced in the security overview's\nsignature_bundle.load_error.","type":"string"}},"type":"object"},"config.SensitiveDataDetectionConfig":{"description":"Sensitive data detection settings (Spec 026)","properties":{"categories":{"additionalProperties":{"type":"boolean"},"description":"Enable/disable specific detection categories","type":"object"},"custom_patterns":{"description":"User-defined detection patterns","items":{"$ref":"#/components/schemas/config.CustomPattern"},"type":"array","uniqueItems":false},"enabled":{"description":"Enable sensitive data detection (default: true)","type":"boolean"},"entropy_threshold":{"description":"Shannon entropy threshold for high-entropy detection (default: 4.5)","type":"number"},"max_payload_size_kb":{"description":"Max size to scan before truncating (default: 1024)","type":"integer"},"scan_requests":{"description":"Scan tool call arguments (default: true)","type":"boolean"},"scan_responses":{"description":"Scan tool responses (default: true)","type":"boolean"},"sensitive_keywords":{"description":"Keywords to flag","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"config.ServerConfig":{"properties":{"args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"auto_approve_tool_changes":{"description":"AutoApproveToolChanges is the per-server intent to auto-approve tool\nchanges/additions (disabling per-server rug-pull protection). Supersedes\nskip_quarantine. MCP-2930 only ACCEPTS, persists, and migrates this flag — it\nis NOT yet consulted at runtime; auto-approval is still governed by\nSkipQuarantine until the trust-baseline behavior change (MCP-2931) migrates the\nruntime consumers onto it.\nTri-state pointer (mirrors QuarantineEnabled): nil = unset (inherit/migrate\nfrom legacy skip_quarantine), explicit true/false = honored as-is so an\nexplicit auto_approve_tool_changes:false overrides a legacy skip_quarantine:true.\nRead via IsAutoApproveToolChanges().","type":"boolean"},"command":{"type":"string"},"created":{"type":"string"},"disabled_tools":{"description":"Denylist: these tools are hidden; mutually exclusive with enabled_tools","items":{"type":"string"},"type":"array","uniqueItems":false},"enabled":{"type":"boolean"},"enabled_tools":{"description":"Allowlist: only these tools are exposed; mutually exclusive with disabled_tools","items":{"type":"string"},"type":"array","uniqueItems":false},"env":{"additionalProperties":{"type":"string"},"type":"object"},"expose_prompts":{"description":"ExposePrompts overrides whether this server's advertised MCP prompts are\naggregated into mcpproxy's prompts/list. nil (default) inherits the\ndefault-aggregate behavior (included if the server advertises\nCapabilities.Prompts); false excludes it regardless of capability.","type":"boolean"},"forward_headers":{"description":"ForwardHeaders lists inbound MCP-client header NAMES (never values) that\nare copied into this server's upstream tools/call requests (Spec 112).\nExact names, case-insensitive, at most 32, no wildcards. Empty forwards\nnothing. Only HTTP-based transports (http, streamable-http) forward; the\ndeny list (Authorization, Host, hop-by-hop, ...) is enforced at runtime\nregardless of what is configured here.","items":{"type":"string"},"type":"array","uniqueItems":false},"headers":{"additionalProperties":{"type":"string"},"description":"For HTTP servers","type":"object"},"health_check_interval":{"description":"Per-server discovery \u0026 health-check overrides (spec 074). Same *Duration\ntri-state as the global keys: nil = inherit the global value (or default),\npointer to 0s = disabled for this server, positive = that interval.\nHealthCheckInterval is fully wired into the per-server health loop;\nToolDiscoveryInterval is accepted/validated and round-trips for\nforward-compat, but the periodic index sweep is governed by the global\ncadence in this iteration (see spec 074 plan §C).","type":"string"},"init_timeout":{"description":"InitTimeout overrides the global init_timeout for this server's MCP\n` + "`" + `initialize` + "`" + ` handshake deadline (MCP-3322 / GH #760). *Duration tri-state:\nnil = inherit the global value (or 30s default), positive = that deadline.\nResolved by Config.ResolveInitTimeout; validated to {0} ∪ [1s, 30m]. Raise\nthis for upstreams that do legitimate first-run warmup (e.g. caching many\nchannels/users) before responding to ` + "`" + `initialize` + "`" + `.","type":"string"},"isolation":{"$ref":"#/components/schemas/config.IsolationConfig"},"launcher_wait_timeout":{"description":"LauncherWaitTimeout caps how long mcpproxy will wait for a locally-launched\nHTTP/SSE upstream's URL to become reachable after Spawn(). Only consulted\nwhen the server is configured with both Command and an HTTP/SSE URL — i.e.,\nmcpproxy starts the process AND connects via network. Stdio servers ignore\nthis field. Zero or unset → 30s default.","type":"string"},"max_concurrent_requests":{"description":"Per-server concurrency overrides — scope (c) of FR-020 (spec 093, #955).\nTri-state per setting, exactly like HealthCheckInterval: absent = inherit\nthe per-server default set (server_concurrency_defaults), explicit 0 =\ndisable that setting for this server (0 max = no per-server limiter at\nall; 0 queue_size = no pending capacity, shed immediately at the cap),\npositive = override. The global aggregate limiter is never inherited from\nhere — it applies on top, so effective concurrency is min(per-server,\nglobal). Resolved by Config.ResolveServerConcurrency.","type":"integer"},"name":{"type":"string"},"oauth":{"$ref":"#/components/schemas/config.OAuthConfig"},"protocol":{"description":"stdio, http, sse, streamable-http, auto","type":"string"},"quarantined":{"description":"Security quarantine status","type":"boolean"},"queue_size":{"type":"integer"},"queue_timeout":{"type":"string"},"reconnect_on_use":{"description":"Attempt reconnection when a tool call targets a disconnected server","type":"boolean"},"shared":{"description":"Server edition: shared with all users","type":"boolean"},"skip_quarantine":{"description":"SkipQuarantine is DEPRECATED (MCP-2930): use AutoApproveToolChanges instead.\nKept for back-compat parsing; on config load a legacy skip_quarantine:true is\nmigrated to auto_approve_tool_changes:true only when the new field is unset\n(see normalizeServerQuarantineFlags).","type":"boolean"},"source_registry_id":{"description":"SourceRegistryID records which registry this server was added from (empty\nfor manually-configured servers). MCP-866: surfaced in the approval /\nquarantine view so a reviewer can see a server's origin.","type":"string"},"source_registry_provenance":{"description":"SourceRegistryProvenance records the source registry's provenance at add\ntime (RegistryProvenanceOfficial / RegistryProvenanceCustom). It is purely\ninformational (MCP-1072) — surfaced so a reviewer can see a server's origin\n— and no longer gates quarantine or skip_quarantine.","type":"string"},"tool_discovery_interval":{"type":"string"},"toon_output":{"description":"ToonOutput overrides the global toon_output mode for this server's\ntools (spec 084, FR-001). Plain string, not a pointer: \"\"/absent =\ninherit the global value; \"off\"|\"adaptive\"|\"always\" = override (\"off\"\nis the explicit force-off). Resolved by Config.ResolveToonOutput.","type":"string"},"trust_mode":{"description":"TrustMode is the per-server trust tier: auto|scan|manual. Supersedes\nauto_approve_tool_changes (spec 086). An empty value is derived from the\nlegacy fields at load via normalizeServerQuarantineFlags; the single\nresolution point is EffectiveTrustMode(), which treats an empty or\nunrecognized value as manual (secure by default). Read via\nEffectiveTrustMode(), never the raw string.","type":"string"},"updated":{"type":"string"},"url":{"type":"string"},"working_dir":{"description":"Working directory for stdio servers","type":"string"}},"type":"object"},"config.TLSConfig":{"description":"TLS configuration","properties":{"certs_dir":{"description":"Directory for certificates","type":"string"},"enabled":{"description":"Enable HTTPS","type":"boolean"},"hsts":{"description":"Enable HTTP Strict Transport Security","type":"boolean"},"require_client_cert":{"description":"Enable mTLS","type":"boolean"}},"type":"object"},"config.TelemetryConfig":{"description":"Telemetry settings (Spec 036)","properties":{"anonymous_id":{"description":"Auto-generated UUIDv4","type":"string"},"anonymous_id_created_at":{"description":"Spec 042 (Tier 2) additions — all default-zero, all backwards-compatible.","type":"string"},"enabled":{"description":"Default: true (opt-out)","type":"boolean"},"endpoint":{"description":"Override for testing","type":"string"},"last_reported_version":{"description":"Upgrade funnel","type":"string"},"last_startup_outcome":{"description":"success|port_conflict|db_locked|...","type":"string"},"notice_shown":{"description":"First-run notice flag","type":"boolean"}},"type":"object"},"config.TokenizerConfig":{"description":"Tokenizer configuration for token counting","properties":{"default_model":{"description":"Default model for tokenization (e.g., \"gpt-4\")","type":"string"},"enabled":{"description":"Enable token counting","type":"boolean"},"encoding":{"description":"Default encoding (e.g., \"cl100k_base\")","type":"string"}},"type":"object"},"config.TracingExporterConfig":{"description":"Tracing gates the OpenTelemetry OTLP trace exporter (MCP-32). Disabled by\ndefault.","properties":{"enabled":{"description":"Enabled turns on OTLP trace export for tool calls and upstream hops.","type":"boolean"},"endpoint":{"description":"Endpoint is the collector address as host:port (no scheme), e.g.\n\"localhost:4318\" for http or \"localhost:4317\" for grpc.","type":"string"},"protocol":{"description":"Protocol selects the OTLP transport: \"http\" or \"grpc\".","type":"string"},"sample_rate":{"description":"SampleRate is the head-based trace sampling ratio in [0,1]. Default 0.1.\nOmit the key for the 0.1 default; set it to 0 to sample nothing.","type":"number"}},"type":"object"},"config.UpdateCheckConfig":{"description":"Update-check settings (Spec 079 FR-012): config-file control of the\nbackground upgrade-awareness checker (internal/updatecheck). nil =\nenabled on the stable channel (existing default behavior). The existing\nenvironment switches keep working and WIN over these keys (FR-014):\nMCPPROXY_DISABLE_AUTO_UPDATE=true force-disables even when\nenabled=true, and MCPPROXY_ALLOW_PRERELEASE_UPDATES=true force-selects\nthe rc channel even when channel=stable.","properties":{"channel":{"description":"Channel selects which releases are offered as updates: \"stable\"\n(default; prereleases never offered) or \"rc\" (prereleases included).\nEmpty resolves to stable. Validated in ValidateDetailed.\n\nNOTE: for a RELEASED build the running binary's own version is\nauthoritative and overrides this field — a stable build is never\noffered an RC (even with channel=rc), and an RC build always tracks the\nrc channel. This field only takes effect on dev/unstamped builds. See\ninternal/updatecheck.Checker.IncludePrereleases.","type":"string"},"enabled":{"description":"Enabled gates all update checking. Tri-state: nil/absent = enabled\n(default true, matching pre-079 behavior). When false, no network\ncheck is performed and no upgrade nudge appears on any surface\n(FR-015) — /api/v1/info omits the update object entirely.","type":"boolean"}},"type":"object"},"configimport.FailedServer":{"properties":{"details":{"type":"string"},"error":{"type":"string"},"name":{"type":"string"}},"type":"object"},"configimport.ImportSummary":{"properties":{"failed":{"type":"integer"},"imported":{"type":"integer"},"skipped":{"type":"integer"},"total":{"type":"integer"}},"type":"object"},"configimport.SkippedServer":{"properties":{"name":{"type":"string"},"reason":{"description":"\"already_exists\", \"filtered_out\", \"invalid_name\", \"self_reference\"","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"},"credential":{"description":"Credential is the masked client credential the write embedded\n(` + "`" + `mcp_cli_••••` + "`" + `); the real secret is never returned (FR-024). Empty for a\nkeyless entry, a disconnect and every refusal.","type":"string"},"credential_revoke_error":{"description":"CredentialRevokeError is set when a disconnect removed the entry but\nrevoking the client credential failed. The disconnect still succeeded;\nthe credential is still active and ` + "`" + `mcpproxy client forget \u003cid\u003e` + "`" + `\nretries the revoke.","type":"string"},"credential_revoked":{"description":"CredentialRevoked names the credential that was revoked: by an undo,\nbecause the restored config no longer holds it (plan D16), or by a\ndisconnect, which cuts the client off (a later undo of that disconnect\ndoes not bring the credential back).","type":"string"},"display_path":{"description":"DisplayPath is ConfigPath with the home directory shortened to \"~\"\n(FR-037). Populated for every result whose ConfigPath is known.","type":"string"},"keyless":{"description":"Keyless is true when the entry was written with no credential.","type":"boolean"},"message":{"type":"string"},"mode":{"type":"string"},"profile":{"description":"Profile and Mode are the binding of the credential this write minted or\nkept. Profile \"\" means the built-in All servers scope.","type":"string"},"reload_hint":{"description":"ReloadHint is this client's instruction for making the write take\neffect (FR-037/FR-042), e.g. \"Restart Cursor to load MCPProxy\". Empty\nfor an unknown client.","type":"string"},"rotation":{"description":"Rotation is \"finalized\" when the write replaced an active credential's\nsecret (staged rotation, FR-021a), empty otherwise.","type":"string"},"server_name":{"type":"string"},"success":{"type":"boolean"},"token_name":{"description":"TokenName is the client credential's token name (` + "`" + `client-\u003cid\u003e` + "`" + `).","type":"string"}},"type":"object"},"contracts.APIResponse":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"},"contracts.ActivityDetailResponse":{"properties":{"activity":{"$ref":"#/components/schemas/contracts.ActivityRecord"}},"type":"object"},"contracts.ActivityListResponse":{"properties":{"activities":{"items":{"$ref":"#/components/schemas/contracts.ActivityRecord"},"type":"array","uniqueItems":false},"limit":{"type":"integer"},"offset":{"type":"integer"},"total":{"type":"integer"}},"type":"object"},"contracts.ActivityPerServer":{"properties":{"calls":{"description":"Calls counted per storage.CountsAsCall","type":"integer"},"errors":{"description":"Of those calls, how many failed","type":"integer"},"last_call_at":{"description":"LastCallAt is RFC3339, or \"\" if the server had no call in the period\n(PerServer only lists servers that did, so this is always set).","type":"string"},"name":{"description":"Server name","type":"string"}},"type":"object"},"contracts.ActivityRecord":{"properties":{"agent_name":{"description":"Agent token name when auth_type is \"agent\"","type":"string"},"arguments":{"description":"Tool call arguments","type":"object"},"auth_type":{"description":"\"admin\", \"agent\", \"user\" or \"admin_user\"; empty without an auth context or on another caller's row for a scoped caller","type":"string"},"block_reason":{"description":"Typed cause of a profile policy refusal","type":"string"},"client_id":{"description":"Client id from the client-credential binding","type":"string"},"client_name":{"description":"Self-reported clientInfo.name (advisory)","type":"string"},"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_class":{"description":"Call-error taxonomy (Spec 113-c FR-045): what failed and whose fault it\nis. Absent on success and on records written before Spec 113.","type":"string"},"error_message":{"description":"Error details if status is \"error\"","type":"string"},"fault_domain":{"description":"upstream|proxy|client","type":"string"},"has_sensitive_data":{"description":"Sensitive data detection fields (Spec 026)","type":"boolean"},"id":{"description":"Unique identifier (ULID format)","type":"string"},"max_severity":{"description":"Highest severity level detected (critical, high, medium, low)","type":"string"},"metadata":{"description":"Additional context-specific data","type":"object"},"parent_id":{"description":"Correlation id of the parent call (the code_execution whose sandbox issued this sub-call)","type":"string"},"profile":{"description":"Scope attribution (Spec 108 FR-029): the profile, client and token in\neffect when the call ran. Stamped at write time, never rewritten. For a\nscoped (non-admin) caller the profile/profile_source/client_id/token_name\nof a row it did not make are blanked (binding disclosure is admin-only);\nclient_name is self-reported and stays. Absent on records written before\nSpec 108 and on records with no MCP/REST request context.","type":"string"},"profile_source":{"description":"pin|binding|url|session|anonymous|none","type":"string"},"request_bytes":{"description":"Byte sizes measured pre-truncation, mirroring storage.ActivityRecord\n(Spec 069 A1). They are the only cost signal a bodies-off export carries:\nwith payloads suppressed there is no text left to measure, so a consumer\naccounting for a record it cannot read has nothing else to go on. They are\nbyte LENGTHS, not token counts — the basis for an explicit estimate, never\na measured figure (spec 103, contracts/replay-input.md).\n\nZero means UNKNOWN, not free: legacy records predate the measurement and\ncode-execution sub-calls record both as zero. Hence omitempty — an absent\nkey tells a consumer to fall to exclusion accounting, whereas a present\nzero would read as a costless call and silently understate the workload.","type":"integer"},"request_id":{"description":"HTTP request ID for correlation","type":"string"},"response":{"description":"Tool response (potentially truncated)","type":"string"},"response_bytes":{"description":"Raw upstream response size in bytes before truncation","type":"integer"},"response_truncated":{"description":"True if response was truncated","type":"boolean"},"server_name":{"description":"Name of upstream MCP server","type":"string"},"session_id":{"description":"MCP transport session ID (regenerated on every reconnect)","type":"string"},"source":{"$ref":"#/components/schemas/contracts.ActivitySource"},"status":{"description":"Result status: \"success\", \"error\", \"blocked\", \"rejected\"","type":"string"},"timestamp":{"description":"When activity occurred","type":"string"},"token_name":{"description":"Agent/client token name","type":"string"},"tool_name":{"description":"Name of tool called","type":"string"},"type":{"$ref":"#/components/schemas/contracts.ActivityType"},"upstream_http_status":{"description":"Upstream HTTP status when known","type":"integer"},"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"},"call_count":{"description":"CallCount is how many of those records are CALLS THE USER MADE, as\ndefined once in storage.CountsAsCall and shared with the usage aggregate\nbehind the Usage tab (audit finding F1, #1046). TotalCount answers \"how\nmany rows does the Activity Log have\"; CallCount answers \"how many calls\nwere there\". They are different questions — quarantine auto-approvals,\nsystem start, security scans and management chatter are events, not calls\n— and printing either one under the other's label is how the same instance\ncame to report 51 calls on one screen and 19 on another.","type":"integer"},"call_error_count":{"description":"CallErrorCount is the failures within CallCount, so an error RATE computed\nfrom this response has one denominator. It is not ErrorCount: a policy\nblock is a failed call but carries status \"blocked\", and a shed call is an\nerror in neither sense because it never ran.","type":"integer"},"end_time":{"description":"End of the period (RFC3339)","type":"string"},"error_count":{"description":"Count of error activities","type":"integer"},"other_count":{"description":"OtherCount is every record whose status is outside the four-value\nvocabulary above, so that\n\n\tsuccess + error + blocked + rejected + other == total\n\nholds by construction. The status field is a CLOSED vocabulary for tool\ncalls, but the activity log is wider than tool calls: a quarantine change\nstores its ACTION there (\"approved\", \"auto_approved\"), a policy decision\nstores its DECISION (\"allow\"). Those rows were counted in the total and in\nnone of the four buckets, so the Activity Log's own status tiles summed to\nless than the denominator printed beside them — 15+4+0+0 under a \"42\"\n(audit finding F2, #1046). The residual now has a name and a tile.","type":"integer"},"per_server":{"description":"PerServer covers EVERY server with at least one call in the period\n(unlike TopServers, which is capped at 5 and carries no error counts).\nSpec 109 FR-013: the server-card stats line and the macOS Servers rows\nread this — one ` + "`" + `GET /activity/summary` + "`" + ` response per page load — rather\nthan issuing a per-server activity query each. Computed in the same\ncounting pass as the totals above, from the same CountsAsCall/\nIsManagementBuiltin definitions TopServers already uses.","items":{"$ref":"#/components/schemas/contracts.ActivityPerServer"},"type":"array","uniqueItems":false},"period":{"description":"Time period (1h, 24h, 7d, 30d)","type":"string"},"rejected_count":{"description":"RejectedCount is the number of calls shed by a concurrency limiter before\nthey reached an upstream (spec 093). Counted separately from errors: it is\nproxy backpressure, not an upstream fault, and it is the signal an\noperator right-sizes max_concurrent_requests against.","type":"integer"},"start_time":{"description":"Start of the period (RFC3339)","type":"string"},"success_count":{"description":"Count of successful activities","type":"integer"},"top_servers":{"description":"Top servers by activity count","items":{"$ref":"#/components/schemas/contracts.ActivityTopServer"},"type":"array","uniqueItems":false},"top_tools":{"description":"Top tools by activity count","items":{"$ref":"#/components/schemas/contracts.ActivityTopTool"},"type":"array","uniqueItems":false},"total_count":{"description":"Total activity count","type":"integer"}},"type":"object"},"contracts.ActivityTopServer":{"properties":{"count":{"description":"Activity count","type":"integer"},"name":{"description":"Server name","type":"string"}},"type":"object"},"contracts.ActivityTopTool":{"properties":{"count":{"description":"Activity count","type":"integer"},"server":{"description":"Server name","type":"string"},"tool":{"description":"Tool name","type":"string"}},"type":"object"},"contracts.ActivityType":{"description":"Type of activity","type":"string","x-enum-varnames":["ActivityTypeToolCall","ActivityTypePolicyDecision","ActivityTypeQuarantineChange","ActivityTypeServerChange"]},"contracts.AddFromRegistryRequest":{"properties":{"enabled":{"description":"defaults to true when nil","type":"boolean"},"env":{"additionalProperties":{"type":"string"},"description":"overrides + required-input values","type":"object"},"name":{"description":"optional name override","type":"string"}},"type":"object"},"contracts.AddRegistrySourceRequest":{"properties":{"id":{"description":"derived from the host when empty","type":"string"},"name":{"description":"defaults to the id","type":"string"},"protocol":{"description":"defaults to modelcontextprotocol/registry","type":"string"},"url":{"description":"required https registry URL","type":"string"}},"type":"object"},"contracts.AttentionFix":{"properties":{"label":{"type":"string"},"target":{"type":"string"},"verb":{"type":"string"}},"type":"object"},"contracts.AttentionItem":{"properties":{"detail":{"type":"string"},"fix":{"$ref":"#/components/schemas/contracts.AttentionFix"},"id":{"description":"kind:type:subject[:state]","type":"string"},"kind":{"type":"string"},"rank":{"type":"integer"},"since":{"type":"string"},"subject":{"$ref":"#/components/schemas/contracts.AttentionSubject"},"summary":{"type":"string"}},"type":"object"},"contracts.AttentionSubject":{"properties":{"id":{"type":"string"},"name":{"type":"string"},"type":{"description":"server|tool|client|setting","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"},"incarnation":{"description":"Incarnation is the committed incarnation of the server the tools were\nread for (Spec 116 FR-017), captured before they were fetched; omitted\nwhen unknown.","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":{"counts":{"$ref":"#/components/schemas/contracts.ViewAsCounts"},"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\", \"edit_url\", or \"\" (none)\nInvariant: Action always equals Actions[0], or \"\" when Actions is empty.","type":"string"},"actions":{"description":"Actions lists every applicable next step in priority order (FR-012):\nlogin \u003e set_secret \u003e configure \u003e edit_url \u003e approve \u003e restart \u003e\nview_logs \u003e enable. Always non-nil (empty slice, never null).","items":{"type":"string"},"type":"array","uniqueItems":false},"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"},"status":{"description":"Status is the ONE status vocabulary rendered as text on every surface\n(Web UI, macOS, tray, CLI) — Spec 109 FR-010/FR-011. Values: \"ready\",\n\"connecting\", \"sign_in_required\", \"needs_review\", \"needs_secret\",\n\"needs_config\", \"error\", \"disabled\". Unlike Level (a severity signal for\nbadge/tray coloring only), no renderer may print Level as text.","type":"string"},"summary":{"description":"Summary is a human-readable status message (e.g., \"Connected (5 tools)\")","type":"string"},"usable":{"description":"Usable reports whether the server can currently serve tool calls. True\nonly when Status == \"ready\".","type":"boolean"}},"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":{"description":"Enabled is the EFFECTIVE isolation state for this server: whether its\nprocess is actually CONFINED, after the global setting, the per-server\noverride, the structural gates and the host's capabilities. It is NOT the\nraw per-server override — read EnabledOverride for that (GH #1142).\n\nREAD-ONLY. The write surfaces reject an ` + "`" + `enabled` + "`" + ` key precisely because\nit is derived: echoing it back would convert \"inherits the global\nsetting\" into a permanent explicit override. Write EnabledOverride.\n\nIt stays a non-pointer bool that is always present on the wire: the macOS\ntray decodes it as a non-optional Swift Bool, so omitting or nulling the\nkey would fail Codable for the whole server payload. Older clients that\nread this field now simply get a true answer.","type":"boolean"},"enabled_override":{"description":"EnabledOverride is the RAW per-server ` + "`" + `isolation.enabled` + "`" + ` override, as\npersisted. Absent means \"inherit the global setting\" — which is a\ndistinct state from an explicit false, and the distinction the reporting\nbug used to destroy.","type":"boolean"},"extra_args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"image":{"type":"string"},"memory_limit":{"type":"string"},"mode_override":{"description":"ModeOverride is the RAW per-server ` + "`" + `isolation.mode` + "`" + ` override\n(\"docker\" | \"sandbox\" | \"none\"). Absent means \"inherit\".","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.IsolationEffective":{"description":"IsolationEffective exposes the resolved isolation state (and the rule\nthat decided it) so clients can distinguish \"inherits global\" from an\nexplicit per-server choice. Read-only; never consumed on PATCH.","properties":{"global_mode":{"description":"GlobalMode is what \"inherit\" resolves to right now.","type":"string"},"inherited":{"description":"Inherited is true when the server sets neither ` + "`" + `isolation.enabled` + "`" + ` nor\n` + "`" + `isolation.mode` + "`" + `, so its state tracks the global setting.","type":"boolean"},"isolated":{"description":"Isolated reports whether the process is actually CONFINED. It is NOT\nsimply Mode != \"none\": \"sandbox\" on a host that cannot enforce Landlock\n(any non-Linux OS, or a kernel without the LSM) runs the server\nunconfined, and Source then says \"sandbox-unavailable\" (GH #1142).","type":"boolean"},"mode":{"description":"Mode is the effective isolation mode: \"docker\" | \"sandbox\" | \"none\" —\nexactly what the spawn path branches on.","type":"string"},"source":{"description":"Source names the deciding rule: \"global\", \"server-mode\",\n\"server-opt-out\", \"server-opt-in-ignored\", \"not-stdio\",\n\"already-docker\", \"sandbox-unavailable\" or \"unsupported-mode\".\nTreat an unrecognized value as \"global\".","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_id":{"description":"Scope attribution (Spec 108 FR-033). ClientID and TokenName are the\ncredential the session initialized with; Profile and ProfileSource are\nthe session's latest effective resolution. Empty on legacy sessions.","type":"string"},"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"},"profile":{"type":"string"},"profile_source":{"type":"string"},"start_time":{"type":"string"},"status":{"type":"string"},"token_name":{"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_server_metadata_url":{"type":"string"},"auth_url":{"type":"string"},"authorization_endpoint":{"description":"Spec 113-b operator overrides of OAuth discovery (https-or-loopback URLs,\nnever credentials). Empty means \"use discovery\".","type":"string"},"client_id":{"type":"string"},"extra_params":{"additionalProperties":{"type":"string"},"type":"object"},"pkce_enabled":{"type":"boolean"},"redirect_port":{"type":"integer"},"registration_endpoint":{"type":"string"},"scopes":{"items":{"type":"string"},"type":"array","uniqueItems":false},"token_endpoint":{"type":"string"},"token_expires_at":{"description":"When the OAuth token expires","type":"string"},"token_url":{"type":"string"},"token_valid":{"description":"Whether token is currently valid","type":"boolean"}},"type":"object"},"contracts.OAuthErrorDetails":{"description":"Structured discovery/failure details","properties":{"authorization_server_metadata":{"$ref":"#/components/schemas/contracts.MetadataStatus"},"dcr_status":{"$ref":"#/components/schemas/contracts.DCRStatus"},"protected_resource_metadata":{"$ref":"#/components/schemas/contracts.MetadataStatus"},"server_url":{"type":"string"}},"type":"object"},"contracts.OAuthFlowError":{"properties":{"correlation_id":{"description":"Flow tracking ID for log correlation","type":"string"},"debug_hint":{"description":"CLI command for log lookup","type":"string"},"details":{"$ref":"#/components/schemas/contracts.OAuthErrorDetails"},"error_code":{"description":"Machine-readable error code (e.g., OAUTH_NO_METADATA)","type":"string"},"error_type":{"description":"Category of OAuth runtime failure","type":"string"},"message":{"description":"Human-readable error description","type":"string"},"request_id":{"description":"HTTP request ID (from PR #237)","type":"string"},"server_name":{"description":"Server that failed OAuth","type":"string"},"success":{"description":"Always false","type":"boolean"},"suggestion":{"description":"Actionable remediation hint","type":"string"}},"type":"object"},"contracts.OAuthIssue":{"properties":{"documentation_url":{"type":"string"},"error":{"type":"string"},"issue":{"type":"string"},"missing_params":{"items":{"type":"string"},"type":"array","uniqueItems":false},"resolution":{"type":"string"},"server_name":{"type":"string"}},"type":"object"},"contracts.OAuthRequirement":{"properties":{"expires_at":{"type":"string"},"message":{"type":"string"},"server_name":{"type":"string"},"state":{"type":"string"}},"type":"object"},"contracts.OAuthStartResponse":{"properties":{"auth_url":{"description":"Authorization URL (always included for manual use)","type":"string"},"browser_error":{"description":"Error message if browser launch failed","type":"string"},"browser_opened":{"description":"Whether browser launch succeeded","type":"boolean"},"correlation_id":{"description":"UUID for tracking this flow","type":"string"},"message":{"description":"Human-readable status message","type":"string"},"server_name":{"description":"Name of the server being authenticated","type":"string"},"success":{"description":"Always true for successful start","type":"boolean"}},"type":"object"},"contracts.PreflightPolicy":{"properties":{"exclude_destructive":{"type":"boolean"},"exclude_open_world":{"type":"boolean"},"read_only_only":{"type":"boolean"}},"type":"object"},"contracts.PreflightReason":{"type":"string","x-enum-varnames":["PreflightReasonServerInitializing","PreflightReasonServerUnhealthy","PreflightReasonServerDisabled","PreflightReasonServerQuarantined","PreflightReasonToolPendingApproval","PreflightReasonToolChanged","PreflightReasonToolBlockedByUser","PreflightReasonOAuthRequired","PreflightReasonHashMismatch","PreflightReasonServerNotInScope","PreflightReasonToolDeniedByConfig","PreflightReasonMissingAnnotation","PreflightReasonPolicyFiltered","PreflightReasonNotFound","PreflightReasonServerNotConfigured","PreflightReasonToolBlockedByProfile","PreflightReasonTokenPermissionDenied"]},"contracts.PreflightRequest":{"properties":{"policy":{"$ref":"#/components/schemas/contracts.PreflightPolicy"},"profile":{"description":"Profile evaluates under a named profile's server scope and tool policy;\nit can only narrow a credential's own pin. Unknown: 400.","type":"string"},"tools":{"description":"Tools is 1..100 entries BEFORE dedup; duplicates are collapsed, and\nduplicate ids carrying different pins are a validation error.","items":{"$ref":"#/components/schemas/contracts.PreflightToolRef"},"type":"array","uniqueItems":false},"wait_ms":{"description":"WaitMS polls local state for up to this many milliseconds (cap 10000)\nwhile every failure is retryable-class.","type":"integer"}},"type":"object"},"contracts.PreflightResponse":{"properties":{"checked_at":{"type":"string"},"tools":{"description":"Tools are ordered by first occurrence of each unique id in the request.","items":{"$ref":"#/components/schemas/contracts.PreflightToolResult"},"type":"array","uniqueItems":false},"verdict":{"$ref":"#/components/schemas/contracts.PreflightVerdict"},"waited_ms":{"description":"WaitedMS is present when wait_ms was requested (0 when the wait\nsemaphore was exhausted and the request resolved immediately).","type":"integer"}},"type":"object"},"contracts.PreflightStatus":{"type":"string","x-enum-varnames":["PreflightStatusReady","PreflightStatusUnavailable"]},"contracts.PreflightToolRef":{"properties":{"id":{"description":"ID is a canonical \"\u003cserver\u003e:\u003ctool\u003e\" id. A malformed id is answered with a\nper-ID not_found carrying a format hint, never a request-level error.","type":"string"},"pin_hash":{"description":"PinHash is \"sha256/v{N}:{hex}\" — the schema version is embedded so a\nproxy-side hash-algorithm bump is distinguishable from upstream drift.","type":"string"}},"type":"object"},"contracts.PreflightToolResult":{"properties":{"action":{"type":"string"},"detail":{"type":"string"},"did_you_mean":{"description":"DidYouMean carries up to 3 nearest caller-visible ids on not_found. It\nnever crosses a scope boundary and never names a quarantined server's\ntools.","items":{"type":"string"},"type":"array","uniqueItems":false},"hash":{"description":"Hash is the tool's current pin (\"sha256/v{N}:{hex}\") — operator tier,\nready results only. Never disclosed to an agent token.","type":"string"},"id":{"type":"string"},"reason":{"$ref":"#/components/schemas/contracts.PreflightReason"},"remediation":{"type":"string"},"retryable":{"type":"boolean"},"status":{"$ref":"#/components/schemas/contracts.PreflightStatus"}},"type":"object"},"contracts.PreflightVerdict":{"type":"string","x-enum-varnames":["PreflightVerdictReady","PreflightVerdictDegradedRetryable","PreflightVerdictBlocked","PreflightVerdictUnknownIDs"]},"contracts.QuarantineStats":{"description":"Tool quarantine metrics for this server","properties":{"blocked_count":{"description":"Number of disabled (blocked) tools","type":"integer"},"changed_count":{"description":"Number of tools whose description/schema changed since approval","type":"integer"},"pending_count":{"description":"Number of newly discovered tools awaiting approval","type":"integer"}},"type":"object"},"contracts.RefreshRegistryResponse":{"properties":{"cleared":{"description":"number of cached entries dropped","type":"integer"},"registry_id":{"type":"string"}},"type":"object"},"contracts.Registry":{"properties":{"count":{"description":"number or string","type":"string"},"description":{"type":"string"},"id":{"type":"string"},"name":{"type":"string"},"protocol":{"type":"string"},"provenance":{"description":"Provenance is the trust tag (MCP-866): \"official/trusted\" for built-in\ndefaults, \"custom/unverified\" for user-added registries.","type":"string"},"servers_url":{"type":"string"},"tags":{"items":{"type":"string"},"type":"array","uniqueItems":false},"trusted":{"description":"Trusted indicates whether this is an official, shipped-by-default\nregistry. Trust is derived from membership in the default set, never\nfrom self-assertion in config.","type":"boolean"},"url":{"type":"string"}},"type":"object"},"contracts.RegistryCacheInfo":{"properties":{"age_seconds":{"type":"number"},"stale":{"type":"boolean"}},"type":"object"},"contracts.RegistryUnavailable":{"properties":{"reason":{"type":"string"}},"type":"object"},"contracts.ReplayToolCallRequest":{"properties":{"arguments":{"description":"Modified arguments for replay","type":"object"}},"type":"object"},"contracts.ReplayToolCallResponse":{"properties":{"error":{"description":"Error if replay failed","type":"string"},"new_call_id":{"description":"ID of the newly created call","type":"string"},"new_tool_call":{"$ref":"#/components/schemas/contracts.ToolCallRecord"},"replayed_from":{"description":"Original call ID","type":"string"},"success":{"type":"boolean"}},"type":"object"},"contracts.RepositoryInfo":{"description":"Detected package info","properties":{"npm":{"$ref":"#/components/schemas/contracts.NPMPackageInfo"}},"type":"object"},"contracts.RepositoryServer":{"properties":{"connect_url":{"description":"Alternative connection URL","type":"string"},"created_at":{"type":"string"},"description":{"type":"string"},"id":{"type":"string"},"install_cmd":{"description":"Installation command","type":"string"},"name":{"type":"string"},"registry":{"description":"Which registry this came from","type":"string"},"repository_info":{"$ref":"#/components/schemas/contracts.RepositoryInfo"},"source_code_url":{"description":"Source repository URL","type":"string"},"updated_at":{"type":"string"},"url":{"description":"MCP endpoint for remote servers only","type":"string"}},"type":"object"},"contracts.SearchRegistryServersResponse":{"properties":{"cache":{"$ref":"#/components/schemas/contracts.RegistryCacheInfo"},"query":{"type":"string"},"registry_id":{"type":"string"},"servers":{"items":{"$ref":"#/components/schemas/contracts.RepositoryServer"},"type":"array","uniqueItems":false},"tag":{"type":"string"},"total":{"type":"integer"},"unavailable":{"$ref":"#/components/schemas/contracts.RegistryUnavailable"}},"type":"object"},"contracts.SearchResult":{"properties":{"matches":{"type":"integer"},"score":{"type":"number"},"snippet":{"type":"string"},"tool":{"$ref":"#/components/schemas/contracts.Tool"}},"type":"object"},"contracts.SearchToolsResponse":{"properties":{"query":{"type":"string"},"results":{"items":{"$ref":"#/components/schemas/contracts.SearchResult"},"type":"array","uniqueItems":false},"took":{"type":"string"},"total":{"type":"integer"}},"type":"object"},"contracts.SecurityScanSummary":{"description":"Latest security scan results summary","properties":{"deep_scan":{"$ref":"#/components/schemas/contracts.DeepScanDescriptor"},"finding_counts":{"$ref":"#/components/schemas/contracts.FindingCounts"},"last_scan_at":{"type":"string"},"risk_score":{"description":"0-100","type":"integer"},"scanners_failed":{"type":"integer"},"scanners_run":{"description":"Scanner coverage for the primary (baseline) scan pass — informational only.\nSpec 077 US3 (FR-008/FR-014): Status is derived SOLELY from the\ndeterministic baseline findings; a failed Docker deep scanner no longer\ndowngrades a clean verdict. That failure is surfaced via DeepScan instead.","type":"integer"},"scanners_total":{"type":"integer"},"status":{"description":"\"clean\", \"warnings\", \"dangerous\", \"failed\", \"not_scanned\", \"scanning\"","type":"string"}},"type":"object"},"contracts.Server":{"properties":{"args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"authenticated":{"description":"OAuth authentication status","type":"boolean"},"auto_approve_tool_changes":{"description":"AutoApproveToolChanges mirrors config.ServerConfig.AutoApproveToolChanges\n(MCP-2930): the per-server intent to auto-approve new/changed tools past\nthe trust baseline. Tri-state *bool — nil means \"never set\" (omitted from\nthe payload), so the Web UI toggle (MCP-2932) can distinguish unset from\nan explicit false. Read-only on the GET path; PATCH/POST accept it via\nAddServerRequest.","type":"boolean"},"command":{"type":"string"},"connected":{"type":"boolean"},"connected_at":{"type":"string"},"connecting":{"type":"boolean"},"created":{"type":"string"},"diagnostic":{"$ref":"#/components/schemas/contracts.Diagnostic"},"enabled":{"type":"boolean"},"env":{"additionalProperties":{"type":"string"},"type":"object"},"error_code":{"type":"string"},"expose_prompts":{"description":"ExposePrompts mirrors config.ServerConfig.ExposePrompts (F9): the per-server\nprompt-aggregation override. Tri-state *bool — nil/omitted means \"inherit\ndefault aggregation\". Surfaced on GET so a caller that PATCHed the override\ncan read it back; PATCH/POST accept it via AddServerRequest.","type":"boolean"},"forward_headers":{"description":"ForwardHeaders mirrors config.ServerConfig.ForwardHeaders (Spec 112): the\nallowlist of inbound MCP client header NAMES forwarded to this server on\ntools/call. Names only, never values. Omitted when empty.","items":{"type":"string"},"type":"array","uniqueItems":false},"generation":{"description":"Generation is the server's committed config generation (Spec 116\nFR-017): it changes whenever the server's configuration changes.","type":"integer"},"headers":{"additionalProperties":{"type":"string"},"type":"object"},"health":{"$ref":"#/components/schemas/contracts.HealthStatus"},"id":{"type":"string"},"incarnation":{"description":"Incarnation changes only when the name is removed and created again\n(Spec 116 FR-017). Approval endpoints accept it back as\nexpected_incarnation and answer 409 server_replaced on a mismatch.","type":"integer"},"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"},"isolation_effective":{"$ref":"#/components/schemas/contracts.IsolationEffective"},"last_error":{"type":"string"},"last_reconnect_at":{"type":"string"},"last_retry_time":{"type":"string"},"max_concurrent_requests":{"description":"Spec 093 (GH #955) — per-server concurrency overrides, scope (c) of\nFR-020. Each setting is tri-state: nil (omitted) means \"inherit\nserver_concurrency_defaults\", 0 disables that setting for this server,\npositive overrides it. Surfaced on the GET path so a caller can read back\nwhat it set; PATCH/POST accept them via AddServerRequest. The effective\nconcurrency for a server is additionally bounded by the global aggregate\nlimiter, which is NOT an inheritance source for these fields.","type":"integer"},"name":{"type":"string"},"oauth":{"$ref":"#/components/schemas/contracts.OAuthConfig"},"oauth_status":{"description":"OAuth status: \"authenticated\", \"expired\", \"error\", \"none\"","type":"string"},"protocol":{"type":"string"},"quarantine":{"$ref":"#/components/schemas/contracts.QuarantineStats"},"quarantined":{"type":"boolean"},"queue_size":{"type":"integer"},"queue_timeout":{"type":"string"},"reconnect_count":{"type":"integer"},"reconnect_on_use":{"description":"Attempt reconnection when a tool call targets this disconnected server","type":"boolean"},"retry_count":{"type":"integer"},"retry_stopped":{"description":"RetryStopped reports that automatic reconnection has been given up for\ngood because the failure is deterministic and unrecoverable — a missing\nbinary, an image without the interpreter, an unparseable config (GH\n#1145). It is NOT ordinary exponential backoff, which keeps retrying;\nnothing will happen until the user fixes the config or restarts the\nserver. RetryStoppedCode is the stable MCPX_* code that proved it and\nRetryStoppedReason the catalog message explaining how to fix it. All three\nare omitted for servers that are healthy or still retrying.","type":"boolean"},"retry_stopped_code":{"type":"string"},"retry_stopped_reason":{"type":"string"},"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"},"estimated":{"description":"Estimated (Spec 109-k FR-070-ish, url-filter-contract.md / audit F-Token):\ntrue while AverageQueryResultSize is a synthetic simulation (a sample of\nthe first ` + "`" + `tools_limit` + "`" + ` tools' schemas — no real retrieve_tools call has\ncompleted yet in this runtime's usage aggregate); false once at least one\nreal retrieve_tools call has, at which point AverageQueryResultSize is\nderived from the real observed average response size instead. The Web\nUI and macOS render an \"estimate\" label while this is true.","type":"boolean"},"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.Tier":{"description":"Tier is computed by AnnotationTier (Spec 109 FR-028/X11) from\nAnnotations — read|write|destructive|unannotated. Set by every producer\nof a Tool (enrichServerTools, the global tools handler); never left for\na consuming surface to compute.","type":"string","x-enum-varnames":["TierRead","TierWrite","TierDestructive","TierUnannotated","TierUnknown"]},"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":{"access":{"$ref":"#/components/schemas/contracts.ToolAccess"},"annotations":{"$ref":"#/components/schemas/contracts.ToolAnnotation"},"approval_status":{"type":"string"},"config_denied":{"description":"ConfigDenied is true when the tool is denied by the server's static\nenabled_tools / disabled_tools config. The user cannot override this toggle.","type":"boolean"},"description":{"type":"string"},"disabled":{"description":"Disabled mirrors ToolApprovalRecord.Disabled so per-tool enable state is\navailable without a second round-trip to the approvals endpoint. Absent\nin the JSON when false (default) to keep responses compact.","type":"boolean"},"hash":{"description":"Hash is the tool's current stored hash rendered in the preflight pin\nformat \"sha256/v{N}:{hex}\" (Spec 098 FR-011), where N is the approval\nrecord's HashSchemaVersion. It is the authoring surface for\n` + "`" + `POST /api/v1/preflight` + "`" + ` pins and ` + "`" + `mcpproxy tools preflight --pin` + "`" + `:\ncopy the value straight into a pin.\n\nDisclosure is OPERATOR TIER ONLY — same rule as the preflight per-tool\nresult. The field is omitted for agent-token callers and for tools with\nno stored hash (no approval record yet, or a record written before\nhashes existed).","type":"string"},"held_reason":{"description":"HeldReason, HeldVerdict and HeldSignals mirror the same-named fields on\nstorage.ToolApprovalRecord: the offline-scan evidence that made\ntrust_mode: scan hold this tool for review (spec 086 FR-018). HeldSignals\nnames the matched deterministic check ids, e.g.\n\"tpa.TPA-2026-0001.hidden_instruction\", so a reviewer can see WHY the tool\nis held. All three are omitted for tools that are not held by the scan gate\n(including every record written before the field existed).","type":"string"},"held_signals":{"items":{"type":"string"},"type":"array","uniqueItems":false},"held_verdict":{"type":"string"},"last_used":{"type":"string"},"name":{"type":"string"},"profile_tier":{"description":"ProfileTier and Access are present only in a view-as listing (a tools\nlisting with ?client= or ?profile=, Spec 108 FR-032). ProfileTier is the\ntool's tier under the viewed subject's profile (Tier above stays the\nintrinsic tier); Access is the subject's verdict for the tool. Absent\notherwise, so an ordinary listing is byte-identical to before.","type":"string","x-enum-varnames":["TierRead","TierWrite","TierDestructive","TierUnannotated","TierUnknown"]},"schema":{"type":"object"},"server_incarnation":{"description":"ServerIncarnation is the committed incarnation of the server this row\nwas read for (Spec 116 FR-017), captured before the tools were fetched.\nA client echoes it as expected_incarnation on a per-tool decision, so a\nstale row is refused (409 server_replaced) after a same-name re-add.\nOmitted when the server has no committed incarnation.","type":"integer"},"server_name":{"type":"string"},"tier":{"$ref":"#/components/schemas/contracts.Tier"},"usage":{"type":"integer"}},"type":"object"},"contracts.ToolAccess":{"properties":{"callable":{"type":"boolean"},"reason":{"type":"string"},"visible":{"type":"boolean"}},"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"},"arguments_truncated":{"description":"ArgumentsTruncated marks Arguments as a placeholder rather than the\narguments the tool was called with. Replaying such a record without\nsupplying arguments explicitly is refused.","type":"boolean"},"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"},"response_bytes":{"description":"Marshalled response size before truncation","type":"integer"},"response_truncated":{"description":"ResponseTruncated and ResponseBytes describe a STORAGE-side cut (#1176):\nthe caller received the response whole, and only the persisted copy was\nshortened to tool_call_max_response_size. When ResponseTruncated is true\nthe Response object carries {truncated, original_bytes, preview, note}\ninstead of the upstream result, and ResponseBytes is its size before the\ncut.","type":"boolean"},"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"},"behind_summary":{"description":"Spec 079 FR-002 — how far behind the running build is. All four are\nadditive (FR-021) and absent when the delta could not be resolved, in\nwhich case every surface renders its pre-delta wording.","type":"string"},"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, scoop, 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"},"releases_behind":{"description":"Releases on the offered channel between the running and offered versions","type":"integer"},"releases_behind_saturated":{"description":"ReleasesBehind is a lower bound: the running build predates the scanned release window","type":"boolean"},"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"},"weeks_behind":{"description":"Whole weeks between the two releases' publish dates; 0 is a real value, absent means unknown","type":"integer"}},"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_estimated":{"description":"TokensSavedEstimated echoes ServerTokenMetrics.Estimated (Spec 109-k):\ntrue while TokensSaved is a synthetic simulation rather than derived\nfrom a real retrieve_tools call. Dropped (false, the zero value) for a\nscoped caller along with TokensSaved itself, above.","type":"boolean"},"tokens_saved_percentage":{"type":"number"},"tools":{"items":{"$ref":"#/components/schemas/contracts.UsageToolStat"},"type":"array","uniqueItems":false},"total_calls":{"description":"TotalCalls and TotalErrors are the headline counts for the window: the sum\nof the timeline this same response carries, so the tiles and the histogram\nunder them cannot disagree. They are NOT the sum of Tools — that list is\nlifetime-cumulative, upstream-only and truncated to top-N, and summing it\nclient-side is what made the Usage tab print a third number for the same\n24 hours (audit finding F1, #1046). The population is\nstorage.CountsAsCall, shared with ActivitySummaryResponse.CallCount.\n\nTwo bounds on how exactly this matches the Activity Log's own count.\nBoth are bounded and disclosed, unlike the population mismatch they\nreplace, which was unbounded and silent:\n\n - Window granularity is the timeline's: whole hour buckets, so the span\n is the requested window rounded up to a bucket edge.\n - This response is served from a snapshot behind a short read cache\n (observability.usage_cache_ttl, 5s by default) so the endpoint never\n scans the activity log per request, while the summary endpoint counts\n live. Calls that land inside that window appear on the Activity Log\n first. FreshnessMs and GeneratedAt say how old the figures are, and\n the Usage tab prints it (\"Updated 3s ago\").","type":"integer"},"total_errors":{"type":"integer"},"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_exceeds":{"type":"boolean"},"p50_ms":{"description":"P50Ms and P95Ms are read off a fixed latency histogram, so they are BUCKET\nBOUNDS, not measured durations: the true percentile is at or below the\nvalue, and a client must render it as a bound (\"≤ 5 ms\"). P50Exceeds /\nP95Exceeds flip that reading for the unbounded overflow bucket, where the\nvalue is the last bound and the truth is above it (\"\u003e 10 s\").","type":"integer"},"p95_exceeds":{"type":"boolean"},"p95_ms":{"type":"integer"},"rejected":{"description":"spec 093: shed by a concurrency limit; never executed, so excluded from calls/latency","type":"integer"},"server":{"type":"string"},"sized_calls":{"description":"calls with known response size (basis for avg_resp_bytes)","type":"integer"},"tool":{"type":"string"},"total_req_bytes":{"type":"integer"},"total_resp_bytes":{"type":"integer"}},"type":"object"},"contracts.ValidateConfigResponse":{"properties":{"errors":{"items":{"$ref":"#/components/schemas/contracts.ValidationError"},"type":"array","uniqueItems":false},"valid":{"type":"boolean"}},"type":"object"},"contracts.ValidationError":{"properties":{"field":{"type":"string"},"message":{"type":"string"}},"type":"object"},"contracts.ViewAsCounts":{"description":"Counts is present only for a NON-administrator profile view-as (Spec 108\nFR-032): the response then lists only the visible rows, and this is the\nonly trace of the rest, with no per-server, per-tier or per-reason\nbreakdown.","properties":{"hidden":{"type":"integer"},"visible":{"type":"integer"}},"type":"object"},"data":{"properties":{"data":{"$ref":"#/components/schemas/contracts.InfoResponse"}},"type":"object"},"httpapi.AccessExplanationData":{"properties":{"first_failure":{"type":"string","x-enum-varnames":["StepCredential","StepProfile","StepServerInScope","StepToolRule","StepTierCap","StepTokenPermission","StepGlobalGate","StepServerState","StepToolApproval"]},"fixes":{"items":{"$ref":"#/components/schemas/runtime.Fix"},"type":"array","uniqueItems":false},"profile":{"$ref":"#/components/schemas/runtime.ExplainProfileView"},"steps":{"items":{"$ref":"#/components/schemas/runtime.ExplainStepView"},"type":"array","uniqueItems":false},"subject":{"$ref":"#/components/schemas/runtime.ExplainSubjectView"},"tool":{"type":"string"},"verdict":{"$ref":"#/components/schemas/profile.ExplainVerdict"}},"type":"object"},"httpapi.AddServerRequest":{"properties":{"args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"auto_approve_tool_changes":{"description":"AutoApproveToolChanges is the per-server intent to auto-approve\nnew/changed tools past the trust baseline (MCP-2930). Tri-state *bool:\na nil pointer means \"leave unchanged\" on PATCH; a present value\n(including false) is applied. Mirrors config.ServerConfig's *bool\nsemantics — do NOT collapse to a plain bool, or an omitted field would\nsilently reset a previously-set value.","type":"boolean"},"command":{"type":"string"},"enabled":{"type":"boolean"},"env":{"additionalProperties":{"type":"string"},"type":"object"},"expose_prompts":{"description":"ExposePrompts is the per-server override for prompt aggregation (F9):\nwhether this server's advertised MCP prompts are merged into mcpproxy's\nprompts/list. Tri-state *bool mirroring config.ServerConfig.ExposePrompts —\na nil pointer means \"leave unchanged\" on PATCH (and \"inherit the default\naggregate behavior\" on create); a present value (including false) is applied.","type":"boolean"},"forward_headers":{"description":"ForwardHeaders is the per-server allowlist of inbound MCP client header\nNAMES forwarded to this server on tools/call (Spec 112). Names only, never\nvalues. On PATCH a nil slice (field omitted) leaves the stored allowlist\nunchanged and an empty array ([]) clears it. Invalid, denied, duplicate\nand static-header-colliding names are rejected with 400.","items":{"type":"string"},"type":"array","uniqueItems":false},"headers":{"additionalProperties":{"type":"string"},"type":"object"},"init_timeout":{"description":"InitTimeout is the per-server MCP ` + "`" + `initialize` + "`" + ` handshake deadline override\n(MCP-3322 / GH #760), serialized as a duration string (e.g. \"120s\"). A nil\npointer means \"leave unchanged\" on PATCH; a present value is applied.\nMirrors config.ServerConfig.InitTimeout's *Duration tri-state.","type":"string"},"isolation":{"$ref":"#/components/schemas/httpapi.IsolationRequest"},"max_concurrent_requests":{"description":"MaxConcurrentRequests / QueueSize / QueueTimeout are the per-server\nconcurrency overrides (spec 093 / GH #955, FR-020 scope (c)). Each is\ntri-state: a nil pointer means \"leave unchanged\" on PATCH and \"inherit\nserver_concurrency_defaults\" on create; an explicit 0 disables that\nsetting for this server; a positive value overrides it. Do NOT collapse\nthem to plain values — an omitted field would then silently reset a\nconfigured limit.","type":"integer"},"name":{"type":"string"},"protocol":{"type":"string"},"quarantined":{"type":"boolean"},"queue_size":{"type":"integer"},"queue_timeout":{"type":"string"},"reconnect_on_use":{"type":"boolean"},"trust_mode":{"description":"TrustMode is the per-server trust tier (spec 086): \"auto\", \"scan\", or\n\"manual\". Empty means \"leave unchanged\" on PATCH (and inherit the migrated\ndefault on create). A non-empty value is applied to ServerConfig.TrustMode\nand resolved by EffectiveTrustMode (an unrecognized value fails closed to\nmanual). This is the REST seam for changing the trust tier via\nPOST/PATCH /api/v1/servers.","type":"string"},"url":{"type":"string"},"working_dir":{"type":"string"}},"type":"object"},"httpapi.BindingGuardResponse":{"properties":{"bindings":{"items":{"$ref":"#/components/schemas/httpapi.GuardBinding"},"type":"array","uniqueItems":false},"code":{"description":"binding_bypassable_without_auth","type":"string"},"error":{"description":"Human-readable, byte-stable refusal text","type":"string"},"fixes":{"items":{"$ref":"#/components/schemas/httpapi.GuardFixOption"},"type":"array","uniqueItems":false},"request_id":{"type":"string"},"success":{"description":"Always false","type":"boolean"}},"type":"object"},"httpapi.BulkAssignRequest":{"properties":{"from_profile":{"type":"string"},"mode":{"type":"string"},"to_profile":{"type":"string"}},"type":"object"},"httpapi.BulkAssignResponse":{"properties":{"moved":{"items":{"type":"string"},"type":"array","uniqueItems":false},"skipped":{"items":{"$ref":"#/components/schemas/httpapi.BulkAssignSkipped"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.BulkAssignSkipped":{"properties":{"client_id":{"type":"string"},"code":{"type":"string"},"error":{"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.ClientBindingErrorResponse":{"properties":{"code":{"type":"string"},"error":{"type":"string"},"field":{"type":"string"},"request_id":{"type":"string"},"success":{"description":"Always false","type":"boolean"}},"type":"object"},"httpapi.ClientBindingResponse":{"properties":{"client":{"$ref":"#/components/schemas/httpapi.clientPresence"},"warnings":{"items":{"$ref":"#/components/schemas/httpapi.ClientWarning"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.ClientSnippet":{"properties":{"generic_http":{"type":"string"},"header_name":{"type":"string"}},"type":"object"},"httpapi.ClientWarning":{"properties":{"action":{"$ref":"#/components/schemas/runtime.WarningAction"},"bindings":{"items":{"$ref":"#/components/schemas/runtime.BindingRef"},"type":"array","uniqueItems":false},"client_id":{"type":"string"},"code":{"$ref":"#/components/schemas/profile.WarningCode"},"fixes":{"items":{"$ref":"#/components/schemas/runtime.GuardFix"},"type":"array","uniqueItems":false},"message":{"type":"string"},"severity":{"$ref":"#/components/schemas/profile.WarningSeverity"}},"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"},"keyless":{"type":"boolean"},"mode":{"type":"string"},"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"},"profile":{"description":"Profile, Mode and Keyless are the client-credential intent (Spec 108\nFR-024). Profile names the profile the client's credential binds to;\n\"\" is the built-in All servers scope. Omitted means \"not specified\": a\nfresh credential defaults to All servers and a reconnect keeps the\nclient's existing binding (a reconnect never silently widens a locked\nclient). Mode is locked|switchable (default locked for a named profile,\nswitchable for All servers). Keyless writes a credential-less entry and\nis only possible while require_mcp_auth is off; it cannot carry a\nprofile or mode.","type":"string"},"server_name":{"description":"Defaults to \"mcpproxy\"","type":"string"}},"type":"object"},"httpapi.CreateClientRequest":{"properties":{"display_name":{"type":"string"},"expires_in":{"description":"ExpiresIn is a duration like 30d or 720h; default and cap are 365 days.","type":"string"},"id":{"type":"string"},"mode":{"type":"string"},"profile":{"type":"string"},"purpose":{"description":"Purpose is the stated, unenforced task brief (Spec 115), at most 500\ncharacters; screened for secrets before anything is stored.","type":"string"}},"type":"object"},"httpapi.CreateClientResponse":{"properties":{"client":{"$ref":"#/components/schemas/httpapi.clientPresence"},"credential":{"type":"string"},"snippet":{"$ref":"#/components/schemas/httpapi.ClientSnippet"}},"type":"object"},"httpapi.EffectiveToolsData":{"properties":{"counts":{"$ref":"#/components/schemas/runtime.EffectiveCounts"},"profile":{"type":"string"},"stale_classification_reasons":{"additionalProperties":{"type":"string"},"description":"StaleClassificationReasons maps each stale classify entry to why it no\nlonger applies: profile.StaleClassificationAnnotated or\nprofile.StaleClassificationMissing. It is computed over the UNFILTERED\ntool set, so a server or reason filter never changes it. Administrators\nonly.","type":"object"},"stale_classifications":{"description":"StaleClassifications lists classify entries for tools that are now\nannotated or no longer exist (FR-005). Administrators only.","items":{"type":"string"},"type":"array","uniqueItems":false},"tools":{"items":{"$ref":"#/components/schemas/runtime.EffectiveTool"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.EnvFieldPreview":{"properties":{"empty_or_placeholder":{"type":"boolean"},"name":{"type":"string"},"secret_like":{"type":"boolean"},"value_present":{"type":"boolean"}},"type":"object"},"httpapi.FinalizeClientResponse":{"properties":{"client":{"$ref":"#/components/schemas/httpapi.clientPresence"},"rotation":{"$ref":"#/components/schemas/httpapi.RotationState"}},"type":"object"},"httpapi.ForgetClientResponse":{"properties":{"disconnect_error":{"type":"string"},"disconnected":{"type":"boolean"},"revoked":{"type":"string"}},"type":"object"},"httpapi.GuardBinding":{"properties":{"client_id":{"type":"string"},"mode":{"type":"string"},"profile":{"type":"string"},"token_name":{"type":"string"}},"type":"object"},"httpapi.GuardFixOption":{"properties":{"kind":{"type":"string"},"target":{"type":"string"}},"type":"object"},"httpapi.HeaderFieldPreview":{"properties":{"empty_or_placeholder":{"type":"boolean"},"name":{"type":"string"},"secret_like":{"type":"boolean"},"value_present":{"type":"boolean"}},"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":{"allow_paste_fallback":{"description":"AllowPasteFallback opts into detecting a bare URL or a single command\nline (FR-064) when Format/format detection would otherwise fail — see\nconfigimport.ImportOptions.AllowPasteFallback for why this must stay\nopt-in (review round 4 F-E). Only the interactive Paste tab sets this;\nevery other caller of this endpoint (the general \"Import config\"\npanel, or a direct API call) leaves it false and gets a clear\n\"unable to detect configuration format\" error for a plain one-liner\ninstead of it being silently guessed at and, on apply, added as a\nreal server with no confirmation step.","type":"boolean"},"content":{"description":"Raw JSON or TOML content","type":"string"},"env_override":{"additionalProperties":{"type":"string"},"description":"EnvOverride/HeaderOverride (Spec 109 FR-064/065, PR review round 4\nF-A/F-D fix): the Paste tab's per-field Value/Secret edits — a plain\nvalue the user typed, or a keyring ref path if they chose Secret —\nkeyed by field name. Applied to the matching imported server's\nEnv/Headers ONLY when preview=false, directly on the server this\nrequest's own raw Content parses to server-side. This is what lets\nthe apply call carry the user's edited env/header values without\never round-tripping the redacted preview: url/command/args on apply\nalways come from re-parsing Content here, never from a client-held\npreview response, so a credential embedded in a URL query param or\nargv flag (which the preview necessarily redacted for display) is\nnever overwritten with the masked placeholder.","type":"object"},"format":{"description":"Optional format hint","type":"string"},"header_override":{"additionalProperties":{"type":"string"},"type":"object"},"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"},"env":{"items":{"$ref":"#/components/schemas/httpapi.EnvFieldPreview"},"type":"array","uniqueItems":false},"fields_skipped":{"items":{"type":"string"},"type":"array","uniqueItems":false},"headers":{"items":{"$ref":"#/components/schemas/httpapi.HeaderFieldPreview"},"type":"array","uniqueItems":false},"name":{"type":"string"},"original_name":{"type":"string"},"protocol":{"type":"string"},"source_format":{"type":"string"},"summary":{"description":"Summary, Tags, Env and Headers are the Spec 109 FR-064 preview\nenrichment (contracts/rest-api.md \"Import preview\"). Summary and Tags\nare built from the already-redacted URL/Command/Args above, so a\nsecret embedded in argv or a URL query never reaches Summary either.\nEnv/Headers never carry the raw value — only its presence and two\nbooleans a surface uses to default the Value/Secret toggle (FR-065).","type":"string"},"tags":{"items":{"type":"string"},"type":"array","uniqueItems":false},"url":{"type":"string"},"warnings":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.IsolationRequest":{"description":"Isolation carries per-server Docker isolation overrides (enabled,\nmode_override, image, network_mode, extra_args, working_dir). A nil\npointer means \"do not touch isolation config\". A present object is\napplied field-by-field ON TOP of the persisted overrides, so omitting a\nfield leaves it alone; clear an individual override by sending it\nexplicitly (` + "`" + `\"enabled\": null` + "`" + `, ` + "`" + `\"image\": \"\"` + "`" + `).","properties":{"enabled":{"description":"Enabled exists ONLY to detect and reject an echoed-back read. It is the\neffective state on the read surface and is never writable; see validate().","type":"boolean"},"enabled_override":{"description":"EnabledOverride is the tri-state per-server override — the RAW value, the\nsame one reads return as ` + "`" + `enabled_override` + "`" + `. It has THREE meaningful wire\nstates, and collapsing them is what silently un-isolated servers\n(GH #1142):\n - absent → leave the persisted override untouched\n - null → clear the override, back to inheriting the global\n - true / false → set an explicit opt-in / opt-out","type":"boolean"},"extra_args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"image":{"type":"string"},"mode_override":{"description":"ModeOverride sets ` + "`" + `isolation.mode` + "`" + ` (\"docker\" | \"sandbox\" | \"none\").\nnil leaves the persisted value alone; an empty string clears it. An\nunrecognized value is rejected with a 400 rather than persisted.","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"},"connected_client_id":{"description":"ConnectedClientID records a successful connect write for this client id\n(Spec 109-b FR-042, review round 6). The REST connect endpoint\n(POST /api/v1/connect/{client}) already records this itself on success;\nthis field exists so ` + "`" + `mcpproxy connect` + "`" + ` — which writes the client's\nconfig file directly, without going through that endpoint, so the\ncommand still works when no daemon is running — can relay the same\nevent to a daemon that IS running, keeping ClientConnectedAt in sync\nacross both surfaces. Must be a known id from the fixed connect client\nregistry (internal/connect.GetAllClients); any other value is rejected,\nmatching the field's \"bounded by the registry\" invariant\n(data-model.md §7).","type":"string"},"disconnected_client_id":{"description":"DisconnectedClientID relays a successful local CLI disconnect to a running\ndaemon, which cannot observe the CLI's direct config-file edit itself.\nLike ConnectedClientID, it is bounded to the fixed client registry.","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.OnboardingStateResponse":{"properties":{"configured_server_count":{"description":"ConfiguredServerCount is the number of upstream MCP servers configured\nin mcpproxy (counts both enabled and disabled).","type":"integer"},"connected_client_count":{"description":"ConnectedClientCount is the number of supported clients currently\npointing at mcpproxy.","type":"integer"},"connected_client_ids":{"description":"ConnectedClientIDs are the identifiers of supported clients currently\npointing at mcpproxy. Drawn exclusively from the fixed adapter table —\nuser-entered values never appear here.","items":{"type":"string"},"type":"array","uniqueItems":false},"first_mcp_client_ever":{"description":"FirstMCPClientEver is true once any MCP client has successfully completed\nan ` + "`" + `initialize` + "`" + ` round-trip with this mcpproxy. Sourced from the Spec 044\nactivation bucket. Drives the Verify tab's \"green check\" state.","type":"boolean"},"has_configured_server":{"description":"HasConfiguredServer is true if at least one upstream MCP server is\nconfigured (regardless of current connection health).","type":"boolean"},"has_connected_client":{"description":"HasConnectedClient is true if at least one supported AI client currently\nhas mcpproxy registered in its config.","type":"boolean"},"has_usable_server":{"description":"HasUsableServer is true once at least one enabled, non-quarantined\nserver has usable health and at least one approved (non-disabled) tool.\nThis is the real \"the wizard has something to try\" signal:\nHasConfiguredServer only means a server entry exists, even while every\none of them sits quarantined, requires sign-in, or has zero approved\ntools. The Servers step and Setup badge use this instead of\nHasConfiguredServer, which is kept above for compatibility.\nHealthStatus.Usable is the shared\nreadiness contract used by every UI surface (Spec 109-c, tasks.md T051).","type":"boolean"},"incomplete_tab_count":{"description":"IncompleteTabCount is the number of wizard tabs whose state is incomplete.\nDrives the sidebar Setup entry's badge. Formula:\n +1 if HasConnectedClient == false\n +1 if HasUsableServer == false\n +1 if FirstMCPClientEver == false","type":"integer"},"mcp_clients_seen_ever":{"description":"MCPClientsSeenEver is the capped list of recognized client names that\nhave ever called this mcpproxy. Names come from the MCP ` + "`" + `initialize` + "`" + `\npayload's ` + "`" + `clientInfo.name` + "`" + ` field, sanitized. Surfaces on the Verify tab\nso the user can see whether their real IDE — not a test client — has\nconnected.","items":{"type":"string"},"type":"array","uniqueItems":false},"should_show_wizard":{"description":"ShouldShowWizard is the derived flag the frontend uses to decide\nwhether to auto-show. True when not engaged and IncompleteTabCount \u003e 0\n(Spec 046 v2 — semantics widened to also count the Verify tab).","type":"boolean"},"state":{"$ref":"#/components/schemas/storage.OnboardingState"},"usable_servers":{"description":"UsableServers lists the names behind HasUsableServer, for the Verify\nstep's suggested-prompt generator (FR-042): prompts are only built from\ntools of servers in this list, never from a quarantined or toolless one.","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.ProfileConflictResponse":{"properties":{"code":{"type":"string"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"description":"Always false","type":"boolean"},"used_by":{"$ref":"#/components/schemas/httpapi.ProfileUsedByData"}},"type":"object"},"httpapi.ProfileDeleteData":{"properties":{"anonymous_profile_moved_to":{"type":"string"},"deleted":{"type":"string"},"moved":{"$ref":"#/components/schemas/runtime.MovedRefs"}},"type":"object"},"httpapi.ProfileListData":{"properties":{"anonymous_profile":{"type":"string"},"profiles":{"items":{"$ref":"#/components/schemas/runtime.ProfileView"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.ProfileRenameData":{"properties":{"moved":{"$ref":"#/components/schemas/runtime.MovedRefs"},"profile":{"$ref":"#/components/schemas/runtime.ProfileView"}},"type":"object"},"httpapi.ProfileTryData":{"properties":{"hidden":{"items":{"$ref":"#/components/schemas/runtime.TryHidden"},"type":"array","uniqueItems":false},"hidden_by_profile":{"type":"integer"},"hidden_truncated":{"type":"boolean"},"results":{"items":{"additionalProperties":{},"type":"object"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.ProfileUsedByData":{"properties":{"anonymous_profile":{"type":"boolean"},"clients":{"items":{"$ref":"#/components/schemas/runtime.UsedByClient"},"type":"array","uniqueItems":false},"tokens":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.ProfileViewData":{"properties":{"blocked_24h":{"type":"integer"},"calls_24h":{"type":"integer"},"code_execution":{"type":"boolean"},"description":{"type":"string"},"effective_code_execution":{"type":"boolean"},"effective_servers":{"items":{"type":"string"},"type":"array","uniqueItems":false},"effective_unannotated":{"type":"string"},"is_legacy":{"type":"boolean"},"management_tools":{"type":"boolean"},"max_tier":{"type":"string"},"name":{"type":"string"},"servers":{"items":{"type":"string"},"type":"array","uniqueItems":false},"switchable_to":{"items":{"type":"string"},"type":"array","uniqueItems":false},"title":{"type":"string"},"tool_count":{"description":"ToolCount is the v2 field: indexed tools on the effective servers.\nDeprecated: use tool_counts.","type":"integer"},"tool_counts":{"$ref":"#/components/schemas/runtime.ToolCounts"},"tools":{"$ref":"#/components/schemas/config.ProfileToolRules"},"unannotated":{"type":"string"},"used_by":{"$ref":"#/components/schemas/runtime.UsedBy"}},"type":"object"},"httpapi.ProfileWriteData":{"properties":{"profile":{"$ref":"#/components/schemas/runtime.ProfileView"},"warnings":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.PutClientBindingRequest":{"properties":{"mode":{"type":"string"},"profile":{"type":"string"}},"type":"object"},"httpapi.RenameProfileRequest":{"properties":{"new_name":{"type":"string"}},"type":"object"},"httpapi.RotateClientRequest":{"properties":{"precondition_token":{"description":"PreconditionToken is the token of GET /connect/{client}/preview; it\nbinds a supported client's rotation to what the preview showed.","type":"string"}},"type":"object"},"httpapi.RotateClientResponse":{"properties":{"client":{"$ref":"#/components/schemas/httpapi.clientPresence"},"connect":{"$ref":"#/components/schemas/connect.ConnectResult"},"credential":{"type":"string"},"rotation":{"$ref":"#/components/schemas/httpapi.RotationState"},"snippet":{"$ref":"#/components/schemas/httpapi.ClientSnippet"}},"type":"object"},"httpapi.RotationState":{"properties":{"state":{"type":"string"}},"type":"object"},"httpapi.SetActiveProfileRequest":{"properties":{"active_profile":{"type":"string"},"profile":{"type":"string"}},"type":"object"},"httpapi.StaleReviewErrorResponse":{"properties":{"error":{"type":"string"},"error_code":{"type":"string"},"request_id":{"type":"string"},"stale_tools":{"$ref":"#/components/schemas/httpapi.StaleToolsDetail"},"success":{"type":"boolean"}},"type":"object"},"httpapi.StaleToolsDetail":{"properties":{"changed":{"items":{"type":"string"},"type":"array","uniqueItems":false},"missing_blocks":{"items":{"type":"string"},"type":"array","uniqueItems":false},"unreviewed":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.TryProfileRequest":{"properties":{"limit":{"type":"integer"},"profile":{"$ref":"#/components/schemas/config.ProfileConfig"},"query":{"type":"string"}},"type":"object"},"httpapi.UndoConnectRequest":{"properties":{"backup_name":{"description":"BackupName is the bare filename (filepath.Base) of the backup returned as\nbackup_path by the preceding connect — a name, never a path. Undo resolves\nthe full path server-side by joining it with the client's own config\ndirectory, so a client-supplied value can never contribute a directory\ncomponent (traversal is impossible by construction). Empty means the\nconnect created the file (no prior file existed), so undo removes it.","type":"string"},"server_name":{"description":"Defaults to \"mcpproxy\"","type":"string"}},"type":"object"},"httpapi.UpdateFailureRequest":{"properties":{"stage":{"description":"Stage is the failure stage of the update session.","enum":["appcast","download","install","other"],"type":"string"}},"type":"object"},"httpapi.UpgradeAdminKeyHoldersRequest":{"properties":{"apply":{"type":"boolean"},"mode":{"type":"string"},"precondition_token":{"type":"string"},"profile":{"type":"string"}},"type":"object"},"httpapi.approveToolsRequest":{"properties":{"approve_all":{"type":"boolean"},"expected_hashes":{"additionalProperties":{"type":"string"},"description":"ExpectedHashes binds the approval to the reviewed definitions (Spec\n117 PR-2, #1566): tool name -\u003e the review's current_hash. Absent or\nnull is an unbound (legacy) request; {} is bound.","type":["object","null"]},"expected_incarnation":{"description":"ExpectedIncarnation binds the approval to the server the client\nreviewed (Spec 116 FR-017); 409 server_replaced on a mismatch. It is\nindependent of ExpectedHashes and the two may be sent together.","type":"integer"},"tools":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.attentionResponse":{"properties":{"count":{"type":"integer"},"generated_at":{"type":"string"},"items":{"items":{"$ref":"#/components/schemas/contracts.AttentionItem"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.clientPresence":{"properties":{"active_sessions":{"type":"integer"},"blocked_24h":{"type":"integer"},"calls_24h":{"type":"integer"},"config_path":{"type":"string"},"connected":{"type":"boolean"},"connection_unverified":{"type":"boolean"},"credential_checked_at":{"type":"string"},"credential_state":{"$ref":"#/components/schemas/profile.CredentialState"},"display_name":{"type":"string"},"display_path":{"type":"string"},"expires_at":{"type":"string"},"icon":{"type":"string"},"id":{"type":"string"},"installed":{"type":"boolean"},"issuer":{"$ref":"#/components/schemas/auth.CredentialIssuer"},"kind":{"type":"string"},"last_seen":{"type":"string"},"lease":{"type":"boolean"},"profile":{"type":"string"},"profile_missing":{"type":"boolean"},"profile_mode":{"type":"string"},"profile_source":{"description":"profile_source is pin (locked) or binding (switchable) and is empty when\ncredential_state is not client.","type":"string"},"profile_state":{"type":"string"},"profile_title":{"type":"string"},"purpose":{"type":"string"},"reload_hint":{"type":"string"},"revoked_at":{"description":"Spec 115 lifecycle fields of the client's credential record (additive):\nwhen it was revoked, who issued it, its stated (unenforced) purpose,\nwhether it is a task lease (lifetime at issue \u003c= 24h) and whether its\nprofile still exists (ok | dangling | none).","type":"string"},"rotation_pending":{"type":"boolean"},"sessions":{"items":{"$ref":"#/components/schemas/httpapi.clientSession"},"type":"array","uniqueItems":false},"state":{"type":"string"},"token_name":{"type":"string"}},"type":"object"},"httpapi.clientSession":{"properties":{"id":{"type":"string"},"last_activity":{"type":"string"},"profile":{"description":"Profile and ProfileSource are the session's latest effective resolution\n(Spec 108-e FR-033); empty on legacy sessions.","type":"string"},"profile_source":{"type":"string"},"started_at":{"type":"string"},"work_session_id":{"type":"string"}},"type":"object"},"httpapi.clientsResponse":{"properties":{"clients":{"items":{"$ref":"#/components/schemas/httpapi.clientPresence"},"type":"array","uniqueItems":false},"routing":{},"warnings":{"items":{"$ref":"#/components/schemas/httpapi.ClientWarning"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.expectedIncarnationBody":{"properties":{"expected_incarnation":{"type":"integer"}},"type":"object"},"httpapi.securityApproveRequest":{"properties":{"block":{"items":{"type":"string"},"type":"array","uniqueItems":false},"expected_hashes":{"additionalProperties":{"type":"string"},"description":"ExpectedHashes binds the approval to the reviewed definitions (Spec\n117 PR-2, #1566): tool name -\u003e the review's current_hash. Absent or\nnull is an unbound (legacy) request; {} binds to the empty snapshot.\nIndependent of ExpectedIncarnation: a request may carry both, and the\ntwo refusals stay distinct (review_out_of_date vs server_replaced).","type":["object","null"]},"expected_incarnation":{"description":"ExpectedIncarnation binds the approval to the server the client\nreviewed (Spec 116 FR-017): a remove and re-add since then is refused\nwith 409 server_replaced and the replacement stays quarantined.","type":"integer"},"force":{"type":"boolean"}},"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"},"profile.AccessStepStatus":{"type":"string","x-enum-varnames":["AccessStepPass","AccessStepFail","AccessStepSkip"]},"profile.AccessSubjectKind":{"type":"string","x-enum-varnames":["AccessSubjectClient","AccessSubjectProfile","AccessSubjectToken","AccessSubjectAnonymous"]},"profile.CredentialState":{"description":"Spec 108-f ClientView additions (data-model §7). The Spec 109 fields above\nare unchanged; everything below is additive.\n\ncredential_state is what the client's connection carries (client |\nadmin_key | none | revoked | expired | unknown). The stat-only list never\nreads a config: it reports the token store, else the last on-demand\nobservation (credential_checked_at says when), else unknown.","type":"string","x-enum-varnames":["CredentialStateClient","CredentialStateAdminKey","CredentialStateNone","CredentialStateRevoked","CredentialStateExpired","CredentialStateUnknown"]},"profile.ExplainStep":{"type":"string","x-enum-varnames":["StepCredential","StepProfile","StepServerInScope","StepToolRule","StepTierCap","StepTokenPermission","StepGlobalGate","StepServerState","StepToolApproval"]},"profile.ExplainVerdict":{"type":"string","x-enum-varnames":["ExplainVerdictAllowed","ExplainVerdictBlocked","ExplainVerdictHidden"]},"profile.FixAction":{"type":"string","x-enum-varnames":["FixAllowInProfile","FixClassifyInProfile","FixAddServerToProfile","FixMoveClient","FixEditToken","FixEnableServer","FixApproveTool","FixChangeSetting","FixReconnectClient"]},"profile.WarningCode":{"type":"string","x-enum-varnames":["WarningAnonymousDeniedByBindingGuard","WarningClientHoldsAdminKey","WarningClientCredentialExpiring","WarningClientRotationPending","WarningProfileMissing","WarningClientTokenNameConflict"]},"profile.WarningSeverity":{"type":"string","x-enum-varnames":["WarningSeverityWarn","WarningSeverityInfo"]},"runtime.BindingRef":{"properties":{"client_id":{"type":"string"},"mode":{"type":"string"},"profile":{"type":"string"},"token_name":{"type":"string"}},"type":"object"},"runtime.EffectiveCounts":{"properties":{"by_reason":{"additionalProperties":{"type":"integer"},"type":"object"},"callable":{"type":"integer"},"hidden":{"type":"integer"},"visible":{"type":"integer"}},"type":"object"},"runtime.EffectiveTool":{"properties":{"access":{"$ref":"#/components/schemas/runtime.ToolAccessView"},"classification_stale":{"type":"boolean"},"intrinsic_tier":{"type":"string"},"profile_tier":{"type":"string"},"server":{"type":"string"},"tool":{"type":"string"}},"type":"object"},"runtime.ExplainProfileView":{"properties":{"name":{"type":"string"},"source":{"type":"string"}},"type":"object"},"runtime.ExplainStepView":{"properties":{"detail":{"type":"string"},"status":{"$ref":"#/components/schemas/profile.AccessStepStatus"},"step":{"$ref":"#/components/schemas/profile.ExplainStep"}},"type":"object"},"runtime.ExplainSubjectView":{"properties":{"kind":{"$ref":"#/components/schemas/profile.AccessSubjectKind"},"name":{"type":"string"}},"type":"object"},"runtime.Fix":{"properties":{"action":{"$ref":"#/components/schemas/profile.FixAction"},"label":{"type":"string"},"profile":{"description":"Profile is set for move_client only: the destination profile slug the fix\nmoves the client to. Target stays the client id (UIs navigate by it) and\nLabel carries the title, so neither can name the slug.","type":"string"},"step":{"type":"string","x-enum-varnames":["StepCredential","StepProfile","StepServerInScope","StepToolRule","StepTierCap","StepTokenPermission","StepGlobalGate","StepServerState","StepToolApproval"]},"target":{"type":"string"}},"type":"object"},"runtime.GuardFix":{"properties":{"kind":{"type":"string"},"target":{"type":"string"}},"type":"object"},"runtime.MovedRefs":{"properties":{"clients":{"items":{"type":"string"},"type":"array","uniqueItems":false},"tokens":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"runtime.ProfileView":{"properties":{"blocked_24h":{"type":"integer"},"calls_24h":{"type":"integer"},"code_execution":{"type":"boolean"},"description":{"type":"string"},"effective_code_execution":{"type":"boolean"},"effective_servers":{"items":{"type":"string"},"type":"array","uniqueItems":false},"effective_unannotated":{"type":"string"},"is_legacy":{"type":"boolean"},"management_tools":{"type":"boolean"},"max_tier":{"type":"string"},"name":{"type":"string"},"servers":{"items":{"type":"string"},"type":"array","uniqueItems":false},"switchable_to":{"items":{"type":"string"},"type":"array","uniqueItems":false},"title":{"type":"string"},"tool_count":{"description":"ToolCount is the v2 field: indexed tools on the effective servers.\nDeprecated: use tool_counts.","type":"integer"},"tool_counts":{"$ref":"#/components/schemas/runtime.ToolCounts"},"tools":{"$ref":"#/components/schemas/config.ProfileToolRules"},"unannotated":{"type":"string"},"used_by":{"$ref":"#/components/schemas/runtime.UsedBy"}},"type":"object"},"runtime.ToolAccessView":{"properties":{"callable":{"type":"boolean"},"reason":{"type":"string"},"visible":{"type":"boolean"}},"type":"object"},"runtime.ToolCounts":{"properties":{"destructive":{"type":"integer"},"read":{"type":"integer"},"unannotated_hidden":{"type":"integer"},"write":{"type":"integer"}},"type":"object"},"runtime.TryHidden":{"properties":{"reason":{"type":"string"},"server":{"type":"string"},"tool":{"type":"string"}},"type":"object"},"runtime.UsedBy":{"properties":{"anonymous_profile":{"type":"boolean"},"clients":{"items":{"$ref":"#/components/schemas/runtime.UsedByClient"},"type":"array","uniqueItems":false},"tokens":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"runtime.UsedByClient":{"properties":{"id":{"type":"string"},"mode":{"type":"string"}},"type":"object"},"runtime.WarningAction":{"properties":{"kind":{"type":"string"},"target":{"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"},"storage.ClientCredentialObservation":{"properties":{"at":{"type":"string"},"state":{"type":"string"}},"type":"object"},"storage.OnboardingState":{"description":"State is the persisted wizard engagement record. Engaged is true once\nthe wizard was shown and the user completed or skipped it.","properties":{"client_connected_at":{"additionalProperties":{"type":"string"},"description":"ClientConnectedAt records, per client id from the fixed connect client\nregistry (internal/connect.GetAllClients — never user input), the last\ntime a connect write succeeded for that client (Spec 109-b FR-042).\nWritten by the connect success path through UpdateOnboardingState so a\nconcurrent onboarding/mark write can never drop it. Consumed by the\nVerify step / presence layer to tell \"connected, never seen\" apart from\n\"connected seconds ago, hasn't reconnected yet\".","type":"object"},"client_credential_observed":{"additionalProperties":{"$ref":"#/components/schemas/storage.ClientCredentialObservation"},"description":"ClientCredentialObserved records, per client id, the LAST credential\nclassification an on-demand read produced (Spec 108-f FR-025, F11): what\nthe client's config held (client | admin_key | none | revoked | expired)\nand when it was read. The stat-only GET /clients listing never reads a\nconfig (Spec 075: no macOS App-Data prompt from a list), so this is how\nit still reports a client that holds the admin key across restarts. It is\nwritten only when the classification CHANGES, by every on-demand read\n(GET /clients/{id}, GET /connect/{client}, the admin-key upgrade preview,\na connect write) and deleted on disconnect. Additive: an older binary\nignores it, and a record without it reads as \"unknown\".","type":"object"},"client_disconnected_at":{"additionalProperties":{"type":"string"},"type":"object"},"client_last_seen":{"additionalProperties":{"type":"string"},"description":"ClientLastSeen records the latest MCP initialize for a recognised client\nalias. It is intentionally kept with onboarding state: presence is a\nlocal UI concern and must still work when telemetry is disabled.","type":"object"},"connect_step_status":{"description":"ConnectStepStatus is one of: \"\", \"completed\", \"completed_external\",\n\"skipped\" (Spec 080 FR-001). \"completed_external\" records a dismissal\nwhere the connect step was untouched but the install was already\nconnected outside the wizard (CLI, ConnectModal, manual config).","type":"string"},"engaged":{"description":"Engaged is true once the wizard was shown and the user completed or\nskipped it. Once true, the wizard does not auto-show again, even if\nstate regresses (e.g. user disconnects all clients).","type":"boolean"},"engaged_at":{"description":"EngagedAt is the timestamp of completion or explicit skip.","type":"string"},"first_shown_at":{"description":"FirstShownAt is the timestamp of first wizard render.","type":"string"},"server_step_status":{"description":"ServerStepStatus is one of: \"\", \"completed\", \"skipped\".","type":"string"}},"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/access/explain":{"get":{"description":"For one subject (exactly one of client=\u003cid\u003e, token=\u003cname\u003e, profile=\u003cname\u003e or anonymous=true) and one upstream tool (server:tool), returns the ordered chain of gates a real call would meet - credential, profile, server_in_scope, tool_rule, tier_cap, token_permission, global_gate, server_state, tool_approval - the overall verdict (allowed, blocked, hidden), the first failing step and the fixes for it in preference order. The chain is the one every discovery and dispatch path walks, so ` + "`" + `allowed` + "`" + ` equals what a real call does. Built-in management tools are not explained (400). A client credential is explained with client=, never token=. Administrators only.","parameters":[{"description":"Upstream tool as server:tool","in":"query","name":"tool","required":true,"schema":{"type":"string"}},{"description":"Client id","in":"query","name":"client","schema":{"type":"string"}},{"description":"Regular agent token name","in":"query","name":"token","schema":{"type":"string"}},{"description":"Profile name","in":"query","name":"profile","schema":{"type":"string"}},{"description":"Explain an anonymous (credential-less) caller","in":"query","name":"anonymous","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":"The explanation"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Not exactly one subject, a built-in tool, or token=client-\u003cid\u003e"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Administrator credentials required"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"client / token / profile not found"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Explain why a tool is or is not reachable","tags":["access"]}},"/api/v1/activity":{"get":{"description":"Returns paginated list of activity records with optional filtering","parameters":[{"description":"Filter by activity type(s), comma-separated for multiple (Spec 024)","in":"query","name":"type","schema":{"enum":["tool_call","policy_decision","quarantine_change","server_change","system_start","system_stop","internal_tool_call","config_change","preflight","prompt_get","profile_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","rejected"],"type":"string"}},{"description":"Filter by intent operation type (Spec 018)","in":"query","name":"intent_type","schema":{"enum":["read","write","destructive"],"type":"string"}},{"description":"Filter by HTTP request ID for log correlation (Spec 021)","in":"query","name":"request_id","schema":{"type":"string"}},{"description":"Filter by parent call id — returns the sub-calls one code_execution issued","in":"query","name":"parent_id","schema":{"type":"string"}},{"description":"Filter by call-error class (Spec 113-c)","in":"query","name":"error_class","schema":{"enum":["network","timeout","http","jsonrpc","tool_error","session_terminated","auth","proxy_policy","proxy_internal","cancelled"],"type":"string"}},{"description":"Filter by fault domain (Spec 113-c)","in":"query","name":"fault_domain","schema":{"enum":["upstream","proxy","client"],"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":"Alias of token (Spec 028)","in":"query","name":"agent","schema":{"type":"string"}},{"description":"Filter by the token in effect for the call; - selects records with no token (Spec 108)","in":"query","name":"token","schema":{"type":"string"}},{"description":"Filter by the profile in effect for the call; - selects records with no profile (Spec 108)","in":"query","name":"profile","schema":{"type":"string"}},{"description":"Filter by client id; - selects records with no client (Spec 108)","in":"query","name":"client","schema":{"type":"string"}},{"description":"Filter by the client's self-reported name (advisory; Spec 108)","in":"query","name":"client_name","schema":{"type":"string"}},{"description":"Filter by auth type (Spec 028)","in":"query","name":"auth_type","schema":{"enum":["admin","agent"],"type":"string"}},{"description":"Filter activities after this time (RFC3339)","in":"query","name":"start_time","schema":{"type":"string"}},{"description":"Filter activities before this time (RFC3339)","in":"query","name":"end_time","schema":{"type":"string"}},{"description":"Maximum records to return (1-100, default 50)","in":"query","name":"limit","schema":{"type":"integer"}},{"description":"Pagination offset (default 0)","in":"query","name":"offset","schema":{"type":"integer"}},{"description":"Omit arguments, response and metadata except a contextual whitelist (intent.reason, intent.operation_type, decision, reason, client_name, client_version) (default: false). For clients that render summary fields only; has_sensitive_data is still derived before metadata is dropped.","in":"query","name":"exclude_payloads","schema":{"type":"boolean"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Unauthorized"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Internal Server Error"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"List activity records","tags":["Activity"]}},"/api/v1/activity/export":{"get":{"description":"Exports activity records in JSON Lines or CSV format for compliance","parameters":[{"description":"Export format: json (default) or csv","in":"query","name":"format","schema":{"type":"string"}},{"description":"Filter by activity type","in":"query","name":"type","schema":{"type":"string"}},{"description":"Filter by server name","in":"query","name":"server","schema":{"type":"string"}},{"description":"Filter by tool name","in":"query","name":"tool","schema":{"type":"string"}},{"description":"Filter by MCP transport session ID","in":"query","name":"session_id","schema":{"type":"string"}},{"description":"Filter by work session (one client, one project, across reconnects)","in":"query","name":"work_session_id","schema":{"type":"string"}},{"description":"Filter by status","in":"query","name":"status","schema":{"type":"string"}},{"description":"Filter by HTTP request ID for log correlation (Spec 021)","in":"query","name":"request_id","schema":{"type":"string"}},{"description":"Filter by parent call id — exports the sub-calls one code_execution issued","in":"query","name":"parent_id","schema":{"type":"string"}},{"description":"Filter by call-error class (Spec 113-c)","in":"query","name":"error_class","schema":{"enum":["network","timeout","http","jsonrpc","tool_error","session_terminated","auth","proxy_policy","proxy_internal","cancelled"],"type":"string"}},{"description":"Filter by fault domain (Spec 113-c)","in":"query","name":"fault_domain","schema":{"enum":["upstream","proxy","client"],"type":"string"}},{"description":"Alias of token (Spec 028)","in":"query","name":"agent","schema":{"type":"string"}},{"description":"Filter by the token in effect for the call; - selects records with no token (Spec 108)","in":"query","name":"token","schema":{"type":"string"}},{"description":"Filter by the profile in effect for the call; - selects records with no profile (Spec 108)","in":"query","name":"profile","schema":{"type":"string"}},{"description":"Filter by client id; - selects records with no client (Spec 108)","in":"query","name":"client","schema":{"type":"string"}},{"description":"Filter by the client's self-reported name (advisory; Spec 108)","in":"query","name":"client_name","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"},"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":"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"}},{"description":"Alias of token (Spec 028)","in":"query","name":"agent","schema":{"type":"string"}},{"description":"Count only records made under this token; - selects records with no token (Spec 108)","in":"query","name":"token","schema":{"type":"string"}},{"description":"Count only records made under this profile; - selects records with no profile (Spec 108)","in":"query","name":"profile","schema":{"type":"string"}},{"description":"Count only records made by this client id; - selects records with no client (Spec 108)","in":"query","name":"client","schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Unauthorized"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Internal Server Error"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"Get activity summary statistics","tags":["Activity"]}},"/api/v1/activity/usage":{"get":{"description":"Returns the actor-owned usage aggregate (per-tool rollup + timeline + tokens-saved headline) for the Web UI usage graphs (Spec 069). Served from an in-memory snapshot — never a per-request full-log scan. Per-tool metrics are lifetime-cumulative; ` + "`" + `window` + "`" + ` scopes the timeline and filters the tool list to tools active within the span.","parameters":[{"description":"Time window for timeline + tool-list membership","in":"query","name":"window","schema":{"enum":["24h","7d","all"],"type":"string"}},{"description":"Filter to one server","in":"query","name":"server","schema":{"type":"string"}},{"description":"Filter to one tool","in":"query","name":"tool","schema":{"type":"string"}},{"description":"Filter to tools with activity of this status","in":"query","name":"status","schema":{"enum":["success","error","blocked","rejected"],"type":"string"}},{"description":"Top-N tools by sort key; remainder folded into 'other' (default 20)","in":"query","name":"top","schema":{"type":"integer"}},{"description":"Ranking key for the per-tool list","in":"query","name":"sort","schema":{"enum":["calls","resp_bytes","error_rate","p95"],"type":"string"}},{"description":"Alias of token (Spec 028)","in":"query","name":"agent","schema":{"type":"string"}},{"description":"Restrict to calls made under this token; - selects records with no token. Computed from a window-bounded scan (Spec 108)","in":"query","name":"token","schema":{"type":"string"}},{"description":"Restrict to calls made under this profile; - selects records with no profile. Computed from a window-bounded scan (Spec 108)","in":"query","name":"profile","schema":{"type":"string"}},{"description":"Restrict to calls made by this client id; - selects records with no client. Computed from a window-bounded scan (Spec 108)","in":"query","name":"client","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"}},"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/attention":{"get":{"description":"One list, one count, every surface (Web UI, macOS tray/Home, CLI) reads from it. Filtered per caller: an administrator sees every item; a scoped caller (agent token, or a non-admin server-edition OAuth user session) sees only server items whose server it may enumerate; every other subject type (client, setting) is administrator-only.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.attentionResponse"}}},"description":"The needs-attention list"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get the needs-attention list","tags":["attention"]}},"/api/v1/catalog/search":{"get":{"description":"Fans out to every enabled catalog source (registry) in parallel, merges, de-duplicates and ranks the results (FR-060/061). An empty q returns official + popular sections instead of a flat list. Open to any authenticated caller — added is the only field filtered per caller scope (FR-007).","parameters":[{"description":"Free-text search","in":"query","name":"q","schema":{"type":"string"}},{"description":"Narrow to one catalog source id","in":"query","name":"source","schema":{"type":"string"}},{"description":"Not supported: catalog entries carry no tags; a non-empty value returns 400","in":"query","name":"tag","schema":{"type":"string"}},{"description":"Max results (default 20, max 50)","in":"query","name":"limit","schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"tag filtering is not supported"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"configured servers could not be listed"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Search the server catalog across every enabled source","tags":["catalog"]}},"/api/v1/clients":{"get":{"description":"Lists every client row (supported, other and custom) with its presence, its credential and its profile binding, plus response-level warnings. The list is content-read-free (Spec 075): credential_state comes from the token store, else the last on-demand observation (credential_checked_at), else unknown. It runs only the time-based half of the rotation reconciler. The profile and client filters match the CURRENT binding and are applied AFTER warnings are computed over the full set.","parameters":[{"description":"Rows whose active credential is bound to this profile; - = bound to All servers","in":"query","name":"profile","schema":{"type":"string"}},{"description":"Exact client id","in":"query","name":"client","schema":{"type":"string"}}],"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":"Client rows, routing and warnings"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Administrator credentials required"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"List clients","tags":["clients"]},"post":{"description":"Creates a per-client credential for a client that is NOT in the connect registry (a script, a CI job, an editor without a connect adapter). The secret (mcp_cli_...) is returned ONCE, with a paste-ready snippet that carries it in the X-API-Key header. The id follows the client-id rule (lower-case letters, digits, '-' or '_', at most 56 characters) and must not be a supported client's id. A named profile binds the credential locked unless mode says otherwise. Refused with 409 binding_bypassable_without_auth when the binding would be bypassable while require_mcp_auth is off (FR-008a), and 409 conflicting_token when a regular token holds client-\u003cid\u003e. Every value (id, display_name, profile, mode, expires_in, purpose) passes the shared secret-shaped input screen first: a credential, the API key or a detector-flagged secret answers 400 secret_in_argument naming the field, and nothing is minted. A malformed body answers a sanitized 400 that never echoes a caller key or value. Writes one profile_change record with change issue (Spec 115) and publishes credentials.changed. Personal edition only.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.CreateClientRequest"}}},"description":"id (required), optional display_name, profile, mode, expires_in and purpose","required":true},"responses":{"201":{"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":"The new client row, its credential (once) and a snippet"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ClientBindingErrorResponse"}}},"description":"Invalid input; field names the offending input"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Administrator credentials required"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.BindingGuardResponse"}}},"description":"binding_bypassable_without_auth, or conflicting_token (ClientCredentialConflictResponse)"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Add a custom client","tags":["clients"]}},"/api/v1/clients/bulk-assign":{"post":{"description":"Moves every client credential bound to from_profile (\"\" = All servers) onto to_profile (\"\" = All servers). The guard and the credential precondition apply PER CLIENT: a refused client is reported in skipped[] with the code the single operation returns, the others move and each writes its own assign record and emits client.binding_changed. Personal edition only.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.BulkAssignRequest"}}},"description":"from_profile and to_profile (both required; empty = All servers) and optional mode","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"Moved client ids and the clients that were skipped"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ClientBindingErrorResponse"}}},"description":"Invalid input; field names the offending input"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Administrator credentials required"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Move every client of one profile to another","tags":["clients"]}},"/api/v1/clients/upgrade-admin-key-holders":{"post":{"description":"Without apply: classifies every supported client whose config still holds the instance admin API key (an on-demand read per client) and returns preview[] (display path, what would change, the masked credential mcp_cli_••••, the binding and a precondition_token per client), a combined precondition_token, and guard when the whole request would be refused by FR-008a; next_step is rotate_admin_api_key when nothing holds the key. Nothing is written or minted. With apply=true: recomputes the preview, refuses with 409 precondition_failed when a sent precondition_token no longer matches, runs the FR-008a guard over the WHOLE request (409 binding_bypassable_without_auth, nothing minted, no file written), then connects each client with force bound to its own token; one client's failure does not stop the others. Each mint writes its own assign record. The guard only applies when a named profile is given. Personal edition only.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.UpgradeAdminKeyHoldersRequest"}}},"description":"Optional profile and mode for every upgraded client; apply and precondition_token"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"UpgradePreview (no apply) or UpgradeResult (apply)"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ClientBindingErrorResponse"}}},"description":"Invalid input; field names the offending input"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Administrator credentials required"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.BindingGuardResponse"}}},"description":"precondition_failed, or binding_bypassable_without_auth"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Upgrade every client that holds the admin API key","tags":["clients"]}},"/api/v1/clients/{client}":{"delete":{"description":"Revokes the client's credential; it stops authenticating at once. With disconnect=true a SUPPORTED client's mcpproxy entry is removed from its config FIRST, but the credential is revoked either way: revocation never waits for a config write, so a failed write (for example a macOS App-Data denial) leaves the client cut off and is reported as disconnected:false with disconnect_error. A client with no credential record is 409 no_client_credential. disconnect=true on a custom client is ignored. Writes one forget record naming the revoked token. The FR-008a guard is not involved: revoking only removes a binding.","parameters":[{"description":"Client id","in":"path","name":"client","required":true,"schema":{"type":"string"}},{"description":"Also remove the entry from the client's config (supported clients)","in":"query","name":"disconnect","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":"Revoked token name and what happened to the config entry"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Administrator credentials required"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ClientBindingErrorResponse"}}},"description":"no_client_credential"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Forget a client (revoke its credential)","tags":["clients"]},"get":{"description":"Returns one client row with its sessions (newest 20, each with its latest effective profile). This is the only read that may open the client's config file: it runs the full rotation reconciler first, classifies the credential the config holds and records the observation. No scope filter is honoured on this route.","parameters":[{"description":"Client id","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"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":"Client row"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Administrator credentials required"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"client not found"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get one client","tags":["clients"]}},"/api/v1/clients/{client}/binding":{"put":{"description":"Changes the profile binding (and optionally the locked/switchable mode) of a\nclient that holds an active client credential. One service operation shared by\nevery surface: it updates the credential in the token store, clears the stored\nset_profile selection of every live session of that credential, sends\nnotifications/tools/list_changed to each, emits client.binding_changed and writes\none profile_change activity record. It never touches the client's config file.\nA client without an active client credential (none, admin key, revoked or expired)\nis refused with 409 no_client_credential and nothing is minted or written. A\nreassignment that would leave the binding bypassable while require_mcp_auth is off\nis refused with 409 binding_bypassable_without_auth (FR-008a). Personal edition only.","parameters":[{"description":"Client id (lower-case letters, digits, '-' or '_', at most 56 characters)","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.PutClientBindingRequest"}}},"description":"profile (required; empty = All servers) and optional mode (locked|switchable)","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"The client row after the change, with its warnings"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ClientBindingErrorResponse"}}},"description":"Invalid input; field names the offending input"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Administrator credentials required"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.BindingGuardResponse"}}},"description":"no_client_credential, connect_in_progress (a connect of this client is mid-write), or binding_bypassable_without_auth with bindings and fixes"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Reassign a client's profile and/or mode","tags":["clients"]}},"/api/v1/clients/{client}/rotate":{"post":{"description":"Replaces a client's secret without ever invalidating the old one mid-flight (FR-021a). A SUPPORTED client is rewritten through connect: staged rotation, binding kept, finalized when the config write succeeds, rolled back (the old secret keeps working) when it fails; a preview's precondition_token binds the call, a mismatch is the connect 409 precondition_failed. A CUSTOM client gets a new secret returned ONCE with a snippet; both secrets authenticate until POST /clients/{client}/rotate/finalize or 24 hours. A client with no active credential is 409 no_client_credential. The FR-008a guard is not involved: a rotation never changes a binding's reach. Writes one rotate record when it finalizes.","parameters":[{"description":"Client id","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.RotateClientRequest"}}},"description":"Optional precondition_token of a connect preview"},"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":"The client row and the rotation state"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ClientBindingErrorResponse"}}},"description":"Invalid input"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Administrator credentials required (or macOS App-Data block)"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ConnectConflictResponse"}}},"description":"no_client_credential, precondition_failed, connect_in_progress (another connect of this client is mid-write), or credential_superseded (the written credential was replaced or revoked before it could be finalized; reconnect)"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Rotate a client's credential","tags":["clients"]}},"/api/v1/clients/{client}/rotate/finalize":{"post":{"description":"Promotes a client's pending secret: the old secret stops authenticating. Idempotent: with no rotation in progress it is a no-op success. A client with no client credential is 409 no_client_credential. Writes one rotate record when it promotes.","parameters":[{"description":"Client id","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"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":"The client row; rotation.state is finalized"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Administrator credentials required"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ClientBindingErrorResponse"}}},"description":"no_client_credential, or connect_in_progress (a connect of this client is mid-write)"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Finalize a staged rotation","tags":["clients"]}},"/api/v1/code/scripts":{"get":{"description":"List the stored scripts available to the code_execution tool. Scripts are ` + "`" + `\u003cname\u003e.js` + "`" + ` / ` + "`" + `\u003cname\u003e.ts` + "`" + ` files in the ` + "`" + `scripts/` + "`" + ` directory next to the active configuration file. Entries are advisory: ` + "`" + `ok` + "`" + ` scripts are invocable, ` + "`" + `ambiguous` + "`" + ` names have both extensions, and ` + "`" + `invalid` + "`" + ` ones report why (empty, oversized, unreadable, non-regular). Read-only — there is no write surface for stored scripts. Administrator-only (Spec 105 FR-012): an agent token, whatever its server scope, is refused with 403 — the listing is the enumeration the missing-script error withholds from a scoped caller.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Stored scripts and the directory they were read from"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Agent tokens cannot list stored scripts"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"List stored code-execution scripts","tags":["code"]}},"/api/v1/config":{"get":{"description":"Retrieves the current MCPProxy configuration including all server definitions, global settings, and runtime parameters","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetConfigResponse"}}},"description":"Configuration retrieved successfully"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Agent tokens cannot read the configuration document"},"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)"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.BindingGuardResponse"}}},"description":"binding_bypassable_without_auth: the patch would let a client bound to a named profile escape it while require_mcp_auth is off (FR-008a); nothing was written"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"telemetry.enabled is locked while an environment variable (DO_NOT_TRACK, CI, MCPPROXY_TELEMETRY=false) disables telemetry; nothing was written (FR-044a)"},"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)"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.BindingGuardResponse"}}},"description":"binding_bypassable_without_auth: the write would let a client bound to a named profile escape it while require_mcp_auth is off (FR-008a); nothing was written"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"telemetry.enabled is locked while an environment variable (DO_NOT_TRACK, CI, MCPPROXY_TELEMETRY=false) disables telemetry; nothing was written (FR-044a)"},"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)"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.BindingGuardResponse"}}},"description":"binding_bypassable_without_auth (FR-008a); nothing was written"},"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. This stat-only listing never reads\na client config (Spec 075), so a client whose config exists reports\ncredential_state \"unknown\"; GET /connect/{client} resolves it.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"List of ClientStatus objects"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Administrator credentials required"}},"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.\nAlso revokes the client's credential (it stops authenticating at once); the result names it in credential_revoked, or reports credential_revoke_error when the revoke failed after the entry was removed. Undoing a disconnect does not restore the credential: a later connect mints a new one.","parameters":[{"description":"Client ID (claude-code, claude-desktop, cursor, windsurf, vscode, codex, gemini, opencode, zcode)","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.\nFor a connected client it also resolves credential_state (Spec 108 FR-025):\nclient (an active per-client credential), admin_key (the instance admin API\nkey), none, revoked (a rotated-away or revoked credential) or expired. The\ncredential value is classified and never echoed.","parameters":[{"description":"Client ID (claude-code, claude-desktop, cursor, windsurf, vscode, codex, gemini, opencode, zcode)","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"ClientStatus"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Administrator credentials required"},"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.\n\nSpec 108 FR-024: the entry carries a per-client mcp_cli_ credential bound to the\nrequested profile (default: All servers, switchable; a reconnect keeps the client's\nexisting binding unless a profile is given), never the instance admin API key.\nReconnecting over an active credential is a staged rotation: the old secret keeps\nworking until the config write succeeds. keyless=true writes no credential and is\nonly possible while require_mcp_auth is off (400 otherwise, and with a profile).\nA write that would let the client escape its profile by omitting its credential\nwhile require_mcp_auth is off is refused with 409 binding_bypassable_without_auth\n(nothing minted or written); a token named client-\u003cid\u003e held by a regular agent\ntoken is refused with 409 conflicting_token.","parameters":[{"description":"Client ID (claude-code, claude-desktop, cursor, windsurf, vscode, codex, gemini, opencode, zcode)","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, profile, mode, keyless)"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"ConnectResult (credential is the masked mcp_cli_ value, with token_name, profile, mode, keyless and rotation)"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ClientBindingErrorResponse"}}},"description":"Bad request; field names the offending input (profile, mode, keyless)"},"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); or binding_bypassable_without_auth (BindingGuardResponse); or conflicting_token (ClientCredentialConflictResponse); or connect_in_progress (another connect of this client is mid-write) or credential_superseded (the written credential was replaced or revoked before it could be finalized; reconnect)"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable (or no credential store wired while require_mcp_auth is on)"}},"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 per-client credential\nis masked in the payload (credential, always starting mcp_cli_; contains_api_key is\nalways false since connect never writes the admin API key); profile, mode and\nkeyless echo the requested intent.\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, zcode)","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"}},{"description":"Profile the client credential would bind to (empty = All servers); mirror POST connect","in":"query","name":"profile","schema":{"type":"string"}},{"description":"locked|switchable; mirror POST connect","in":"query","name":"mode","schema":{"enum":["locked","switchable"],"type":"string"}},{"description":"Preview a credential-less entry (only while require_mcp_auth is off)","in":"query","name":"keyless","schema":{"type":"boolean"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"ConnectPreview"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ClientBindingErrorResponse"}}},"description":"Invalid intent (field names the input; keyless with auth on or with a profile)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Administrator credentials required or access denied by macOS App Data"},"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. Undo is \"as if the connect never happened\": a\nclient credential the connect minted or rotated is revoked unless the restored\nconfig still holds it, and the result names it in credential_revoked.","parameters":[{"description":"Client ID (claude-code, claude-desktop, cursor, windsurf, vscode, codex, gemini, opencode, zcode)","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\nweb_ui_url carries the ?apikey= credential ONLY for an authenticated admin; a scoped agent token receives the bare URL\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":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"Updated OnboardingStateResponse"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Agent tokens cannot read onboarding state"},"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":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"OnboardingStateResponse"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Agent tokens cannot read onboarding state"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get onboarding wizard state and predicates (Spec 046)","tags":["onboarding"]}},"/api/v1/preflight":{"post":{"description":"Deterministic, side-effect-free availability check for a caller-supplied list of tool IDs (Spec 098). Performs zero upstream calls and mutates no runtime state. HTTP status reports whether the CHECK executed: a fully blocked set is still 200, with the availability verdict in the body. Every executed preflight writes an activity record before the response is returned.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.PreflightRequest"}}},"description":"Tool IDs (1-100 before dedup), optional profile, annotation policy filters and wait budget","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"Preflight verdict and per-tool results"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Validation error (malformed, oversized, doubled or unknown-field body; empty or oversized tool list; conflicting duplicate pins; unknown profile; wait_ms out of range)"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Missing or invalid credentials"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Runtime unavailable, evaluator infrastructure read failure, or the activity record could not be persisted"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"Preflight required tools","tags":["tools"]}},"/api/v1/profiles":{"get":{"description":"Lists every profile as a ProfileView: the config fields as stored, the derived effective values, visible tool counts by tier, 24 h calls and blocked calls, and (administrators only) used_by and the anonymous_profile. A caller that is not an administrator sees only the profiles its entitlement reaches, with servers, rules and switchable_to narrowed to what it may see; an unreachable profile is omitted, never shown empty.","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":"Profile list"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Configuration unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"List configured profiles","tags":["profiles"]},"post":{"description":"Adds a profile. The body is a ProfileConfig. A name that already exists is 409 profile_exists; ` + "`" + `active` + "`" + ` and ` + "`" + `try` + "`" + ` are reserved by the REST API; a fatal validation rule is 400 with the offending field and the unchanged FR-007 text. A write that would let a client bound to a named profile escape it while require_mcp_auth is off is refused 409 binding_bypassable_without_auth (FR-008a) and nothing is written. Writes one profile_change record.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/config.ProfileConfig"}}},"description":"The profile","required":true},"responses":{"201":{"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":"Created, with the validator's warnings"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ClientBindingErrorResponse"}}},"description":"Invalid input; field names the offending input"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Administrator credentials required"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ProfileConflictResponse"}}},"description":"profile_exists, or binding_bypassable_without_auth (BindingGuardResponse)"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Create a profile","tags":["profiles"]}},"/api/v1/profiles/active":{"get":{"description":"Deprecated (Spec 108 FR-039; sends a Deprecation header, successor GET /api/v1/profiles). 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":"Deprecated (Spec 108 FR-039; sends a Deprecation header, successor GET /api/v1/profiles). 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"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot change the active profile)"},"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/profiles/try":{"post":{"description":"Evaluates a DRAFT profile against a query exactly as retrieve_tools would (policy is applied before the limit) and returns the hits plus what the draft hides (hidden_by_profile and up to 100 {server, tool, reason}). The draft is validated as if it replaced the same-named profile, or was appended under a placeholder name when it has none. Nothing is persisted, no record is written, no event is published and the FR-008a guard does not run. Administrators only.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.TryProfileRequest"}}},"description":"draft profile, query and optional limit (default 10, max 50)","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"Search result under the draft"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ClientBindingErrorResponse"}}},"description":"Invalid draft; field names the offending input"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Administrator credentials required"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Try a draft profile","tags":["profiles"]}},"/api/v1/profiles/{name}":{"delete":{"description":"Deletes a profile. 409 profile_in_use (with used_by) while clients or tokens point at it, unless reassign_to names another existing profile (every pin moves there) or force is set (pins are left dangling, which the resolver treats as deny-all). 409 profile_is_anonymous_profile whenever it is the anonymous_profile and reassign_to is absent, even with force. Both paths remove the name from every switchable_to. Guarded by FR-008a. Writes one ` + "`" + `delete` + "`" + ` record.","parameters":[{"description":"Profile name","in":"path","name":"name","required":true,"schema":{"type":"string"}},{"description":"Another existing profile that takes over the pins","in":"query","name":"reassign_to","schema":{"type":"string"}},{"description":"Delete although in use, leaving pins dangling","in":"query","name":"force","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":"Deleted"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ClientBindingErrorResponse"}}},"description":"reassign_to does not name another existing profile"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Administrator credentials required"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"profile not found"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ProfileConflictResponse"}}},"description":"profile_in_use, profile_is_anonymous_profile, or binding_bypassable_without_auth (BindingGuardResponse)"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Delete a profile","tags":["profiles"]},"get":{"description":"Returns the ProfileView of the named profile. A caller that is not an administrator gets the same 404 for an unreachable profile as for an unknown one.","parameters":[{"description":"Profile name","in":"path","name":"name","required":true,"schema":{"type":"string"}}],"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":"The profile"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"profile not found"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get one profile","tags":["profiles"]},"put":{"description":"Replaces the named profile with the body (a ProfileConfig whose name, when sent non-empty, must equal the path; 409 name_mismatch otherwise - a rename has its own route; an omitted or empty name takes the path's). A write whose only change is tools.classify is recorded as ` + "`" + `classify` + "`" + `. Guarded by FR-008a like a create.","parameters":[{"description":"Profile name","in":"path","name":"name","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/config.ProfileConfig"}}},"description":"The profile","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"Updated, with the validator's warnings"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ClientBindingErrorResponse"}}},"description":"Invalid input; field names the offending input"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Administrator credentials required"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"profile not found"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ProfileConflictResponse"}}},"description":"name_mismatch, or binding_bypassable_without_auth (BindingGuardResponse)"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Replace a profile","tags":["profiles"]}},"/api/v1/profiles/{name}/effective-tools":{"get":{"description":"One row per catalog tool with the verdict of the access chain (the same rendering GET /tools?profile= uses): server, tool, intrinsic_tier, profile_tier, access{visible, callable, reason} and classification_stale. With client=\u003cid\u003e the client's credential is evaluated UNDER this profile (\"what would Cursor get here\"). An administrator gets every row plus counts.callable, counts.by_reason and stale_classifications; every other caller gets only visible rows and counts{visible, hidden}, and client= and reason= are refused 403. An unreachable profile answers exactly like an unknown one.","parameters":[{"description":"Profile name","in":"path","name":"name","required":true,"schema":{"type":"string"}},{"description":"Evaluate this client's credential under the profile (administrators only)","in":"query","name":"client","schema":{"type":"string"}},{"description":"Keep only this server's rows","in":"query","name":"server","schema":{"type":"string"}},{"description":"Keep only rows with this access reason (administrators only)","in":"query","name":"reason","schema":{"type":"string"}}],"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":"Effective tools"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"operation requires admin access"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"profile not found / client not found"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Effective tools of a profile","tags":["profiles"]}},"/api/v1/profiles/{name}/rename":{"post":{"description":"Renames a profile and moves every reference to it - token pins, client bindings, other profiles' switchable_to and the anonymous_profile - in one write (D19). Tokens move first, so a failure part-way never widens a scope. Guarded by FR-008a. Writes one ` + "`" + `rename` + "`" + ` record.","parameters":[{"description":"Profile name","in":"path","name":"name","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.RenameProfileRequest"}}},"description":"new_name","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"Renamed; moved lists the clients and tokens that followed"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ClientBindingErrorResponse"}}},"description":"Invalid input; field names the offending input"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Administrator credentials required"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"profile not found"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ProfileConflictResponse"}}},"description":"profile_exists, or binding_bypassable_without_auth (BindingGuardResponse)"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Rename a 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/review":{"get":{"description":"Returns one row per quarantined server or trusted server with pending or changed tools. Scoped callers see only servers they may enumerate.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Review queue"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to load review queue"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get the server review queue","tags":["review"]}},"/api/v1/routing":{"get":{"description":"Get the current routing mode and available MCP endpoints.\nrouting_mode is what /mcp is actually serving; pending_routing_mode carries a\nrestart-pending value persisted on disk (empty when there is none).\ntool_response_mode and direct_tool_response_mode report the two serialization axes, resolved.","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","parameters":[{"description":"Restrict to the profile's effective servers; tool_count becomes the number of that server's tools visible under the profile (Spec 108 FR-032)","in":"query","name":"profile","schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetServersResponse"}}},"description":"Server list with statistics"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unsupported scope filter (client/token), or '-' on a server filter"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unknown or unreachable profile"},"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. Isolation: ` + "`" + `isolation.enabled` + "`" + ` is READ-ONLY (it reports the effective state on reads) and is rejected with 400; set the per-server override via ` + "`" + `isolation.enabled_override` + "`" + ` (true | false | null to clear, omit to leave unchanged). An unrecognized ` + "`" + `isolation.mode_override` + "`" + ` is rejected with 400.","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. A preview (preview=true) of a file with no MCP servers (empty, {}, or no/empty server map) answers 200 with an empty imported list; an apply of such a file and a malformed file still answer 400.","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. Isolation: ` + "`" + `isolation.enabled` + "`" + ` is READ-ONLY (it reports the effective state on reads) and is rejected with 400; set the per-server override via ` + "`" + `isolation.enabled_override` + "`" + ` (true | false | null to clear, omit to leave unchanged). An unrecognized ` + "`" + `isolation.mode_override` + "`" + ` is rejected with 400.","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}/review":{"get":{"description":"Returns redacted server configuration, captured tool definitions, annotations, scan verdicts, and diffs. Scoped callers receive the same 404 for invisible and missing servers.","parameters":[{"description":"Server name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Server review"},"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 load server review"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get review details for a server","tags":["review"]}},"/api/v1/servers/{id}/security/approve":{"post":{"description":"Saves the integrity baseline and optionally blocks selected tools atomically before unquarantining the server. With expected_hashes (tool name -\u003e the review's current_hash; {} for an empty review) the approval is bound to the reviewed definitions: when a tool the approval would make reachable (pending, or approved and enabled, minus block) changed or appeared since the review, or a block target no longer exists, the response is 409 review_out_of_date, nothing is written and the server stays quarantined. Tools listed after the approval wait for review.","parameters":[{"description":"Server name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.securityApproveRequest"}}},"description":"Force approval and tool names to keep disabled"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Server approved"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid request or unknown tool block target"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not configured"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.StaleReviewErrorResponse"}}},"description":"Dangerous scan verdict requires force, approval failed, the review is out of date (error_code review_out_of_date), or server_replaced (the server was removed or re-added since expected_incarnation)"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Approve a server after security review","tags":["security"]}},"/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"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"server_replaced: the server kept being removed or replaced while its tools were read"},"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/approve":{"post":{"description":"Approves the given pending/changed tools (or all of them with approve_all). With expected_hashes (tool name -\u003e the review's current_hash) the approval is bound to the reviewed definitions: when a requested tool (approve_all: any pending or changed tool) no longer matches, the response is 409 review_out_of_date and nothing is approved.","parameters":[{"description":"Server name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.approveToolsRequest"}}},"description":"Tools to approve, or approve_all, with an optional review binding","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Approval outcome"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid request"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.StaleReviewErrorResponse"}}},"description":"The review is out of date; nothing was approved"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Approve 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. An optional expected_incarnation binds the write to the reviewed server (Spec 116): a remove and re-add since then answers 409 server_replaced and writes nothing.","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)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"server_replaced: the server was removed or re-added since expected_incarnation"},"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":{"$ref":"#/components/schemas/httpapi.expectedIncarnationBody"}}},"description":"Optional incarnation of the reviewed server (Spec 116)"},"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"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"server_replaced: the server was removed or re-added since expected_incarnation"},"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":{"$ref":"#/components/schemas/httpapi.expectedIncarnationBody"}}},"description":"Optional incarnation of the reviewed server (Spec 116)"},"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"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"server_replaced: the server was removed or re-added since expected_incarnation"},"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"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.expectedIncarnationBody"}}},"description":"Optional incarnation of the reviewed server (Spec 116)"},"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"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"server_replaced: the server was removed or re-added since expected_incarnation"},"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"}},{"description":"Filter by the session's latest effective profile; - selects sessions with none (Spec 108)","in":"query","name":"profile","schema":{"type":"string"}},{"description":"Filter by the client id the session's credential is bound to; - selects sessions with none (Spec 108)","in":"query","name":"client","schema":{"type":"string"}},{"description":"Filter by the token name the session initialized with; - selects sessions with none (Spec 108)","in":"query","name":"token","schema":{"type":"string"}},{"description":"Alias of token","in":"query","name":"agent","schema":{"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, token and agent naming different tokens, or client_name (not supported here; filter by client)"},"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":"Agent tokens cannot read MCP session history"},"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"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Agent tokens cannot read MCP session history"},"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"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Agent tokens cannot read deployment-wide 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\ntelemetry (admin only): effective telemetry state {enabled, source: env|config|default, disabled_by}","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"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Agent tokens cannot read the deployment telemetry payload"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Telemetry service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Preview next telemetry heartbeat payload","tags":["telemetry"]}},"/api/v1/telemetry/update-failure":{"post":{"description":"Records one terminal update-session failure, identified only by its\nstage (appcast, download, install, other). The body carries no error\ntext, URL, or version — the stage is the only value transmitted.\nReturns 204 both when the occurrence was durably persisted and when\ntelemetry is inactive at event time (config opt-out, environment\nopt-out, CI, or dev build), in which case nothing is recorded.\nCallers cannot and need not distinguish the two.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.UpdateFailureRequest"}}},"description":"Update failure stage","required":true},"responses":{"204":{"description":"Accepted (recorded, or a deliberate no-op while telemetry is inactive)"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Malformed body, unknown field, trailing value, or stage outside the closed set"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Persistence failure"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Record a desktop auto-update failure occurrence (Spec 095)","tags":["telemetry"]}},"/api/v1/tool-calls":{"get":{"description":"Retrieves paginated tool call history across all upstream servers or filtered by session ID. Includes execution timestamps, arguments, results, and error information for debugging and auditing.","parameters":[{"description":"Maximum number of records to return (1-100, default 50)","in":"query","name":"limit","schema":{"type":"integer"}},{"description":"Number of records to skip for pagination (default 0)","in":"query","name":"offset","schema":{"type":"integer"}},{"description":"Filter tool calls by MCP session ID","in":"query","name":"session_id","schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetToolCallsResponse"}}},"description":"Tool calls retrieved successfully"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to get tool calls"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get tool call history","tags":["tool-calls"]}},"/api/v1/tool-calls/{id}":{"get":{"description":"Retrieves detailed information about a specific tool call execution including full request arguments, response data, execution time, and any errors encountered.","parameters":[{"description":"Tool call ID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetToolCallDetailResponse"}}},"description":"Tool call details retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Tool call ID required"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Tool call not found"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get tool call details by ID","tags":["tool-calls"]}},"/api/v1/tool-calls/{id}/replay":{"post":{"description":"Re-executes a previous tool call with optional modified arguments. Useful for debugging and testing tool behavior with different inputs. Creates a new tool call record linked to the original.","parameters":[{"description":"Original tool call ID to replay","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ReplayToolCallRequest"}}},"description":"Optional modified arguments for replay"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ReplayToolCallResponse"}}},"description":"Tool call replayed successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Tool call ID required or invalid JSON payload"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Shed by a concurrency limit (Retry-After header carries the wait hint)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to replay tool call"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Replay a tool call","tags":["tool-calls"]}},"/api/v1/tools":{"get":{"description":"Consolidated, read-only listing of all tools from every configured server (including disabled servers and disabled/config-denied tools), enriched with approval state and 30-day usage. Backs the global Tools page and the CLI global ` + "`" + `tools list` + "`" + ` (spec 050, issue #437).","parameters":[{"description":"View-as (administrator only): the tools this client would see, each with its access verdict {visible, callable, reason} and profile_tier (Spec 108 FR-032)","in":"query","name":"client","schema":{"type":"string"}},{"description":"View-as: the tools this profile would expose. An administrator gets every row with a verdict; any other caller gets only the visible rows plus counts {visible, hidden} (Spec 108 FR-032)","in":"query","name":"profile","schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GlobalToolsResponse"}}},"description":"All tools across all servers"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unsupported scope filter (token), both client and profile, or '-' on a tools filter"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"client= requires administrator credentials"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unknown or unreachable client / profile"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Could not enumerate servers"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"List every tool across all servers","tags":["tools"]}},"/api/v1/tools/call":{"post":{"description":"Execute a tool on an upstream MCP server (wrapper around MCP tool calls)","requestBody":{"content":{"application/json":{"schema":{"properties":{"arguments":{"type":"object"},"tool_name":{"type":"string"}},"type":"object"}}},"description":"Tool call request with tool name and arguments","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Tool call result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (invalid payload or missing tool name)"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Shed by a concurrency limit (Retry-After header carries the wait hint)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error or tool execution failure"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Call a tool","tags":["tools"]}},"/healthz":{"get":{"description":"Get comprehensive health status including all component health (Kubernetes-compatible liveness probe)","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/observability.HealthResponse"}}},"description":"Service is healthy"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/observability.HealthResponse"}}},"description":"Service is unhealthy"}},"summary":"Get health status","tags":["health"]}},"/readyz":{"get":{"description":"Get readiness status including all component readiness checks (Kubernetes-compatible readiness probe)","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/observability.ReadinessResponse"}}},"description":"Service is ready"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/observability.ReadinessResponse"}}},"description":"Service is not ready"}},"summary":"Get readiness status","tags":["health"]}}}, diff --git a/oas/swagger.yaml b/oas/swagger.yaml index ff8263bee..87faa200f 100644 --- a/oas/swagger.yaml +++ b/oas/swagger.yaml @@ -3398,7 +3398,7 @@ components: type: string install_channel: description: Detected install channel (homebrew, dmg, deb, rpm, docker, - go-install, windows-installer, tarball, unknown) — Spec 079 FR-008 + go-install, windows-installer, scoop, tarball, unknown) — Spec 079 FR-008 type: string is_prerelease: description: Whether the latest version is a prerelease diff --git a/scripts/registry-preflight.sh b/scripts/registry-preflight.sh new file mode 100755 index 000000000..de0196735 --- /dev/null +++ b/scripts/registry-preflight.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# Pre-publish checks for the official MCP registry entry (Spec 121 FR-005). +# +# scripts/registry-preflight.sh v0.71.1 +# +# The registry stores each version once and never lets it be replaced, so a +# bad publish can't be fixed by publishing again. Before `mcp-publisher publish` +# this verifies that the image the stamped server.json points at +# 1. exists and is anonymously pullable, +# 2. carries the registry ownership label equal to the server.json name, and +# 3. starts with the exact env/port mapping server.json declares and answers +# an MCP `initialize` handshake. +# Exit 0 = safe to publish; non-zero = print the cause and skip the publish. +# +# Env: IMAGE (default: first oci package of server.json, stamped for the tag) +# SERVER_JSON (default server.json) SMOKE_PORT (default 18391) +# SKIP_SMOKE=1 / SKIP_LABEL=1 to run only one half (local debugging). +set -euo pipefail + +TAG="${1:-}" +SERVER_JSON="${SERVER_JSON:-server.json}" +SMOKE_PORT="${SMOKE_PORT:-18391}" +HERE="$(cd "$(dirname "$0")" && pwd)" +LABEL_KEY="io.modelcontextprotocol.server.name" + +die() { echo "registry-preflight: $*" >&2; exit 1; } + +[[ -n "$TAG" ]] || die "usage: registry-preflight.sh vX.Y.Z" +STAMPED="$("$HERE/stamp-server-json.sh" "$TAG" "$SERVER_JSON")" || die "cannot stamp $SERVER_JSON for $TAG" +NAME="$(jq -r .name <<<"$STAMPED")" +IMAGE="${IMAGE:-$(jq -r '[.packages[] | select(.registryType=="oci")][0].identifier // empty' <<<"$STAMPED")}" +[[ -n "$IMAGE" ]] || die "server.json has no oci package" + +# ---- 1+2: anonymous manifest + config label ------------------------------- +if [[ "${SKIP_LABEL:-0}" != "1" ]]; then + [[ "$IMAGE" == ghcr.io/* ]] || die "label check implemented for ghcr.io images only (got $IMAGE)" + REF="${IMAGE#ghcr.io/}"; REPO="${REF%:*}"; TAGREF="${REF##*:}" + TOKEN="$(curl -fsS "https://ghcr.io/token?service=ghcr.io&scope=repository:${REPO}:pull" | jq -r .token)" \ + || die "cannot get an anonymous pull token for ${REPO} (is the package private?)" + [[ -n "$TOKEN" && "$TOKEN" != "null" ]] || die "empty anonymous token for ${REPO}" + ACCEPT='application/vnd.oci.image.index.v1+json,application/vnd.docker.distribution.manifest.list.v2+json,application/vnd.oci.image.manifest.v1+json,application/vnd.docker.distribution.manifest.v2+json' + fetch() { curl -fsSL -H "Authorization: Bearer $TOKEN" -H "Accept: $ACCEPT" "$1"; } + MANIFEST="$(fetch "https://ghcr.io/v2/${REPO}/manifests/${TAGREF}")" \ + || die "image tag ${TAGREF} not found or not anonymously pullable: ${IMAGE}" + if jq -e '.manifests' >/dev/null 2>&1 <<<"$MANIFEST"; then + DIGEST="$(jq -r '[.manifests[] | select(.platform.os=="linux" and .platform.architecture=="amd64")][0].digest' <<<"$MANIFEST")" + [[ -n "$DIGEST" && "$DIGEST" != "null" ]] || die "no linux/amd64 manifest in ${IMAGE}" + MANIFEST="$(fetch "https://ghcr.io/v2/${REPO}/manifests/${DIGEST}")" || die "cannot fetch manifest ${DIGEST}" + fi + CFG="$(jq -r .config.digest <<<"$MANIFEST")" + GOT="$(fetch "https://ghcr.io/v2/${REPO}/blobs/${CFG}" | jq -r --arg k "$LABEL_KEY" '.config.Labels[$k] // empty')" \ + || die "cannot read image config ${CFG}" + [[ "$GOT" == "$NAME" ]] || die "image ${IMAGE} label ${LABEL_KEY}='${GOT}', want '${NAME}' (registry would reject the publish)" + echo "ok: ${IMAGE} is anonymously pullable and carries ${LABEL_KEY}=${NAME}" +fi + +# ---- 3: smoke test with the declared env / port ---------------------------- +if [[ "${SKIP_SMOKE:-0}" != "1" ]]; then + command -v docker >/dev/null || die "docker is required for the smoke test (SKIP_SMOKE=1 to bypass)" + ENVS=() + while IFS= read -r line; do ENVS+=("-e" "$line"); done < <(jq -r '[.packages[] | select(.registryType=="oci")][0].environmentVariables // [] | .[] | "\(.name)=\(.value // "")"' <<<"$STAMPED") + CID="$(docker run -d --rm -p "127.0.0.1:${SMOKE_PORT}:8080" ${ENVS[@]+"${ENVS[@]}"} "$IMAGE")" || die "docker run failed for ${IMAGE}" + trap 'docker rm -f "$CID" >/dev/null 2>&1 || true' EXIT + URL="$(jq -r '[.packages[] | select(.registryType=="oci")][0].transport.url' <<<"$STAMPED" | sed "s#localhost:8080#127.0.0.1:${SMOKE_PORT}#")" + BODY='{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"registry-preflight","version":"1"}}}' + OK="" + for _ in $(seq 1 30); do + RESP="$(curl -sS -m 5 -X POST "$URL" -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' -d "$BODY" 2>/dev/null || true)" + if grep -q '"serverInfo"' <<<"$RESP"; then OK=1; break; fi + sleep 1 + done + [[ -n "$OK" ]] || { docker logs "$CID" 2>&1 | tail -20 >&2 || true; die "MCP initialize against ${URL} did not succeed within 30s"; } + echo "ok: ${IMAGE} answered MCP initialize at ${URL}" +fi diff --git a/scripts/stamp-server-json.sh b/scripts/stamp-server-json.sh new file mode 100755 index 000000000..26ba118de --- /dev/null +++ b/scripts/stamp-server-json.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Stamp server.json for a release tag (Spec 121 FR-004). +# +# scripts/stamp-server-json.sh v0.71.1 [server.json] > stamped.json +# +# - .version <- tag without the leading "v" (registry rule) +# - every oci package identifier <- tag WITH its "v" (the image tag spelling +# release.yml build-docker pushes) +# Used by BOTH registry publishers (release.yml and retry-sign-release.yml) so +# a stale committed identifier can never reach the immutable registry. +set -euo pipefail + +TAG="${1:-}" +IN="${2:-server.json}" + +if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "stamp-server-json: tag must be a stable vX.Y.Z (got '${TAG}')" >&2 + exit 2 +fi +if [[ ! -f "$IN" ]]; then + echo "stamp-server-json: $IN not found" >&2 + exit 2 +fi +if [[ "$(jq '(.packages // []) | length' "$IN")" -eq 0 ]]; then + echo "stamp-server-json: $IN has no packages; refusing to publish an install-less registry entry" >&2 + exit 3 +fi + +jq --arg v "${TAG#v}" --arg t "$TAG" ' + .version = $v + | .packages |= map( + if .registryType == "oci" + then .identifier |= sub(":[^:/]+$"; ":" + $t) + else . end) +' "$IN" diff --git a/scripts/stamp-server-json.test.sh b/scripts/stamp-server-json.test.sh new file mode 100755 index 000000000..9a8c6071e --- /dev/null +++ b/scripts/stamp-server-json.test.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Tests for scripts/stamp-server-json.sh (Spec 121 T020). +set -euo pipefail +cd "$(dirname "$0")/.." +S=scripts/stamp-server-json.sh +fail() { echo "FAIL: $*" >&2; exit 1; } + +out=$($S v0.71.1) +[[ "$(jq -r .version <<<"$out")" == "0.71.1" ]] || fail "version not stamped without v" +[[ "$(jq -r '.packages[0].identifier' <<<"$out")" == "ghcr.io/smart-mcp-proxy/mcpproxy-server:v0.71.1" ]] || fail "identifier tag not stamped with v: $(jq -r '.packages[0].identifier' <<<"$out")" +[[ "$(jq -r .name <<<"$out")" == "$(jq -r .name server.json)" ]] || fail "name changed" + +# idempotent +tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT +echo "$out" >"$tmp" +out2=$($S v0.71.1 "$tmp") +[[ "$out" == "$out2" ]] || fail "not idempotent" + +# a digest-less identifier with a registry port keeps its host:port +jq '.packages[0].identifier = "localhost:5000/x/y:v0.0.1"' server.json >"$tmp" +[[ "$($S v1.2.3 "$tmp" | jq -r '.packages[0].identifier')" == "localhost:5000/x/y:v1.2.3" ]] || fail "registry port mangled" + +# refuses: rc tag, bad tag, no packages +$S v1.2.3-rc.1 >/dev/null 2>&1 && fail "accepted an RC tag" +$S 1.2.3 >/dev/null 2>&1 && fail "accepted a tag without v" +jq '.packages = []' server.json >"$tmp" +$S v1.2.3 "$tmp" >/dev/null 2>&1 && fail "accepted empty packages" +echo "ok stamp-server-json" diff --git a/server.json b/server.json index 7a749f5d5..561e1a580 100644 --- a/server.json +++ b/server.json @@ -9,5 +9,28 @@ "source": "github" }, "version": "0.33.1", - "packages": [] + "packages": [ + { + "registryType": "oci", + "identifier": "ghcr.io/smart-mcp-proxy/mcpproxy-server:v0.0.0", + "runtimeHint": "docker", + "runtimeArguments": [ + { + "type": "named", + "name": "-p", + "value": "127.0.0.1:8080:8080" + } + ], + "transport": { + "type": "streamable-http", + "url": "http://localhost:8080/mcp" + }, + "environmentVariables": [ + { + "name": "MCPPROXY_INSTALL_CHANNEL", + "value": "registry" + } + ] + } + ] } From 8dc613ee190c85960656a92ca05b44fd83049245 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Tue, 20 Oct 2026 19:06:05 +0300 Subject: [PATCH 3/6] feat(distribution): winget/scoop publishing, agent install prompt, llms.txt guard, install-channel hardening Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/distribution-checks.yml | 60 +++++++ .github/workflows/docs.yml | 7 + .github/workflows/installer-channel-test.yml | 94 ++++++++++ .../workflows/publish-windows-packages.yml | 160 ++++++++++++++++++ .github/workflows/release.yml | 14 ++ .github/workflows/retry-sign-release.yml | 12 ++ README.md | 58 +++++++ .../distribution_workflow_test.go | 83 +++++++++ cmd/release-gate/workflow_audit_test.go | 23 +-- docs/configuration.md | 2 + docs/features/telemetry.md | 24 +++ docs/getting-started/agent-install.md | 85 ++++++++++ docs/getting-started/installation.md | 29 ++++ docs/mcp-registry-publishing.md | 38 +++++ docs/release-runbook.md | 57 ++++++- internal/telemetry/install_channel.go | 32 +++- .../install_channel_mkfifo_unix_test.go | 7 + .../install_channel_mkfifo_windows_test.go | 7 + internal/telemetry/install_channel_test.go | 61 ++++++- internal/telemetry/telemetry.go | 5 +- internal/updatecheck/channel.go | 28 ++- internal/updatecheck/channel_scoop_test.go | 16 +- scripts/gen-scoop-manifest.sh | 70 ++++++++ scripts/gen-scoop-manifest.test.sh | 28 +++ scripts/gen-winget-manifest.sh | 103 +++++++++++ scripts/gen-winget-manifest.test.sh | 52 ++++++ scripts/installer.iss | 32 +++- scripts/sync-agent-prompt.sh | 44 +++++ scripts/sync-agent-prompt.test.sh | 43 +++++ scripts/testdata/checksums-v0.71.1.txt | 24 +++ website/docusaurus.config.js | 103 ++++++++--- website/package.json | 4 +- website/scripts/check-llms.mjs | 57 +++++++ website/scripts/check-llms.test.mjs | 40 +++++ website/sidebars.js | 1 + 35 files changed, 1451 insertions(+), 52 deletions(-) create mode 100644 .github/workflows/distribution-checks.yml create mode 100644 .github/workflows/installer-channel-test.yml create mode 100644 .github/workflows/publish-windows-packages.yml create mode 100644 docs/getting-started/agent-install.md create mode 100644 internal/telemetry/install_channel_mkfifo_unix_test.go create mode 100644 internal/telemetry/install_channel_mkfifo_windows_test.go create mode 100755 scripts/gen-scoop-manifest.sh create mode 100755 scripts/gen-scoop-manifest.test.sh create mode 100755 scripts/gen-winget-manifest.sh create mode 100755 scripts/gen-winget-manifest.test.sh create mode 100755 scripts/sync-agent-prompt.sh create mode 100755 scripts/sync-agent-prompt.test.sh create mode 100644 scripts/testdata/checksums-v0.71.1.txt create mode 100644 website/scripts/check-llms.mjs create mode 100644 website/scripts/check-llms.test.mjs diff --git a/.github/workflows/distribution-checks.yml b/.github/workflows/distribution-checks.yml new file mode 100644 index 000000000..2e728a217 --- /dev/null +++ b/.github/workflows/distribution-checks.yml @@ -0,0 +1,60 @@ +name: Distribution checks + +# Spec 121: fast PR guards for the install surfaces (registry entry, scoop and +# winget manifest generators, the agent install prompt). No secrets, no release. +on: + pull_request: + paths: + - 'server.json' + - 'Dockerfile' + - 'README.md' + - 'docs/getting-started/**' + - 'scripts/stamp-server-json*' + - 'scripts/registry-preflight.sh' + - 'scripts/gen-scoop-manifest*' + - 'scripts/gen-winget-manifest*' + - 'scripts/sync-agent-prompt*' + - 'scripts/testdata/**' + - '.github/workflows/distribution-checks.yml' + - '.github/workflows/release.yml' + - '.github/workflows/retry-sign-release.yml' + - '.github/workflows/publish-windows-packages.yml' + workflow_dispatch: + +permissions: + contents: read + +jobs: + scripts: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Generator and stamping tests + run: | + scripts/stamp-server-json.test.sh + scripts/gen-scoop-manifest.test.sh + scripts/gen-winget-manifest.test.sh + scripts/sync-agent-prompt.test.sh + + - name: Agent install prompt is in sync (README <-> docs) + run: scripts/sync-agent-prompt.sh --check + + - name: Install mcp-publisher + run: | + curl -fsSL "https://github.com/modelcontextprotocol/registry/releases/latest/download/mcp-publisher_linux_amd64.tar.gz" | tar xz mcp-publisher + + # Schema + semantic validation of the stamped entry, so an invalid + # `packages` block fails the PR instead of silently skipping a release's + # registry publish (that job is continue-on-error by design). + - name: Validate stamped server.json + run: | + scripts/stamp-server-json.sh v0.0.1 server.json > "${RUNNER_TEMP}/server.json" + ./mcp-publisher validate "${RUNNER_TEMP}/server.json" + + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: go.mod + + - name: Workflow and installer invariants + run: go test ./cmd/release-gate/ -run 'Registry|Windows|Installer|PublishJobsGated' -count=1 diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 0d2ec37e8..be46bd856 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -57,6 +57,13 @@ jobs: echo "value=${VERSION}" >> "$GITHUB_OUTPUT" echo "Docs version badge: ${VERSION:-}" + # Spec 121: the llms.txt guard (install page first, every link resolves, + # size budget) has its own tests; the guard itself runs as the build's + # `postbuild` step. + - name: Test llms.txt guard + working-directory: website + run: npm run test:llms + - name: Build documentation working-directory: website env: diff --git a/.github/workflows/installer-channel-test.yml b/.github/workflows/installer-channel-test.yml new file mode 100644 index 000000000..482e1ced0 --- /dev/null +++ b/.github/workflows/installer-channel-test.yml @@ -0,0 +1,94 @@ +name: Windows installer channel marker + +# Spec 121 FR-014: the Inno installer must (a) write the winget channel marker +# only for /CHANNEL=winget, (b) remove a stale marker on any other run and on +# uninstall, and (c) never block on a dialog in silent mode (winget runs it +# silently). Builds a throwaway installer around dummy binaries and exercises +# the real silent install / reinstall / uninstall lifecycle on a disposable +# runner (it modifies the machine PATH registry value, which is why this must +# never be pointed at a developer machine). +on: + pull_request: + paths: + - 'scripts/installer.iss' + - '.github/workflows/installer-channel-test.yml' + workflow_dispatch: + +permissions: + contents: read + +jobs: + lifecycle: + runs-on: windows-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Ensure Inno Setup + shell: pwsh + run: | + if (-not (Test-Path "C:\Program Files (x86)\Inno Setup 6\ISCC.exe")) { + choco install innosetup -y --no-progress + } + + - name: Build a throwaway installer + shell: pwsh + run: | + New-Item -ItemType Directory -Force dist\windows-amd64 | Out-Null + Copy-Item "$env:WINDIR\System32\whoami.exe" dist\windows-amd64\mcpproxy.exe + Copy-Item "$env:WINDIR\System32\whoami.exe" dist\windows-amd64\mcpproxy-tray.exe + & "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" /DVersion=0.0.1 /DArch=amd64 "/DBinPath=..\dist\windows-amd64" scripts\installer.iss + if ($LASTEXITCODE -ne 0) { throw "ISCC failed" } + if (-not (Test-Path dist\mcpproxy-setup-0.0.1-amd64.exe)) { throw "installer not produced" } + + - name: Exercise channel marker lifecycle + shell: pwsh + run: | + $setup = (Resolve-Path dist\mcpproxy-setup-0.0.1-amd64.exe).Path + $dir = "C:\mcpproxy-e7-test" + $marker = Join-Path $dir "install-channel" + + function Run-Silent([string]$exe, [string[]]$extra) { + $a = @("/VERYSILENT", "/SUPPRESSMSGBOXES", "/NORESTART") + $extra + $p = Start-Process -FilePath $exe -ArgumentList $a -PassThru + if (-not $p.WaitForExit(180000)) { $p.Kill(); throw "$exe blocked (>180s): a dialog or prompt is waiting in silent mode" } + if ($p.ExitCode -ne 0) { throw "$exe exited $($p.ExitCode)" } + } + + # 1. winget install writes exactly the token + Run-Silent $setup @("/DIR=$dir", "/CHANNEL=winget") + if (-not (Test-Path $marker)) { throw "marker missing after /CHANNEL=winget" } + if ((Get-Content $marker -Raw).Trim() -ne "winget") { throw "marker content wrong: $(Get-Content $marker -Raw)" } + + # 2. direct reinstall (no switch) removes the stale marker + Run-Silent $setup @("/DIR=$dir") + if (Test-Path $marker) { throw "stale marker survived a direct reinstall" } + + # 3. an unknown channel value is never written + Run-Silent $setup @("/DIR=$dir", "/CHANNEL=C:\Users\secret") + if (Test-Path $marker) { throw "an arbitrary /CHANNEL value was written" } + + # 4. uninstall removes the marker with the app + Run-Silent $setup @("/DIR=$dir", "/CHANNEL=winget") + if (-not (Test-Path $marker)) { throw "marker missing before uninstall" } + Run-Silent (Join-Path $dir "unins000.exe") @() + if (Test-Path $marker) { throw "marker survived uninstall" } + + - name: Silent install must not block on an over-long PATH + shell: pwsh + run: | + # Disposable runner only: push the machine PATH past the 2047-char limit. + $orig = [Environment]::GetEnvironmentVariable("Path", "Machine") + $pad = ";C:\e7-pad\" + ("x" * 200) + $long = $orig + while ($long.Length -lt 2200) { $long += $pad } + [Environment]::SetEnvironmentVariable("Path", $long, "Machine") + try { + $setup = (Resolve-Path dist\mcpproxy-setup-0.0.1-amd64.exe).Path + $p = Start-Process -FilePath $setup -ArgumentList @("/VERYSILENT","/SUPPRESSMSGBOXES","/NORESTART","/DIR=C:\mcpproxy-e7-path") -PassThru + if (-not $p.WaitForExit(180000)) { $p.Kill(); throw "installer blocked on the PATH-length dialog in silent mode" } + if ($p.ExitCode -ne 0) { throw "installer exited $($p.ExitCode)" } + if (Test-Path C:\mcpproxy-e7-path\unins000.exe) { Start-Process C:\mcpproxy-e7-path\unins000.exe -ArgumentList "/VERYSILENT","/SUPPRESSMSGBOXES","/NORESTART" -Wait } + } finally { + [Environment]::SetEnvironmentVariable("Path", $orig, "Machine") + } diff --git a/.github/workflows/publish-windows-packages.yml b/.github/workflows/publish-windows-packages.yml new file mode 100644 index 000000000..9d99e79b5 --- /dev/null +++ b/.github/workflows/publish-windows-packages.yml @@ -0,0 +1,160 @@ +name: Publish Windows packages + +# Spec 121 (E7): keep the scoop bucket and the winget community repository in +# step with every STABLE release, with no human in the loop after the one-time +# setup in docs/release-runbook.md#windows-package-managers-one-time-setup. +# +# Reusable: release.yml and retry-sign-release.yml call it after the GitHub +# Release exists. Also manually dispatchable with a tag to republish a release +# (idempotent: an unchanged scoop manifest is not recommitted; an existing +# winget version or open submission is reported and skipped). +# +# Both jobs SKIP (notice, success) when their credentials / target repositories +# are not configured, so an unconfigured fork or a first release never fails. +on: + workflow_call: + inputs: + tag: + description: Release tag (vX.Y.Z) + required: true + type: string + secrets: + SCOOP_BUCKET_TOKEN: + required: false + WINGET_TOKEN: + required: false + workflow_dispatch: + inputs: + tag: + description: Release tag to publish (vX.Y.Z) + required: true + type: string + +permissions: + contents: read + +jobs: + scoop: + name: scoop bucket + runs-on: ubuntu-latest + # Optional surface: a failure here must never turn a published release red. + continue-on-error: true + # Stable tags only: an RC must never reach a package manager's default channel. + if: ${{ !contains(inputs.tag, '-') }} + env: + TAG: ${{ inputs.tag }} + BUCKET: smart-mcp-proxy/scoop-bucket + BUCKET_TOKEN: ${{ secrets.SCOOP_BUCKET_TOKEN }} + GH_TOKEN: ${{ github.token }} + steps: + - name: Check configuration + id: cfg + run: | + if [ -z "${BUCKET_TOKEN}" ]; then + echo "::notice::SCOOP_BUCKET_TOKEN is not set; skipping scoop publish (see docs/release-runbook.md#windows-package-managers-one-time-setup)" + echo "run=false" >> "$GITHUB_OUTPUT" + else + echo "run=true" >> "$GITHUB_OUTPUT" + fi + + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + if: steps.cfg.outputs.run == 'true' + + - name: Fetch release checksums + if: steps.cfg.outputs.run == 'true' + run: gh release download "${TAG}" --repo "${{ github.repository }}" --pattern checksums.txt --dir . + + - name: Generate and push scoop manifest + if: steps.cfg.outputs.run == 'true' + run: | + set -euo pipefail + if ! git clone --depth 1 "https://x-access-token:${BUCKET_TOKEN}@github.com/${BUCKET}.git" bucket 2>/dev/null; then + echo "::notice::${BUCKET} is not reachable with SCOOP_BUCKET_TOKEN; skipping scoop publish" + exit 0 + fi + mkdir -p bucket/bucket + scripts/gen-scoop-manifest.sh "${TAG}" checksums.txt > bucket/bucket/mcpproxy.json + cd bucket + if git diff --quiet -- bucket/mcpproxy.json && [ -z "$(git ls-files --others --exclude-standard bucket/mcpproxy.json)" ]; then + echo "scoop manifest for ${TAG} already published; nothing to do" + exit 0 + fi + git config user.name "mcpproxy-release[bot]" + git config user.email "release-bot@mcpproxy.app" + git add bucket/mcpproxy.json + git commit -m "mcpproxy: update to ${TAG#v}" + git push origin HEAD + + winget: + name: winget community repository + runs-on: ubuntu-latest + continue-on-error: true + if: ${{ !contains(inputs.tag, '-') }} + env: + TAG: ${{ inputs.tag }} + PKG_ID: smart-mcp-proxy.mcpproxy + WINGET_TOKEN: ${{ secrets.WINGET_TOKEN }} + GH_TOKEN: ${{ github.token }} + steps: + - name: Check configuration + id: cfg + run: | + if [ -z "${WINGET_TOKEN}" ]; then + echo "::notice::WINGET_TOKEN is not set; skipping winget publish (see docs/release-runbook.md#windows-package-managers-one-time-setup)" + echo "run=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + # The first submission of a new package id is manual (moderated by + # Microsoft); this job only updates a package that already exists. + if ! GH_TOKEN="${WINGET_TOKEN}" gh api "repos/microsoft/winget-pkgs/contents/manifests/s/smart-mcp-proxy/mcpproxy" >/dev/null 2>&1; then + echo "::notice::${PKG_ID} does not exist in microsoft/winget-pkgs yet; submit it once by hand first (docs/release-runbook.md#windows-package-managers-one-time-setup)" + echo "run=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "run=true" >> "$GITHUB_OUTPUT" + + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + if: steps.cfg.outputs.run == 'true' + + - name: Fetch release checksums + if: steps.cfg.outputs.run == 'true' + run: gh release download "${TAG}" --repo "${{ github.repository }}" --pattern checksums.txt --dir . + + - name: Submit manifest pull request + if: steps.cfg.outputs.run == 'true' + env: + GH_TOKEN: ${{ secrets.WINGET_TOKEN }} + run: | + set -euo pipefail + VER="${TAG#v}" + DIR="manifests/s/smart-mcp-proxy/mcpproxy/${VER}" + FORK_OWNER="$(gh api user --jq .login)" + + # Idempotence: skip when the version is already merged or a PR is open. + if gh api "repos/microsoft/winget-pkgs/contents/${DIR}" >/dev/null 2>&1; then + echo "winget ${VER} already exists upstream; nothing to do"; exit 0 + fi + OPEN="$(gh pr list --repo microsoft/winget-pkgs --state open --search "${PKG_ID} version ${VER} in:title" --json number --jq 'length')" + if [ "${OPEN}" != "0" ]; then + echo "an open winget-pkgs PR for ${PKG_ID} ${VER} already exists; nothing to do"; exit 0 + fi + + scripts/gen-winget-manifest.sh "${TAG}" checksums.txt out + + # Work on the fork (created once by the maintainer), synced to upstream master. + gh repo sync "${FORK_OWNER}/winget-pkgs" --branch master --force >/dev/null + git clone --depth 1 --filter=blob:none --sparse "https://x-access-token:${WINGET_TOKEN}@github.com/${FORK_OWNER}/winget-pkgs.git" fork + cd fork + BRANCH="mcpproxy-${VER}" + git checkout -b "${BRANCH}" + mkdir -p "${DIR}" + cp ../out/*.yaml "${DIR}/" + git config user.name "mcpproxy-release[bot]" + git config user.email "release-bot@mcpproxy.app" + git add --sparse "${DIR}" + git commit -m "New version: ${PKG_ID} version ${VER}" + git push --force origin "${BRANCH}" + gh pr create --repo microsoft/winget-pkgs --base master \ + --head "${FORK_OWNER}:${BRANCH}" \ + --title "New version: ${PKG_ID} version ${VER}" \ + --body "Automated update from https://github.com/${{ github.repository }}/releases/tag/${TAG} (manifest generated by scripts/gen-winget-manifest.sh; installer hashes taken from the release's checksums.txt)." diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 81914056a..bd6144b73 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2212,6 +2212,20 @@ jobs: event-type: update-version client-payload: '{"version": "${{ github.ref_name }}"}' + # Spec 121: scoop bucket + winget community repository. Reusable workflow so a + # recovered release can republish by manual dispatch with a tag. Stable only; + # each inner job skips (notice) when its credentials are not configured. + publish-windows-packages: + name: Publish Windows packages + needs: release + if: startsWith(github.ref, 'refs/tags/v') && !contains(github.ref_name, '-') && github.repository == 'smart-mcp-proxy/mcpproxy-go' + permissions: + contents: read + uses: ./.github/workflows/publish-windows-packages.yml + with: + tag: ${{ github.ref_name }} + secrets: inherit + mcp-registry: name: Publish to MCP Registry needs: release diff --git a/.github/workflows/retry-sign-release.yml b/.github/workflows/retry-sign-release.yml index 18fea59ad..884059bb3 100644 --- a/.github/workflows/retry-sign-release.yml +++ b/.github/workflows/retry-sign-release.yml @@ -999,6 +999,18 @@ jobs: event-type: update-version client-payload: '{"version": "${{ inputs.tag }}"}' + # Spec 121: scoop bucket + winget; same reusable workflow as release.yml. + publish-windows-packages: + name: Publish Windows packages + needs: [release] + if: ${{ !cancelled() && (needs.release.result == 'success' || inputs.post_release_only) && !contains(inputs.tag, '-') && github.repository == 'smart-mcp-proxy/mcpproxy-go' }} + permissions: + contents: read + uses: ./.github/workflows/publish-windows-packages.yml + with: + tag: ${{ inputs.tag }} + secrets: inherit + # Publish to the official MCP Registry. Mirrors release.yml's mcp-registry # job; continue-on-error because a registry hiccup must not block a release. mcp-registry: diff --git a/README.md b/README.md index c162ba4b1..6a6f90394 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,64 @@ What the repository does measure, as a first-party, deterministic estimate (see ## Quick Start +### Fastest: paste this into your agent + +Paste the prompt below into Claude Code, Codex or another coding agent. It installs MCPProxy for your OS, starts it, and connects the client it is running in. It stops to ask before touching an existing install and never prints your keys. ([Same prompt on the docs site](https://docs.mcpproxy.app/getting-started/agent-install).) + + +```text +Install MCPProxy (https://mcpproxy.app), start it, and connect it to the AI client you are running in. Work step by step and show me each command before you run it. + +RULES +- Do not read, print or paste ~/.mcpproxy/mcp_config.json, any API key, token or credential. Use the commands below, which return masked output. +- If MCPProxy is already installed or running (step 1), STOP and ask me what to do. Never reinstall over a running instance. +- Do not use sudo unless a step needs it; tell me first. +- Install only from the sources listed here and verify checksums where stated. + +1. CHECK FOR AN EXISTING INSTALL + Run: command -v mcpproxy (Windows PowerShell: Get-Command mcpproxy) + Then: mcpproxy status (it reports whether an instance is running) + If either shows MCPProxy is present or running, report that and ask me before doing anything else. + +2. INSTALL (pick the first that applies to this OS) + macOS / Linux with Homebrew: brew install smart-mcp-proxy/mcpproxy/mcpproxy + Windows with winget: winget install --id smart-mcp-proxy.mcpproxy -e + Windows with scoop: scoop bucket add mcpproxy https://github.com/smart-mcp-proxy/scoop-bucket ; scoop install mcpproxy + Otherwise (Linux without Homebrew, or no package manager), the release archive: + VER=$(curl -fsSL https://api.github.com/repos/smart-mcp-proxy/mcpproxy-go/releases/latest | sed -n 's/.*"tag_name": *"v\([^"]*\)".*/\1/p' | head -1) + OS=linux; ARCH=$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/') # use OS=darwin on macOS + cd "$(mktemp -d)" + curl -fsSLO "https://github.com/smart-mcp-proxy/mcpproxy-go/releases/download/v${VER}/mcpproxy-${VER}-${OS}-${ARCH}.tar.gz" + curl -fsSLO "https://github.com/smart-mcp-proxy/mcpproxy-go/releases/download/v${VER}/checksums.txt" + grep " mcpproxy-${VER}-${OS}-${ARCH}.tar.gz\$" checksums.txt | (sha256sum -c - 2>/dev/null || shasum -a 256 -c -) + tar xzf "mcpproxy-${VER}-${OS}-${ARCH}.tar.gz" && mkdir -p ~/.local/bin && install -m 0755 mcpproxy ~/.local/bin/mcpproxy + mkdir -p ~/.mcpproxy && echo curl > ~/.mcpproxy/install-channel # records the install channel only + (Make sure ~/.local/bin is on PATH; the checksum line must print "OK" or STOP.) + Prefer the macOS desktop app? Tell me; it is: brew install --cask smart-mcp-proxy/mcpproxy/mcpproxy + +3. START IT + mcpproxy serve --listen 127.0.0.1:8080 > ~/.mcpproxy/serve.log 2>&1 & # Windows: Start-Process mcpproxy -ArgumentList serve -WindowStyle Hidden + If port 8080 is taken, use another, e.g. --listen 127.0.0.1:18080. + Wait until `mcpproxy status` reports it is running (retry for up to 30 seconds). + +4. VERIFY (these commands print masked output) + mcpproxy status + mcpproxy doctor + +5. CONNECT THE CLIENT YOU ARE RUNNING IN + Claude Code: mcpproxy connect claude-code + Codex: mcpproxy connect codex + Others (cursor, windsurf, vscode, gemini, opencode): mcpproxy connect + Claude Desktop is not in this list: tell me to open the Connect wizard in the MCPProxy Web UI (the URL printed by `mcpproxy status --web-url`). + Then run: mcpproxy connect --list and confirm the client shows as connected. + +6. TELL ME WHAT HAPPENED + - What you installed and how, and the Web UI URL from `mcpproxy status --web-url`. + - That the client needs a reload or restart before it sees MCPProxy (Claude Code: start a new session or run /mcp; Codex: restart it). Do not claim it is connected until I have done that. + - MCPProxy sends anonymous usage telemetry. To turn it off: set MCPPROXY_TELEMETRY=false before starting it, or toggle it in Settings. Details: https://docs.mcpproxy.app/features/telemetry +``` + + ### 1. Install **macOS (Recommended - DMG Installer):** diff --git a/cmd/release-gate/distribution_workflow_test.go b/cmd/release-gate/distribution_workflow_test.go index 18c8941d4..6fbad0b33 100644 --- a/cmd/release-gate/distribution_workflow_test.go +++ b/cmd/release-gate/distribution_workflow_test.go @@ -118,3 +118,86 @@ func TestRegistryPublishAfterImagePush(t *testing.T) { } } } + +// Windows package publishing (Spec 121 FR-012/FR-017): stable-only, gated by the +// release, non-blocking, reachable from both release workflows and by manual +// dispatch, and skipping (not failing) when unconfigured. +func TestWindowsPackagesWorkflowInvariants(t *testing.T) { + dir := filepath.Join("..", "..", ".github", "workflows") + wf := parseWorkflow(t, filepath.Join(dir, "publish-windows-packages.yml")) + raw := repoFile(t, ".github", "workflows", "publish-windows-packages.yml") + + for _, want := range []string{"workflow_call:", "workflow_dispatch:"} { + if !strings.Contains(raw, want) { + t.Errorf("publish-windows-packages.yml must declare %s (reusable and manually dispatchable)", want) + } + } + for _, name := range []string{"scoop", "winget"} { + j, ok := wf.Jobs[name] + if !ok { + t.Fatalf("missing job %q", name) + } + if !strings.Contains(j.If.Value, "'-'") { + t.Errorf("job %q must be stable-only (if: %q)", name, j.If.Value) + } + if strings.TrimSpace(j.ContinueOnError.Value) != "true" { + t.Errorf("job %q must be continue-on-error: an optional surface never fails a release", name) + } + } + if !strings.Contains(raw, "SCOOP_BUCKET_TOKEN is not set") || !strings.Contains(raw, "WINGET_TOKEN is not set") { + t.Error("both jobs must skip with a notice when their token is not configured") + } + if strings.Contains(raw, "@main") || strings.Contains(raw, "winget-releaser") { + t.Error("no unpinned third-party action: manifests are generated by scripts/gen-winget-manifest.sh") + } + + for _, name := range []string{"release.yml", "retry-sign-release.yml"} { + caller := parseWorkflow(t, filepath.Join(dir, name)) + j, ok := caller.Jobs["publish-windows-packages"] + if !ok { + t.Errorf("%s must call publish-windows-packages", name) + continue + } + if !strings.HasSuffix(j.Uses, "publish-windows-packages.yml") { + t.Errorf("%s: publish-windows-packages must use the reusable workflow, got %q", name, j.Uses) + } + if !caller.transitiveNeeds("publish-windows-packages")["release"] { + t.Errorf("%s: publish-windows-packages must need the release job", name) + } + if !strings.Contains(j.If.Value, "'-'") { + t.Errorf("%s: publish-windows-packages must exclude prerelease tags (if: %q)", name, j.If.Value) + } + } + // release.yml's gate audit must classify this caller as a publisher. + rel := parseWorkflow(t, filepath.Join(dir, "release.yml")) + if !rel.Jobs["publish-windows-packages"].publishes() { + t.Error("publish-windows-packages must be classified as a publisher by the QA-gate audit") + } + gate := rel.gateJobName() + if !rel.transitiveNeeds("publish-windows-packages")[gate] { + t.Errorf("publish-windows-packages must transitively depend on the qa gate %q", gate) + } +} + +// The Inno script must stay silent-safe and own its channel marker (FR-014). +func TestInstallerChannelMarkerContract(t *testing.T) { + iss := repoFile(t, "scripts", "installer.iss") + for _, want := range []string{ + "{param:CHANNEL|}", // winget switch + "'winget'", // only this token is ever written + "DeleteFile(MarkerPath)", // stale marker removed on a plain install + `Name: "{app}\install-channel"`, // removed on uninstall + "if not WizardSilent then", // no blocking dialog under winget + } { + if !strings.Contains(iss, want) { + t.Errorf("installer.iss lost %q (Spec 121 FR-014)", want) + } + } + // No MsgBox may be reachable unguarded from NeedsAddPath. + idx := strings.Index(iss, "function NeedsAddPath") + end := strings.Index(iss[idx:], "// Install-channel marker") + body := iss[idx : idx+end] + if strings.Count(body, "MsgBox(") != 1 || !strings.Contains(body, "if not WizardSilent then\n MsgBox(") { + t.Error("NeedsAddPath must show its warning only when not WizardSilent") + } +} diff --git a/cmd/release-gate/workflow_audit_test.go b/cmd/release-gate/workflow_audit_test.go index f3370eca6..4c41c58d5 100644 --- a/cmd/release-gate/workflow_audit_test.go +++ b/cmd/release-gate/workflow_audit_test.go @@ -107,17 +107,18 @@ func (j job) disabled() bool { // user-facing artifact (GitHub release, docker image, homebrew tap, docs site, // package repos, MCP registry, provenance attachment, marketing dispatch). var publishSignatures = []string{ - "action-gh-release", // create GitHub Release + upload assets - "gh release upload", // upload additional release assets - "gh release create", // create a release - "mcp-publisher", // publish to the MCP registry - "pages deploy", // wrangler pages deploy (docs site) - "wrangler-action", // cloudflare pages deploy - "repository-dispatch", // trigger the marketing site update - "build-push-action", // docker build + push - "slsa-github-generator", // SLSA provenance attached to the release - "homebrew_tap_token", // push to the homebrew tap - "publish.sh", // publish apt/rpm repos + "action-gh-release", // create GitHub Release + upload assets + "gh release upload", // upload additional release assets + "gh release create", // create a release + "mcp-publisher", // publish to the MCP registry + "pages deploy", // wrangler pages deploy (docs site) + "wrangler-action", // cloudflare pages deploy + "repository-dispatch", // trigger the marketing site update + "build-push-action", // docker build + push + "slsa-github-generator", // SLSA provenance attached to the release + "homebrew_tap_token", // push to the homebrew tap + "publish.sh", // publish apt/rpm repos + "publish-windows-packages", // scoop bucket + winget (reusable workflow, Spec 121) } func (j job) publishes() bool { diff --git a/docs/configuration.md b/docs/configuration.md index c773b0594..6c9b83f45 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1700,6 +1700,8 @@ triggers a prompt re-check. | `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. | +**Install channel.** `MCPPROXY_INSTALL_CHANNEL=` declares how this install was obtained, for the anonymous `install_channel` telemetry field only (one of `brew`, `dmg`, `winget`, `scoop`, `windows-installer`, `deb`, `rpm`, `docker`, `registry`, `curl`, `tarball`, `go-install`; anything else is ignored). It never changes update behavior. See [Telemetry](features/telemetry.md#install-channel-install_channel). + 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 diff --git a/docs/features/telemetry.md b/docs/features/telemetry.md index 246ca36d3..9f4ba96f4 100644 --- a/docs/features/telemetry.md +++ b/docs/features/telemetry.md @@ -284,6 +284,30 @@ transmissible if telemetry is re-enabled later. session id, the number of tools in the response, or any part of the suggestion string the agent saw. +## Install channel (`install_channel`) + +Added without a schema bump (it is purely additive and always present on an enabled heartbeat; consumers detect it by presence). It says **how this install was obtained**, as one value of a closed set: + +| Value | Meaning | +|---|---| +| `brew` | Homebrew formula or cask | +| `dmg` | macOS installer / app bundle | +| `winget` | Windows, installed by winget (installer ran with `/CHANNEL=winget`) | +| `scoop` | Windows, installed by scoop | +| `windows-installer` | Windows installer downloaded directly | +| `deb` / `rpm` | apt / dnf repository packages | +| `docker` | container image | +| `registry` | container started from the official MCP registry entry (declared via `MCPPROXY_INSTALL_CHANNEL=registry`) | +| `curl` | release archive installed by a script or an agent following the [agent install prompt](../getting-started/agent-install.md) | +| `tarball` / `go-install` | release archive / `go install` | +| `unknown` | nothing reliable identified | + +Resolution order: (1) the `MCPPROXY_INSTALL_CHANNEL` environment variable; (2) an installer-owned `install-channel` marker beside the executable (written only for `winget`); (3) the existing update-channel detector (the same one behind `mcpproxy update` and `/api/v1/info`); (4) a data-directory `install-channel` marker containing `curl`, honoured **only** when step 3 found `tarball`, `go-install` or `unknown`, so a stale hint can never mask a Homebrew, winget or installer install. + +Privacy: only the enum token is ever read or sent. Markers are read only when telemetry is enabled, only if they are small regular files, and anything outside the closed set is discarded; paths, file contents and hostnames never reach the payload, and the anonymity scanner rejects any other value before send. The field means the *current* channel, not the original one; count new installs per channel by each machine's first-seen date, not by repeated heartbeats. + +Receivers: `install_channel` is a new top-level string. Receivers that ignore unknown fields need no change; storing and charting it requires a column and a dashboard dimension in the telemetry worker and dashboard repositories. + ## One-time opt-out signal When telemetry transitions from **enabled to disabled** (via the CLI, the config diff --git a/docs/getting-started/agent-install.md b/docs/getting-started/agent-install.md new file mode 100644 index 000000000..588e22535 --- /dev/null +++ b/docs/getting-started/agent-install.md @@ -0,0 +1,85 @@ +--- +id: agent-install +title: Install with your AI agent +sidebar_label: Install with your agent +description: "Paste one prompt into Claude Code or Codex and let it install, start and connect MCPProxy for you" +keywords: [agent, claude code, codex, install, prompt, llms, automation] +--- + +# Install with your AI agent + +Paste the prompt below into **Claude Code**, **Codex** or any coding agent that can run shell commands. The agent installs MCPProxy for your operating system, starts it, checks that it is healthy, and registers MCPProxy as an MCP server in the client it is running in. + +The prompt is written so the agent **stops and asks you** before it replaces or restarts anything that is already running, and it **never prints your config file or any API key**. + + +```text +Install MCPProxy (https://mcpproxy.app), start it, and connect it to the AI client you are running in. Work step by step and show me each command before you run it. + +RULES +- Do not read, print or paste ~/.mcpproxy/mcp_config.json, any API key, token or credential. Use the commands below, which return masked output. +- If MCPProxy is already installed or running (step 1), STOP and ask me what to do. Never reinstall over a running instance. +- Do not use sudo unless a step needs it; tell me first. +- Install only from the sources listed here and verify checksums where stated. + +1. CHECK FOR AN EXISTING INSTALL + Run: command -v mcpproxy (Windows PowerShell: Get-Command mcpproxy) + Then: mcpproxy status (it reports whether an instance is running) + If either shows MCPProxy is present or running, report that and ask me before doing anything else. + +2. INSTALL (pick the first that applies to this OS) + macOS / Linux with Homebrew: brew install smart-mcp-proxy/mcpproxy/mcpproxy + Windows with winget: winget install --id smart-mcp-proxy.mcpproxy -e + Windows with scoop: scoop bucket add mcpproxy https://github.com/smart-mcp-proxy/scoop-bucket ; scoop install mcpproxy + Otherwise (Linux without Homebrew, or no package manager), the release archive: + VER=$(curl -fsSL https://api.github.com/repos/smart-mcp-proxy/mcpproxy-go/releases/latest | sed -n 's/.*"tag_name": *"v\([^"]*\)".*/\1/p' | head -1) + OS=linux; ARCH=$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/') # use OS=darwin on macOS + cd "$(mktemp -d)" + curl -fsSLO "https://github.com/smart-mcp-proxy/mcpproxy-go/releases/download/v${VER}/mcpproxy-${VER}-${OS}-${ARCH}.tar.gz" + curl -fsSLO "https://github.com/smart-mcp-proxy/mcpproxy-go/releases/download/v${VER}/checksums.txt" + grep " mcpproxy-${VER}-${OS}-${ARCH}.tar.gz\$" checksums.txt | (sha256sum -c - 2>/dev/null || shasum -a 256 -c -) + tar xzf "mcpproxy-${VER}-${OS}-${ARCH}.tar.gz" && mkdir -p ~/.local/bin && install -m 0755 mcpproxy ~/.local/bin/mcpproxy + mkdir -p ~/.mcpproxy && echo curl > ~/.mcpproxy/install-channel # records the install channel only + (Make sure ~/.local/bin is on PATH; the checksum line must print "OK" or STOP.) + Prefer the macOS desktop app? Tell me; it is: brew install --cask smart-mcp-proxy/mcpproxy/mcpproxy + +3. START IT + mcpproxy serve --listen 127.0.0.1:8080 > ~/.mcpproxy/serve.log 2>&1 & # Windows: Start-Process mcpproxy -ArgumentList serve -WindowStyle Hidden + If port 8080 is taken, use another, e.g. --listen 127.0.0.1:18080. + Wait until `mcpproxy status` reports it is running (retry for up to 30 seconds). + +4. VERIFY (these commands print masked output) + mcpproxy status + mcpproxy doctor + +5. CONNECT THE CLIENT YOU ARE RUNNING IN + Claude Code: mcpproxy connect claude-code + Codex: mcpproxy connect codex + Others (cursor, windsurf, vscode, gemini, opencode): mcpproxy connect + Claude Desktop is not in this list: tell me to open the Connect wizard in the MCPProxy Web UI (the URL printed by `mcpproxy status --web-url`). + Then run: mcpproxy connect --list and confirm the client shows as connected. + +6. TELL ME WHAT HAPPENED + - What you installed and how, and the Web UI URL from `mcpproxy status --web-url`. + - That the client needs a reload or restart before it sees MCPProxy (Claude Code: start a new session or run /mcp; Codex: restart it). Do not claim it is connected until I have done that. + - MCPProxy sends anonymous usage telemetry. To turn it off: set MCPPROXY_TELEMETRY=false before starting it, or toggle it in Settings. Details: https://docs.mcpproxy.app/features/telemetry +``` + + +## What the prompt does + +| Step | Why | +|------|-----| +| Existing-install check | Never overwrites a running MCPProxy or the tray app that already owns port 8080 | +| Package manager first | Homebrew, winget and scoop keep MCPProxy updatable with their normal upgrade command | +| Checksum-verified archive fallback | Used only when no package manager applies; the archive's SHA-256 is checked against the release's `checksums.txt` | +| `mcpproxy connect` | Writes a per-client credential for you and returns it masked; the agent never handles your admin API key | +| Truthful follow-up | Clients load MCP servers at start, so the agent asks you to reload instead of claiming a live connection | + +## Install without an agent + +See [Installation](/getting-started/installation) for the macOS app, Windows installer, winget, scoop, Linux packages and Docker. + +## For agents and tools + +This site publishes [`/llms.txt`](https://docs.mcpproxy.app/llms.txt) (an index of the documentation, starting with this page) and [`/llms-full.txt`](https://docs.mcpproxy.app/llms-full.txt) (the full text). diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index e2e1698bb..c697bc5c2 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -101,12 +101,41 @@ The installer will: - Add MCPProxy to your system PATH - Create Start Menu shortcuts +### winget + +```powershell +winget install --id smart-mcp-proxy.mcpproxy -e +winget upgrade --id smart-mcp-proxy.mcpproxy # update +winget uninstall --id smart-mcp-proxy.mcpproxy +``` + +This runs the same signed installer silently (administrator rights are requested). + +### Scoop + +```powershell +scoop bucket add mcpproxy https://github.com/smart-mcp-proxy/scoop-bucket +scoop install mcpproxy +scoop update mcpproxy # update +scoop uninstall mcpproxy +``` + +Scoop installs the release archive for your user (no administrator rights), +puts `mcpproxy` on your PATH and adds a Start-menu shortcut for the tray app. +Update with scoop: `mcpproxy update` points you at `scoop update mcpproxy` +rather than replacing a scoop-managed file. + ### Manual Installation 1. Download the Windows binary from the releases page 2. Extract to a directory of your choice 3. Add the directory to your PATH +## Let your AI agent do it + +Prefer to hand the whole thing to Claude Code or Codex? See +[Install with your AI agent](/getting-started/agent-install). + ## Linux ### Debian / Ubuntu — apt repository (recommended) diff --git a/docs/mcp-registry-publishing.md b/docs/mcp-registry-publishing.md index 6b44958e5..c51c9e224 100644 --- a/docs/mcp-registry-publishing.md +++ b/docs/mcp-registry-publishing.md @@ -90,6 +90,44 @@ mcp-publisher status --status deleted \ io.github.smart-mcp-proxy/mcpproxy-go 0.33.1 ``` +## Packages (what registry-fed catalogs show) + +`server.json` carries one `oci` package: the Server-edition image +`ghcr.io/smart-mcp-proxy/mcpproxy-server`, streamable-HTTP at +`http://localhost:8080/mcp`. The registry has **no raw-binary package type** +(`npm`, `pypi`, `nuget`, `cargo`, `oci`, `mcpb` only), so release archives are not +listed; a Claude Desktop `.mcpb` bundle is a separate follow-up +(`specs/121-install-surfaces/followup-mcpb.md`). + +How it stays correct on every release (Spec 121): + +1. `Dockerfile` and the `build-docker` job label the image + `io.modelcontextprotocol.server.name=io.github.smart-mcp-proxy/mcpproxy-go`. + The registry rejects a package whose image lacks this label, and a version + is immutable once published. A Go test (`cmd/release-gate`) keeps + `server.json` `name`, the Dockerfile label and the workflow label equal. +2. `scripts/stamp-server-json.sh vX.Y.Z` sets `.version` (no `v`) and the image + tag in the package identifier (with `v`, as the image is tagged). + **Both** publishers use it: `release.yml` and `retry-sign-release.yml`. +3. `scripts/registry-preflight.sh vX.Y.Z` runs before `mcp-publisher publish`: + the tag exists and is anonymously pullable, carries the label, and the + container started with the exact environment/port `server.json` declares + answers an MCP `initialize`. A failure skips the publish (the job is + non-blocking) with the cause in the log. +4. `MCPPROXY_INSTALL_CHANNEL=registry` is declared in the package's + `environmentVariables`, so installs started from the registry entry report the + `registry` channel in telemetry. + +Versions published before the label existed (≤ 0.71.x) stay package-less; the +first stable release after Spec 121 is the first with a package. +The image is the Server edition (distroless: no shell, Node or uv), so it cannot +launch stdio upstreams; the Personal-edition image of Spec 111 will replace it +in the entry once published. + +Local checks: `scripts/stamp-server-json.test.sh`, +`mcp-publisher validate` on the stamped file, and (needs Docker and a published +labelled image) `scripts/registry-preflight.sh vX.Y.Z`. + ## What Requires the User - **Nothing, for the normal release path.** The `mcp-registry` job in [`.github/workflows/release.yml`](../.github/workflows/release.yml) already runs `mcp-publisher login github-oidc` + `mcp-publisher publish` on every tag (`id-token: write`, `continue-on-error: true`). The workflow's repo OIDC identity proves `smart-mcp-proxy` org membership, which owns the `io.github.smart-mcp-proxy` namespace — no secret or interactive login is involved. diff --git a/docs/release-runbook.md b/docs/release-runbook.md index df2fb2bae..664e8301b 100644 --- a/docs/release-runbook.md +++ b/docs/release-runbook.md @@ -462,6 +462,60 @@ recent stable release succeeded, the delta is code — not infra: --- +## Windows package managers (one-time setup) + +Spec 121 / experiment E7. After the first stable release that contains it, +`publish-windows-packages.yml` keeps a scoop bucket and the winget community +repository current with **no per-release action**. Both jobs *skip with a +notice* (never fail the release) until the setup below is done, so nothing is +lost by doing it later. + +| Surface | Needs once | Secret | +|---|---|---| +| scoop | Create the public repo `smart-mcp-proxy/scoop-bucket` (any initial commit, e.g. a README) | `SCOOP_BUCKET_TOKEN` — fine-grained PAT, **Contents: read/write on that repo only** | +| winget | Fork `microsoft/winget-pkgs`, then submit the **first** version by hand (below) | `WINGET_TOKEN` — classic PAT with `public_repo`, owned by the account that owns the fork | + +Store both as **repository secrets** of `smart-mcp-proxy/mcpproxy-go` +(the workflow reads them via `secrets: inherit`). + +**First winget submission** (Microsoft moderates a new package id; expect days +to weeks of review latency): + +```powershell +# Windows, once. Generates the manifests exactly as the workflow will. +gh release download vX.Y.Z -R smart-mcp-proxy/mcpproxy-go -p checksums.txt +bash scripts/gen-winget-manifest.sh vX.Y.Z checksums.txt out +winget validate --manifest out +winget install --manifest out # local smoke test (installs MCPProxy; uninstall afterwards) +wingetcreate submit --token out # or open the PR from your fork by hand +``` + +Once the PR merges, the `winget` job takes over: it detects that +`manifests/s/smart-mcp-proxy/mcpproxy` exists upstream and opens one PR per +release from the fork (skipping a version that is already merged or has an +open PR). + +**Re-running for a release** (recovery, or after finishing the setup): Actions → +*Publish Windows packages* → *Run workflow* → tag `vX.Y.Z`. The scoop commit is +a no-op when the manifest is unchanged. + +**Verify a release** (after the job ran): + +```powershell +scoop bucket add mcpproxy https://github.com/smart-mcp-proxy/scoop-bucket +scoop install mcpproxy ; mcpproxy --version ; scoop update mcpproxy +winget search smart-mcp-proxy.mcpproxy +``` + +The installer reads `/CHANNEL=winget` (passed through the manifest's +`InstallerSwitches.Custom`) and writes an `install-channel` marker beside +`mcpproxy.exe`; telemetry then reports `install_channel: winget`. +`.github/workflows/installer-channel-test.yml` exercises that lifecycle +(install, reinstall, uninstall, silent install with an over-long PATH) on a +disposable Windows runner whenever `scripts/installer.iss` changes. + +--- + ## Tag-cutting cheat sheet Bundled for reference; full detail in [`docs/releasing.md`](./releasing.md). @@ -485,7 +539,8 @@ Expected green jobs on tag push: 6. `publish-linux-repos` (apt + rpm to R2) 7. `deploy-docs` (Docusaurus → Cloudflare Pages, non-blocking) 8. `trigger-marketing-update` (non-blocking) -9. `mcp-registry` (non-blocking) +9. `mcp-registry` (non-blocking; stamps `server.json`, runs `scripts/registry-preflight.sh`) +10. `publish-windows-packages` (non-blocking; scoop bucket + winget, skips until configured) If any of 1–6 go red, consult the matching SPOF section above. 7–9 are marked `continue-on-error: true` — they do not block the release but should be diff --git a/internal/telemetry/install_channel.go b/internal/telemetry/install_channel.go index a858fef60..1fa179a8c 100644 --- a/internal/telemetry/install_channel.go +++ b/internal/telemetry/install_channel.go @@ -1,6 +1,7 @@ package telemetry import ( + "errors" "io" "os" "path/filepath" @@ -140,16 +141,43 @@ func resolveInstallChannel(d installChannelDeps) InstallChannel { return detected } -// readCapped reads at most max bytes of a regular file. +// readCapped reads a marker file safely. It only ever opens a REGULAR file +// (a FIFO or device would block or stall payload construction), follows a +// symlink only to a regular target, and detects overflow instead of accepting +// a truncated prefix: a file longer than max is an error, so an oversized file +// that merely starts with a valid token ("winget" + 4 KB of whitespace) is +// rejected rather than honoured. func readCapped(path string, max int64) ([]byte, error) { + fi, err := os.Stat(path) + if err != nil { + return nil, err + } + if !fi.Mode().IsRegular() { + return nil, errNotRegularMarker + } + if fi.Size() > max { + return nil, errMarkerTooLarge + } f, err := os.Open(path) if err != nil { return nil, err } defer f.Close() - return io.ReadAll(io.LimitReader(f, max)) + b, err := io.ReadAll(io.LimitReader(f, max+1)) + if err != nil { + return nil, err + } + if int64(len(b)) > max { + return nil, errMarkerTooLarge + } + return b, nil } +var ( + errNotRegularMarker = errors.New("install-channel marker is not a regular file") + errMarkerTooLarge = errors.New("install-channel marker exceeds size cap") +) + // readChannelMarker returns the trimmed, lower-cased, size-capped marker // token, or "" when it is absent, unreadable or empty. Validation against the // closed set is the caller's job. diff --git a/internal/telemetry/install_channel_mkfifo_unix_test.go b/internal/telemetry/install_channel_mkfifo_unix_test.go new file mode 100644 index 000000000..babad0b22 --- /dev/null +++ b/internal/telemetry/install_channel_mkfifo_unix_test.go @@ -0,0 +1,7 @@ +//go:build !windows + +package telemetry + +import "syscall" + +func mkfifo(path string) error { return syscall.Mkfifo(path, 0o600) } diff --git a/internal/telemetry/install_channel_mkfifo_windows_test.go b/internal/telemetry/install_channel_mkfifo_windows_test.go new file mode 100644 index 000000000..8cdf0d721 --- /dev/null +++ b/internal/telemetry/install_channel_mkfifo_windows_test.go @@ -0,0 +1,7 @@ +//go:build windows + +package telemetry + +import "errors" + +func mkfifo(string) error { return errors.New("no FIFOs on windows") } diff --git a/internal/telemetry/install_channel_test.go b/internal/telemetry/install_channel_test.go index 7e4d806a2..214a8df7b 100644 --- a/internal/telemetry/install_channel_test.go +++ b/internal/telemetry/install_channel_test.go @@ -4,6 +4,7 @@ import ( "os" "strings" "testing" + "time" "github.com/smart-mcp-proxy/mcpproxy-go/internal/updatecheck" ) @@ -120,6 +121,10 @@ func TestResolveInstallChannel_Precedence(t *testing.T) { // The marker reader must cap what it reads so a hostile or corrupt file can // never allocate or leak beyond a single short token. func TestReadChannelMarker_CapsAndTrims(t *testing.T) { + t.Run("regular file ok", func(t *testing.T) { testMarkerBasics(t) }) +} + +func testMarkerBasics(t *testing.T) { dir := t.TempDir() path := dir + "/install-channel" if err := os.WriteFile(path, []byte(" winget \r\n"), 0o600); err != nil { @@ -131,10 +136,62 @@ func TestReadChannelMarker_CapsAndTrims(t *testing.T) { if err := os.WriteFile(path, []byte(strings.Repeat("a", 1<<20)), 0o600); err != nil { t.Fatal(err) } - if got := readChannelMarker(path, readCapped); len(got) > markerMaxBytes { - t.Errorf("marker read not capped: %d bytes", len(got)) + if got := readChannelMarker(path, readCapped); got != "" { + t.Errorf("oversized marker must be rejected, got %q", got) + } + // An oversized file that STARTS with a valid token must not be honoured. + if err := os.WriteFile(path, []byte("winget"+strings.Repeat(" ", 4096)), 0o600); err != nil { + t.Fatal(err) + } + if got := readChannelMarker(path, readCapped); got != "" { + t.Errorf("oversized marker with valid prefix must be rejected, got %q", got) } if got := readChannelMarker(dir+"/absent", readCapped); got != "" { t.Errorf("absent marker = %q, want empty", got) } } + +// A FIFO must never block payload construction, and a directory is not a marker. +func TestReadChannelMarker_RejectsNonRegularFiles(t *testing.T) { + dir := t.TempDir() + if got := readChannelMarker(dir, readCapped); got != "" { + t.Errorf("directory marker = %q, want empty", got) + } + fifo := dir + "/install-channel" + if err := mkfifo(fifo); err != nil { + t.Skipf("mkfifo unsupported here: %v", err) + } + done := make(chan string, 1) + go func() { done <- readChannelMarker(fifo, readCapped) }() + select { + case got := <-done: + if got != "" { + t.Errorf("FIFO marker = %q, want empty", got) + } + case <-time.After(2 * time.Second): + t.Fatal("reading a FIFO marker blocked") + } +} + +// A symlink to a regular file is followed; a symlink to a directory is not a marker. +func TestReadChannelMarker_Symlinks(t *testing.T) { + dir := t.TempDir() + real := dir + "/real" + if err := os.WriteFile(real, []byte("winget"), 0o600); err != nil { + t.Fatal(err) + } + link := dir + "/link" + if err := os.Symlink(real, link); err != nil { + t.Skipf("symlink unsupported: %v", err) + } + if got := readChannelMarker(link, readCapped); got != "winget" { + t.Errorf("symlink to regular file = %q, want winget", got) + } + dlink := dir + "/dlink" + if err := os.Symlink(dir, dlink); err != nil { + t.Skip() + } + if got := readChannelMarker(dlink, readCapped); got != "" { + t.Errorf("symlink to directory = %q, want empty", got) + } +} diff --git a/internal/telemetry/telemetry.go b/internal/telemetry/telemetry.go index 3b931e587..83d833dce 100644 --- a/internal/telemetry/telemetry.go +++ b/internal/telemetry/telemetry.go @@ -1576,7 +1576,10 @@ func (s *Service) buildHeartbeatWithOneShots(consumeOneShots bool) HeartbeatPayl // Spec 121: install channel. Resolved only when telemetry is effectively // enabled so a disabled install (or a payload preview of one) never reads // a channel marker from disk. - if s.envDisabledReason == EnvDisabledNone && EffectiveTelemetryEnabled(cfg) { + // Eligibility is read live under s.mu (telemetryEnabledLive), not from the + // cfg snapshot taken at the top of this function: a disable that lands + // mid-build must stop the marker read, and the snapshot may predate it. + if s.envDisabledReason == EnvDisabledNone && s.telemetryEnabledLive() { dataDir := "" if cfg != nil { dataDir = cfg.DataDir diff --git a/internal/updatecheck/channel.go b/internal/updatecheck/channel.go index 00272c419..a6a1e69c2 100644 --- a/internal/updatecheck/channel.go +++ b/internal/updatecheck/channel.go @@ -230,7 +230,7 @@ func (d *channelDetector) detect() string { // (version, hash). The apps// layout is scoop's own and is checked // on a slash-normalised, lower-cased path so it holds for both the // per-user (~\scoop) and global (C:\ProgramData\scoop) roots. - if isScoopPath(path) { + if d.isScoopInstall(path) { return ChannelScoop } @@ -326,10 +326,26 @@ func (d *channelDetector) isGoInstall() bool { return semver.IsValid(v) } -// isScoopPath reports whether path lies inside scoop's package layout for -// mcpproxy (/scoop/apps/mcpproxy/...). Windows paths are normalised so -// the check also works when the detector is exercised on another OS. -func isScoopPath(path string) bool { +// isScoopInstall reports whether the resolved executable path is scoop-owned. +// Two independent signals, either of which suffices: +// - the default layout /scoop/apps/mcpproxy/..., or +// - a custom root ($SCOOP / $SCOOP_GLOBAL): /apps/mcpproxy// with +// scoop's own install.json beside the executable (scoop writes it into +// every version directory; `current` is a junction that EvalSymlinks has +// already resolved to that directory). +// +// Windows paths are normalised so the check also works when the detector is +// exercised on another OS. A bare apps/mcpproxy directory without that +// evidence is NOT scoop: guessing would withhold self-update from an +// unrelated extracted archive (FR-009: never guess). +func (d *channelDetector) isScoopInstall(path string) bool { p := strings.ToLower(strings.ReplaceAll(path, `\`, "/")) - return strings.Contains(p, "/scoop/apps/mcpproxy/") + if strings.Contains(p, "/scoop/apps/mcpproxy/") { + return true + } + if !strings.Contains(p, "/apps/mcpproxy/") { + return false + } + dir := path[:strings.LastIndexAny(path, `\/`)+1] + return d.statFile(dir+"install.json") == nil } diff --git a/internal/updatecheck/channel_scoop_test.go b/internal/updatecheck/channel_scoop_test.go index ad583bb92..1e0cee9ce 100644 --- a/internal/updatecheck/channel_scoop_test.go +++ b/internal/updatecheck/channel_scoop_test.go @@ -1,6 +1,11 @@ package updatecheck -import "testing" +import ( + "os" + "path/filepath" + "strings" + "testing" +) // Spec 121 FR-016: a scoop-owned binary must be recognised as its own channel. // The Windows release zip scoop installs is stamped "tarball" (weak), which @@ -17,6 +22,8 @@ func TestDetectChannel_Scoop(t *testing.T) { {"scoop versioned dir", ChannelTarball, `C:\Users\u\scoop\apps\mcpproxy\0.71.1\mcpproxy.exe`, ChannelScoop}, {"scoop global install", ChannelTarball, `C:\ProgramData\scoop\apps\mcpproxy\current\mcpproxy.exe`, ChannelScoop}, {"mixed case and slashes", ChannelTarball, `c:/Users/U/Scoop/Apps/MCPProxy/current/mcpproxy.exe`, ChannelScoop}, + {"custom SCOOP root with install.json evidence", ChannelTarball, `D:\Tools\apps\mcpproxy\0.71.1\mcpproxy.exe`, ChannelScoop}, + {"apps/mcpproxy without scoop evidence stays tarball", ChannelTarball, `E:\apps\mcpproxy\mcpproxy.exe`, ChannelTarball}, {"unstamped build still scoop", "", `C:\Users\u\scoop\apps\mcpproxy\current\mcpproxy.exe`, ChannelScoop}, {"other scoop app is not mcpproxy", ChannelTarball, `C:\Users\u\scoop\apps\othertool\current\mcpproxy.exe`, ChannelTarball}, {"plain extracted zip stays tarball", ChannelTarball, `C:\tools\mcpproxy\mcpproxy.exe`, ChannelTarball}, @@ -29,6 +36,13 @@ func TestDetectChannel_Scoop(t *testing.T) { d.marker = tt.marker path := tt.path d.execPath = func() (string, error) { return path, nil } + // scoop writes install.json beside the executable in the version dir. + d.statFile = func(p string) error { + if strings.Contains(tt.name, "install.json evidence") && strings.HasSuffix(filepath.ToSlash(strings.ReplaceAll(p, `\`, "/")), "/0.71.1/install.json") { + return nil + } + return os.ErrNotExist + } if got := d.detect(); got != tt.want { t.Errorf("detect() = %q, want %q", got, tt.want) } diff --git a/scripts/gen-scoop-manifest.sh b/scripts/gen-scoop-manifest.sh new file mode 100755 index 000000000..793ea0a90 --- /dev/null +++ b/scripts/gen-scoop-manifest.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +# Render the scoop manifest for a release (Spec 121 FR-010). +# +# scripts/gen-scoop-manifest.sh v0.71.1 checksums.txt > mcpproxy.json +# +# Archive URLs and SHA-256 hashes come from the release's own checksums.txt +# (the single hash source for every asset), so a manifest can never describe a +# file the release did not publish. Scoop installs the flat release zip +# (mcpproxy.exe + mcpproxy-tray.exe): no installer, no admin rights, and a +# path the update-channel detector recognises as scoop-owned. +set -euo pipefail + +TAG="${1:-}" +SUMS="${2:-}" +REPO="${REPO:-smart-mcp-proxy/mcpproxy-go}" + +if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "gen-scoop-manifest: tag must be a stable vX.Y.Z (got '${TAG}')" >&2 + exit 2 +fi +if [[ -z "$SUMS" || ! -f "$SUMS" ]]; then + echo "gen-scoop-manifest: checksums file '${SUMS}' not found" >&2 + exit 2 +fi +VER="${TAG#v}" + +hash_for() { # + local h + h="$(awk -v f="$1" '$2 == f {print $1; exit}' "$SUMS")" + if [[ ! "$h" =~ ^[a-f0-9]{64}$ ]]; then + echo "gen-scoop-manifest: no sha256 for $1 in $SUMS" >&2 + exit 3 + fi + printf '%s' "$h" +} + +AMD="mcpproxy-${VER}-windows-amd64.zip" +ARM="mcpproxy-${VER}-windows-arm64.zip" +BASE="https://github.com/${REPO}/releases/download/${TAG}" + +# Resolve hashes into variables first: an `exit` inside a command substitution +# that sits in an argument list would not abort the script. +AMD_HASH="$(hash_for "$AMD")" || exit 3 +ARM_HASH="$(hash_for "$ARM")" || exit 3 + +jq -n \ + --arg ver "$VER" --arg repo "$REPO" \ + --arg amd_url "${BASE}/${AMD}" --arg amd_hash "$AMD_HASH" \ + --arg arm_url "${BASE}/${ARM}" --arg arm_hash "$ARM_HASH" ' +{ + version: $ver, + description: "Smart MCP proxy: BM25 tool discovery, security quarantine and token savings for AI agents", + homepage: "https://mcpproxy.app", + license: "MIT", + architecture: { + "64bit": {url: $amd_url, hash: $amd_hash}, + arm64: {url: $arm_url, hash: $arm_hash} + }, + bin: ["mcpproxy.exe"], + shortcuts: [["mcpproxy-tray.exe", "MCPProxy"]], + notes: "Start the proxy with `mcpproxy serve` (or launch MCPProxy from the Start menu), then connect your AI client with `mcpproxy connect `. Update with `scoop update mcpproxy`.", + checkver: {github: ("https://github.com/" + $repo)}, + autoupdate: { + architecture: { + "64bit": {url: ("https://github.com/" + $repo + "/releases/download/v$version/mcpproxy-$version-windows-amd64.zip")}, + arm64: {url: ("https://github.com/" + $repo + "/releases/download/v$version/mcpproxy-$version-windows-arm64.zip")} + }, + hash: {url: ("https://github.com/" + $repo + "/releases/download/v$version/checksums.txt")} + } +}' diff --git a/scripts/gen-scoop-manifest.test.sh b/scripts/gen-scoop-manifest.test.sh new file mode 100755 index 000000000..dde89e131 --- /dev/null +++ b/scripts/gen-scoop-manifest.test.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Tests for scripts/gen-scoop-manifest.sh (Spec 121 T030). +set -euo pipefail +cd "$(dirname "$0")/.." +G=scripts/gen-scoop-manifest.sh +SUMS=scripts/testdata/checksums-v0.71.1.txt +fail() { echo "FAIL: $*" >&2; exit 1; } + +out=$($G v0.71.1 "$SUMS") +jq -e . >/dev/null <<<"$out" || fail "not valid JSON" +[[ "$(jq -r .version <<<"$out")" == "0.71.1" ]] || fail "version" +[[ "$(jq -r '.architecture["64bit"].url' <<<"$out")" == "https://github.com/smart-mcp-proxy/mcpproxy-go/releases/download/v0.71.1/mcpproxy-0.71.1-windows-amd64.zip" ]] || fail "amd64 url" +[[ "$(jq -r '.architecture["64bit"].hash' <<<"$out")" == "b12a610322c76eac3a283ae4099497c32c2568be69a46952228a94c6fa9d6d8e" ]] || fail "amd64 hash" +[[ "$(jq -r '.architecture.arm64.hash' <<<"$out")" == "ff924617ed04072ce9a221ce23b608ad6f7e4fff7d59df4471513503dc277ee4" ]] || fail "arm64 hash" +[[ "$(jq -r '.bin[0]' <<<"$out")" == "mcpproxy.exe" ]] || fail "bin" +[[ "$(jq -r '.autoupdate.hash.url' <<<"$out")" == *'v$version/checksums.txt' ]] || fail "autoupdate hash url" +[[ "$(jq -r '.checkver.github' <<<"$out")" == "https://github.com/smart-mcp-proxy/mcpproxy-go" ]] || fail "checkver" + +# the -latest aliases must never be picked: the pinned asset name is exact +grep -q 'mcpproxy-latest-windows' <<<"$out" && fail "manifest references a -latest alias" + +# fails when the release lacks an asset hash +tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT +grep -v 'windows-arm64.zip' "$SUMS" >"$tmp" +$G v0.71.1 "$tmp" >/dev/null 2>&1 && fail "accepted checksums without arm64" +$G v0.71.1-rc.1 "$SUMS" >/dev/null 2>&1 && fail "accepted an RC tag" +$G v9.9.9 "$SUMS" >/dev/null 2>&1 && fail "accepted a version with no hashes" +echo "ok gen-scoop-manifest" diff --git a/scripts/gen-winget-manifest.sh b/scripts/gen-winget-manifest.sh new file mode 100755 index 000000000..1e4e64b93 --- /dev/null +++ b/scripts/gen-winget-manifest.sh @@ -0,0 +1,103 @@ +#!/usr/bin/env bash +# Render the winget (microsoft/winget-pkgs) manifest set for a release +# (Spec 121 FR-011/FR-013). +# +# scripts/gen-winget-manifest.sh v0.71.1 checksums.txt OUTDIR +# +# Writes the three-file multi-manifest (version / installer / defaultLocale) +# for package smart-mcp-proxy.mcpproxy. Installer URLs and SHA-256 come from +# the release's own checksums.txt. The installer is the signed Inno Setup exe; +# winget passes /CHANNEL=winget so the install records its channel marker +# (scripts/installer.iss), and the silent switches keep it dialog-free. +set -euo pipefail + +TAG="${1:-}"; SUMS="${2:-}"; OUT="${3:-}" +REPO="${REPO:-smart-mcp-proxy/mcpproxy-go}" +PKG="${PACKAGE_ID:-smart-mcp-proxy.mcpproxy}" +MANIFEST_VERSION="1.9.0" +# Inno AppId (scripts/installer.iss UpgradeCode) + "_is1" = the ARP ProductCode. +PRODUCT_CODE="{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}_is1" + +if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "gen-winget-manifest: tag must be a stable vX.Y.Z (got '${TAG}')" >&2; exit 2 +fi +[[ -f "$SUMS" ]] || { echo "gen-winget-manifest: checksums file '${SUMS}' not found" >&2; exit 2; } +[[ -n "$OUT" ]] || { echo "usage: gen-winget-manifest.sh vX.Y.Z checksums.txt OUTDIR" >&2; exit 2; } +VER="${TAG#v}" +BASE="https://github.com/${REPO}/releases/download/${TAG}" + +sha_for() { # winget wants upper-case hex + local h + h="$(awk -v f="$1" '$2 == f {print toupper($1); exit}' "$SUMS")" + [[ "$h" =~ ^[A-F0-9]{64}$ ]] || { echo "gen-winget-manifest: no sha256 for $1 in $SUMS" >&2; return 1; } + printf '%s' "$h" +} +AMD="mcpproxy-setup-${TAG}-amd64.exe"; ARM="mcpproxy-setup-${TAG}-arm64.exe" +AMD_SHA="$(sha_for "$AMD")" || exit 3 +ARM_SHA="$(sha_for "$ARM")" || exit 3 + +mkdir -p "$OUT" +cat >"$OUT/${PKG}.yaml" <"$OUT/${PKG}.installer.yaml" <"$OUT/${PKG}.locale.en-US.yaml" <&2 diff --git a/scripts/gen-winget-manifest.test.sh b/scripts/gen-winget-manifest.test.sh new file mode 100755 index 000000000..fb588bdc0 --- /dev/null +++ b/scripts/gen-winget-manifest.test.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# Tests for scripts/gen-winget-manifest.sh (Spec 121 T030). +set -euo pipefail +cd "$(dirname "$0")/.." +G=scripts/gen-winget-manifest.sh +SUMS=scripts/testdata/checksums-v0.71.1.txt +fail() { echo "FAIL: $*" >&2; exit 1; } +out=$(mktemp -d); trap 'rm -rf "$out"' EXIT + +$G v0.71.1 "$SUMS" "$out" 2>/dev/null +for f in smart-mcp-proxy.mcpproxy.yaml smart-mcp-proxy.mcpproxy.installer.yaml smart-mcp-proxy.mcpproxy.locale.en-US.yaml; do + [[ -s "$out/$f" ]] || fail "missing $f" +done +I="$out/smart-mcp-proxy.mcpproxy.installer.yaml" +grep -q '^PackageVersion: 0.71.1$' "$I" || fail "version" +grep -q '^InstallerType: inno$' "$I" || fail "installer type" +grep -q '^ Custom: /CHANNEL=winget$' "$I" || fail "winget channel switch missing" +grep -q '/VERYSILENT /SUPPRESSMSGBOXES /NORESTART' "$I" || fail "silent switches" +grep -q 'InstallerUrl: https://github.com/smart-mcp-proxy/mcpproxy-go/releases/download/v0.71.1/mcpproxy-setup-v0.71.1-amd64.exe' "$I" || fail "amd64 url" +grep -q 'InstallerSha256: 3061B1EC1A2E298CF781C5FF48F48C0C697FB2C8E7AF402F6D956E04179533F5' "$I" || fail "amd64 sha (upper-case)" +grep -q 'InstallerSha256: 14C3030D11765AE31F668D6968B1355989A5323E141C8DA9736F65892902AA33' "$I" || fail "arm64 sha" +# architectures paired with the right exe +awk '/Architecture: x64/{a=1} a&&/InstallerUrl/{print; exit}' "$I" | grep -q 'amd64.exe' || fail "x64 paired with wrong installer" +awk '/Architecture: arm64/{a=1} a&&/InstallerUrl/{print; exit}' "$I" | grep -q 'arm64.exe' || fail "arm64 paired with wrong installer" +# every file parses as YAML and shares identifier+version +for f in "$out"/*.yaml; do + python3 -I - "$f" <<'PY' || fail "$f is not valid YAML or lacks identity" +import sys, re +t = open(sys.argv[1]).read() +assert re.search(r'^PackageIdentifier: smart-mcp-proxy\.mcpproxy$', t, re.M) +assert re.search(r'^PackageVersion: 0\.71\.1$', t, re.M) +assert re.search(r'^ManifestVersion: 1\.9\.0$', t, re.M) +PY +done +# Schema validation against Microsoft's published schemas when check-jsonschema +# (or uvx) and the network are available; a network failure only warns. +CJ=""; command -v check-jsonschema >/dev/null 2>&1 && CJ="check-jsonschema" +[[ -z "$CJ" ]] && command -v uvx >/dev/null 2>&1 && CJ="uvx check-jsonschema" +if [[ -n "$CJ" ]]; then + for pair in "version:smart-mcp-proxy.mcpproxy.yaml" "installer:smart-mcp-proxy.mcpproxy.installer.yaml" "defaultLocale:smart-mcp-proxy.mcpproxy.locale.en-US.yaml"; do + kind="${pair%%:*}"; file="$out/${pair#*:}" + if ! res=$($CJ --schemafile "https://aka.ms/winget-manifest.${kind}.1.9.0.schema.json" "$file" 2>&1); then + if grep -qiE 'validation error|is not valid|Failed validating' <<<"$res"; then fail "winget ${kind} manifest violates the schema: $res"; fi + echo "note: ${kind} schema check skipped (offline?)" >&2 + fi + done +fi +rm -rf "$out"; out=$(mktemp -d) +grep -v 'setup-v0.71.1-arm64.exe' "$SUMS" >"$out/sums" +$G v0.71.1 "$out/sums" "$out/o" >/dev/null 2>&1 && fail "accepted checksums without arm64 installer" +$G v0.71.1-rc.1 "$SUMS" "$out/o" >/dev/null 2>&1 && fail "accepted an RC tag" +echo "ok gen-winget-manifest" diff --git a/scripts/installer.iss b/scripts/installer.iss index 4c981226c..0330d3203 100644 --- a/scripts/installer.iss +++ b/scripts/installer.iss @@ -75,6 +75,10 @@ Source: "{#BinPath}\mcpproxy-tray.exe"; DestDir: "{app}"; Flags: ignoreversion Source: "{#ReleaseNotesPath}"; DestDir: "{app}\docs"; DestName: "RELEASE_NOTES.md"; Flags: ignoreversion #endif +[UninstallDelete] +; Spec 121: the channel marker is installer-owned and removed with the app. +Type: files; Name: "{app}\install-channel" + [Icons] ; Start Menu shortcut (FR-004) Name: "{group}\{#AppName}"; Filename: "{app}\{#AppExeName}"; WorkingDir: "{app}" @@ -146,12 +150,36 @@ begin // PATH length validation (FR-015) - Windows limit is 2047 characters if Result and (Length(OrigPath) + Length(Param) + 1 > 2047) then begin - MsgBox('Warning: Adding MCPProxy to PATH would exceed Windows limit (2047 characters).' + #13#10 + - 'You may need to manually clean up your PATH environment variable.', mbError, MB_OK); + // Spec 121 FR-014: winget (and any /VERYSILENT deployment) must never block + // on a dialog. In silent mode the PATH is left untouched and the install + // still succeeds; `mcpproxy.exe` remains reachable from the install dir. + if not WizardSilent then + MsgBox('Warning: Adding MCPProxy to PATH would exceed Windows limit (2047 characters).' + #13#10 + + 'You may need to manually clean up your PATH environment variable.', mbError, MB_OK); Result := False; end; end; +// Install-channel marker (Spec 121 FR-014). winget runs this installer with +// /CHANNEL=winget; the marker beside the installed executable lets the core +// report the winget channel in telemetry. Any other run (a direct download, +// an upgrade over a winget install) removes a stale marker so attribution +// can never outlive the channel it described. Only the exact token "winget" +// is ever written; any other /CHANNEL value is ignored. +procedure CurStepChanged(CurStep: TSetupStep); +var + MarkerPath: string; +begin + if CurStep = ssPostInstall then + begin + MarkerPath := ExpandConstant('{app}\install-channel'); + if Lowercase(ExpandConstant('{param:CHANNEL|}')) = 'winget' then + SaveStringToFile(MarkerPath, 'winget', False) + else + DeleteFile(MarkerPath); + end; +end; + // Process detection (optional - check if mcpproxy apps are running) function InitializeSetup(): Boolean; begin diff --git a/scripts/sync-agent-prompt.sh b/scripts/sync-agent-prompt.sh new file mode 100755 index 000000000..857cffd1f --- /dev/null +++ b/scripts/sync-agent-prompt.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# Keep the README "Paste this into your agent" block identical to the prompt in +# docs/getting-started/agent-install.md (Spec 121 FR-021). The docs page is the +# single source of truth. +# +# scripts/sync-agent-prompt.sh # rewrite the README block from the docs page +# scripts/sync-agent-prompt.sh --check # exit 1 if they differ (CI) +set -euo pipefail +cd "$(dirname "$0")/.." + +SRC="${AGENT_PROMPT_SRC:-docs/getting-started/agent-install.md}" +DST="${AGENT_PROMPT_DST:-README.md}" +START='' +END='' + +extract() { # : print the marked region including markers + awk -v s="$START" -v e="$END" '$0==s{p=1} p{print} $0==e{p=0}' "$1" +} + +want="$(extract "$SRC")" +[[ -n "$want" ]] || { echo "sync-agent-prompt: no marked prompt in $SRC" >&2; exit 2; } +have="$(extract "$DST")" +[[ -n "$have" ]] || { echo "sync-agent-prompt: no marked block in $DST (add the start/end markers)" >&2; exit 2; } + +if [[ "${1:-}" == "--check" ]]; then + if [[ "$want" != "$have" ]]; then + echo "sync-agent-prompt: $DST has drifted from $SRC; run scripts/sync-agent-prompt.sh" >&2 + diff <(printf '%s\n' "$want") <(printf '%s\n' "$have") >&2 || true + exit 1 + fi + echo "ok: agent prompt in sync" + exit 0 +fi + +tmp="$(mktemp)"; wantf="$(mktemp)" +trap 'rm -f "$tmp" "$wantf"' EXIT +printf '%s\n' "$want" >"$wantf" +awk -v s="$START" -v e="$END" -v f="$wantf" ' + $0==s {while ((getline l < f) > 0) print l; skip=1; next} + $0==e {skip=0; next} + !skip {print} +' "$DST" >"$tmp" +cat "$tmp" >"$DST" +echo "updated $DST" diff --git a/scripts/sync-agent-prompt.test.sh b/scripts/sync-agent-prompt.test.sh new file mode 100755 index 000000000..63d3b56bb --- /dev/null +++ b/scripts/sync-agent-prompt.test.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# Tests for scripts/sync-agent-prompt.sh (Spec 121 T040). +set -euo pipefail +cd "$(dirname "$0")/.." +S=scripts/sync-agent-prompt.sh +fail() { echo "FAIL: $*" >&2; exit 1; } +d=$(mktemp -d); trap 'rm -rf "$d"' EXIT + +cat >"$d/src.md" <<'EOF' +intro + +```text +step one +step two $VAR "quoted" \ backslash +``` + +outro +EOF +cat >"$d/dst.md" <<'EOF' +# readme +before + +old text + +after +EOF +export AGENT_PROMPT_SRC="$d/src.md" AGENT_PROMPT_DST="$d/dst.md" + +$S --check >/dev/null 2>&1 && fail "drift not detected" +$S >/dev/null +$S --check >/dev/null || fail "not in sync after sync" +grep -q 'step two \$VAR "quoted" \\ backslash' "$d/dst.md" || fail "special characters mangled" +grep -q '^before$' "$d/dst.md" && grep -q '^after$' "$d/dst.md" || fail "surrounding text lost" +grep -q 'old text' "$d/dst.md" && fail "old block survived" + +# the repository's own files are in sync +unset AGENT_PROMPT_SRC AGENT_PROMPT_DST +$S --check >/dev/null || fail "README.md has drifted from docs/getting-started/agent-install.md" + +# missing markers are an error, not a silent pass +echo "no markers" >"$d/none.md" +AGENT_PROMPT_SRC="$d/none.md" AGENT_PROMPT_DST="$d/dst.md" $S --check >/dev/null 2>&1 && fail "accepted a source without markers" +echo "ok sync-agent-prompt" diff --git a/scripts/testdata/checksums-v0.71.1.txt b/scripts/testdata/checksums-v0.71.1.txt new file mode 100644 index 000000000..1274e435b --- /dev/null +++ b/scripts/testdata/checksums-v0.71.1.txt @@ -0,0 +1,24 @@ +1de05b6905a71b13a04fabbf3a97e13e0a3feea1ddd1de8a48171c6c17bebbb4 CHANGELOG.md +0361bf874ca75f72de0edc8681f06d64003674d2f7946308b5c6043bb1f6dc9c mcpproxy-0.71.1-1.aarch64.rpm +7e6c8919e4f8890e5a13d65366a4b0e320b6e7a0e7ff4aa844126792d7950e2b mcpproxy-0.71.1-1.x86_64.rpm +3b880103c09157d20c3f042e53a1d4d6f4c0737c1d711f062bcc3ecb82241a03 mcpproxy-0.71.1-darwin-amd64-installer.dmg +cd63f1a31585436207ff0698244a8c24633c11f950d8db906e990d3df6013964 mcpproxy-0.71.1-darwin-amd64.app.zip +bb0d7c9571c61b6e362728da5489de18efceb99b151feb73e21b32f615672212 mcpproxy-0.71.1-darwin-amd64.tar.gz +872ec16e16cacae402e7ad51b46222d758a0196e38d16160bb3734591837762a mcpproxy-0.71.1-darwin-arm64-installer.dmg +25b2ab9bee86e43ce78b5f0d5654d40a61b400b02ffd9a2b83c2a4ceb924abc7 mcpproxy-0.71.1-darwin-arm64.app.zip +24752b5f2ead7e63831cb1f1f7f613b488791311101ac3172968a3729ca18dc1 mcpproxy-0.71.1-darwin-arm64.tar.gz +d312bd3516ba39c705958a097b889402d08b07a74f67128394f4e27610e182fc mcpproxy-0.71.1-linux-amd64.tar.gz +af407f17a72a3da7351780af6f87e6359b04d39081c1cad47946ce0939b109b8 mcpproxy-0.71.1-linux-arm64.tar.gz +b12a610322c76eac3a283ae4099497c32c2568be69a46952228a94c6fa9d6d8e mcpproxy-0.71.1-windows-amd64.zip +ff924617ed04072ce9a221ce23b608ad6f7e4fff7d59df4471513503dc277ee4 mcpproxy-0.71.1-windows-arm64.zip +6e525f2e7efb210a4854b8b788b9b78b682d69d5f061f71f03ba6a56a857ba45 mcpproxy-0.71.1.spdx.json +0d43e9c280caa376421582afd8b1a2fcd3cb264b8d07aa59de4e896c0b957254 mcpproxy-latest-darwin-amd64.tar.gz +3fa4d60c52bd7947b70521553b21bdb681336e0bbf00950221070d01629c9e27 mcpproxy-latest-darwin-arm64.tar.gz +d312bd3516ba39c705958a097b889402d08b07a74f67128394f4e27610e182fc mcpproxy-latest-linux-amd64.tar.gz +af407f17a72a3da7351780af6f87e6359b04d39081c1cad47946ce0939b109b8 mcpproxy-latest-linux-arm64.tar.gz +b12a610322c76eac3a283ae4099497c32c2568be69a46952228a94c6fa9d6d8e mcpproxy-latest-windows-amd64.zip +ff924617ed04072ce9a221ce23b608ad6f7e4fff7d59df4471513503dc277ee4 mcpproxy-latest-windows-arm64.zip +3061b1ec1a2e298cf781c5ff48f48c0c697fb2c8e7af402f6d956e04179533f5 mcpproxy-setup-v0.71.1-amd64.exe +14c3030d11765ae31f668d6968b1355989a5323e141c8da9736f65892902aa33 mcpproxy-setup-v0.71.1-arm64.exe +6da0e79f90f58bd6e1d611a046809bcaebd30591f1435f237310753be85ca681 mcpproxy_0.71.1_amd64.deb +9372281ad36b96f2d9fac1fbc2648b2d696873fc068da04d2cbb3d1ea4bf4863 mcpproxy_0.71.1_arm64.deb diff --git a/website/docusaurus.config.js b/website/docusaurus.config.js index 8fd119956..1a0be1266 100644 --- a/website/docusaurus.config.js +++ b/website/docusaurus.config.js @@ -1,8 +1,71 @@ // @ts-check // Note: type annotations allow type checking and IDEs autocompletion +const fs = require('fs'); +const path = require('path'); const {themes: prismThemes} = require('prism-react-renderer'); +// Published documentation: the Docusaurus docs plugin serves ONLY these paths. +// Hoisted into constants so the llms.txt generator below can be restricted to +// exactly the same set (Spec 121): the llms plugin walks the whole docs +// directory, so without this it advertised ~70 internal plans/audits/research +// pages whose URLs 404 on the live site. +const docsInclude = [ + 'intro.md', + 'getting-started/**/*.{md,mdx}', + 'configuration/**/*.{md,mdx}', + 'cli/**/*.{md,mdx}', + 'api/**/*.{md,mdx}', + 'web-ui/**/*.{md,mdx}', + 'features/**/*.{md,mdx}', + 'code_execution/**/*.{md,mdx}', + 'operations/**/*.{md,mdx}', + 'errors/**/*.{md,mdx}', + 'development/**/*.{md,mdx}', + 'contributing.md', + // Standalone references with no structured counterpart + 'cli-client-mode.md', + 'cli-output-formatting.md', + 'logging.md', + 'prerelease-builds.md', + 'registries.md', + 'socket-communication.md', +]; +// Internal working documents that live in the repo but are not published. +const docsExclude = [ + 'development/sandbox-snap-docker-harness.md', + 'development/sandbox-spike-mcp-34.md', +]; + +const llmsRootContent = [ + 'MCPProxy is a local-first smart proxy for the Model Context Protocol: it indexes every upstream MCP server\'s tools for BM25 discovery, quarantines unreviewed servers and tool changes, and cuts tool-schema token cost.', + '', + 'To install, start and connect it from an agent, follow https://docs.mcpproxy.app/getting-started/agent-install (a paste-ready prompt for Claude Code and Codex). Package managers: Homebrew (`brew install smart-mcp-proxy/mcpproxy/mcpproxy`), winget (`winget install smart-mcp-proxy.mcpproxy`), scoop, apt/dnf, Docker.', +].join('\n'); + +/** + * ignoreFiles patterns for docusaurus-plugin-llms: every markdown file/dir in + * the docs tree that the docs plugin does NOT publish. Derived from + * docsInclude by top-level name, so adding a page to the allowlist also adds + * it to the llms bundles with no second list to maintain. + */ +function unpublishedDocsPatterns() { + const docsDir = path.join(__dirname, 'docs'); + if (!fs.existsSync(docsDir)) return []; + const allowedFiles = new Set(docsInclude.filter((g) => !g.includes('/'))); + const allowedDirs = new Set(docsInclude.filter((g) => g.includes('/')).map((g) => g.split('/')[0])); + const out = []; + for (const entry of fs.readdirSync(docsDir, {withFileTypes: true})) { + if (entry.isDirectory()) { + if (!allowedDirs.has(entry.name)) out.push(`${entry.name}/**`); + } else if (/\.mdx?$/.test(entry.name) && !allowedFiles.has(entry.name)) { + out.push(entry.name); + } + } + // Explicitly excluded pages inside a published directory. + return out.concat(docsExclude); +} + /** @type {import('@docusaurus/types').Config} */ // Version shown in the navbar badge. Supplied by CI: docs.yml derives it from // the latest stable release tag reachable from main, and release.yml's @@ -54,32 +117,9 @@ const config = { sidebarPath: './sidebars.js', editUrl: 'https://github.com/smart-mcp-proxy/mcpproxy-go/edit/main/', // Only include structured documentation pages - include: [ - 'intro.md', - 'getting-started/**/*.{md,mdx}', - 'configuration/**/*.{md,mdx}', - 'cli/**/*.{md,mdx}', - 'api/**/*.{md,mdx}', - 'web-ui/**/*.{md,mdx}', - 'features/**/*.{md,mdx}', - 'code_execution/**/*.{md,mdx}', - 'operations/**/*.{md,mdx}', - 'errors/**/*.{md,mdx}', - 'development/**/*.{md,mdx}', - 'contributing.md', - // Standalone references with no structured counterpart - 'cli-client-mode.md', - 'cli-output-formatting.md', - 'logging.md', - 'prerelease-builds.md', - 'registries.md', - 'socket-communication.md', - ], + include: docsInclude, // Internal working documents that live in the repo but are not published - exclude: [ - 'development/sandbox-snap-docker-harness.md', - 'development/sandbox-spike-mcp-34.md', - ], + exclude: docsExclude, }, blog: false, theme: { @@ -97,7 +137,20 @@ const config = { generateLLMsFullTxt: true, excludeImports: true, removeDuplicateHeadings: true, + // Spec 121: bound llms-full.txt for agents. `development/**` holds + // contributor plans, audits and research reports (about 2.2 MB of the + // old 3 MB file) that no end-user agent task needs; the pages stay + // published on the site, only the LLM bundles omit them. The agent + // install page sorts first (see includeOrder) and + // website/scripts/check-llms.mjs enforces both invariants after build. + ignoreFiles: unpublishedDocsPatterns().concat(['intro.md']), + // intro.md is served at "/" (slug: /) but the plugin links it as + // /docs, which 404s, so it is left out of the index and replaced by + // this short, agent-oriented summary that leads with how to install. + rootContent: llmsRootContent, + fullRootContent: llmsRootContent, includeOrder: [ + 'getting-started/agent-install*', 'getting-started/*', 'configuration/*', 'cli/*', diff --git a/website/package.json b/website/package.json index 3897d1176..0806c15e9 100644 --- a/website/package.json +++ b/website/package.json @@ -11,7 +11,9 @@ "clear": "docusaurus clear", "serve": "docusaurus serve", "write-translations": "docusaurus write-translations", - "write-heading-ids": "docusaurus write-heading-ids" + "write-heading-ids": "docusaurus write-heading-ids", + "postbuild": "node scripts/check-llms.mjs build", + "test:llms": "node --test scripts/check-llms.test.mjs" }, "dependencies": { "@docusaurus/core": "^3.10.2", diff --git a/website/scripts/check-llms.mjs b/website/scripts/check-llms.mjs new file mode 100644 index 000000000..ae171a458 --- /dev/null +++ b/website/scripts/check-llms.mjs @@ -0,0 +1,57 @@ +// Post-build guard for the LLM bundles (Spec 121 FR-022/FR-023). +// +// node scripts/check-llms.mjs [buildDir] +// +// docusaurus-plugin-llms walks the whole docs directory, so it once advertised +// ~70 internal pages whose URLs 404 on the live site. This check fails the docs +// build when +// - llms.txt / llms-full.txt is missing, +// - the agent install page is not the first entry of llms.txt, +// - any llms.txt link does not resolve to a page that was actually built, or +// - llms-full.txt exceeds the size budget agents can reasonably ingest. +import { existsSync, readFileSync, statSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export const MAX_FULL_BYTES = 2 * 1024 * 1024; // 2 MiB (was ~3 MB with unpublished pages) +export const FIRST_PAGE = '/getting-started/agent-install'; +const SITE = 'https://docs.mcpproxy.app'; + +/** @returns {string[]} problems; empty when the bundles are healthy. */ +export function checkLlms(buildDir) { + const problems = []; + const txt = path.join(buildDir, 'llms.txt'); + const full = path.join(buildDir, 'llms-full.txt'); + if (!existsSync(txt)) problems.push('llms.txt was not generated'); + if (!existsSync(full)) problems.push('llms-full.txt was not generated'); + if (problems.length) return problems; + + const links = [...readFileSync(txt, 'utf8').matchAll(/^- \[[^\]]*\]\((https?:\/\/[^)\s]+)\)/gm)].map((m) => m[1]); + if (links.length === 0) problems.push('llms.txt lists no pages'); + else if (new URL(links[0]).pathname.replace(/\/$/, '') !== FIRST_PAGE) { + problems.push(`llms.txt must start with ${FIRST_PAGE}, starts with ${links[0]}`); + } + for (const link of links) { + const u = new URL(link); + if (`${u.protocol}//${u.host}` !== SITE) continue; // external links are not ours to verify + const rel = u.pathname.replace(/^\/|\/$/g, ''); + const candidates = [path.join(buildDir, rel, 'index.html'), path.join(buildDir, `${rel}.html`)]; + if (!candidates.some((c) => existsSync(c))) { + problems.push(`llms.txt links to ${link}, which is not a built page (404 on the live site)`); + } + } + const size = statSync(full).size; + if (size > MAX_FULL_BYTES) problems.push(`llms-full.txt is ${size} bytes, over the ${MAX_FULL_BYTES} byte budget`); + return problems; +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + const dir = path.resolve(process.argv[2] ?? 'build'); + const problems = checkLlms(dir); + if (problems.length) { + console.error('check-llms: FAILED'); + for (const p of problems) console.error(` - ${p}`); + process.exit(1); + } + console.log(`check-llms: ok (${path.join(dir, 'llms.txt')})`); +} diff --git a/website/scripts/check-llms.test.mjs b/website/scripts/check-llms.test.mjs new file mode 100644 index 000000000..933e4794c --- /dev/null +++ b/website/scripts/check-llms.test.mjs @@ -0,0 +1,40 @@ +// node --test scripts/check-llms.test.mjs (Spec 121 T043) +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { checkLlms, MAX_FULL_BYTES } from './check-llms.mjs'; + +function fixture({ links, full = 'x', pages = [] }) { + const dir = mkdtempSync(path.join(tmpdir(), 'llms-')); + writeFileSync(path.join(dir, 'llms.txt'), links.map((l) => `- [t](${l}): d`).join('\n')); + writeFileSync(path.join(dir, 'llms-full.txt'), full); + for (const p of pages) { + mkdirSync(path.join(dir, p), { recursive: true }); + writeFileSync(path.join(dir, p, 'index.html'), ''); + } + return dir; +} +const S = 'https://docs.mcpproxy.app'; + +test('healthy bundle passes', () => { + const dir = fixture({ links: [`${S}/getting-started/agent-install`, `${S}/cli/x`], pages: ['getting-started/agent-install', 'cli/x'] }); + assert.deepEqual(checkLlms(dir), []); +}); +test('install page must be first', () => { + const dir = fixture({ links: [`${S}/cli/x`, `${S}/getting-started/agent-install`], pages: ['cli/x', 'getting-started/agent-install'] }); + assert.match(checkLlms(dir).join('\n'), /must start with/); +}); +test('a link to an unpublished page fails (the 404 class)', () => { + const dir = fixture({ links: [`${S}/getting-started/agent-install`, `${S}/docs/superpowers/plans/x`], pages: ['getting-started/agent-install'] }); + assert.match(checkLlms(dir).join('\n'), /not a built page/); +}); +test('oversized llms-full fails', () => { + const dir = fixture({ links: [`${S}/getting-started/agent-install`], pages: ['getting-started/agent-install'], full: 'x'.repeat(MAX_FULL_BYTES + 1) }); + assert.match(checkLlms(dir).join('\n'), /budget/); +}); +test('missing files fail', () => { + const dir = mkdtempSync(path.join(tmpdir(), 'llms-')); + assert.match(checkLlms(dir).join('\n'), /not generated/); +}); diff --git a/website/sidebars.js b/website/sidebars.js index f8975e67f..0c0e56a97 100644 --- a/website/sidebars.js +++ b/website/sidebars.js @@ -26,6 +26,7 @@ const sidebars = { // "Getting Started" card used to land. link: { type: 'generated-index', slug: '/getting-started' }, items: [ + 'getting-started/agent-install', 'getting-started/installation', 'getting-started/quick-start', ], From a881894f186fcc5603de8504d049d3ef0ce62e7f Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Tue, 20 Oct 2026 19:24:16 +0300 Subject: [PATCH 4/6] feat(distribution): keep docs allowlist parseable, prompt background-mode hint, CLAUDE.md entry, review log Co-Authored-By: Claude Sonnet 5.5 --- CLAUDE.md | 1 + README.md | 1 + .../distribution_workflow_test.go | 2 +- docs/getting-started/agent-install.md | 1 + specs/121-install-surfaces/plan.md | 27 +++++--- specs/121-install-surfaces/review-log.md | 18 +++++ specs/121-install-surfaces/spec.md | 4 +- website/docusaurus.config.js | 68 ++++++++++--------- 8 files changed, 78 insertions(+), 44 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 2df9189ce..f29e29483 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -146,6 +146,7 @@ tail -f ~/Library/Logs/mcpproxy/main.log # main log (macOS; Linux: ~/.mcpproxy/ - **Windows installer**: [docs/github-actions-windows-wix-research.md](docs/github-actions-windows-wix-research.md). **Prerelease** (`next` branch + `v*-rc.*` tags, opt-in, off stable channels): [docs/prerelease-builds.md](docs/prerelease-builds.md). ## Recent Changes +- 121-install-surfaces (E7): Go 1.26 + CI/docs — `server.json` OCI package (image label `io.modelcontextprotocol.server.name`, `scripts/stamp-server-json.sh` + `scripts/registry-preflight.sh` shared by `release.yml` and `retry-sign-release.yml`), reusable `publish-windows-packages.yml` (scoop bucket + winget PR from `scripts/gen-{scoop,winget}-manifest.sh`), Inno `/CHANNEL=winget` marker, new `scoop` update channel (no self-update), heartbeat `install_channel` closed enum (`internal/telemetry/install_channel.go`, no schema bump), agent install prompt (`docs/getting-started/agent-install.md` ↔ README via `scripts/sync-agent-prompt.sh`) and `website/scripts/check-llms.mjs` (llms.txt links must resolve). Docs: `docs/release-runbook.md` "Windows package managers". **No new dependencies.** - quarantine-approval-pilot: Go 1.26, tests + CI only — independent policy model `internal/qapilot` (no production imports), deterministic scenario inventory S01–S26 + 896-cell dispatch table + REST authz table + counting-upstream dispatch check, check ledger (`QAPILOT_REPORT_DIR`) gated by `go run ./cmd/qapilot report`, versioned mutation set `tests/qapilot/mutations.json` run by `qapilot mutate` in disposable worktrees; workflow `.github/workflows/quarantine-pilot.yml` (3 PR checks + scheduled rapid exploration). Docs: `docs/development/quarantine-approval-pilot.md`. **No new dependencies.** - 117-review-bound-approvals PR-3 (#1565): Go 1.26, backend-only — every upstream `tools/list` carries a `managed.InventoryCapture{Seq, Epoch, Client}` (process-global sequence stamped by the flight leader; `ListToolsCaptured`/`ListToolsFreshCaptured`; `Client.Retire`/`CaptureIsCurrent`; the manager retires replaced/removed clients; `Manager.DiscoverToolsReportCaptured` gives the sweep one capture per server). The runtime admits each inventory under the server's approval lock (`admitInventoryHeld`, per-server `applied`/`definitions` in `toolApprovalServerLock`): an older, retired or reconnected capture writes nothing (records, index, last-good snapshot + its capture, StateView); a lenient empty result is an observation fence; the approval reindex/sweep fallback re-apply the snapshot only while its capture is current, else re-list/skip. Docs: `docs/features/security-quarantine.md` "Tool lists are applied in the order they were captured". **No new dependencies.** - 116-config-commit-consistency PR2: Go 1.26 + a small Web UI change — removal purges the whole name-keyed purge set in the commit's bbolt transaction (`internal/storage/purge.go`, exact OAuth key match), in-memory purge, incarnation-bound approval writers (`internal/runtime/approval_incarnation.go`, REST `expected_incarnation` → 409 `server_replaced`), incarnation-conditional scan/identity/history writes. **No new dependencies.** diff --git a/README.md b/README.md index 6a6f90394..1609f70fb 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,7 @@ RULES 3. START IT mcpproxy serve --listen 127.0.0.1:8080 > ~/.mcpproxy/serve.log 2>&1 & # Windows: Start-Process mcpproxy -ArgumentList serve -WindowStyle Hidden If port 8080 is taken, use another, e.g. --listen 127.0.0.1:18080. + It must keep running after your command returns: start it with your tool's background/long-running mode rather than relying on a trailing `&`, which some agent shells kill when the call ends. Wait until `mcpproxy status` reports it is running (retry for up to 30 seconds). 4. VERIFY (these commands print masked output) diff --git a/cmd/release-gate/distribution_workflow_test.go b/cmd/release-gate/distribution_workflow_test.go index 6fbad0b33..6e5df6fe6 100644 --- a/cmd/release-gate/distribution_workflow_test.go +++ b/cmd/release-gate/distribution_workflow_test.go @@ -95,7 +95,7 @@ func TestRegistryPublishersShareStampAndPreflight(t *testing.T) { if stamp < 0 || pre < 0 || pub < 0 { t.Fatalf("%s mcp-registry must stamp (%d), preflight (%d) and publish (%d)", name, stamp, pre, pub) } - if !(stamp < pre && pre < pub) { + if stamp >= pre || pre >= pub { t.Errorf("%s: order must be stamp < preflight < publish (got %d, %d, %d)", name, stamp, pre, pub) } if strings.Contains(run, `'.version = $v'`) { diff --git a/docs/getting-started/agent-install.md b/docs/getting-started/agent-install.md index 588e22535..b89314539 100644 --- a/docs/getting-started/agent-install.md +++ b/docs/getting-started/agent-install.md @@ -46,6 +46,7 @@ RULES 3. START IT mcpproxy serve --listen 127.0.0.1:8080 > ~/.mcpproxy/serve.log 2>&1 & # Windows: Start-Process mcpproxy -ArgumentList serve -WindowStyle Hidden If port 8080 is taken, use another, e.g. --listen 127.0.0.1:18080. + It must keep running after your command returns: start it with your tool's background/long-running mode rather than relying on a trailing `&`, which some agent shells kill when the call ends. Wait until `mcpproxy status` reports it is running (retry for up to 30 seconds). 4. VERIFY (these commands print masked output) diff --git a/specs/121-install-surfaces/plan.md b/specs/121-install-surfaces/plan.md index 14d4c6cf4..57874044a 100644 --- a/specs/121-install-surfaces/plan.md +++ b/specs/121-install-surfaces/plan.md @@ -48,7 +48,7 @@ No violations. ### B. Windows - New reusable workflow `.github/workflows/publish-windows-packages.yml` (`workflow_call` + `workflow_dispatch` with `tag`): jobs `scoop` and `winget`, both `if: !contains(tag,'-')`, each first checking its secret/repo is configured (else `::notice::` and exit 0). - `scripts/gen-scoop-manifest.sh ` → `mcpproxy.json` (version, per-arch `architecture.{64bit,arm64}.{url,hash}` from `mcpproxy--windows-.zip`, `bin: [mcpproxy.exe]`, `shortcuts` for the tray, `checkver: github`, `autoupdate` with `$version` templates and a `hash` url to `checksums.txt`). Job commits it to `smart-mcp-proxy/scoop-bucket/bucket/mcpproxy.json` using `SCOOP_BUCKET_TOKEN`; idempotent (no diff → no commit). -- winget: SHA-pinned `vedantmgoyal9/winget-releaser` (wingetcreate wrapper), `identifier: smart-mcp-proxy.mcpproxy`, `installers-regex: 'mcpproxy-setup-v.*-(amd64|arm64)\.exe$'`, token `WINGET_TOKEN`; a checked-in template `packaging/windows/winget/` (YAML 3-file skeleton incl. `InstallerType: inno`, `Scope: machine`, `InstallerSwitches.Custom: /CHANNEL=winget`, `Silent: /VERYSILENT /SUPPRESSMSGBOXES /NORESTART`) is what the human uses for the one-time first submission (`wingetcreate new` flags in docs). Job only runs if the package exists in winget-pkgs (checked via the GitHub API), else notice. +- winget: `scripts/gen-winget-manifest.sh ` renders the 3-file manifest (`InstallerType: inno`, `Scope: machine`, `InstallerSwitches.Custom: /CHANNEL=winget`, silent switches, `ProductCode` from the Inno AppId, x64/arm64 paired with the right exe); fixture-tested and schema-validated against Microsoft's published schemas. The `winget` job opens the PR to `microsoft/winget-pkgs` from the maintainer's fork with `gh` (no third-party action: `winget-releaser` pulls `cargo-binstall@main` unpinned), only if the package already exists upstream and the version is neither merged nor has an open PR. The first submission is manual (runbook). - `release.yml` calls the reusable workflow after `release`; `retry-sign-release.yml` calls it too (post-release-only path). - Inno (`scripts/installer.iss`): `{param:CHANNEL|}`-style handling in `[Code]`: write `{app}\install-channel` (value validated to `winget`) in `CurStepChanged(ssPostInstall)` if the param is present, else delete a stale one; `[UninstallDelete]` removes it; `NeedsAddPath`'s `MsgBox` guarded by `not WizardSilent`. - Detector: `ChannelScoop="scoop"` in `knownChannels`; path heuristic (`\scoop\apps\mcpproxy\` or `\scoop\persist\`, case-insensitive, slash-normalized) before the tarball fallback; `UpdateCommand` → `scoop update mcpproxy`; `decideAction` → command; `UpdateBanner.vue` + generated types doc strings updated. @@ -60,7 +60,7 @@ No violations. - Prompt content: detect OS; check `command -v mcpproxy` / existing instance first and stop to ask if present; macOS/Linux → `brew install smart-mcp-proxy/mcpproxy/mcpproxy` (cask on macOS desktop), Windows → `winget install smart-mcp-proxy.mcpproxy` else `scoop`, else release archive with `checksums.txt` verification and a data-dir marker `curl`; `mcpproxy serve` (background); verify with `mcpproxy doctor` / `mcpproxy upstream list`; `mcpproxy connect `; tell user to reload the client; never read/print config or keys. ### D. Telemetry -- `internal/telemetry/install_channel.go`: `type InstallChannel string` + `AllInstallChannels()`; `ResolveInstallChannel(deps)` where deps injects env getter, exe path, file reader, data dir, detector func — pure and table-testable. `MapDetectorChannel(string) InstallChannel` is total (homebrew→brew, dmg, deb, rpm, docker, go-install, windows-installer, tarball, scoop, unknown→unknown, anything else→unknown). +- `internal/telemetry/install_channel.go`: `type InstallChannel string` + `AllInstallChannels()`; `resolveInstallChannel(deps)` where deps injects env getter, exe path, file reader, data dir, detector func — pure and table-testable. `MapDetectorChannel(string) InstallChannel` is total (homebrew→brew, dmg, deb, rpm, docker, go-install, windows-installer, tarball, scoop, unknown→unknown, anything else→unknown). - `HeartbeatPayload.InstallChannel string json:"install_channel,omitempty"`; filled in `BuildPayload` only on the enabled path; resolved once per process (cached via `sync.Once`, since channel cannot change while running) — preview path uses the same cached resolver but only when telemetry is enabled. No `SchemaVersion` bump (FR-030). - Anonymity scanner (`anonymity.go`) allow-lists the field's closed values; `payload_privacy_test.go` gains a canary (marker content `C:\Users\secret` and an out-of-set token must not appear). - `MCPPROXY_INSTALL_CHANNEL` documented in `docs/features/telemetry.md` and `docs/configuration.md` env table (env var only; no config field → config checklist N/A). @@ -74,21 +74,32 @@ No violations. specs/121-install-surfaces/{spec,plan,research,data-model,quickstart,tasks,review-log,followup-mcpb}.md, contracts/, checklists/ server.json # + packages Dockerfile # + LABEL -scripts/{stamp-server-json,registry-preflight,gen-scoop-manifest,sync-agent-prompt}.sh (+ tests) +scripts/{stamp-server-json,registry-preflight,gen-scoop-manifest,gen-winget-manifest,sync-agent-prompt}.sh (+ tests, testdata/) scripts/installer.iss # channel switch, silent-safe -packaging/windows/winget/ # one-time bootstrap templates -.github/workflows/{release,retry-sign-release,publish-windows-packages,docs}.yml +.github/workflows/{release,retry-sign-release,publish-windows-packages,installer-channel-test,distribution-checks,docs}.yml internal/updatecheck/{channel,guidance}.go (+tests) internal/telemetry/{install_channel,telemetry,anonymity}.go (+tests) cmd/mcpproxy/update_cmd.go (+test) # scoop action frontend/src/components/UpdateBanner.vue, frontend/src/types/contracts.ts, cmd/generate-types website/{docusaurus.config.js,sidebars.js,scripts/check-llms.mjs,package.json} -docs/getting-started/{installation,agent-install}.md, docs/features/telemetry.md, docs/mcp-registry-publishing.md, docs/release-runbook.md, docs/operations/windows-package-managers.md, README.md +docs/getting-started/{installation,agent-install}.md, docs/features/telemetry.md, docs/configuration.md, docs/mcp-registry-publishing.md, docs/release-runbook.md (maintainer steps), README.md ``` ## Risks -- Winget-releaser/first-submission delays are human/Microsoft-bound → job skips cleanly; docs checklist. -- Inno changes cannot be executed on macOS CI; verified by installer.iss review + a Windows runner dry compile is out of reach locally → noted in PR as "needs first-release verification"; the `[Code]` change is minimal. +- First-submission delays are human/Microsoft-bound → winget job skips cleanly until the package exists; checklist in `docs/release-runbook.md`. +- Inno changes cannot be run locally (macOS): `.github/workflows/installer-channel-test.yml` builds a throwaway installer on a Windows runner and exercises silent install / reinstall / uninstall / long-PATH, triggered by changes to `installer.iss`. - Registry immutability: preflight is the only guard; smoke test uses the exact declared args. - Merge collision with concurrent specs on `telemetry.go`/`release.yml`: kept additive and localized. + +## Changes after the Sol plan review (round 1, see review-log.md) + +- Scoop detection also accepts custom `$SCOOP`/`$SCOOP_GLOBAL` roots via scoop's own `install.json` beside the executable. +- Telemetry eligibility is read live under the service mutex (`telemetryEnabledLive`); marker reads accept only regular files, reject oversized files rather than truncating, and never block on a FIFO. +- The reusable Windows workflow is classified as a publisher by the QA-gate audit and has its own invariant tests. +- winget uses our generator, not a third-party action; local generation + schema validation are tested. +- Installer lifecycle is tested on a Windows runner (new workflow), not only reviewed. +- Server.json schema validation runs in PR CI (`distribution-checks.yml`) using the same stamped file the release publishes. +- The llms plugin walked the whole docs tree: the live `llms.txt` advertised ~70 unpublished pages (404). Ignore patterns are now derived from the docs `include` allowlist, `intro.md` (linked as `/docs`, a 404) is replaced by `rootContent`, and `check-llms.mjs` fails the build on any non-resolving link. +- Isolated agent-prompt end-to-end run is scheduled (T045) with redacted evidence in the PR. +- Known, not fixed here: the ARM64 Inno installer declares `ArchitecturesAllowed=x64compatible arm64` (pre-existing); winget selects by manifest architecture so installs are unaffected. diff --git a/specs/121-install-surfaces/review-log.md b/specs/121-install-surfaces/review-log.md index 30e9d879f..5853b009e 100644 --- a/specs/121-install-surfaces/review-log.md +++ b/specs/121-install-surfaces/review-log.md @@ -15,3 +15,21 @@ | 9 | P2 | Inno `MsgBox` blocks silent winget install | Fixed: silent-safe (FR-014, US2.8) | | 10 | P2 | Registry acceptance didn't run the package | Fixed: smoke test with declared env/port (FR-005, US1.7); container smoke-tested manually | | 11 | P3 | "Nothing is sent" vs opt-out beacon; preview path | Fixed: wording + preview gating (US4.6) | + +## Round 1 — plan.md — verdict: CHANGES REQUIRED → 12 of 13 applied, 1 deferred + +| # | Sev | Finding | Disposition | +|---|-----|---------|-------------| +| 1 | P1 | Scoop custom roots fall through to `tarball` → self-update | Fixed: `install.json` evidence beside the exe; tests | +| 2 | P2 | Telemetry eligibility read from a config snapshot, not live | Fixed: `telemetryEnabledLive()` | +| 3 | P2 | Marker reader: FIFO blocking, truncated-prefix acceptance | Fixed: regular-file only, overflow rejects; FIFO/symlink/oversize tests | +| 4 | P2 | New publisher escapes the QA-gate audit | Fixed: signature added + invariant tests | +| 5 | P2 | No local winget generator/validator | Fixed: `gen-winget-manifest.sh` + tests, schema-validated | +| 6 | P2 | winget tag dispatch/idempotence underspecified | Fixed: explicit tag, existing-version/open-PR checks | +| 7 | P2 | ARM64 installer advertises x64 | Deferred (pre-existing; winget unaffected; noted in PR) | +| 8 | P2 | Installer lifecycle untested | Fixed: Windows runner workflow | +| 9 | P2 | No PR-CI schema validation of server.json | Fixed: `distribution-checks.yml` | +| 10 | P2 | Isolated agent-prompt run unscheduled | Done: see PR body for the run | +| 11 | P2 | OpenAPI regeneration missing | Fixed: `make swagger` | +| 12 | P3 | Declaration acceptance rules inconsistent | Fixed: data-model/tests/docs now source-specific | +| 13 | P3 | Receiver compat unverified | Verified by reading `mcpproxy-telemetry/src/validation.ts` (known fields only, unknown top-level fields not rejected); listed as follow-up | diff --git a/specs/121-install-surfaces/spec.md b/specs/121-install-surfaces/spec.md index 937f66fbe..cc7c33b32 100644 --- a/specs/121-install-surfaces/spec.md +++ b/specs/121-install-surfaces/spec.md @@ -88,8 +88,8 @@ The heartbeat carries `install_channel`, a closed enum, so the E7 success measur 1. **Given** any running install, **When** the heartbeat is built, **Then** `install_channel` is exactly one value of the closed set: `brew`, `dmg`, `winget`, `scoop`, `windows-installer`, `deb`, `rpm`, `docker`, `registry`, `curl`, `tarball`, `go-install`, `unknown`. 2. **Given** an install whose channel the existing detector already knows (Homebrew, DMG, deb, rpm, docker, Windows installer, tarball, go install, and the new scoop), **When** no explicit declaration exists, **Then** the detector's answer is reported, mapped onto the closed set. -3. **Given** an *authoritative* declaration (environment variable, or an installer-owned `install-channel` marker file next to the executable) whose value is in the closed set, **When** the heartbeat is built, **Then** the declared value wins; **Given** an out-of-set value, **Then** it is ignored and never transmitted. -4. **Given** a *weak* declaration (a marker file in the data directory, written by the agent prompt for archive installs), **When** the detector's answer is `tarball`, `go-install` or `unknown`, **Then** the declared value is reported; **When** the detector positively identified a package-manager/installer channel, **Then** the weak declaration is ignored (a stale `curl` marker cannot mask a later Homebrew or winget install). +3. **Given** an *authoritative* declaration (the `MCPPROXY_INSTALL_CHANNEL` environment variable, accepting any set member except `unknown`; or an installer-owned `install-channel` marker file next to the executable, accepting only `winget`) whose value is valid for its source, **When** the heartbeat is built, **Then** the declared value wins; **Given** an out-of-set value, **Then** it is ignored and never transmitted. +4. **Given** a *weak* declaration (a marker file in the data directory containing only `curl`, written by the agent prompt for archive installs), **When** the detector's answer is `tarball`, `go-install` or `unknown`, **Then** the declared value is reported; **When** the detector positively identified a package-manager/installer channel, **Then** the weak declaration is ignored (a stale `curl` marker cannot mask a later Homebrew or winget install). 5. **Given** a winget install, **When** the installer ran with the winget switch, **Then** the marker file written beside the installed executable makes the heartbeat say `winget`; **When** the installer later runs without the switch (direct reinstall/upgrade), **Then** the stale marker is removed; **When** the app is uninstalled, **Then** the marker is removed with it. 6. **Given** telemetry is disabled, **When** the process runs or a payload preview is requested, **Then** no usage heartbeat is sent and no marker is read by the telemetry path (the existing one-time opt-out beacon is unchanged). 7. **Given** a user-facing surface that already shows the install channel for update guidance (`/api/v1/info`, `mcpproxy status`, `mcpproxy doctor`, `mcpproxy update`), **When** this feature ships, **Then** their behavior is unchanged for every existing channel; the only change is the new scoop channel (guidance `scoop update mcpproxy`, no self-update). diff --git a/website/docusaurus.config.js b/website/docusaurus.config.js index 1a0be1266..d455c8d22 100644 --- a/website/docusaurus.config.js +++ b/website/docusaurus.config.js @@ -6,36 +6,38 @@ const path = require('path'); const {themes: prismThemes} = require('prism-react-renderer'); // Published documentation: the Docusaurus docs plugin serves ONLY these paths. -// Hoisted into constants so the llms.txt generator below can be restricted to +// Hoisted into one constant so the llms.txt generator below can be restricted to // exactly the same set (Spec 121): the llms plugin walks the whole docs // directory, so without this it advertised ~70 internal plans/audits/research // pages whose URLs 404 on the live site. -const docsInclude = [ - 'intro.md', - 'getting-started/**/*.{md,mdx}', - 'configuration/**/*.{md,mdx}', - 'cli/**/*.{md,mdx}', - 'api/**/*.{md,mdx}', - 'web-ui/**/*.{md,mdx}', - 'features/**/*.{md,mdx}', - 'code_execution/**/*.{md,mdx}', - 'operations/**/*.{md,mdx}', - 'errors/**/*.{md,mdx}', - 'development/**/*.{md,mdx}', - 'contributing.md', - // Standalone references with no structured counterpart - 'cli-client-mode.md', - 'cli-output-formatting.md', - 'logging.md', - 'prerelease-builds.md', - 'registries.md', - 'socket-communication.md', -]; -// Internal working documents that live in the repo but are not published. -const docsExclude = [ - 'development/sandbox-snap-docker-harness.md', - 'development/sandbox-spike-mcp-34.md', -]; +const docsPublished = { + include: [ + 'intro.md', + 'getting-started/**/*.{md,mdx}', + 'configuration/**/*.{md,mdx}', + 'cli/**/*.{md,mdx}', + 'api/**/*.{md,mdx}', + 'web-ui/**/*.{md,mdx}', + 'features/**/*.{md,mdx}', + 'code_execution/**/*.{md,mdx}', + 'operations/**/*.{md,mdx}', + 'errors/**/*.{md,mdx}', + 'development/**/*.{md,mdx}', + 'contributing.md', + // Standalone references with no structured counterpart + 'cli-client-mode.md', + 'cli-output-formatting.md', + 'logging.md', + 'prerelease-builds.md', + 'registries.md', + 'socket-communication.md', + ], + // Internal working documents that live in the repo but are not published. + exclude: [ + 'development/sandbox-snap-docker-harness.md', + 'development/sandbox-spike-mcp-34.md', + ], +}; const llmsRootContent = [ 'MCPProxy is a local-first smart proxy for the Model Context Protocol: it indexes every upstream MCP server\'s tools for BM25 discovery, quarantines unreviewed servers and tool changes, and cuts tool-schema token cost.', @@ -46,14 +48,14 @@ const llmsRootContent = [ /** * ignoreFiles patterns for docusaurus-plugin-llms: every markdown file/dir in * the docs tree that the docs plugin does NOT publish. Derived from - * docsInclude by top-level name, so adding a page to the allowlist also adds + * docsPublished.include by top-level name, so adding a page to the allowlist also adds * it to the llms bundles with no second list to maintain. */ function unpublishedDocsPatterns() { const docsDir = path.join(__dirname, 'docs'); if (!fs.existsSync(docsDir)) return []; - const allowedFiles = new Set(docsInclude.filter((g) => !g.includes('/'))); - const allowedDirs = new Set(docsInclude.filter((g) => g.includes('/')).map((g) => g.split('/')[0])); + const allowedFiles = new Set(docsPublished.include.filter((g) => !g.includes('/'))); + const allowedDirs = new Set(docsPublished.include.filter((g) => g.includes('/')).map((g) => g.split('/')[0])); const out = []; for (const entry of fs.readdirSync(docsDir, {withFileTypes: true})) { if (entry.isDirectory()) { @@ -63,7 +65,7 @@ function unpublishedDocsPatterns() { } } // Explicitly excluded pages inside a published directory. - return out.concat(docsExclude); + return out.concat(docsPublished.exclude); } /** @type {import('@docusaurus/types').Config} */ @@ -117,9 +119,9 @@ const config = { sidebarPath: './sidebars.js', editUrl: 'https://github.com/smart-mcp-proxy/mcpproxy-go/edit/main/', // Only include structured documentation pages - include: docsInclude, + include: docsPublished.include, // Internal working documents that live in the repo but are not published - exclude: docsExclude, + exclude: docsPublished.exclude, }, blog: false, theme: { From ad0e50d1ae0cac5c5ff42d22516bb63ce2ee10ae Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Tue, 20 Oct 2026 19:31:43 +0300 Subject: [PATCH 5/6] fix(distribution): address final Sol review (prompt key leak, scoop downgrade/legacy marker, llms canary) Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/publish-windows-packages.yml | 8 ++++++++ README.md | 8 ++++---- docs/getting-started/agent-install.md | 8 ++++---- docs/getting-started/installation.md | 2 +- internal/updatecheck/channel.go | 10 ++++++++-- internal/updatecheck/channel_scoop_test.go | 9 +++++++-- specs/121-install-surfaces/review-log.md | 17 +++++++++++++++++ website/scripts/check-llms.mjs | 12 ++++++++++++ website/scripts/check-llms.test.mjs | 4 ++++ 9 files changed, 65 insertions(+), 13 deletions(-) diff --git a/.github/workflows/publish-windows-packages.yml b/.github/workflows/publish-windows-packages.yml index 9d99e79b5..3733e365e 100644 --- a/.github/workflows/publish-windows-packages.yml +++ b/.github/workflows/publish-windows-packages.yml @@ -73,6 +73,14 @@ jobs: exit 0 fi mkdir -p bucket/bucket + # Never move the bucket backwards: re-running an older release (recovery, + # manual dispatch) must not make new installs get an older version. + CUR="$(jq -r '.version // empty' bucket/bucket/mcpproxy.json 2>/dev/null || true)" + NEW="${TAG#v}" + if [ -n "${CUR}" ] && [ "${CUR}" != "${NEW}" ] && [ "$(printf '%s\n%s\n' "${CUR}" "${NEW}" | sort -V | tail -1)" = "${CUR}" ]; then + echo "::notice::scoop bucket already at ${CUR}, newer than ${NEW}; not downgrading" + exit 0 + fi scripts/gen-scoop-manifest.sh "${TAG}" checksums.txt > bucket/bucket/mcpproxy.json cd bucket if git diff --quiet -- bucket/mcpproxy.json && [ -z "$(git ls-files --others --exclude-standard bucket/mcpproxy.json)" ]; then diff --git a/README.md b/README.md index 1609f70fb..abe6c7482 100644 --- a/README.md +++ b/README.md @@ -95,10 +95,10 @@ RULES Prefer the macOS desktop app? Tell me; it is: brew install --cask smart-mcp-proxy/mcpproxy/mcpproxy 3. START IT - mcpproxy serve --listen 127.0.0.1:8080 > ~/.mcpproxy/serve.log 2>&1 & # Windows: Start-Process mcpproxy -ArgumentList serve -WindowStyle Hidden + mkdir -p ~/.mcpproxy && mcpproxy serve --listen 127.0.0.1:8080 > ~/.mcpproxy/serve.log 2>&1 & # Windows: Start-Process mcpproxy -ArgumentList serve -WindowStyle Hidden If port 8080 is taken, use another, e.g. --listen 127.0.0.1:18080. It must keep running after your command returns: start it with your tool's background/long-running mode rather than relying on a trailing `&`, which some agent shells kill when the call ends. - Wait until `mcpproxy status` reports it is running (retry for up to 30 seconds). + Wait until `mcpproxy status` reports it is running (retry for up to 30 seconds). Never run `mcpproxy status --show-key` or `--web-url`: they print the admin key. 4. VERIFY (these commands print masked output) mcpproxy status @@ -108,11 +108,11 @@ RULES Claude Code: mcpproxy connect claude-code Codex: mcpproxy connect codex Others (cursor, windsurf, vscode, gemini, opencode): mcpproxy connect - Claude Desktop is not in this list: tell me to open the Connect wizard in the MCPProxy Web UI (the URL printed by `mcpproxy status --web-url`). + Claude Desktop is not in this list: tell me to open the Connect wizard in the MCPProxy Web UI at http://127.0.0.1:/ui/ (I can get my sign-in link by running `mcpproxy status --web-url` myself in a terminal; do NOT run that command, it prints the admin key). Then run: mcpproxy connect --list and confirm the client shows as connected. 6. TELL ME WHAT HAPPENED - - What you installed and how, and the Web UI URL from `mcpproxy status --web-url`. + - What you installed and how, and the Web UI address http://127.0.0.1:/ui/ (never include an API key or a URL containing `apikey=`). - That the client needs a reload or restart before it sees MCPProxy (Claude Code: start a new session or run /mcp; Codex: restart it). Do not claim it is connected until I have done that. - MCPProxy sends anonymous usage telemetry. To turn it off: set MCPPROXY_TELEMETRY=false before starting it, or toggle it in Settings. Details: https://docs.mcpproxy.app/features/telemetry ``` diff --git a/docs/getting-started/agent-install.md b/docs/getting-started/agent-install.md index b89314539..677bc38e7 100644 --- a/docs/getting-started/agent-install.md +++ b/docs/getting-started/agent-install.md @@ -44,10 +44,10 @@ RULES Prefer the macOS desktop app? Tell me; it is: brew install --cask smart-mcp-proxy/mcpproxy/mcpproxy 3. START IT - mcpproxy serve --listen 127.0.0.1:8080 > ~/.mcpproxy/serve.log 2>&1 & # Windows: Start-Process mcpproxy -ArgumentList serve -WindowStyle Hidden + mkdir -p ~/.mcpproxy && mcpproxy serve --listen 127.0.0.1:8080 > ~/.mcpproxy/serve.log 2>&1 & # Windows: Start-Process mcpproxy -ArgumentList serve -WindowStyle Hidden If port 8080 is taken, use another, e.g. --listen 127.0.0.1:18080. It must keep running after your command returns: start it with your tool's background/long-running mode rather than relying on a trailing `&`, which some agent shells kill when the call ends. - Wait until `mcpproxy status` reports it is running (retry for up to 30 seconds). + Wait until `mcpproxy status` reports it is running (retry for up to 30 seconds). Never run `mcpproxy status --show-key` or `--web-url`: they print the admin key. 4. VERIFY (these commands print masked output) mcpproxy status @@ -57,11 +57,11 @@ RULES Claude Code: mcpproxy connect claude-code Codex: mcpproxy connect codex Others (cursor, windsurf, vscode, gemini, opencode): mcpproxy connect - Claude Desktop is not in this list: tell me to open the Connect wizard in the MCPProxy Web UI (the URL printed by `mcpproxy status --web-url`). + Claude Desktop is not in this list: tell me to open the Connect wizard in the MCPProxy Web UI at http://127.0.0.1:/ui/ (I can get my sign-in link by running `mcpproxy status --web-url` myself in a terminal; do NOT run that command, it prints the admin key). Then run: mcpproxy connect --list and confirm the client shows as connected. 6. TELL ME WHAT HAPPENED - - What you installed and how, and the Web UI URL from `mcpproxy status --web-url`. + - What you installed and how, and the Web UI address http://127.0.0.1:/ui/ (never include an API key or a URL containing `apikey=`). - That the client needs a reload or restart before it sees MCPProxy (Claude Code: start a new session or run /mcp; Codex: restart it). Do not claim it is connected until I have done that. - MCPProxy sends anonymous usage telemetry. To turn it off: set MCPPROXY_TELEMETRY=false before starting it, or toggle it in Settings. Details: https://docs.mcpproxy.app/features/telemetry ``` diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index c697bc5c2..af71081ab 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -123,7 +123,7 @@ scoop uninstall mcpproxy Scoop installs the release archive for your user (no administrator rights), puts `mcpproxy` on your PATH and adds a Start-menu shortcut for the tray app. Update with scoop: `mcpproxy update` points you at `scoop update mcpproxy` -rather than replacing a scoop-managed file. +rather than replacing a scoop-managed file (for a global scoop install, run `scoop update mcpproxy --global`). ### Manual Installation diff --git a/internal/updatecheck/channel.go b/internal/updatecheck/channel.go index a6a1e69c2..ec7835110 100644 --- a/internal/updatecheck/channel.go +++ b/internal/updatecheck/channel.go @@ -330,10 +330,15 @@ func (d *channelDetector) isGoInstall() bool { // Two independent signals, either of which suffices: // - the default layout /scoop/apps/mcpproxy/..., or // - a custom root ($SCOOP / $SCOOP_GLOBAL): /apps/mcpproxy// with -// scoop's own install.json beside the executable (scoop writes it into +// scoop's own scoop-install.json (legacy: install.json) beside the executable (scoop writes it into // every version directory; `current` is a junction that EvalSymlinks has // already resolved to that directory). // +// Known limitation: the update command is always the per-user +// `scoop update mcpproxy`; a global install (C:\ProgramData\scoop) needs +// `--global`, which the channel identifier does not carry. The install is still +// protected from self-update, which is the safety property that matters. +// // Windows paths are normalised so the check also works when the detector is // exercised on another OS. A bare apps/mcpproxy directory without that // evidence is NOT scoop: guessing would withhold self-update from an @@ -347,5 +352,6 @@ func (d *channelDetector) isScoopInstall(path string) bool { return false } dir := path[:strings.LastIndexAny(path, `\/`)+1] - return d.statFile(dir+"install.json") == nil + // scoop-install.json is the current name; install.json is the legacy one. + return d.statFile(dir+"scoop-install.json") == nil || d.statFile(dir+"install.json") == nil } diff --git a/internal/updatecheck/channel_scoop_test.go b/internal/updatecheck/channel_scoop_test.go index 1e0cee9ce..2e4e5ce44 100644 --- a/internal/updatecheck/channel_scoop_test.go +++ b/internal/updatecheck/channel_scoop_test.go @@ -23,6 +23,7 @@ func TestDetectChannel_Scoop(t *testing.T) { {"scoop global install", ChannelTarball, `C:\ProgramData\scoop\apps\mcpproxy\current\mcpproxy.exe`, ChannelScoop}, {"mixed case and slashes", ChannelTarball, `c:/Users/U/Scoop/Apps/MCPProxy/current/mcpproxy.exe`, ChannelScoop}, {"custom SCOOP root with install.json evidence", ChannelTarball, `D:\Tools\apps\mcpproxy\0.71.1\mcpproxy.exe`, ChannelScoop}, + {"custom SCOOP root with scoop-install.json only", ChannelTarball, `D:\Tools\apps\mcpproxy\0.71.2\mcpproxy.exe`, ChannelScoop}, {"apps/mcpproxy without scoop evidence stays tarball", ChannelTarball, `E:\apps\mcpproxy\mcpproxy.exe`, ChannelTarball}, {"unstamped build still scoop", "", `C:\Users\u\scoop\apps\mcpproxy\current\mcpproxy.exe`, ChannelScoop}, {"other scoop app is not mcpproxy", ChannelTarball, `C:\Users\u\scoop\apps\othertool\current\mcpproxy.exe`, ChannelTarball}, @@ -36,9 +37,13 @@ func TestDetectChannel_Scoop(t *testing.T) { d.marker = tt.marker path := tt.path d.execPath = func() (string, error) { return path, nil } - // scoop writes install.json beside the executable in the version dir. + // scoop writes scoop-install.json (legacy: install.json) beside the executable in the version dir. d.statFile = func(p string) error { - if strings.Contains(tt.name, "install.json evidence") && strings.HasSuffix(filepath.ToSlash(strings.ReplaceAll(p, `\`, "/")), "/0.71.1/install.json") { + q := filepath.ToSlash(strings.ReplaceAll(p, `\`, "/")) + if strings.Contains(tt.name, "install.json evidence") && strings.HasSuffix(q, "/0.71.1/install.json") { + return nil + } + if strings.Contains(tt.name, "scoop-install.json only") && strings.HasSuffix(q, "/0.71.2/scoop-install.json") { return nil } return os.ErrNotExist diff --git a/specs/121-install-surfaces/review-log.md b/specs/121-install-surfaces/review-log.md index 5853b009e..eda948f09 100644 --- a/specs/121-install-surfaces/review-log.md +++ b/specs/121-install-surfaces/review-log.md @@ -33,3 +33,20 @@ | 11 | P2 | OpenAPI regeneration missing | Fixed: `make swagger` | | 12 | P3 | Declaration acceptance rules inconsistent | Fixed: data-model/tests/docs now source-specific | | 13 | P3 | Receiver compat unverified | Verified by reading `mcpproxy-telemetry/src/validation.ts` (known fields only, unknown top-level fields not rejected); listed as follow-up | + +## Round 2 — final diff (Go part, CI/packaging/docs part) — verdict: CHANGES REQUIRED → 5 of 6 applied, 1 documented + +Both chunks reviewed read-only by Sol; no read refused. (Reviewer also confirmed no deadlock: both heartbeat entry points hold `deliveryMu`, not `s.mu`, so `telemetryEnabledLive()` is safe.) + +| # | Sev | Finding | Disposition | +|---|-----|---------|-------------| +| 1 | P1 | Agent prompt ran `mcpproxy status --web-url`, which prints the admin key | Fixed + verified (`--web-url` prints `?apikey=`); prompt now forbids `--web-url`/`--show-key` | +| 2 | P2 | `serve > ~/.mcpproxy/serve.log` fails on a fresh brew install | Fixed: `mkdir -p ~/.mcpproxy &&` | +| 3 | P2 | Re-running an older release could downgrade the scoop bucket | Fixed: version compare, skip with notice | +| 4 | P3 | `llms-full.txt` exclusions unenforced | Fixed as a canary check (full bundle has no per-doc URLs to audit; both bundles share one generator run) | +| 5 | P2 | Scoop custom-root detection needs `scoop-install.json` (current name) | Fixed: both names accepted, test | +| 6 | P2 | Global scoop install gets `scoop update mcpproxy` (needs `--global`) | Documented as a known limitation (install is still protected from self-update); docs note the flag | + +## Isolated agent-prompt run (FR-024) + +A fresh `claude -p` (Sonnet) session ran the prompt verbatim plus a harness notice that routed every shell command through a wrapper (scratch HOME, PATH without Homebrew or the real mcpproxy; the live tray core owned :8080). Observed end state (credentials redacted): the real v0.71.1 darwin-arm64 release archive was installed to `$HOME/.local/bin` (checksum path), `~/.mcpproxy/install-channel` = `curl`, `mcpproxy serve` started on **127.0.0.1:18080** after the 8080 conflict, and `mcpproxy connect claude-code` wrote a per-client `mcp_cli_…` credential entry (`connect --list`: Claude Code `yes`). The live instance and the real `~/.claude.json` were untouched. Findings that changed the prompt: the `--web-url` key leak (above), and the serve process did not survive the end of the agent session, so the prompt now tells the agent to use its tool's background mode. The agent's final summary message was replaced by a user-level Stop hook, so conclusions rest on the end state above, not on the transcript. Codex was not exercised. diff --git a/website/scripts/check-llms.mjs b/website/scripts/check-llms.mjs index ae171a458..7a8f296c4 100644 --- a/website/scripts/check-llms.mjs +++ b/website/scripts/check-llms.mjs @@ -16,6 +16,14 @@ import { fileURLToPath } from 'node:url'; export const MAX_FULL_BYTES = 2 * 1024 * 1024; // 2 MiB (was ~3 MB with unpublished pages) export const FIRST_PAGE = '/getting-started/agent-install'; const SITE = 'https://docs.mcpproxy.app'; +// llms-full.txt carries no per-document URLs, so its document set cannot be +// audited directly; both bundles are produced by the same plugin run with the +// same ignore patterns, and every llms.txt link is verified above. As a canary +// for the full bundle, none of these unpublished documents may appear in it. +export const FORBIDDEN_FULL_HEADINGS = [ + 'Tray Glance Implementation Plan', + 'Spike: non-Docker sandbox mechanism', +]; /** @returns {string[]} problems; empty when the bundles are healthy. */ export function checkLlms(buildDir) { @@ -40,6 +48,10 @@ export function checkLlms(buildDir) { problems.push(`llms.txt links to ${link}, which is not a built page (404 on the live site)`); } } + const fullText = readFileSync(full, 'utf8'); + for (const h of FORBIDDEN_FULL_HEADINGS) { + if (new RegExp(`^## ${h}`, 'm').test(fullText)) problems.push(`llms-full.txt contains the unpublished document "${h}"`); + } const size = statSync(full).size; if (size > MAX_FULL_BYTES) problems.push(`llms-full.txt is ${size} bytes, over the ${MAX_FULL_BYTES} byte budget`); return problems; diff --git a/website/scripts/check-llms.test.mjs b/website/scripts/check-llms.test.mjs index 933e4794c..9098646d5 100644 --- a/website/scripts/check-llms.test.mjs +++ b/website/scripts/check-llms.test.mjs @@ -34,6 +34,10 @@ test('oversized llms-full fails', () => { const dir = fixture({ links: [`${S}/getting-started/agent-install`], pages: ['getting-started/agent-install'], full: 'x'.repeat(MAX_FULL_BYTES + 1) }); assert.match(checkLlms(dir).join('\n'), /budget/); }); +test('an unpublished document in the full bundle fails', () => { + const dir = fixture({ links: [`${S}/getting-started/agent-install`], pages: ['getting-started/agent-install'], full: '## Tray Glance Implementation Plan\nx' }); + assert.match(checkLlms(dir).join('\n'), /unpublished document/); +}); test('missing files fail', () => { const dir = mkdtempSync(path.join(tmpdir(), 'llms-')); assert.match(checkLlms(dir).join('\n'), /not generated/); From a3c586c4b0bcca7cb3cbc680cb699a8c41dc7417 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Thu, 22 Oct 2026 17:07:45 +0300 Subject: [PATCH 6/6] test(distribution): make install-channel and installer tests OS-independent TestResolveInstallChannel_Precedence used literal '/' marker keys while the resolver joins with the OS separator, and the installer contract compared CRLF text on Windows checkouts. Both failed only on windows-latest. Co-Authored-By: Claude Sonnet 5.5 --- cmd/release-gate/distribution_workflow_test.go | 3 ++- internal/telemetry/install_channel_test.go | 7 +++++-- internal/telemetry/payload_install_channel_test.go | 5 +++-- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/cmd/release-gate/distribution_workflow_test.go b/cmd/release-gate/distribution_workflow_test.go index 6e5df6fe6..f6f328549 100644 --- a/cmd/release-gate/distribution_workflow_test.go +++ b/cmd/release-gate/distribution_workflow_test.go @@ -181,7 +181,8 @@ func TestWindowsPackagesWorkflowInvariants(t *testing.T) { // The Inno script must stay silent-safe and own its channel marker (FR-014). func TestInstallerChannelMarkerContract(t *testing.T) { - iss := repoFile(t, "scripts", "installer.iss") + // Windows checkouts may convert line endings (.gitattributes); compare LF-normalised. + iss := strings.ReplaceAll(repoFile(t, "scripts", "installer.iss"), "\r\n", "\n") for _, want := range []string{ "{param:CHANNEL|}", // winget switch "'winget'", // only this token is ever written diff --git a/internal/telemetry/install_channel_test.go b/internal/telemetry/install_channel_test.go index 214a8df7b..5e71728f7 100644 --- a/internal/telemetry/install_channel_test.go +++ b/internal/telemetry/install_channel_test.go @@ -2,6 +2,7 @@ package telemetry import ( "os" + "path/filepath" "strings" "testing" "time" @@ -84,8 +85,10 @@ func TestInstallChannelEnum_IsClosedAndComplete(t *testing.T) { } func TestResolveInstallChannel_Precedence(t *testing.T) { - const exeMarker = "/opt/app/install-channel" - const dataMarker = "/data/install-channel" + // Built with filepath.Join: the resolver joins with the OS separator, so + // literal "/" keys would never match on Windows. + exeMarker := filepath.Join("/opt/app", "install-channel") + dataMarker := filepath.Join("/data", "install-channel") tests := []struct { name string f fakeChannelDeps diff --git a/internal/telemetry/payload_install_channel_test.go b/internal/telemetry/payload_install_channel_test.go index 4847b370e..d1d2e265b 100644 --- a/internal/telemetry/payload_install_channel_test.go +++ b/internal/telemetry/payload_install_channel_test.go @@ -2,6 +2,7 @@ package telemetry import ( "encoding/json" + "path/filepath" "strings" "testing" ) @@ -78,8 +79,8 @@ func TestResolveInstallChannel_HostileValuesNeverEscape(t *testing.T) { dataDir: "/data", detected: "../../etc/passwd", files: map[string]string{ - "/opt/app/install-channel": `C:\Users\secret`, - "/data/install-channel": strings.Repeat("A", 4096), + filepath.Join("/opt/app", "install-channel"): `C:\Users\secret`, + filepath.Join("/data", "install-channel"): strings.Repeat("A", 4096), }, } got := resolveInstallChannel(f.deps())