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
6 changes: 6 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,12 @@ jobs:
CGO_LDFLAGS: "-mmacosx-version-min=13.0"
run: |
VERSION=${GITHUB_REF#refs/tags/}
# NOTE (Spec 079): the updatecheck.buildChannel install-channel marker is
# intentionally NOT stamped here — this one matrix binary feeds the tarball,
# Homebrew, and DMG artifacts, so any single channel value would be wrong for
# some consumers. Those installs rely on runtime heuristics
# (internal/updatecheck/channel.go); only single-channel pipelines stamp it
# (Dockerfile -> docker, scripts/build-windows-installer.ps1 -> windows-installer).
LDFLAGS="-s -w -X main.version=${VERSION} -X github.com/smart-mcp-proxy/mcpproxy-go/internal/httpapi.buildVersion=${VERSION}"

# Determine clean binary name and build flags
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ ARG COMMIT=unknown
ARG BUILD_DATE=unknown
RUN CGO_ENABLED=0 go build \
-tags server \
-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" \
-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" \
-o /mcpproxy ./cmd/mcpproxy

# Runtime stage
Expand Down
6 changes: 6 additions & 0 deletions cmd/generate-types/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,12 @@ export interface UpdateInfo {
checked_at?: string; // ISO date string
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).
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).
update_command?: string;
}

export interface InfoResponse {
Expand Down
25 changes: 25 additions & 0 deletions cmd/mcpproxy/doctor_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,29 @@ import (
"github.com/smart-mcp-proxy/mcpproxy-go/internal/cliclient"
"github.com/smart-mcp-proxy/mcpproxy-go/internal/config"
"github.com/smart-mcp-proxy/mcpproxy-go/internal/socket"
"github.com/smart-mcp-proxy/mcpproxy-go/internal/updatecheck"
)

// doctorUpdateActionLine renders the guided-update action for the /api/v1/info
// update object (Spec 079 US2, FR-009): the channel's exact one-line command
// when one exists, the channel-appropriate guidance otherwise, or "" when the
// daemon reported no install channel (older daemons). The release URL is
// already printed on the Download line, so guidance uses the generic
// "releases page" wording.
func doctorUpdateActionLine(updateInfo map[string]interface{}) string {
if cmd := getStringField(updateInfo, "update_command"); cmd != "" {
return "Run: " + cmd
}
channel := getStringField(updateInfo, "install_channel")
if channel == "" {
return ""
}
if guidance := updatecheck.GuidanceLine(channel, ""); guidance != "" {
return "Update: " + guidance
}
return ""
}

var (
doctorCmd = &cobra.Command{
Use: "doctor",
Expand Down Expand Up @@ -302,6 +323,10 @@ func outputDiagnostics(diag map[string]interface{}, info map[string]interface{},
if releaseURL != "" {
fmt.Printf("Download: %s\n", releaseURL)
}
// Spec 079 US2 (FR-009): channel-aware guided update.
if action := doctorUpdateActionLine(updateInfo); action != "" {
fmt.Println(action)
}
} else {
fmt.Printf("Version: %s (latest)\n", version)
}
Expand Down
50 changes: 50 additions & 0 deletions cmd/mcpproxy/doctor_update_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package main

import "testing"

// Spec 079 US2: doctor prints a guided-update action line after the
// Download line — the exact channel command when one exists, otherwise the
// channel-appropriate guidance (FR-009).
func TestDoctorUpdateActionLine(t *testing.T) {
tests := []struct {
name string
updateInfo map[string]interface{}
expected string
}{
{
name: "channel with command renders Run line",
updateInfo: map[string]interface{}{
"available": true,
"latest_version": "v0.48.0",
"install_channel": "homebrew",
"update_command": "brew upgrade mcpproxy",
},
expected: "Run: brew upgrade mcpproxy",
},
{
name: "guidance-only channel renders Update line",
updateInfo: map[string]interface{}{
"available": true,
"latest_version": "v0.48.0",
"install_channel": "windows-installer",
},
expected: "Update: Download the latest Windows installer from the releases page",
},
{
name: "older daemon without channel info renders nothing",
updateInfo: map[string]interface{}{
"available": true,
"latest_version": "v0.48.0",
},
expected: "",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := doctorUpdateActionLine(tt.updateInfo); got != tt.expected {
t.Errorf("doctorUpdateActionLine() = %q, want %q", got, tt.expected)
}
})
}
}
39 changes: 31 additions & 8 deletions cmd/mcpproxy/status_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"github.com/smart-mcp-proxy/mcpproxy-go/internal/cliclient"
"github.com/smart-mcp-proxy/mcpproxy-go/internal/config"
"github.com/smart-mcp-proxy/mcpproxy-go/internal/socket"
"github.com/smart-mcp-proxy/mcpproxy-go/internal/updatecheck"
)

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

// ServerEditionStatusInfo holds server-edition-specific status information.
Expand Down Expand Up @@ -328,6 +331,12 @@ func extractStatusUpdate(infoData map[string]interface{}) *StatusUpdateInfo {
if v, ok := updateData["check_error"].(string); ok {
u.CheckError = v
}
if v, ok := updateData["install_channel"].(string); ok {
u.InstallChannel = v
}
if v, ok := updateData["update_command"].(string); ok {
u.UpdateCommand = v
}
return u
}

Expand All @@ -344,10 +353,24 @@ func statusVersionSuffix(u *StatusUpdateInfo) string {
return ""
}
if u.Available && u.LatestVersion != "" {
// Spec 079 US2 (FR-009): append the channel's exact one-line update
// command, or the channel-appropriate guidance when no command is
// safe. Older daemons omit install_channel — render the legacy form.
action := ""
switch {
case u.UpdateCommand != "":
action = " — Run: " + u.UpdateCommand
case u.InstallChannel != "":
// The release URL already appears in the suffix; pass "" so the
// guidance says "the releases page" instead of repeating it.
if g := updatecheck.GuidanceLine(u.InstallChannel, ""); g != "" {
action = " — " + g
}
}
if u.ReleaseURL != "" {
return fmt.Sprintf(" (update available: %s — %s)", u.LatestVersion, u.ReleaseURL)
return fmt.Sprintf(" (update available: %s — %s%s)", u.LatestVersion, u.ReleaseURL, action)
}
return fmt.Sprintf(" (update available: %s)", u.LatestVersion)
return fmt.Sprintf(" (update available: %s%s)", u.LatestVersion, action)
}
if u.LatestVersion != "" {
// A successful check confirmed we are current.
Expand Down
70 changes: 70 additions & 0 deletions cmd/mcpproxy/status_update_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,54 @@ func TestStatusVersionSuffix(t *testing.T) {
update: &StatusUpdateInfo{},
expected: "",
},
{
// Spec 079 US2 (FR-009): channels with a safe one-line command
// surface it right on the Version line.
name: "update available with channel command",
update: &StatusUpdateInfo{
Available: true,
LatestVersion: "v0.48.0",
ReleaseURL: "https://github.com/smart-mcp-proxy/mcpproxy-go/releases/tag/v0.48.0",
InstallChannel: "homebrew",
UpdateCommand: "brew upgrade mcpproxy",
},
expected: " (update available: v0.48.0 — https://github.com/smart-mcp-proxy/mcpproxy-go/releases/tag/v0.48.0 — Run: brew upgrade mcpproxy)",
},
{
// No-command channel gets the channel-appropriate guidance line
// instead of a possibly-wrong command (FR-009).
name: "update available with guidance-only channel",
update: &StatusUpdateInfo{
Available: true,
LatestVersion: "v0.48.0",
ReleaseURL: "https://github.com/smart-mcp-proxy/mcpproxy-go/releases/tag/v0.48.0",
InstallChannel: "dmg",
},
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)",
},
{
// A prerelease target on a command channel: the daemon suppresses
// the command (package managers serve stable only), and the
// generic release-page guidance renders instead of nothing.
name: "prerelease-suppressed command channel falls back to guidance",
update: &StatusUpdateInfo{
Available: true,
LatestVersion: "v0.48.0-rc.1",
ReleaseURL: "https://github.com/smart-mcp-proxy/mcpproxy-go/releases/tag/v0.48.0-rc.1",
InstallChannel: "homebrew",
},
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)",
},
{
// Older daemons omit install_channel: render exactly as before.
name: "update available without channel info keeps legacy format",
update: &StatusUpdateInfo{
Available: true,
LatestVersion: "v0.48.0",
ReleaseURL: "https://github.com/smart-mcp-proxy/mcpproxy-go/releases/tag/v0.48.0",
},
expected: " (update available: v0.48.0 — https://github.com/smart-mcp-proxy/mcpproxy-go/releases/tag/v0.48.0)",
},
}

for _, tt := range tests {
Expand Down Expand Up @@ -181,6 +229,28 @@ func TestExtractStatusUpdate(t *testing.T) {
}
})

t.Run("install channel and update command parsed", func(t *testing.T) {
infoData := map[string]interface{}{
"update": map[string]interface{}{
"available": true,
"latest_version": "v0.48.0",
"install_channel": "homebrew",
"update_command": "brew upgrade mcpproxy",
},
}

u := extractStatusUpdate(infoData)
if u == nil {
t.Fatal("expected non-nil update info")
}
if u.InstallChannel != "homebrew" {
t.Errorf("expected InstallChannel homebrew, got %q", u.InstallChannel)
}
if u.UpdateCommand != "brew upgrade mcpproxy" {
t.Errorf("expected UpdateCommand, got %q", u.UpdateCommand)
}
})

t.Run("check error preserved for machine output", func(t *testing.T) {
infoData := map[string]interface{}{
"update": map[string]interface{}{
Expand Down
6 changes: 5 additions & 1 deletion docs/api/rest-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -837,7 +837,9 @@ Get application info, version, and update availability.
"latest_version": "v1.3.0",
"release_url": "https://github.com/smart-mcp-proxy/mcpproxy-go/releases/tag/v1.3.0",
"checked_at": "2025-01-15T10:30:00Z",
"is_prerelease": false
"is_prerelease": false,
"install_channel": "homebrew",
"update_command": "brew upgrade mcpproxy"
}
}
}
Expand All @@ -859,6 +861,8 @@ Get application info, version, and update availability.
| `update.checked_at` | string | ISO 8601 timestamp of last update check |
| `update.is_prerelease` | boolean | Whether the latest version is a prerelease |
| `update.check_error` | string | Error message if update check failed |
| `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. |
| `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. |

:::tip Update Checking
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.
Expand Down
74 changes: 73 additions & 1 deletion docs/features/version-updates.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,14 +153,86 @@ Response includes an `update` field when version information is available:
"latest_version": "v1.3.0",
"release_url": "https://github.com/smart-mcp-proxy/mcpproxy-go/releases/tag/v1.3.0",
"checked_at": "2025-01-15T10:30:00Z",
"is_prerelease": false
"is_prerelease": false,
"install_channel": "homebrew",
"update_command": "brew upgrade mcpproxy"
}
}
}
```

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

## How the Install Channel Is Detected

MCPProxy identifies how it was installed so it can show the right update
instruction for your setup (`install_channel` in `/api/v1/info`, plus the
guided command in `mcpproxy status`, `mcpproxy doctor`, and the Web UI
banner).

Detection prefers a **build-time channel marker** stamped into
single-channel artifacts at packaging time (the Docker image and the Windows
installer). When no marker is present — the release archives feed the
tarball, Homebrew, and DMG channels from one binary — runtime heuristics run
in decreasing confidence order:

1. **Homebrew**: the (symlink-resolved) executable path lives under a
Homebrew prefix (`/opt/homebrew/`, a `Cellar/` path, or
`/home/linuxbrew/.linuxbrew`).
2. **Docker**: `/.dockerenv` exists.
3. **deb / rpm**: on Linux, the executable is exactly `/usr/bin/mcpproxy`,
the owning package manager confirms it owns the file
(`/var/lib/dpkg/info/mcpproxy.list` for deb; for rpm, an rpm database
exists **and** a one-shot `rpm -qf /usr/bin/mcpproxy` query names the
`mcpproxy` package), **and** the MCPProxy repository is configured
(`/etc/apt/sources.list.d/mcpproxy.list` resp.
`/etc/yum.repos.d/mcpproxy.repo`, as written by the documented setup).
The repo signal matters because the apt/dnf commands only work against
`apt.mcpproxy.app` / `rpm.mcpproxy.app`: a standalone `.deb`/`.rpm`
downloaded from a GitHub release is dpkg/rpm-owned but has no upgrade
candidate, so it stays `unknown` and gets release-page guidance. A binary
merely copied to `/usr/bin` (e.g. an AUR or manual install) also stays
`unknown`.
4. **DMG**: on macOS, the executable runs from an `.app/Contents/MacOS` or
`.app/Contents/Resources/bin` bundle path, or is the tray-staged core at
`~/Library/Application Support/mcpproxy/bin/mcpproxy` (the process that
actually serves the API for DMG installs; only the tray's bundle-staging
writes that directory).
5. **go install**: the Go toolchain stamped a real module version into the
binary's build info while no release version was stamped via ldflags.
For this channel the checker also adopts that module version as its
current version (the ldflags default would read "development" and
disable update checks entirely).
6. Otherwise the channel is **`unknown`**.

Ambiguity always resolves to `unknown`: MCPProxy never guesses a channel,
because a wrong update command is worse than a generic instruction.

### Update Commands per Channel

| Channel | `update_command` | Guidance shown instead |
|---------|------------------|------------------------|
| `homebrew` | `brew upgrade mcpproxy` | — |
| `deb` | `sudo apt update && sudo apt install --only-upgrade mcpproxy` | — |
| `rpm` | `sudo dnf upgrade mcpproxy` | — |
| `go-install` | `go install github.com/smart-mcp-proxy/mcpproxy-go/cmd/mcpproxy@latest` | — |
| `dmg` | — | Download the latest DMG (release page is deep-linked) |
| `windows-installer` | — | Download the latest Windows installer |
| `docker` | — | Pull or rebuild the newer image for your deployment |
| `tarball` / `unknown` | — | Download the latest release from the releases page |

Every surface always deep-links the release notes for the latest version,
whether or not a command is available.

**Prerelease targets** (the `rc` channel or
`MCPPROXY_ALLOW_PRERELEASE_UPDATES=true`) are the exception: prereleases are
published only to the GitHub pre-release channel, so the package-manager
commands above would not deliver them (`brew`/`apt`/`dnf` serve stable
artifacts, and Go's `@latest` resolves to the newest stable). When the offered
version is a prerelease, only `go-install` gets a command — pinned to the
exact version (`…/cmd/mcpproxy@v0.48.0-rc.1`) — and every other channel falls
back to the release-page guidance.

## Updating MCPProxy

### Homebrew (macOS/Linux)
Expand Down
Loading
Loading