Skip to content

Commit 6c14e74

Browse files
authored
feat(update): channel-aware guided update command (Spec 079 US2) (#818)
Detect the install channel (build-time marker + conservative runtime heuristics) and surface the correct one-line update command per channel (homebrew/deb/rpm/go-install) with a generic releases-page fallback elsewhere, across mcpproxy status, doctor, and the Web UI banner. Additive API fields only (FR-021). Channel markers stamped in the Docker and Windows-installer build paths. Closes the upgrade-nudge-channel roadmap task.
1 parent 7ad7ae6 commit 6c14e74

27 files changed

Lines changed: 1717 additions & 46 deletions

.github/workflows/release.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -392,6 +392,12 @@ jobs:
392392
CGO_LDFLAGS: "-mmacosx-version-min=13.0"
393393
run: |
394394
VERSION=${GITHUB_REF#refs/tags/}
395+
# NOTE (Spec 079): the updatecheck.buildChannel install-channel marker is
396+
# intentionally NOT stamped here — this one matrix binary feeds the tarball,
397+
# Homebrew, and DMG artifacts, so any single channel value would be wrong for
398+
# some consumers. Those installs rely on runtime heuristics
399+
# (internal/updatecheck/channel.go); only single-channel pipelines stamp it
400+
# (Dockerfile -> docker, scripts/build-windows-installer.ps1 -> windows-installer).
395401
LDFLAGS="-s -w -X main.version=${VERSION} -X github.com/smart-mcp-proxy/mcpproxy-go/internal/httpapi.buildVersion=${VERSION}"
396402
397403
# Determine clean binary name and build flags

Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ ARG COMMIT=unknown
1919
ARG BUILD_DATE=unknown
2020
RUN CGO_ENABLED=0 go build \
2121
-tags server \
22-
-ldflags "-X main.version=${VERSION} -X main.commit=${COMMIT} -X main.date=${BUILD_DATE} -X github.com/smart-mcp-proxy/mcpproxy-go/internal/httpapi.buildVersion=${VERSION} -s -w" \
22+
-ldflags "-X main.version=${VERSION} -X main.commit=${COMMIT} -X main.date=${BUILD_DATE} -X github.com/smart-mcp-proxy/mcpproxy-go/internal/httpapi.buildVersion=${VERSION} -X github.com/smart-mcp-proxy/mcpproxy-go/internal/updatecheck.buildChannel=docker -s -w" \
2323
-o /mcpproxy ./cmd/mcpproxy
2424

2525
# Runtime stage

cmd/generate-types/main.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -391,6 +391,12 @@ export interface UpdateInfo {
391391
checked_at?: string; // ISO date string
392392
is_prerelease?: boolean;
393393
check_error?: string;
394+
// Spec 079 US2 (additive per FR-021): detected install channel (homebrew,
395+
// dmg, deb, rpm, docker, go-install, windows-installer, tarball, unknown).
396+
install_channel?: string;
397+
// One-line update command for the channel; only present when an update is
398+
// available and the channel has a safe command (FR-009).
399+
update_command?: string;
394400
}
395401
396402
export interface InfoResponse {

cmd/mcpproxy/doctor_cmd.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,29 @@ import (
1414
"github.com/smart-mcp-proxy/mcpproxy-go/internal/cliclient"
1515
"github.com/smart-mcp-proxy/mcpproxy-go/internal/config"
1616
"github.com/smart-mcp-proxy/mcpproxy-go/internal/socket"
17+
"github.com/smart-mcp-proxy/mcpproxy-go/internal/updatecheck"
1718
)
1819

20+
// doctorUpdateActionLine renders the guided-update action for the /api/v1/info
21+
// update object (Spec 079 US2, FR-009): the channel's exact one-line command
22+
// when one exists, the channel-appropriate guidance otherwise, or "" when the
23+
// daemon reported no install channel (older daemons). The release URL is
24+
// already printed on the Download line, so guidance uses the generic
25+
// "releases page" wording.
26+
func doctorUpdateActionLine(updateInfo map[string]interface{}) string {
27+
if cmd := getStringField(updateInfo, "update_command"); cmd != "" {
28+
return "Run: " + cmd
29+
}
30+
channel := getStringField(updateInfo, "install_channel")
31+
if channel == "" {
32+
return ""
33+
}
34+
if guidance := updatecheck.GuidanceLine(channel, ""); guidance != "" {
35+
return "Update: " + guidance
36+
}
37+
return ""
38+
}
39+
1940
var (
2041
doctorCmd = &cobra.Command{
2142
Use: "doctor",
@@ -302,6 +323,10 @@ func outputDiagnostics(diag map[string]interface{}, info map[string]interface{},
302323
if releaseURL != "" {
303324
fmt.Printf("Download: %s\n", releaseURL)
304325
}
326+
// Spec 079 US2 (FR-009): channel-aware guided update.
327+
if action := doctorUpdateActionLine(updateInfo); action != "" {
328+
fmt.Println(action)
329+
}
305330
} else {
306331
fmt.Printf("Version: %s (latest)\n", version)
307332
}

cmd/mcpproxy/doctor_update_test.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package main
2+
3+
import "testing"
4+
5+
// Spec 079 US2: doctor prints a guided-update action line after the
6+
// Download line — the exact channel command when one exists, otherwise the
7+
// channel-appropriate guidance (FR-009).
8+
func TestDoctorUpdateActionLine(t *testing.T) {
9+
tests := []struct {
10+
name string
11+
updateInfo map[string]interface{}
12+
expected string
13+
}{
14+
{
15+
name: "channel with command renders Run line",
16+
updateInfo: map[string]interface{}{
17+
"available": true,
18+
"latest_version": "v0.48.0",
19+
"install_channel": "homebrew",
20+
"update_command": "brew upgrade mcpproxy",
21+
},
22+
expected: "Run: brew upgrade mcpproxy",
23+
},
24+
{
25+
name: "guidance-only channel renders Update line",
26+
updateInfo: map[string]interface{}{
27+
"available": true,
28+
"latest_version": "v0.48.0",
29+
"install_channel": "windows-installer",
30+
},
31+
expected: "Update: Download the latest Windows installer from the releases page",
32+
},
33+
{
34+
name: "older daemon without channel info renders nothing",
35+
updateInfo: map[string]interface{}{
36+
"available": true,
37+
"latest_version": "v0.48.0",
38+
},
39+
expected: "",
40+
},
41+
}
42+
43+
for _, tt := range tests {
44+
t.Run(tt.name, func(t *testing.T) {
45+
if got := doctorUpdateActionLine(tt.updateInfo); got != tt.expected {
46+
t.Errorf("doctorUpdateActionLine() = %q, want %q", got, tt.expected)
47+
}
48+
})
49+
}
50+
}

cmd/mcpproxy/status_cmd.go

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717
"github.com/smart-mcp-proxy/mcpproxy-go/internal/cliclient"
1818
"github.com/smart-mcp-proxy/mcpproxy-go/internal/config"
1919
"github.com/smart-mcp-proxy/mcpproxy-go/internal/socket"
20+
"github.com/smart-mcp-proxy/mcpproxy-go/internal/updatecheck"
2021
)
2122

2223
// StatusInfo holds the collected status data for display.
@@ -42,12 +43,14 @@ type StatusInfo struct {
4243
// (internal/updatecheck.InfoResponseUpdate) for status output. The daemon's
4344
// background checker is the single source of truth; status only renders it.
4445
type StatusUpdateInfo struct {
45-
Available bool `json:"available"`
46-
LatestVersion string `json:"latest_version,omitempty"`
47-
ReleaseURL string `json:"release_url,omitempty"`
48-
CheckedAt string `json:"checked_at,omitempty"` // RFC 3339, as serialized by the daemon
49-
IsPrerelease bool `json:"is_prerelease,omitempty"`
50-
CheckError string `json:"check_error,omitempty"`
46+
Available bool `json:"available"`
47+
LatestVersion string `json:"latest_version,omitempty"`
48+
ReleaseURL string `json:"release_url,omitempty"`
49+
CheckedAt string `json:"checked_at,omitempty"` // RFC 3339, as serialized by the daemon
50+
IsPrerelease bool `json:"is_prerelease,omitempty"`
51+
CheckError string `json:"check_error,omitempty"`
52+
InstallChannel string `json:"install_channel,omitempty"` // Spec 079 FR-008
53+
UpdateCommand string `json:"update_command,omitempty"` // Spec 079 FR-009
5154
}
5255

5356
// ServerEditionStatusInfo holds server-edition-specific status information.
@@ -328,6 +331,12 @@ func extractStatusUpdate(infoData map[string]interface{}) *StatusUpdateInfo {
328331
if v, ok := updateData["check_error"].(string); ok {
329332
u.CheckError = v
330333
}
334+
if v, ok := updateData["install_channel"].(string); ok {
335+
u.InstallChannel = v
336+
}
337+
if v, ok := updateData["update_command"].(string); ok {
338+
u.UpdateCommand = v
339+
}
331340
return u
332341
}
333342

@@ -344,10 +353,24 @@ func statusVersionSuffix(u *StatusUpdateInfo) string {
344353
return ""
345354
}
346355
if u.Available && u.LatestVersion != "" {
356+
// Spec 079 US2 (FR-009): append the channel's exact one-line update
357+
// command, or the channel-appropriate guidance when no command is
358+
// safe. Older daemons omit install_channel — render the legacy form.
359+
action := ""
360+
switch {
361+
case u.UpdateCommand != "":
362+
action = " — Run: " + u.UpdateCommand
363+
case u.InstallChannel != "":
364+
// The release URL already appears in the suffix; pass "" so the
365+
// guidance says "the releases page" instead of repeating it.
366+
if g := updatecheck.GuidanceLine(u.InstallChannel, ""); g != "" {
367+
action = " — " + g
368+
}
369+
}
347370
if u.ReleaseURL != "" {
348-
return fmt.Sprintf(" (update available: %s — %s)", u.LatestVersion, u.ReleaseURL)
371+
return fmt.Sprintf(" (update available: %s — %s%s)", u.LatestVersion, u.ReleaseURL, action)
349372
}
350-
return fmt.Sprintf(" (update available: %s)", u.LatestVersion)
373+
return fmt.Sprintf(" (update available: %s%s)", u.LatestVersion, action)
351374
}
352375
if u.LatestVersion != "" {
353376
// A successful check confirmed we are current.

cmd/mcpproxy/status_update_test.go

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,54 @@ func TestStatusVersionSuffix(t *testing.T) {
6464
update: &StatusUpdateInfo{},
6565
expected: "",
6666
},
67+
{
68+
// Spec 079 US2 (FR-009): channels with a safe one-line command
69+
// surface it right on the Version line.
70+
name: "update available with channel command",
71+
update: &StatusUpdateInfo{
72+
Available: true,
73+
LatestVersion: "v0.48.0",
74+
ReleaseURL: "https://github.com/smart-mcp-proxy/mcpproxy-go/releases/tag/v0.48.0",
75+
InstallChannel: "homebrew",
76+
UpdateCommand: "brew upgrade mcpproxy",
77+
},
78+
expected: " (update available: v0.48.0 — https://github.com/smart-mcp-proxy/mcpproxy-go/releases/tag/v0.48.0 — Run: brew upgrade mcpproxy)",
79+
},
80+
{
81+
// No-command channel gets the channel-appropriate guidance line
82+
// instead of a possibly-wrong command (FR-009).
83+
name: "update available with guidance-only channel",
84+
update: &StatusUpdateInfo{
85+
Available: true,
86+
LatestVersion: "v0.48.0",
87+
ReleaseURL: "https://github.com/smart-mcp-proxy/mcpproxy-go/releases/tag/v0.48.0",
88+
InstallChannel: "dmg",
89+
},
90+
expected: " (update available: v0.48.0 — https://github.com/smart-mcp-proxy/mcpproxy-go/releases/tag/v0.48.0 — Download the latest DMG from the releases page)",
91+
},
92+
{
93+
// A prerelease target on a command channel: the daemon suppresses
94+
// the command (package managers serve stable only), and the
95+
// generic release-page guidance renders instead of nothing.
96+
name: "prerelease-suppressed command channel falls back to guidance",
97+
update: &StatusUpdateInfo{
98+
Available: true,
99+
LatestVersion: "v0.48.0-rc.1",
100+
ReleaseURL: "https://github.com/smart-mcp-proxy/mcpproxy-go/releases/tag/v0.48.0-rc.1",
101+
InstallChannel: "homebrew",
102+
},
103+
expected: " (update available: v0.48.0-rc.1 — https://github.com/smart-mcp-proxy/mcpproxy-go/releases/tag/v0.48.0-rc.1 — Download the latest release from the releases page)",
104+
},
105+
{
106+
// Older daemons omit install_channel: render exactly as before.
107+
name: "update available without channel info keeps legacy format",
108+
update: &StatusUpdateInfo{
109+
Available: true,
110+
LatestVersion: "v0.48.0",
111+
ReleaseURL: "https://github.com/smart-mcp-proxy/mcpproxy-go/releases/tag/v0.48.0",
112+
},
113+
expected: " (update available: v0.48.0 — https://github.com/smart-mcp-proxy/mcpproxy-go/releases/tag/v0.48.0)",
114+
},
67115
}
68116

69117
for _, tt := range tests {
@@ -181,6 +229,28 @@ func TestExtractStatusUpdate(t *testing.T) {
181229
}
182230
})
183231

232+
t.Run("install channel and update command parsed", func(t *testing.T) {
233+
infoData := map[string]interface{}{
234+
"update": map[string]interface{}{
235+
"available": true,
236+
"latest_version": "v0.48.0",
237+
"install_channel": "homebrew",
238+
"update_command": "brew upgrade mcpproxy",
239+
},
240+
}
241+
242+
u := extractStatusUpdate(infoData)
243+
if u == nil {
244+
t.Fatal("expected non-nil update info")
245+
}
246+
if u.InstallChannel != "homebrew" {
247+
t.Errorf("expected InstallChannel homebrew, got %q", u.InstallChannel)
248+
}
249+
if u.UpdateCommand != "brew upgrade mcpproxy" {
250+
t.Errorf("expected UpdateCommand, got %q", u.UpdateCommand)
251+
}
252+
})
253+
184254
t.Run("check error preserved for machine output", func(t *testing.T) {
185255
infoData := map[string]interface{}{
186256
"update": map[string]interface{}{

docs/api/rest-api.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -837,7 +837,9 @@ Get application info, version, and update availability.
837837
"latest_version": "v1.3.0",
838838
"release_url": "https://github.com/smart-mcp-proxy/mcpproxy-go/releases/tag/v1.3.0",
839839
"checked_at": "2025-01-15T10:30:00Z",
840-
"is_prerelease": false
840+
"is_prerelease": false,
841+
"install_channel": "homebrew",
842+
"update_command": "brew upgrade mcpproxy"
841843
}
842844
}
843845
}
@@ -859,6 +861,8 @@ Get application info, version, and update availability.
859861
| `update.checked_at` | string | ISO 8601 timestamp of last update check |
860862
| `update.is_prerelease` | boolean | Whether the latest version is a prerelease |
861863
| `update.check_error` | string | Error message if update check failed |
864+
| `update.install_channel` | string | Detected install channel: `homebrew`, `dmg`, `deb`, `rpm`, `docker`, `go-install`, `windows-installer`, `tarball`, or `unknown`. Always present once detected, even when no update is available. See [Version Updates](/features/version-updates) for how detection works. |
865+
| `update.update_command` | string | Exact one-line update command for the detected channel. Only present when an update is available **and** the channel has a safe command (`homebrew`, `deb`, `rpm`, `go-install`); omitted for `dmg`/`windows-installer`/`tarball`/`docker`/`unknown` so a possibly-wrong command is never suggested. |
862866

863867
:::tip Update Checking
864868
MCPProxy automatically checks for updates every 4 hours. The update information is exposed via this endpoint and used by the tray application and web UI to show update notifications. Use `?refresh=true` to force an immediate re-check. Checking is controlled by the `update_check` config block (`enabled`, `channel`) — see [Version Updates](/features/version-updates); when disabled, `?refresh=true` performs no check and the `update` object is omitted.

docs/features/version-updates.md

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,14 +153,86 @@ Response includes an `update` field when version information is available:
153153
"latest_version": "v1.3.0",
154154
"release_url": "https://github.com/smart-mcp-proxy/mcpproxy-go/releases/tag/v1.3.0",
155155
"checked_at": "2025-01-15T10:30:00Z",
156-
"is_prerelease": false
156+
"is_prerelease": false,
157+
"install_channel": "homebrew",
158+
"update_command": "brew upgrade mcpproxy"
157159
}
158160
}
159161
}
160162
```
161163

162164
See [REST API Documentation](../api/rest-api.md#get-apiv1info) for complete details.
163165

166+
## How the Install Channel Is Detected
167+
168+
MCPProxy identifies how it was installed so it can show the right update
169+
instruction for your setup (`install_channel` in `/api/v1/info`, plus the
170+
guided command in `mcpproxy status`, `mcpproxy doctor`, and the Web UI
171+
banner).
172+
173+
Detection prefers a **build-time channel marker** stamped into
174+
single-channel artifacts at packaging time (the Docker image and the Windows
175+
installer). When no marker is present — the release archives feed the
176+
tarball, Homebrew, and DMG channels from one binary — runtime heuristics run
177+
in decreasing confidence order:
178+
179+
1. **Homebrew**: the (symlink-resolved) executable path lives under a
180+
Homebrew prefix (`/opt/homebrew/`, a `Cellar/` path, or
181+
`/home/linuxbrew/.linuxbrew`).
182+
2. **Docker**: `/.dockerenv` exists.
183+
3. **deb / rpm**: on Linux, the executable is exactly `/usr/bin/mcpproxy`,
184+
the owning package manager confirms it owns the file
185+
(`/var/lib/dpkg/info/mcpproxy.list` for deb; for rpm, an rpm database
186+
exists **and** a one-shot `rpm -qf /usr/bin/mcpproxy` query names the
187+
`mcpproxy` package), **and** the MCPProxy repository is configured
188+
(`/etc/apt/sources.list.d/mcpproxy.list` resp.
189+
`/etc/yum.repos.d/mcpproxy.repo`, as written by the documented setup).
190+
The repo signal matters because the apt/dnf commands only work against
191+
`apt.mcpproxy.app` / `rpm.mcpproxy.app`: a standalone `.deb`/`.rpm`
192+
downloaded from a GitHub release is dpkg/rpm-owned but has no upgrade
193+
candidate, so it stays `unknown` and gets release-page guidance. A binary
194+
merely copied to `/usr/bin` (e.g. an AUR or manual install) also stays
195+
`unknown`.
196+
4. **DMG**: on macOS, the executable runs from an `.app/Contents/MacOS` or
197+
`.app/Contents/Resources/bin` bundle path, or is the tray-staged core at
198+
`~/Library/Application Support/mcpproxy/bin/mcpproxy` (the process that
199+
actually serves the API for DMG installs; only the tray's bundle-staging
200+
writes that directory).
201+
5. **go install**: the Go toolchain stamped a real module version into the
202+
binary's build info while no release version was stamped via ldflags.
203+
For this channel the checker also adopts that module version as its
204+
current version (the ldflags default would read "development" and
205+
disable update checks entirely).
206+
6. Otherwise the channel is **`unknown`**.
207+
208+
Ambiguity always resolves to `unknown`: MCPProxy never guesses a channel,
209+
because a wrong update command is worse than a generic instruction.
210+
211+
### Update Commands per Channel
212+
213+
| Channel | `update_command` | Guidance shown instead |
214+
|---------|------------------|------------------------|
215+
| `homebrew` | `brew upgrade mcpproxy` ||
216+
| `deb` | `sudo apt update && sudo apt install --only-upgrade mcpproxy` ||
217+
| `rpm` | `sudo dnf upgrade mcpproxy` ||
218+
| `go-install` | `go install github.com/smart-mcp-proxy/mcpproxy-go/cmd/mcpproxy@latest` ||
219+
| `dmg` || Download the latest DMG (release page is deep-linked) |
220+
| `windows-installer` || Download the latest Windows installer |
221+
| `docker` || Pull or rebuild the newer image for your deployment |
222+
| `tarball` / `unknown` || Download the latest release from the releases page |
223+
224+
Every surface always deep-links the release notes for the latest version,
225+
whether or not a command is available.
226+
227+
**Prerelease targets** (the `rc` channel or
228+
`MCPPROXY_ALLOW_PRERELEASE_UPDATES=true`) are the exception: prereleases are
229+
published only to the GitHub pre-release channel, so the package-manager
230+
commands above would not deliver them (`brew`/`apt`/`dnf` serve stable
231+
artifacts, and Go's `@latest` resolves to the newest stable). When the offered
232+
version is a prerelease, only `go-install` gets a command — pinned to the
233+
exact version (`…/cmd/mcpproxy@v0.48.0-rc.1`) — and every other channel falls
234+
back to the release-page guidance.
235+
164236
## Updating MCPProxy
165237

166238
### Homebrew (macOS/Linux)

0 commit comments

Comments
 (0)