diff --git a/internal/tools/code_search.go b/internal/tools/code_search.go index 052b0006b..7c5a5b3bd 100644 --- a/internal/tools/code_search.go +++ b/internal/tools/code_search.go @@ -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. diff --git a/internal/tools/query.go b/internal/tools/query.go index 55641b046..3ed58de16 100644 --- a/internal/tools/query.go +++ b/internal/tools/query.go @@ -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 } diff --git a/internal/tools/search.go b/internal/tools/search.go index 52e62d09a..458d6bf1f 100644 --- a/internal/tools/search.go +++ b/internal/tools/search.go @@ -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 } diff --git a/internal/tools/tools.go b/internal/tools/tools.go index 93ac91f2f..f3c366c46 100644 --- a/internal/tools/tools.go +++ b/internal/tools/tools.go @@ -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) } } diff --git a/internal/tools/trace.go b/internal/tools/trace.go index b7cc7bbf3..8f67f6fda 100644 --- a/internal/tools/trace.go +++ b/internal/tools/trace.go @@ -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 { diff --git a/internal/tools/update_test.go b/internal/tools/update_test.go index 16115e8c1..ec3ab0b5f 100644 --- a/internal/tools/update_test.go +++ b/internal/tools/update_test.go @@ -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)") } }