Fix update-available notice: goroutine ordering, body limit, UX

Three bugs prevented the update notice from appearing:
- checkForUpdate goroutine launched after blocking sessionOnce.Do,
  so it never ran before tool calls arrived
- 4KB body limit truncated the ~15KB GitHub API response
- Notice was a JSON map key with no guaranteed ordering

Fixes:
- Move go checkForUpdate() before sessionOnce.Do in all handlers
- Increase body read limit to 64KB
- Prepend notice as separate MCP TextContent block (appears first)
- Link to GitHub releases page instead of go install command
This commit is contained in:
Martin Vogel
2026-02-28 03:17:45 +01:00
parent c10d6676ff
commit c28052dbf3
6 changed files with 44 additions and 26 deletions
+3 -2
View File
@@ -111,9 +111,10 @@ func (s *Server) handleSearchCode(_ context.Context, req *mcp.CallToolRequest) (
"files_count": len(filePaths),
}
s.addIndexStatus(responseData)
s.addUpdateNotice(responseData)
return jsonResult(responseData), nil
result := jsonResult(responseData)
s.addUpdateNotice(result)
return result, nil
}
// collectSearchFilePaths gathers indexed file paths, optionally filtered by a glob pattern.
+3 -2
View File
@@ -36,7 +36,8 @@ func (s *Server) handleQueryGraph(_ context.Context, req *mcp.CallToolRequest) (
"total": len(result.Rows),
}
s.addIndexStatus(responseData)
s.addUpdateNotice(responseData)
return jsonResult(responseData), nil
res := jsonResult(responseData)
s.addUpdateNotice(res)
return res, nil
}
+3 -2
View File
@@ -97,7 +97,8 @@ func (s *Server) handleSearchGraph(_ context.Context, req *mcp.CallToolRequest)
"results": results,
}
s.addIndexStatus(responseData)
s.addUpdateNotice(responseData)
return jsonResult(responseData), nil
result := jsonResult(responseData)
s.addUpdateNotice(result)
return result, nil
}
+18 -10
View File
@@ -108,19 +108,20 @@ func (s *Server) SessionProject() string {
// SetSessionRoot sets the session root path directly (for CLI mode).
func (s *Server) SetSessionRoot(rootPath string) {
go s.checkForUpdate()
s.sessionOnce.Do(func() {
s.sessionRoot = rootPath
if rootPath != "" {
s.sessionProject = filepath.Base(rootPath)
}
})
go s.checkForUpdate()
}
// --- Session detection ---
// onInitialized is called when the client sends notifications/initialized.
func (s *Server) onInitialized(ctx context.Context, req *mcp.InitializedRequest) {
go s.checkForUpdate()
s.sessionOnce.Do(func() {
s.sessionRoot = s.detectSessionRoot(ctx, req.Session)
if s.sessionRoot != "" {
@@ -128,11 +129,11 @@ func (s *Server) onInitialized(ctx context.Context, req *mcp.InitializedRequest)
s.startAutoIndex()
}
})
go s.checkForUpdate()
}
// onRootsChanged re-detects session root if not yet set.
func (s *Server) onRootsChanged(ctx context.Context, req *mcp.RootsListChangedRequest) {
go s.checkForUpdate()
s.sessionOnce.Do(func() {
s.sessionRoot = s.detectSessionRoot(ctx, req.Session)
if s.sessionRoot != "" {
@@ -140,7 +141,6 @@ func (s *Server) onRootsChanged(ctx context.Context, req *mcp.RootsListChangedRe
s.startAutoIndex()
}
})
go s.checkForUpdate()
}
// detectSessionRoot tries multiple fallback strategies to find the project root.
@@ -255,10 +255,10 @@ func (s *Server) addIndexStatus(data map[string]any) {
}
}
// addUpdateNotice injects update_available into the first tool response, then clears itself.
func (s *Server) addUpdateNotice(data map[string]any) {
// addUpdateNotice prepends an update notice to the first tool response, then clears itself.
func (s *Server) addUpdateNotice(result *mcp.CallToolResult) {
if notice, ok := s.updateNotice.Load().(string); ok && notice != "" {
data["update_available"] = notice
result.Content = append([]mcp.Content{&mcp.TextContent{Text: notice}}, result.Content...)
s.updateNotice.Store("")
}
}
@@ -270,22 +270,26 @@ func (s *Server) checkForUpdate() {
req, err := http.NewRequestWithContext(ctx, "GET", releaseURL, http.NoBody)
if err != nil {
slog.Warn("update check: request create failed", "err", err)
return
}
req.Header.Set("Accept", "application/vnd.github+json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
slog.Warn("update check: http failed", "err", err)
return
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
slog.Warn("update check: bad status", "status", resp.StatusCode)
return
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 4096))
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<16))
if err != nil {
slog.Warn("update check: body read failed", "err", err)
return
}
@@ -293,17 +297,21 @@ func (s *Server) checkForUpdate() {
TagName string `json:"tag_name"`
}
if err := json.Unmarshal(body, &release); err != nil {
slog.Warn("update check: json parse failed", "err", err)
return
}
latest := strings.TrimPrefix(release.TagName, "v")
if latest == "" || latest == Version {
slog.Debug("update check: current", "version", Version, "latest", latest)
return
}
if compareVersions(latest, Version) > 0 {
s.updateNotice.Store(fmt.Sprintf(
"v%s → v%s available. Update: go install github.com/DeusData/codebase-memory-mcp/cmd/codebase-memory-mcp@v%s",
Version, latest, latest))
notice := fmt.Sprintf(
"⚡ Update available: v%s → v%s — download at https://github.com/DeusData/codebase-memory-mcp/releases/tag/v%s",
Version, latest, latest)
s.updateNotice.Store(notice)
slog.Info("update available", "current", Version, "latest", latest)
}
}
+3 -2
View File
@@ -122,9 +122,10 @@ func (s *Server) handleTraceCallPath(_ context.Context, req *mcp.CallToolRequest
"total_results": len(allVisited),
}
s.addIndexStatus(responseData)
s.addUpdateNotice(responseData)
return jsonResult(responseData), nil
result := jsonResult(responseData)
s.addUpdateNotice(result)
return result, nil
}
func buildNodeInfo(n *store.Node) map[string]any {
+14 -8
View File
@@ -6,6 +6,8 @@ import (
"net/http/httptest"
"strings"
"testing"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
func TestCompareVersions(t *testing.T) {
@@ -55,8 +57,8 @@ func TestCheckForUpdate_NewerAvailable(t *testing.T) {
if !strings.Contains(notice, "v99.0.0") {
t.Fatalf("notice should mention v99.0.0, got: %s", notice)
}
if !strings.Contains(notice, "go install") {
t.Fatalf("notice should contain install command, got: %s", notice)
if !strings.Contains(notice, "github.com/DeusData/codebase-memory-mcp/releases/tag/v99.0.0") {
t.Fatalf("notice should contain releases link, got: %s", notice)
}
}
@@ -140,15 +142,19 @@ func TestAddUpdateNotice_ShowsOnce(t *testing.T) {
srv := &Server{}
srv.updateNotice.Store("v0.2.0 → v0.3.0 available")
first := map[string]any{}
first := &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: "{}"}}}
srv.addUpdateNotice(first)
if _, ok := first["update_available"]; !ok {
t.Fatal("expected update_available in first map")
if len(first.Content) != 2 {
t.Fatalf("expected 2 content blocks, got %d", len(first.Content))
}
tc, ok := first.Content[0].(*mcp.TextContent)
if !ok || tc.Text != "v0.2.0 → v0.3.0 available" {
t.Fatalf("expected notice as first content block, got %v", first.Content[0])
}
second := map[string]any{}
second := &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: "{}"}}}
srv.addUpdateNotice(second)
if _, ok := second["update_available"]; ok {
t.Fatal("expected update_available to be absent in second map (show-once)")
if len(second.Content) != 1 {
t.Fatal("expected update notice to be absent in second call (show-once)")
}
}