Skip to content

Commit 12cd2bb

Browse files
Dumbrisclaude
andauthored
feat(status): surface update availability in mcpproxy status + dedupe startup update log (#798)
* feat(status): surface update availability in mcpproxy status + dedupe startup update log First slice of specs/079-upgrade-nudge US1 (FR-003, FR-004): make the existing background update check visible where users actually look. ## Changes - mcpproxy status: Version line now mirrors doctor's presentation — "vX (update available: vY — <release URL>)" when behind, "(latest)" after a successful check confirms currency, and a plain version when the check failed or has not completed (quiet on failure, FR-020). - mcpproxy status -o json/yaml: new optional "update" object (available, latest_version, release_url, check_error) extracted from the daemon's GET /api/v1/info payload — no second check pipeline (FR-001); check_error is retained in machine output for diagnostics. - internal/updatecheck: the "Update available" zap Info line is now announced exactly once per detected latest version per process (dedupe by version); repeat 4h ticks for the same version log at Debug only, so startup gets one clear line without timer spam. - docs/cli/status-command.md: document the update annotation and the JSON update field. ## Assumptions (zero-interruption policy) - Human output stays silent on check_error / not-yet-checked rather than showing an error state; JSON keeps check_error for diagnostics (per FR-019/FR-020 machine-readable-still-reports-facts). - "(latest)" is only shown when latest_version is non-empty, i.e. a successful check actually confirmed currency. - No config block, channel detection, delta computation, or Web UI banner in this slice — later 079 slices. ## Testing - New cmd/mcpproxy/status_update_test.go: suffix rendering table, table output, JSON contract (present/omitted), info-payload extraction incl. check_error. - New checker tests with zap observer: once-per-version Info dedupe incl. error+recovery, re-announce on newer version, silent when current. - go build ./cmd/mcpproxy; go test -race ./internal/updatecheck/... ./cmd/mcpproxy/...; golangci-lint v2 (.github/.golangci.yml): clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(status): mirror checked_at/is_prerelease in status update object; mark FR-002 delta follow-up Review follow-ups on the US1 status slice (specs/079-upgrade-nudge): - StatusUpdateInfo now carries checked_at (RFC 3339 string, staleness signal for consumers of `status -o json`) and is_prerelease, so it is an accurate mirror of the /api/v1/info update object as the code comment and docs claim (FR-019/FR-021). Extraction + JSON wire-format tests extended; docs example updated. - Added explicit TODO(spec-079/FR-002) markers at the two surfaces that will grow the "N releases / M weeks behind" delta (statusVersionSuffix and the checker's one-shot Info log). The delta needs the release list + publish dates, which the checker does not fetch yet — owned by a later 079 slice, intentionally not in this one. - FR-004 once-per-version (vs once-per-process) Info announce is kept as-is: the operative clause is "MUST NOT repeatedly log the same availability on a timer", and per-version re-announce matches the Web UI banner's per-version dismissal semantics (FR-005); a genuinely newer release is new information. Locked in by checker_test.go. Testing: go test -race ./internal/updatecheck/... ./cmd/mcpproxy/...; golangci-lint v2 (.github/.golangci.yml) on touched packages: clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent b6fcb29 commit 12cd2bb

5 files changed

Lines changed: 469 additions & 6 deletions

File tree

cmd/mcpproxy/status_cmd.go

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,22 @@ type StatusInfo struct {
3434
SocketPath string `json:"socket_path,omitempty"`
3535
ConfigPath string `json:"config_path,omitempty"`
3636
Version string `json:"version,omitempty"`
37+
Update *StatusUpdateInfo `json:"update,omitempty"`
3738
ServerEditionInfo *ServerEditionStatusInfo `json:"server_edition,omitempty"`
3839
}
3940

41+
// StatusUpdateInfo mirrors the `update` object of GET /api/v1/info
42+
// (internal/updatecheck.InfoResponseUpdate) for status output. The daemon's
43+
// background checker is the single source of truth; status only renders it.
44+
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"`
51+
}
52+
4053
// ServerEditionStatusInfo holds server-edition-specific status information.
4154
type ServerEditionStatusInfo struct {
4255
OAuthProvider string `json:"oauth_provider"`
@@ -212,6 +225,7 @@ func collectStatusFromDaemon(cfg *config.Config, socketPath, configPath string)
212225
if url, ok := infoData["web_ui_url"].(string); ok {
213226
info.WebUIURL = url
214227
}
228+
info.Update = extractStatusUpdate(infoData)
215229
}
216230

217231
// Construct Web UI URL if not provided by daemon
@@ -270,6 +284,61 @@ func extractServerCounts(stats map[string]interface{}) *ServerCounts {
270284
return counts
271285
}
272286

287+
// extractStatusUpdate pulls the `update` object out of the /api/v1/info
288+
// payload. Returns nil when the daemon did not report update state.
289+
func extractStatusUpdate(infoData map[string]interface{}) *StatusUpdateInfo {
290+
updateData, ok := infoData["update"].(map[string]interface{})
291+
if !ok {
292+
return nil
293+
}
294+
295+
u := &StatusUpdateInfo{}
296+
if v, ok := updateData["available"].(bool); ok {
297+
u.Available = v
298+
}
299+
if v, ok := updateData["latest_version"].(string); ok {
300+
u.LatestVersion = v
301+
}
302+
if v, ok := updateData["release_url"].(string); ok {
303+
u.ReleaseURL = v
304+
}
305+
if v, ok := updateData["checked_at"].(string); ok {
306+
u.CheckedAt = v
307+
}
308+
if v, ok := updateData["is_prerelease"].(bool); ok {
309+
u.IsPrerelease = v
310+
}
311+
if v, ok := updateData["check_error"].(string); ok {
312+
u.CheckError = v
313+
}
314+
return u
315+
}
316+
317+
// statusVersionSuffix renders the update annotation appended to the Version
318+
// line, mirroring doctor's presentation. A failed or not-yet-completed check
319+
// renders nothing (quiet on failure; the error stays in JSON for diagnostics).
320+
//
321+
// TODO(spec-079/FR-002): extend the annotation with the human-readable
322+
// "N releases / M weeks behind" delta once internal/updatecheck computes it
323+
// (requires the release list + publish dates, not just the latest release;
324+
// additive per FR-021). This function is the single rendering point.
325+
func statusVersionSuffix(u *StatusUpdateInfo) string {
326+
if u == nil || u.CheckError != "" {
327+
return ""
328+
}
329+
if u.Available && u.LatestVersion != "" {
330+
if u.ReleaseURL != "" {
331+
return fmt.Sprintf(" (update available: %s — %s)", u.LatestVersion, u.ReleaseURL)
332+
}
333+
return fmt.Sprintf(" (update available: %s)", u.LatestVersion)
334+
}
335+
if u.LatestVersion != "" {
336+
// A successful check confirmed we are current.
337+
return " (latest)"
338+
}
339+
return ""
340+
}
341+
273342
// statusMaskAPIKey returns a masked version of the API key showing first and last 4 chars.
274343
func statusMaskAPIKey(apiKey string) string {
275344
if len(apiKey) <= 8 {
@@ -374,7 +443,7 @@ func printStatusTable(info *StatusInfo) {
374443
fmt.Printf(" %-12s %s\n", "Edition:", info.Edition)
375444

376445
if info.Version != "" {
377-
fmt.Printf(" %-12s %s\n", "Version:", info.Version)
446+
fmt.Printf(" %-12s %s%s\n", "Version:", info.Version, statusVersionSuffix(info.Update))
378447
}
379448

380449
fmt.Printf(" %-12s %s\n", "Listen:", info.ListenAddr)

cmd/mcpproxy/status_update_test.go

Lines changed: 285 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,285 @@
1+
package main
2+
3+
import (
4+
"encoding/json"
5+
"strings"
6+
"testing"
7+
)
8+
9+
func TestStatusVersionSuffix(t *testing.T) {
10+
tests := []struct {
11+
name string
12+
update *StatusUpdateInfo
13+
expected string
14+
}{
15+
{
16+
name: "nil update shows nothing",
17+
update: nil,
18+
expected: "",
19+
},
20+
{
21+
name: "update available with release URL",
22+
update: &StatusUpdateInfo{
23+
Available: true,
24+
LatestVersion: "v0.46.0",
25+
ReleaseURL: "https://github.com/smart-mcp-proxy/mcpproxy-go/releases/tag/v0.46.0",
26+
},
27+
expected: " (update available: v0.46.0 — https://github.com/smart-mcp-proxy/mcpproxy-go/releases/tag/v0.46.0)",
28+
},
29+
{
30+
name: "update available without release URL",
31+
update: &StatusUpdateInfo{
32+
Available: true,
33+
LatestVersion: "v0.46.0",
34+
},
35+
expected: " (update available: v0.46.0)",
36+
},
37+
{
38+
name: "up to date after successful check",
39+
update: &StatusUpdateInfo{
40+
Available: false,
41+
LatestVersion: "v0.45.0",
42+
},
43+
expected: " (latest)",
44+
},
45+
{
46+
name: "check error stays quiet",
47+
update: &StatusUpdateInfo{
48+
Available: false,
49+
CheckError: "Get \"https://api.github.com\": dial tcp: no route to host",
50+
},
51+
expected: "",
52+
},
53+
{
54+
name: "check error with stale availability stays quiet",
55+
update: &StatusUpdateInfo{
56+
Available: true,
57+
LatestVersion: "v0.46.0",
58+
CheckError: "rate limited",
59+
},
60+
expected: "",
61+
},
62+
{
63+
name: "check not completed yet shows nothing",
64+
update: &StatusUpdateInfo{},
65+
expected: "",
66+
},
67+
}
68+
69+
for _, tt := range tests {
70+
t.Run(tt.name, func(t *testing.T) {
71+
result := statusVersionSuffix(tt.update)
72+
if result != tt.expected {
73+
t.Errorf("statusVersionSuffix() = %q, want %q", result, tt.expected)
74+
}
75+
})
76+
}
77+
}
78+
79+
func TestStatusTableShowsUpdateAvailability(t *testing.T) {
80+
t.Run("update available", func(t *testing.T) {
81+
info := &StatusInfo{
82+
State: "Running",
83+
Edition: "personal",
84+
ListenAddr: "127.0.0.1:8080",
85+
APIKey: "a1b2****a1b2",
86+
WebUIURL: "http://127.0.0.1:8080/ui/?apikey=test",
87+
Version: "v0.45.0",
88+
Update: &StatusUpdateInfo{
89+
Available: true,
90+
LatestVersion: "v0.46.0",
91+
ReleaseURL: "https://github.com/smart-mcp-proxy/mcpproxy-go/releases/tag/v0.46.0",
92+
},
93+
}
94+
95+
output := captureStdout(t, func() { printStatusTable(info) })
96+
97+
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)") {
98+
t.Errorf("expected update availability on the Version line, output:\n%s", output)
99+
}
100+
})
101+
102+
t.Run("up to date", func(t *testing.T) {
103+
info := &StatusInfo{
104+
State: "Running",
105+
Edition: "personal",
106+
ListenAddr: "127.0.0.1:8080",
107+
APIKey: "a1b2****a1b2",
108+
WebUIURL: "http://127.0.0.1:8080/ui/?apikey=test",
109+
Version: "v0.46.0",
110+
Update: &StatusUpdateInfo{
111+
Available: false,
112+
LatestVersion: "v0.46.0",
113+
},
114+
}
115+
116+
output := captureStdout(t, func() { printStatusTable(info) })
117+
118+
if !strings.Contains(output, "v0.46.0 (latest)") {
119+
t.Errorf("expected '(latest)' on the Version line, output:\n%s", output)
120+
}
121+
})
122+
123+
t.Run("check error shows plain version", func(t *testing.T) {
124+
info := &StatusInfo{
125+
State: "Running",
126+
Edition: "personal",
127+
ListenAddr: "127.0.0.1:8080",
128+
APIKey: "a1b2****a1b2",
129+
WebUIURL: "http://127.0.0.1:8080/ui/?apikey=test",
130+
Version: "v0.45.0",
131+
Update: &StatusUpdateInfo{
132+
CheckError: "dial tcp: no route to host",
133+
},
134+
}
135+
136+
output := captureStdout(t, func() { printStatusTable(info) })
137+
138+
if !strings.Contains(output, "Version:") || !strings.Contains(output, "v0.45.0") {
139+
t.Errorf("expected plain version line, output:\n%s", output)
140+
}
141+
if strings.Contains(output, "update available") || strings.Contains(output, "(latest)") {
142+
t.Errorf("expected no update annotation on check error, output:\n%s", output)
143+
}
144+
if strings.Contains(output, "no route to host") {
145+
t.Errorf("check error must not be surfaced in human output:\n%s", output)
146+
}
147+
})
148+
}
149+
150+
func TestExtractStatusUpdate(t *testing.T) {
151+
t.Run("full update object", func(t *testing.T) {
152+
infoData := map[string]interface{}{
153+
"version": "v0.45.0",
154+
"update": map[string]interface{}{
155+
"available": true,
156+
"latest_version": "v0.46.0-rc.1",
157+
"release_url": "https://github.com/smart-mcp-proxy/mcpproxy-go/releases/tag/v0.46.0-rc.1",
158+
"checked_at": "2026-07-02T10:00:00Z",
159+
"is_prerelease": true,
160+
},
161+
}
162+
163+
u := extractStatusUpdate(infoData)
164+
if u == nil {
165+
t.Fatal("expected non-nil update info")
166+
}
167+
if !u.Available {
168+
t.Error("expected Available=true")
169+
}
170+
if u.LatestVersion != "v0.46.0-rc.1" {
171+
t.Errorf("expected LatestVersion v0.46.0-rc.1, got %q", u.LatestVersion)
172+
}
173+
if u.ReleaseURL != "https://github.com/smart-mcp-proxy/mcpproxy-go/releases/tag/v0.46.0-rc.1" {
174+
t.Errorf("unexpected ReleaseURL %q", u.ReleaseURL)
175+
}
176+
if u.CheckedAt != "2026-07-02T10:00:00Z" {
177+
t.Errorf("expected CheckedAt to be preserved, got %q", u.CheckedAt)
178+
}
179+
if !u.IsPrerelease {
180+
t.Error("expected IsPrerelease=true")
181+
}
182+
})
183+
184+
t.Run("check error preserved for machine output", func(t *testing.T) {
185+
infoData := map[string]interface{}{
186+
"update": map[string]interface{}{
187+
"available": false,
188+
"check_error": "rate limited",
189+
},
190+
}
191+
192+
u := extractStatusUpdate(infoData)
193+
if u == nil {
194+
t.Fatal("expected non-nil update info")
195+
}
196+
if u.CheckError != "rate limited" {
197+
t.Errorf("expected CheckError 'rate limited', got %q", u.CheckError)
198+
}
199+
})
200+
201+
t.Run("missing update object", func(t *testing.T) {
202+
infoData := map[string]interface{}{"version": "v0.45.0"}
203+
if u := extractStatusUpdate(infoData); u != nil {
204+
t.Errorf("expected nil update info, got %+v", u)
205+
}
206+
})
207+
}
208+
209+
func TestStatusJSONIncludesUpdate(t *testing.T) {
210+
info := &StatusInfo{
211+
State: "Running",
212+
ListenAddr: "127.0.0.1:8080",
213+
APIKey: "a1b2****a1b2",
214+
WebUIURL: "http://127.0.0.1:8080/ui/?apikey=test",
215+
Version: "v0.45.0",
216+
Update: &StatusUpdateInfo{
217+
Available: true,
218+
LatestVersion: "v0.46.0",
219+
ReleaseURL: "https://github.com/smart-mcp-proxy/mcpproxy-go/releases/tag/v0.46.0",
220+
CheckedAt: "2026-07-02T10:00:00Z",
221+
IsPrerelease: true,
222+
},
223+
}
224+
225+
output := captureStdout(t, func() {
226+
if err := printStatusJSON(info); err != nil {
227+
t.Errorf("printStatusJSON failed: %v", err)
228+
}
229+
})
230+
231+
var result StatusInfo
232+
if err := json.Unmarshal([]byte(output), &result); err != nil {
233+
t.Fatalf("invalid JSON: %v\nOutput: %s", err, output)
234+
}
235+
236+
if result.Update == nil {
237+
t.Fatal("expected 'update' field in JSON output")
238+
}
239+
if !result.Update.Available {
240+
t.Error("expected update.available=true in JSON output")
241+
}
242+
if result.Update.LatestVersion != "v0.46.0" {
243+
t.Errorf("expected update.latest_version v0.46.0, got %q", result.Update.LatestVersion)
244+
}
245+
246+
// Field names in the wire format must match the /api/v1/info update
247+
// object (snake_case contract, FR-021).
248+
var raw map[string]interface{}
249+
if err := json.Unmarshal([]byte(output), &raw); err != nil {
250+
t.Fatalf("invalid JSON: %v", err)
251+
}
252+
rawUpdate, ok := raw["update"].(map[string]interface{})
253+
if !ok {
254+
t.Fatal("expected raw JSON key 'update'")
255+
}
256+
if v, ok := rawUpdate["checked_at"].(string); !ok || v != "2026-07-02T10:00:00Z" {
257+
t.Errorf("expected raw JSON key 'update.checked_at', got %v", rawUpdate["checked_at"])
258+
}
259+
if v, ok := rawUpdate["is_prerelease"].(bool); !ok || !v {
260+
t.Errorf("expected raw JSON key 'update.is_prerelease'=true, got %v", rawUpdate["is_prerelease"])
261+
}
262+
}
263+
264+
func TestStatusJSONOmitsUpdateWhenAbsent(t *testing.T) {
265+
info := &StatusInfo{
266+
State: "Not running",
267+
ListenAddr: "127.0.0.1:8080 (configured)",
268+
APIKey: "a1b2****a1b2",
269+
WebUIURL: "http://127.0.0.1:8080/ui/?apikey=test",
270+
}
271+
272+
output := captureStdout(t, func() {
273+
if err := printStatusJSON(info); err != nil {
274+
t.Errorf("printStatusJSON failed: %v", err)
275+
}
276+
})
277+
278+
var raw map[string]interface{}
279+
if err := json.Unmarshal([]byte(output), &raw); err != nil {
280+
t.Fatalf("invalid JSON: %v", err)
281+
}
282+
if _, ok := raw["update"]; ok {
283+
t.Error("expected 'update' to be omitted when no update info collected")
284+
}
285+
}

0 commit comments

Comments
 (0)