Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/api/rest-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -925,7 +925,7 @@ Get application info, version, and update availability.
| `pid` | integer | OS process id of the running core (Spec 092 FR-002). A tray that only *attached* to a core holds no process handle for it and the core exposes no shutdown endpoint, so this is the mechanism behind the consent-gated "restart the stale core" action. |
| `update_policy` | object | Effective, hot-reloadable update policy (Spec 092 FR-015). **Always present**, including every field, because the `update` object below is absent both when checking is disabled *and* when no check has produced a result yet — its absence cannot tell a client whether it is allowed to check. |
| `update_policy.enabled` | boolean | Whether **automatic** update checks are allowed: `update_check.enabled`, with `MCPPROXY_DISABLE_AUTO_UPDATE=true` winning over it. A *user-initiated* "Check for Updates" stays available even when this is `false`. The macOS tray gates its Sparkle feed checks on this field. |
| `update_policy.channel` | string | Tracked release channel: `stable` or `rc` (`update_check.channel`, with `MCPPROXY_ALLOW_PRERELEASE_UPDATES=true` forcing `rc`). The tray maps `rc` onto the Sparkle `beta` feed channel. |
| `update_policy.channel` | string | Tracked release channel: `stable` or `rc`. **Derived from the running build's own version** — a released stable build always reports `stable` (never RC) and a released RC build always reports `rc`, regardless of `update_check.channel` / `MCPPROXY_ALLOW_PRERELEASE_UPDATES` (those only affect dev/unstamped builds). The tray maps `rc` onto the Sparkle `beta` feed channel, and additionally clamps to `stable` when its own app bundle is a stable release. |
| `update_policy.nudges_suppressed` | boolean | The core runs in a CI / non-interactive context: UI surfaces must stay quiet while machine-readable fields keep reporting the facts. |
| `update` | object | Update information (may be null if not checked yet; omitted entirely when update checking is disabled via `update_check.enabled: false` or `MCPPROXY_DISABLE_AUTO_UPDATE=true`) |
| `update.available` | boolean | Whether a newer version is available |
Expand Down
2 changes: 1 addition & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -1318,7 +1318,7 @@ offline.
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `enabled` | boolean | `true` | Master switch for update checking. When `false`, no network check is performed (background poll *and* the manual `/api/v1/info?refresh=true` re-check) and no upgrade nudge appears on any surface — the `update` object is omitted from `/api/v1/info`. |
| `channel` | string | `"stable"` | Release channel: `"stable"` (GitHub `releases/latest`; prereleases never offered) or `"rc"` (prerelease tags such as `v0.47.0-rc.1` included). |
| `channel` | string | `"stable"` | Release channel: `"stable"` (GitHub `releases/latest`; prereleases never offered) or `"rc"` (prerelease tags such as `v0.47.0-rc.1` included). **Released builds ignore this field** — the running binary's own version is authoritative (a stable build is never offered an RC; an RC build always tracks `rc`); it only applies to dev/unstamped builds. See [docs/prerelease-builds.md](prerelease-builds.md). |

Both keys are optional and hot-reloadable: editing them (config file or
`POST /api/v1/config/apply`) takes effect without a restart, and re-enabling
Expand Down
23 changes: 21 additions & 2 deletions docs/features/version-updates.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,13 +116,32 @@ are the operator override:

- `MCPPROXY_DISABLE_AUTO_UPDATE=true` disables checking even when
`update_check.enabled` is `true`.
- `MCPPROXY_ALLOW_PRERELEASE_UPDATES=true` selects the prerelease channel even
when `update_check.channel` is `"stable"`.
- `MCPPROXY_ALLOW_PRERELEASE_UPDATES=true` selects the prerelease channel — but
see the build-identity rule below: it only takes effect on dev/unstamped
builds.

The env vars only widen in one direction (disable checks / include
prereleases). They cannot re-enable checking that the config disabled: with
`update_check.enabled: false`, no check runs regardless of environment.

### The running build's version decides the channel

For a **released** build the running binary's own version is authoritative and
overrides both `update_check.channel` and `MCPPROXY_ALLOW_PRERELEASE_UPDATES`:

- a **stable** build (e.g. `v0.56.0`) is **never** offered an RC — a stale `rc`
opt-in left over from a previously-installed RC cannot resurrect RC offers;
- an **RC** build (`-rc.N` / `-next.*`) tracks the **rc** channel automatically
(offered the next RC, and its graduating stable) with no opt-in;
- a **dev / unstamped** build (local `go build`, a `go install @commit`
pseudo-version) has no release identity, so the `channel` config key and
`MCPPROXY_ALLOW_PRERELEASE_UPDATES` still apply — the only way to exercise the
rc channel without a released RC binary.

The macOS tray applies the same rule to itself: a stable tray app never offers
itself an RC even when attached to a newer RC core. See
[Prerelease Builds](https://github.com/smart-mcp-proxy/mcpproxy-go/blob/main/docs/prerelease-builds.md).

### Examples

```bash
Expand Down
13 changes: 12 additions & 1 deletion docs/prerelease-builds.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,18 @@ The GitHub release is created with `prerelease: true`, so it does **not** become
- Does not deploy docs or trigger marketing automation (`deploy-docs`, `trigger-marketing-update` guarded).
- Not offered as an update on **stable channels**:
- The macOS tray uses GitHub `releases/latest`, which excludes prereleases (`native/macos/MCPProxy/MCPProxy/Services/UpdateService.swift`), plus a semver downgrade guard so an `-rc` is never treated as "newer" than the matching stable.
- The backend/tray update check is stable-only by default (`internal/tray/tray.go` → `releases/latest`). Set `MCPPROXY_ALLOW_PRERELEASE_UPDATES=true` to opt in to RC update offers; the core checker can also opt in via `"update_check": { "channel": "rc" }` in `mcp_config.json` (Spec 079 — the Go tray's own self-update check gates on the core's decision by querying `GET /api/v1/info` rather than reading the config file, and selects prereleases via `MCPPROXY_ALLOW_PRERELEASE_UPDATES`; converging it fully onto the shared checker is FR-001a).
- The backend/tray update check is stable-only by default (`internal/tray/tray.go` → `releases/latest`).

### The running build's version decides the channel

For a **released** build the running binary's own version is authoritative and cannot be overridden into offering the wrong channel:

- A **stable** build (e.g. `v0.56.0`) is **never** offered an RC — regardless of `"update_check": { "channel": "rc" }` or `MCPPROXY_ALLOW_PRERELEASE_UPDATES=true`. A stale `rc` opt-in left over from a previously-installed RC therefore can never resurrect RC offers. To test an RC, install one manually (see *Installing an RC* below); the updater then keeps you on the RC track.
- An **RC** build (e.g. `v0.56.0-rc.1`) automatically tracks the **rc** channel: it is offered the next RC, and — by pure semver comparison — its graduating stable (`v0.56.0-rc.1` → `v0.56.0`). No opt-in needed.

The `update_check.channel` config field and `MCPPROXY_ALLOW_PRERELEASE_UPDATES` env flag now only take effect on **dev / unstamped** builds (local `go build`, `go install` without a release tag), where there is no release identity to honor — they let developers exercise the rc path without a released RC binary.

The Swift macOS tray takes the core's `update_policy.channel` (from `GET /api/v1/info`) as an **input**, then applies its own build-identity clamp on top: it narrows the channel to `stable` when the tray app's **own** bundle (`CFBundleShortVersionString`) is a stable release. This matters for mixed versions — a stable tray app attached to a separately-installed **newer RC core** reports `channel=rc` from that core, but the tray must never update *itself* to an RC, so it clamps to stable regardless. The clamp is narrowing-only (it can only pin to stable, never force `rc`); an RC tray app keeps the core's channel. Its Sparkle `allowedChannels` / feed selection then follow the clamped channel. (Spec 079 FR-013a; converging the Go tray's own self-update resolution fully onto the shared checker is FR-001a.)

### Installing an RC

Expand Down
6 changes: 6 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -1343,6 +1343,12 @@ type UpdateCheckConfig struct {
// Channel selects which releases are offered as updates: "stable"
// (default; prereleases never offered) or "rc" (prereleases included).
// Empty resolves to stable. Validated in ValidateDetailed.
//
// NOTE: for a RELEASED build the running binary's own version is
// authoritative and overrides this field — a stable build is never
// offered an RC (even with channel=rc), and an RC build always tracks the
// rc channel. This field only takes effect on dev/unstamped builds. See
// internal/updatecheck.Checker.IncludePrereleases.
Channel string `json:"channel,omitempty" mapstructure:"channel"`
}

Expand Down
41 changes: 41 additions & 0 deletions internal/tray/prerelease_gate_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
//go:build !nogui && !headless && !linux

package tray

import "testing"

// The legacy tray self-update path must respect build identity: a stable build
// is never offered a prerelease (even with MCPPROXY_ALLOW_PRERELEASE_UPDATES
// set), an RC build tracks the rc channel, and a dev build honors the env flag.
func TestApp_IncludePrereleases_BuildIdentity(t *testing.T) {
t.Run("stable build ignores env flag", func(t *testing.T) {
t.Setenv("MCPPROXY_ALLOW_PRERELEASE_UPDATES", "true")
a := &App{version: "v1.2.3"}
if a.includePrereleases() {
t.Error("stable build must never include prereleases, even with the env override")
}
})

t.Run("rc build includes prereleases", func(t *testing.T) {
a := &App{version: "v1.3.0-rc.1"}
if !a.includePrereleases() {
t.Error("rc build must track the rc channel")
}
})

t.Run("dev build honors env flag", func(t *testing.T) {
t.Setenv("MCPPROXY_ALLOW_PRERELEASE_UPDATES", "true")
a := &App{version: "dev"}
if !a.includePrereleases() {
t.Error("dev build must honor the env override")
}
})

t.Run("dev build defaults to stable", func(t *testing.T) {
t.Setenv("MCPPROXY_ALLOW_PRERELEASE_UPDATES", "")
a := &App{version: "dev"}
if a.includePrereleases() {
t.Error("dev build without opt-in must not include prereleases")
}
})
}
21 changes: 19 additions & 2 deletions internal/tray/tray.go
Original file line number Diff line number Diff line change
Expand Up @@ -1132,10 +1132,27 @@ func (a *App) performSelfUpdate() {
}
}

// includePrereleases reports whether this tray build may be offered a
// prerelease. The running build's own version is authoritative: a stable
// build is never offered an RC (even with the env flag set — a stale opt-in
// must not resurrect RC offers), while an RC build tracks the rc channel. A
// dev/unstamped build honors the env opt-in so local testing can exercise it.
// Mirrors internal/updatecheck.Checker.IncludePrereleases.
func (a *App) includePrereleases() bool {
v := a.version
if !strings.HasPrefix(v, "v") {
v = "v" + v
}
if semver.IsValid(v) {
return semver.Prerelease(v) != ""
}
return os.Getenv("MCPPROXY_ALLOW_PRERELEASE_UPDATES") == trueStr
}

// getLatestRelease fetches the latest release information from GitHub
func (a *App) getLatestRelease() (*GitHubRelease, error) {
// Check if prerelease updates are allowed
allowPrerelease := os.Getenv("MCPPROXY_ALLOW_PRERELEASE_UPDATES") == trueStr
// Check if prerelease updates are allowed (build-identity authoritative)
allowPrerelease := a.includePrereleases()

if allowPrerelease {
// Get all releases and find the latest (including prereleases)
Expand Down
65 changes: 61 additions & 4 deletions internal/updatecheck/checker.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"time"

"go.uber.org/zap"
"golang.org/x/mod/module"
"golang.org/x/mod/semver"
)

Expand Down Expand Up @@ -208,12 +209,68 @@ func (c *Checker) enabledLocked() bool {
// MCPPROXY_ALLOW_PRERELEASE_UPDATES=true wins over the config channel
// (Spec 079 FR-014 precedence: env > config); otherwise channel=rc opts in.
func (c *Checker) IncludePrereleases() bool {
if os.Getenv(EnvAllowPrereleaseUpdates) == "true" {
// The running build's own version is authoritative (issue: a stable user
// must never be offered an RC; an RC user may be offered stable or the next
// RC). This deliberately overrides the config/env opt-in for RELEASED
// builds, so a stale `channel: rc` config left over from a
// previously-installed RC cannot resurrect RC offers on a stable build.
switch versionChannelKind(c.version) {
case buildChannelStable:
// A stable build never tracks prereleases, whatever the config/env say.
return false
case buildChannelPrerelease:
// An RC build tracks the rc channel: it is offered the next RC, and —
// via pure-semver comparison (compareVersions) — its graduating stable.
return true
default:
// buildChannelUnknown: a build with no RELEASE identity — an unstamped
// local build ("development"/"dev", which the Start/CheckNow semver
// guard skips entirely) or a `go install @commit` pseudo-version (valid
// semver, so it DOES check). Neither is an -rc.*/-next.* release, so the
// config/env opt-in still applies — the only way to exercise the rc
// channel without a released RC binary.
if os.Getenv(EnvAllowPrereleaseUpdates) == "true" {
return true
}
c.mu.RLock()
defer c.mu.RUnlock()
return c.cfgPrerelease
}
c.mu.RLock()
defer c.mu.RUnlock()
return c.cfgPrerelease
}

// buildChannelKind classifies the running build's own version.
type buildChannelKind int

const (
// buildChannelUnknown is an unstamped/dev build (invalid semver, e.g.
// "development") with no release identity — the config/env opt-in applies.
buildChannelUnknown buildChannelKind = iota
// buildChannelStable is a released stable build (valid semver, no
// prerelease suffix) — never offered a prerelease.
buildChannelStable
// buildChannelPrerelease is a released RC build (valid semver with an
// -rc.N / -next.* suffix) — tracks the rc channel.
buildChannelPrerelease
)

// versionChannelKind classifies a build version for channel selection.
func versionChannelKind(version string) buildChannelKind {
v := ensureVPrefix(version)
if !semver.IsValid(v) {
return buildChannelUnknown
}
// A `go install @commit` pseudo-version (e.g. v0.47.1-0.20260701123456-abc)
// is valid semver WITH a prerelease component, but it is a development
// build off an arbitrary commit — not an -rc.*/-next.* release. Classify it
// as unknown so it is not force-tracked onto the rc channel; the config/env
// opt-in governs it like any other dev build.
if module.IsPseudoVersion(v) {
return buildChannelUnknown
}
if semver.Prerelease(v) != "" {
return buildChannelPrerelease
}
return buildChannelStable
}

// Start begins the background update checker.
Expand Down
98 changes: 98 additions & 0 deletions internal/updatecheck/checker_build_channel_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package updatecheck

import (
"testing"

"go.uber.org/zap"
)

// The running build's own version is authoritative for the update channel: a
// STABLE build must never be offered a prerelease (even if a stale rc config
// or the env flag lingers from a previously-installed RC), while an RC build
// tracks the rc channel so it is offered the next RC (or its graduating
// stable). A dev/unstamped build keeps the config/env opt-in so local testing
// can still exercise the rc path.
func TestChecker_IncludePrereleases_BuildIdentityAuthoritative(t *testing.T) {
t.Run("stable build ignores config rc", func(t *testing.T) {
c := New(zap.NewNop(), "v1.2.3")
c.SetConfig(true, true) // config asks for rc
if c.IncludePrereleases() {
t.Error("stable build must never include prereleases, even with config channel=rc")
}
})

t.Run("stable build ignores env flag", func(t *testing.T) {
t.Setenv(EnvAllowPrereleaseUpdates, "true")
c := New(zap.NewNop(), "v1.2.3")
if c.IncludePrereleases() {
t.Error("stable build must never include prereleases, even with the env override")
}
})

t.Run("rc build includes prereleases by default", func(t *testing.T) {
c := New(zap.NewNop(), "v1.3.0-rc.1")
// No config/env opt-in — build identity alone puts it on the rc track.
if !c.IncludePrereleases() {
t.Error("an rc build must track the rc channel by default")
}
})

t.Run("dev build honors config opt-in", func(t *testing.T) {
c := New(zap.NewNop(), "development")
if c.IncludePrereleases() {
t.Error("dev build defaults to stable")
}
c.SetConfig(true, true)
if !c.IncludePrereleases() {
t.Error("dev build must honor config channel=rc")
}
})

t.Run("dev build honors env opt-in", func(t *testing.T) {
t.Setenv(EnvAllowPrereleaseUpdates, "true")
c := New(zap.NewNop(), "development")
if !c.IncludePrereleases() {
t.Error("dev build must honor the env override")
}
})

t.Run("go-install pseudo-version is dev, not RC", func(t *testing.T) {
// A `go install @commit` pseudo-version is valid semver with a
// prerelease component but is NOT a released RC — it must not be
// force-tracked onto the rc channel; the opt-in governs it.
pseudo := "v0.47.1-0.20260701123456-abcdef123456"
c := New(zap.NewNop(), pseudo)
if c.IncludePrereleases() {
t.Error("pseudo-version must default to stable (not forced onto rc like a real -rc build)")
}
c.SetConfig(true, true)
if !c.IncludePrereleases() {
t.Error("pseudo-version must honor config channel=rc (treated as a dev build)")
}
})
}

// Policy().Channel is derived from IncludePrereleases(), so the same
// build-identity clamp must surface in the policy the tray consumes over
// /api/v1/info — a stable build reports channel=stable regardless of config,
// an rc build reports channel=rc.
func TestChecker_Policy_ChannelFollowsBuildIdentity(t *testing.T) {
t.Setenv("CI", "")
t.Setenv(EnvDisableAutoUpdate, "")
t.Setenv(EnvAllowPrereleaseUpdates, "")

t.Run("stable build reports stable despite config rc", func(t *testing.T) {
c := New(zap.NewNop(), "v1.2.3")
c.SetConfig(true, true)
if got := c.Policy().Channel; got != PolicyChannelStable {
t.Fatalf("stable build channel = %q, want %q", got, PolicyChannelStable)
}
})

t.Run("rc build reports rc", func(t *testing.T) {
c := New(zap.NewNop(), "v1.3.0-rc.2")
if got := c.Policy().Channel; got != PolicyChannelRC {
t.Fatalf("rc build channel = %q, want %q", got, PolicyChannelRC)
}
})
}
16 changes: 9 additions & 7 deletions internal/updatecheck/checker_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -328,20 +328,22 @@ func TestChecker_EnvDisableWinsOverConfig(t *testing.T) {
}
}

// TestChecker_IncludePrereleases_Resolution verifies channel resolution:
// default stable, config channel=rc opts in, and the
// MCPPROXY_ALLOW_PRERELEASE_UPDATES env override wins over a stable config
// channel (FR-013/FR-014).
// TestChecker_IncludePrereleases_Resolution verifies channel resolution for
// dev/unstamped builds, whose channel is NOT fixed by build identity: default
// stable, config channel=rc opts in, and the MCPPROXY_ALLOW_PRERELEASE_UPDATES
// env override wins over a stable config channel (FR-013/FR-014). For a
// released build the running version is authoritative and overrides the
// config/env opt-in — see TestChecker_IncludePrereleases_BuildIdentityAuthoritative.
func TestChecker_IncludePrereleases_Resolution(t *testing.T) {
t.Run("default is stable", func(t *testing.T) {
checker := New(zaptest.NewLogger(t), "v1.0.0")
checker := New(zaptest.NewLogger(t), "development")
if checker.IncludePrereleases() {
t.Error("IncludePrereleases() = true by default, want false (stable channel)")
}
})

t.Run("config rc channel opts in", func(t *testing.T) {
checker := New(zaptest.NewLogger(t), "v1.0.0")
checker := New(zaptest.NewLogger(t), "development")
checker.SetConfig(true, true)
if !checker.IncludePrereleases() {
t.Error("IncludePrereleases() = false, want true after SetConfig(_, true)")
Expand All @@ -350,7 +352,7 @@ func TestChecker_IncludePrereleases_Resolution(t *testing.T) {

t.Run("env override wins over stable config", func(t *testing.T) {
t.Setenv(EnvAllowPrereleaseUpdates, "true")
checker := New(zaptest.NewLogger(t), "v1.0.0")
checker := New(zaptest.NewLogger(t), "development")
checker.SetConfig(true, false)
if !checker.IncludePrereleases() {
t.Error("IncludePrereleases() = false, want true: env override must win")
Expand Down
Loading
Loading