diff --git a/cmd/mcpproxy/status_cmd.go b/cmd/mcpproxy/status_cmd.go index f1a5c4f44..99e1ec51c 100644 --- a/cmd/mcpproxy/status_cmd.go +++ b/cmd/mcpproxy/status_cmd.go @@ -34,9 +34,22 @@ type StatusInfo struct { SocketPath string `json:"socket_path,omitempty"` ConfigPath string `json:"config_path,omitempty"` Version string `json:"version,omitempty"` + Update *StatusUpdateInfo `json:"update,omitempty"` ServerEditionInfo *ServerEditionStatusInfo `json:"server_edition,omitempty"` } +// StatusUpdateInfo mirrors the `update` object of GET /api/v1/info +// (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"` +} + // ServerEditionStatusInfo holds server-edition-specific status information. type ServerEditionStatusInfo struct { OAuthProvider string `json:"oauth_provider"` @@ -212,6 +225,7 @@ func collectStatusFromDaemon(cfg *config.Config, socketPath, configPath string) if url, ok := infoData["web_ui_url"].(string); ok { info.WebUIURL = url } + info.Update = extractStatusUpdate(infoData) } // Construct Web UI URL if not provided by daemon @@ -270,6 +284,61 @@ func extractServerCounts(stats map[string]interface{}) *ServerCounts { return counts } +// extractStatusUpdate pulls the `update` object out of the /api/v1/info +// payload. Returns nil when the daemon did not report update state. +func extractStatusUpdate(infoData map[string]interface{}) *StatusUpdateInfo { + updateData, ok := infoData["update"].(map[string]interface{}) + if !ok { + return nil + } + + u := &StatusUpdateInfo{} + if v, ok := updateData["available"].(bool); ok { + u.Available = v + } + if v, ok := updateData["latest_version"].(string); ok { + u.LatestVersion = v + } + if v, ok := updateData["release_url"].(string); ok { + u.ReleaseURL = v + } + if v, ok := updateData["checked_at"].(string); ok { + u.CheckedAt = v + } + if v, ok := updateData["is_prerelease"].(bool); ok { + u.IsPrerelease = v + } + if v, ok := updateData["check_error"].(string); ok { + u.CheckError = v + } + return u +} + +// statusVersionSuffix renders the update annotation appended to the Version +// line, mirroring doctor's presentation. A failed or not-yet-completed check +// renders nothing (quiet on failure; the error stays in JSON for diagnostics). +// +// TODO(spec-079/FR-002): extend the annotation with the human-readable +// "N releases / M weeks behind" delta once internal/updatecheck computes it +// (requires the release list + publish dates, not just the latest release; +// additive per FR-021). This function is the single rendering point. +func statusVersionSuffix(u *StatusUpdateInfo) string { + if u == nil || u.CheckError != "" { + return "" + } + if u.Available && u.LatestVersion != "" { + if u.ReleaseURL != "" { + return fmt.Sprintf(" (update available: %s — %s)", u.LatestVersion, u.ReleaseURL) + } + return fmt.Sprintf(" (update available: %s)", u.LatestVersion) + } + if u.LatestVersion != "" { + // A successful check confirmed we are current. + return " (latest)" + } + return "" +} + // statusMaskAPIKey returns a masked version of the API key showing first and last 4 chars. func statusMaskAPIKey(apiKey string) string { if len(apiKey) <= 8 { @@ -374,7 +443,7 @@ func printStatusTable(info *StatusInfo) { fmt.Printf(" %-12s %s\n", "Edition:", info.Edition) if info.Version != "" { - fmt.Printf(" %-12s %s\n", "Version:", info.Version) + fmt.Printf(" %-12s %s%s\n", "Version:", info.Version, statusVersionSuffix(info.Update)) } fmt.Printf(" %-12s %s\n", "Listen:", info.ListenAddr) diff --git a/cmd/mcpproxy/status_update_test.go b/cmd/mcpproxy/status_update_test.go new file mode 100644 index 000000000..786200443 --- /dev/null +++ b/cmd/mcpproxy/status_update_test.go @@ -0,0 +1,285 @@ +package main + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestStatusVersionSuffix(t *testing.T) { + tests := []struct { + name string + update *StatusUpdateInfo + expected string + }{ + { + name: "nil update shows nothing", + update: nil, + expected: "", + }, + { + name: "update available with release URL", + update: &StatusUpdateInfo{ + Available: true, + LatestVersion: "v0.46.0", + ReleaseURL: "https://github.com/smart-mcp-proxy/mcpproxy-go/releases/tag/v0.46.0", + }, + expected: " (update available: v0.46.0 — https://github.com/smart-mcp-proxy/mcpproxy-go/releases/tag/v0.46.0)", + }, + { + name: "update available without release URL", + update: &StatusUpdateInfo{ + Available: true, + LatestVersion: "v0.46.0", + }, + expected: " (update available: v0.46.0)", + }, + { + name: "up to date after successful check", + update: &StatusUpdateInfo{ + Available: false, + LatestVersion: "v0.45.0", + }, + expected: " (latest)", + }, + { + name: "check error stays quiet", + update: &StatusUpdateInfo{ + Available: false, + CheckError: "Get \"https://api.github.com\": dial tcp: no route to host", + }, + expected: "", + }, + { + name: "check error with stale availability stays quiet", + update: &StatusUpdateInfo{ + Available: true, + LatestVersion: "v0.46.0", + CheckError: "rate limited", + }, + expected: "", + }, + { + name: "check not completed yet shows nothing", + update: &StatusUpdateInfo{}, + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := statusVersionSuffix(tt.update) + if result != tt.expected { + t.Errorf("statusVersionSuffix() = %q, want %q", result, tt.expected) + } + }) + } +} + +func TestStatusTableShowsUpdateAvailability(t *testing.T) { + t.Run("update available", func(t *testing.T) { + info := &StatusInfo{ + State: "Running", + Edition: "personal", + ListenAddr: "127.0.0.1:8080", + APIKey: "a1b2****a1b2", + WebUIURL: "http://127.0.0.1:8080/ui/?apikey=test", + Version: "v0.45.0", + Update: &StatusUpdateInfo{ + Available: true, + LatestVersion: "v0.46.0", + ReleaseURL: "https://github.com/smart-mcp-proxy/mcpproxy-go/releases/tag/v0.46.0", + }, + } + + output := captureStdout(t, func() { printStatusTable(info) }) + + if !strings.Contains(output, "v0.45.0 (update available: v0.46.0 — https://github.com/smart-mcp-proxy/mcpproxy-go/releases/tag/v0.46.0)") { + t.Errorf("expected update availability on the Version line, output:\n%s", output) + } + }) + + t.Run("up to date", func(t *testing.T) { + info := &StatusInfo{ + State: "Running", + Edition: "personal", + ListenAddr: "127.0.0.1:8080", + APIKey: "a1b2****a1b2", + WebUIURL: "http://127.0.0.1:8080/ui/?apikey=test", + Version: "v0.46.0", + Update: &StatusUpdateInfo{ + Available: false, + LatestVersion: "v0.46.0", + }, + } + + output := captureStdout(t, func() { printStatusTable(info) }) + + if !strings.Contains(output, "v0.46.0 (latest)") { + t.Errorf("expected '(latest)' on the Version line, output:\n%s", output) + } + }) + + t.Run("check error shows plain version", func(t *testing.T) { + info := &StatusInfo{ + State: "Running", + Edition: "personal", + ListenAddr: "127.0.0.1:8080", + APIKey: "a1b2****a1b2", + WebUIURL: "http://127.0.0.1:8080/ui/?apikey=test", + Version: "v0.45.0", + Update: &StatusUpdateInfo{ + CheckError: "dial tcp: no route to host", + }, + } + + output := captureStdout(t, func() { printStatusTable(info) }) + + if !strings.Contains(output, "Version:") || !strings.Contains(output, "v0.45.0") { + t.Errorf("expected plain version line, output:\n%s", output) + } + if strings.Contains(output, "update available") || strings.Contains(output, "(latest)") { + t.Errorf("expected no update annotation on check error, output:\n%s", output) + } + if strings.Contains(output, "no route to host") { + t.Errorf("check error must not be surfaced in human output:\n%s", output) + } + }) +} + +func TestExtractStatusUpdate(t *testing.T) { + t.Run("full update object", func(t *testing.T) { + infoData := map[string]interface{}{ + "version": "v0.45.0", + "update": map[string]interface{}{ + "available": true, + "latest_version": "v0.46.0-rc.1", + "release_url": "https://github.com/smart-mcp-proxy/mcpproxy-go/releases/tag/v0.46.0-rc.1", + "checked_at": "2026-07-02T10:00:00Z", + "is_prerelease": true, + }, + } + + u := extractStatusUpdate(infoData) + if u == nil { + t.Fatal("expected non-nil update info") + } + if !u.Available { + t.Error("expected Available=true") + } + if u.LatestVersion != "v0.46.0-rc.1" { + t.Errorf("expected LatestVersion v0.46.0-rc.1, got %q", u.LatestVersion) + } + if u.ReleaseURL != "https://github.com/smart-mcp-proxy/mcpproxy-go/releases/tag/v0.46.0-rc.1" { + t.Errorf("unexpected ReleaseURL %q", u.ReleaseURL) + } + if u.CheckedAt != "2026-07-02T10:00:00Z" { + t.Errorf("expected CheckedAt to be preserved, got %q", u.CheckedAt) + } + if !u.IsPrerelease { + t.Error("expected IsPrerelease=true") + } + }) + + t.Run("check error preserved for machine output", func(t *testing.T) { + infoData := map[string]interface{}{ + "update": map[string]interface{}{ + "available": false, + "check_error": "rate limited", + }, + } + + u := extractStatusUpdate(infoData) + if u == nil { + t.Fatal("expected non-nil update info") + } + if u.CheckError != "rate limited" { + t.Errorf("expected CheckError 'rate limited', got %q", u.CheckError) + } + }) + + t.Run("missing update object", func(t *testing.T) { + infoData := map[string]interface{}{"version": "v0.45.0"} + if u := extractStatusUpdate(infoData); u != nil { + t.Errorf("expected nil update info, got %+v", u) + } + }) +} + +func TestStatusJSONIncludesUpdate(t *testing.T) { + info := &StatusInfo{ + State: "Running", + ListenAddr: "127.0.0.1:8080", + APIKey: "a1b2****a1b2", + WebUIURL: "http://127.0.0.1:8080/ui/?apikey=test", + Version: "v0.45.0", + Update: &StatusUpdateInfo{ + Available: true, + LatestVersion: "v0.46.0", + ReleaseURL: "https://github.com/smart-mcp-proxy/mcpproxy-go/releases/tag/v0.46.0", + CheckedAt: "2026-07-02T10:00:00Z", + IsPrerelease: true, + }, + } + + output := captureStdout(t, func() { + if err := printStatusJSON(info); err != nil { + t.Errorf("printStatusJSON failed: %v", err) + } + }) + + var result StatusInfo + if err := json.Unmarshal([]byte(output), &result); err != nil { + t.Fatalf("invalid JSON: %v\nOutput: %s", err, output) + } + + if result.Update == nil { + t.Fatal("expected 'update' field in JSON output") + } + if !result.Update.Available { + t.Error("expected update.available=true in JSON output") + } + if result.Update.LatestVersion != "v0.46.0" { + t.Errorf("expected update.latest_version v0.46.0, got %q", result.Update.LatestVersion) + } + + // Field names in the wire format must match the /api/v1/info update + // object (snake_case contract, FR-021). + var raw map[string]interface{} + if err := json.Unmarshal([]byte(output), &raw); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + rawUpdate, ok := raw["update"].(map[string]interface{}) + if !ok { + t.Fatal("expected raw JSON key 'update'") + } + if v, ok := rawUpdate["checked_at"].(string); !ok || v != "2026-07-02T10:00:00Z" { + t.Errorf("expected raw JSON key 'update.checked_at', got %v", rawUpdate["checked_at"]) + } + if v, ok := rawUpdate["is_prerelease"].(bool); !ok || !v { + t.Errorf("expected raw JSON key 'update.is_prerelease'=true, got %v", rawUpdate["is_prerelease"]) + } +} + +func TestStatusJSONOmitsUpdateWhenAbsent(t *testing.T) { + info := &StatusInfo{ + State: "Not running", + ListenAddr: "127.0.0.1:8080 (configured)", + APIKey: "a1b2****a1b2", + WebUIURL: "http://127.0.0.1:8080/ui/?apikey=test", + } + + output := captureStdout(t, func() { + if err := printStatusJSON(info); err != nil { + t.Errorf("printStatusJSON failed: %v", err) + } + }) + + var raw map[string]interface{} + if err := json.Unmarshal([]byte(output), &raw); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + if _, ok := raw["update"]; ok { + t.Error("expected 'update' to be omitted when no update info collected") + } +} diff --git a/docs/cli/status-command.md b/docs/cli/status-command.md index 0f9c53367..1c243cbaa 100644 --- a/docs/cli/status-command.md +++ b/docs/cli/status-command.md @@ -45,7 +45,7 @@ mcpproxy status ``` MCPProxy Status State: Running - Version: v1.2.0 + Version: v1.2.0 (update available: v1.3.0 — https://github.com/smart-mcp-proxy/mcpproxy-go/releases/tag/v1.3.0) Listen: 127.0.0.1:8080 Uptime: 2h 15m API Key: a1b2****gh78 @@ -134,10 +134,26 @@ mcpproxy status -o json }, "socket_path": "/Users/you/.mcpproxy/mcpproxy.sock", "config_path": "/Users/you/.mcpproxy/mcp_config.json", - "version": "v1.2.0" + "version": "v1.2.0", + "update": { + "available": true, + "latest_version": "v1.3.0", + "release_url": "https://github.com/smart-mcp-proxy/mcpproxy-go/releases/tag/v1.3.0", + "checked_at": "2026-07-02T10:00:00Z" + } } ``` +## Update Availability + +When the daemon is running, `status` surfaces the result of the background update check (the same `update` object as `GET /api/v1/info` and `mcpproxy doctor`): + +- **Update available**: `Version: v1.2.0 (update available: v1.3.0 — )` +- **Up to date**: `Version: v1.3.0 (latest)` +- **Check failed or not yet completed** (offline, rate-limited): the version is shown without any annotation. In JSON output the `update.check_error` field retains the failure reason for diagnostics. + +In machine-readable output (`-o json`/`-o yaml`) the `update` object also carries `checked_at` (when the last successful check ran, so consumers can judge staleness) and `is_prerelease` (whether the offered version is a prerelease), matching the `/api/v1/info` contract. + ## API Key Masking By default, the API key is masked showing only the first 4 and last 4 characters: diff --git a/internal/updatecheck/checker.go b/internal/updatecheck/checker.go index 899921214..4476be3e0 100644 --- a/internal/updatecheck/checker.go +++ b/internal/updatecheck/checker.go @@ -31,6 +31,11 @@ type Checker struct { mu sync.RWMutex versionInfo *VersionInfo + // announcedVersion is the latest version already announced at Info level. + // It dedupes the "Update available" log so a given version is announced + // exactly once per process, not on every periodic tick (Spec 079 FR-004). + announcedVersion string + // For testing: allows injection of a custom check function checkFunc func() (*GitHubRelease, error) } @@ -158,12 +163,24 @@ func (c *Checker) updateVersionInfo(release *GitHubRelease, checkError string) { CheckError: "", } - if updateAvailable { + switch { + case updateAvailable && latestVersion != c.announcedVersion: + // Announce each newly detected version exactly once per process; + // subsequent ticks for the same version log at Debug only. + // TODO(spec-079/FR-002): include the "N releases / M weeks behind" + // delta here once the checker fetches the release list + publish + // dates (a later 079 slice extending VersionInfo, additive per + // FR-021). + c.announcedVersion = latestVersion c.logger.Info("Update available", zap.String("current", c.version), zap.String("latest", latestVersion), zap.String("url", release.HTMLURL)) - } else { + case updateAvailable: + c.logger.Debug("Update still available", + zap.String("current", c.version), + zap.String("latest", latestVersion)) + default: c.logger.Debug("Running latest version", zap.String("version", c.version)) } diff --git a/internal/updatecheck/checker_test.go b/internal/updatecheck/checker_test.go index f5eea2199..608dd1957 100644 --- a/internal/updatecheck/checker_test.go +++ b/internal/updatecheck/checker_test.go @@ -1,10 +1,12 @@ package updatecheck import ( + "errors" "testing" "go.uber.org/zap" "go.uber.org/zap/zaptest" + "go.uber.org/zap/zaptest/observer" ) func TestChecker_CheckNow(t *testing.T) { @@ -75,6 +77,80 @@ func TestChecker_CheckNow_NoUpdate(t *testing.T) { } } +// TestChecker_UpdateAvailableLoggedOncePerVersion verifies the "Update available" +// Info log is emitted exactly once per detected latest version, not on every +// periodic tick (FR-004 of specs/079-upgrade-nudge: no repeated log spam). +func TestChecker_UpdateAvailableLoggedOncePerVersion(t *testing.T) { + core, logs := observer.New(zap.InfoLevel) + logger := zap.New(core) + + checker := New(logger, "v1.0.0") + release := &GitHubRelease{ + TagName: "v1.1.0", + HTMLURL: "https://github.com/test/repo/releases/tag/v1.1.0", + } + checker.SetCheckFunc(func() (*GitHubRelease, error) { + return release, nil + }) + + // Simulate the initial check plus two periodic ticks for the same version. + checker.check() + checker.check() + checker.check() + + if got := logs.FilterMessage("Update available").Len(); got != 1 { + t.Errorf("expected exactly 1 'Update available' Info log for the same version, got %d", got) + } + + // A transient failure followed by recovery to the same version must not re-announce. + checker.SetCheckFunc(func() (*GitHubRelease, error) { + return nil, errors.New("network unreachable") + }) + checker.check() + checker.SetCheckFunc(func() (*GitHubRelease, error) { + return release, nil + }) + checker.check() + + if got := logs.FilterMessage("Update available").Len(); got != 1 { + t.Errorf("expected still 1 'Update available' Info log after error+recovery, got %d", got) + } + + // A newer latest version must be announced once more. + newer := &GitHubRelease{ + TagName: "v1.2.0", + HTMLURL: "https://github.com/test/repo/releases/tag/v1.2.0", + } + checker.SetCheckFunc(func() (*GitHubRelease, error) { + return newer, nil + }) + checker.check() + checker.check() + + if got := logs.FilterMessage("Update available").Len(); got != 2 { + t.Errorf("expected 2 'Update available' Info logs after a newer version appeared, got %d", got) + } +} + +// TestChecker_NoUpdateLogWhenCurrent verifies no Info-level nudge is logged +// when the running version is already the latest. +func TestChecker_NoUpdateLogWhenCurrent(t *testing.T) { + core, logs := observer.New(zap.InfoLevel) + logger := zap.New(core) + + checker := New(logger, "v1.1.0") + checker.SetCheckFunc(func() (*GitHubRelease, error) { + return &GitHubRelease{TagName: "v1.1.0"}, nil + }) + + checker.check() + checker.check() + + if got := logs.FilterMessage("Update available").Len(); got != 0 { + t.Errorf("expected no 'Update available' Info log when current, got %d", got) + } +} + func TestChecker_CompareVersions(t *testing.T) { logger := zap.NewNop() checker := New(logger, "v1.0.0") @@ -87,7 +163,7 @@ func TestChecker_CompareVersions(t *testing.T) { {"v1.0.0", "v1.1.0", true}, {"v1.1.0", "v1.0.0", false}, {"v1.0.0", "v1.0.0", false}, - {"1.0.0", "1.1.0", true}, // Without v prefix + {"1.0.0", "1.1.0", true}, // Without v prefix {"v0.11.1", "v0.11.3", true}, {"v0.11.2", "v0.11.2", false}, }