From 27131a5fb298a092a34f661f07a3cb31a1d51397 Mon Sep 17 00:00:00 2001 From: Shane McCarron Date: Sun, 15 Mar 2026 12:05:47 -0500 Subject: [PATCH 1/7] feat(config): add metrics config keys and GetFloat64 helper Co-Authored-By: Claude Sonnet 4.6 --- internal/store/config.go | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/internal/store/config.go b/internal/store/config.go index 76523ae..d02e1db 100644 --- a/internal/store/config.go +++ b/internal/store/config.go @@ -89,6 +89,19 @@ func (c *ConfigStore) GetInt(key string, defaultVal int) int { return n } +// GetFloat64 returns a float64 config value. +func (c *ConfigStore) GetFloat64(key string, defaultVal float64) float64 { + raw := c.Get(key, "") + if raw == "" { + return defaultVal + } + f, err := strconv.ParseFloat(raw, 64) + if err != nil { + return defaultVal + } + return f +} + // Set stores a key-value pair (upsert). func (c *ConfigStore) Set(key, value string) error { _, err := c.db.ExecContext(context.Background(), @@ -143,4 +156,18 @@ const ( // Accepts human-readable sizes: "2G", "512M", "4096M". // Default: empty (no limit). Applied on server startup. ConfigMemLimit = "mem_limit" + + // ConfigMetricsEnabled controls whether token savings estimation is computed + // and included in tool responses. Default: true. + // Set to false with: codebase-memory-mcp config set metrics_enabled false + ConfigMetricsEnabled = "metrics_enabled" + + // ConfigPricingModel selects the token pricing model for cost estimation. + // Supported values: "claude-sonnet" (default), "claude-opus", "gpt-4o", "custom". + // When "custom", ConfigCustomPricePerToken is used. + ConfigPricingModel = "pricing_model" + + // ConfigCustomPricePerToken is the USD cost per output token used when + // ConfigPricingModel is "custom". Example: 0.000015 for $15/M tokens. + ConfigCustomPricePerToken = "custom_price_per_token" ) From 34ba4ae769166a783512576353144399cb2e26ab Mon Sep 17 00:00:00 2001 From: Shane McCarron Date: Sun, 15 Mar 2026 12:07:08 -0500 Subject: [PATCH 2/7] feat(metrics): add token savings estimation package Co-Authored-By: Claude Sonnet 4.6 --- internal/metrics/savings.go | 163 ++++++++++++++++++++++++++++++ internal/metrics/savings_test.go | 166 +++++++++++++++++++++++++++++++ 2 files changed, 329 insertions(+) create mode 100644 internal/metrics/savings.go create mode 100644 internal/metrics/savings_test.go diff --git a/internal/metrics/savings.go b/internal/metrics/savings.go new file mode 100644 index 0000000..66c83fa --- /dev/null +++ b/internal/metrics/savings.go @@ -0,0 +1,163 @@ +package metrics + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "log/slog" + "math" + "os" + "path/filepath" + "sync" + "time" +) + +// TokenMetadata holds per-call token savings estimation. +// Attached as _meta in tool responses when metrics are enabled. +type TokenMetadata struct { + TokensSaved int `json:"tokens_saved"` + BaselineTokens int `json:"baseline_tokens"` + ResponseTokens int `json:"response_tokens"` + CostAvoided float64 `json:"cost_avoided"` + CompressionRatio float64 `json:"compression_ratio"` +} + +// EstimateTokens approximates token count from byte length using +// the standard English heuristic of 1 token ≈ 4 characters. +func EstimateTokens(s string) int { + return len(s) / 4 +} + +// CalculateSavings computes token savings for a single tool call. +// baselineBytes: byte count of all files the user would have read manually. +// responseBytes: byte count of the actual tool response. +// pricePerToken: USD cost per output token (e.g. 0.000015 for Claude Sonnet). +func CalculateSavings(baselineBytes, responseBytes int, pricePerToken float64) TokenMetadata { + baseline := baselineBytes / 4 + response := responseBytes / 4 + saved := baseline - response + if saved < 0 { + saved = 0 + } + ratio := 0.0 + if baseline > 0 { + ratio = math.Round(float64(response)/float64(baseline)*1000) / 1000 + } + return TokenMetadata{ + TokensSaved: saved, + BaselineTokens: baseline, + ResponseTokens: response, + CostAvoided: math.Round(float64(saved)*pricePerToken*1e6) / 1e6, + CompressionRatio: ratio, + } +} + +// savingsRecord is the on-disk format for cumulative savings. +type savingsRecord struct { + InstallID string `json:"install_id"` + TotalTokensSaved int64 `json:"total_tokens_saved"` + TotalCostAvoided float64 `json:"total_cost_avoided"` + LastUpdated string `json:"last_updated"` +} + +// Tracker accumulates token savings across calls and persists totals. +type Tracker struct { + mu sync.Mutex + path string + InstallID string + TotalTokensSaved int64 + TotalCostAvoided float64 +} + +// NewTracker loads or creates savings.json at path. Generates a random +// InstallID on first run. Never returns an error (fail-open). +func NewTracker(path string) *Tracker { + t := &Tracker{path: path} + + data, err := os.ReadFile(path) + if err != nil { + if !os.IsNotExist(err) { + slog.Warn("metrics: failed to read savings file", "path", path, "err", err) + } + t.InstallID = newInstallID() + return t + } + + var rec savingsRecord + if err := json.Unmarshal(data, &rec); err != nil { + slog.Warn("metrics: malformed savings file, starting fresh", "path", path, "err", err) + t.InstallID = newInstallID() + return t + } + + t.InstallID = rec.InstallID + t.TotalTokensSaved = rec.TotalTokensSaved + t.TotalCostAvoided = rec.TotalCostAvoided + return t +} + +// Record atomically increments TotalTokensSaved and TotalCostAvoided +// by the values in meta, then persists to file. +func (t *Tracker) Record(meta TokenMetadata) { + t.mu.Lock() + defer t.mu.Unlock() + + t.TotalTokensSaved += int64(meta.TokensSaved) + t.TotalCostAvoided += meta.CostAvoided + + rec := savingsRecord{ + InstallID: t.InstallID, + TotalTokensSaved: t.TotalTokensSaved, + TotalCostAvoided: t.TotalCostAvoided, + LastUpdated: time.Now().UTC().Format(time.RFC3339), + } + + data, err := json.MarshalIndent(rec, "", " ") + if err != nil { + slog.Warn("metrics: failed to marshal savings", "err", err) + return + } + + dir := filepath.Dir(t.path) + tmp, err := os.CreateTemp(dir, "savings-*.json.tmp") + if err != nil { + slog.Warn("metrics: failed to create temp file", "dir", dir, "err", err) + return + } + tmpName := tmp.Name() + + if _, err := tmp.Write(data); err != nil { + tmp.Close() + os.Remove(tmpName) + slog.Warn("metrics: failed to write temp file", "err", err) + return + } + if err := tmp.Close(); err != nil { + os.Remove(tmpName) + slog.Warn("metrics: failed to close temp file", "err", err) + return + } + + if err := os.Rename(tmpName, t.path); err != nil { + os.Remove(tmpName) + slog.Warn("metrics: failed to rename temp file", "src", tmpName, "dst", t.path, "err", err) + return + } + + slog.Debug("metrics: savings persisted", "path", t.path, "total_tokens_saved", t.TotalTokensSaved) +} + +// Snapshot returns current cumulative totals under lock. +func (t *Tracker) Snapshot() (totalTokensSaved int64, totalCostAvoided float64) { + t.mu.Lock() + defer t.mu.Unlock() + return t.TotalTokensSaved, t.TotalCostAvoided +} + +func newInstallID() string { + b := make([]byte, 8) + if _, err := rand.Read(b); err != nil { + return "unknown" + } + return hex.EncodeToString(b) +} diff --git a/internal/metrics/savings_test.go b/internal/metrics/savings_test.go new file mode 100644 index 0000000..5f569b0 --- /dev/null +++ b/internal/metrics/savings_test.go @@ -0,0 +1,166 @@ +package metrics + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func TestEstimateTokens(t *testing.T) { + if got := EstimateTokens("hello"); got != 1 { + t.Fatalf("EstimateTokens(\"hello\") = %d, want 1", got) + } + if got := EstimateTokens(""); got != 0 { + t.Fatalf("EstimateTokens(\"\") = %d, want 0", got) + } +} + +func TestCalculateSavings(t *testing.T) { + tests := []struct { + name string + baselineBytes int + responseBytes int + pricePerToken float64 + wantTokensSaved int + wantCompressionRatio float64 + }{ + { + name: "NormalCase", + baselineBytes: 4000, + responseBytes: 400, + pricePerToken: 0.000015, + wantTokensSaved: 900, + wantCompressionRatio: 0.1, + }, + { + name: "NoSavings", + baselineBytes: 100, + responseBytes: 200, + pricePerToken: 0.000015, + wantTokensSaved: 0, + wantCompressionRatio: 2.0, + }, + { + name: "ZeroBaseline", + baselineBytes: 0, + responseBytes: 400, + pricePerToken: 0.000015, + wantTokensSaved: 0, + wantCompressionRatio: 0.0, + }, + { + name: "ZeroBoth", + baselineBytes: 0, + responseBytes: 0, + pricePerToken: 0.000015, + wantTokensSaved: 0, + wantCompressionRatio: 0.0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := CalculateSavings(tt.baselineBytes, tt.responseBytes, tt.pricePerToken) + if got.TokensSaved != tt.wantTokensSaved { + t.Errorf("TokensSaved = %d, want %d", got.TokensSaved, tt.wantTokensSaved) + } + if got.CompressionRatio != tt.wantCompressionRatio { + t.Errorf("CompressionRatio = %v, want %v", got.CompressionRatio, tt.wantCompressionRatio) + } + }) + } +} + +func TestCalculateSavings_NormalCaseFields(t *testing.T) { + got := CalculateSavings(4000, 400, 0.000015) + if got.BaselineTokens != 1000 { + t.Errorf("BaselineTokens = %d, want 1000", got.BaselineTokens) + } + if got.ResponseTokens != 100 { + t.Errorf("ResponseTokens = %d, want 100", got.ResponseTokens) + } + if got.CostAvoided != 0.0135 { + t.Errorf("CostAvoided = %v, want 0.0135", got.CostAvoided) + } +} + +func TestTracker_RecordAndPersist(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "savings.json") + + tr := NewTracker(path) + meta := TokenMetadata{TokensSaved: 500, CostAvoided: 0.0075} + tr.Record(meta) + tr.Record(meta) + + totalTokens, totalCost := tr.Snapshot() + if totalTokens != 1000 { + t.Errorf("TotalTokensSaved = %d, want 1000", totalTokens) + } + if totalCost != 0.015 { + t.Errorf("TotalCostAvoided = %v, want 0.015", totalCost) + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("failed to read savings file: %v", err) + } + var rec savingsRecord + if err := json.Unmarshal(data, &rec); err != nil { + t.Fatalf("savings file is not valid JSON: %v", err) + } + if rec.TotalTokensSaved != 1000 { + t.Errorf("file TotalTokensSaved = %d, want 1000", rec.TotalTokensSaved) + } + if rec.InstallID == "" { + t.Error("file InstallID should not be empty") + } + if rec.LastUpdated == "" { + t.Error("file LastUpdated should not be empty") + } +} + +func TestTracker_LoadExisting(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "savings.json") + + existing := savingsRecord{ + InstallID: "abc123", + TotalTokensSaved: 5000, + TotalCostAvoided: 0.075, + LastUpdated: "2026-01-01T00:00:00Z", + } + data, _ := json.Marshal(existing) + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatalf("failed to write test file: %v", err) + } + + tr := NewTracker(path) + if tr.InstallID != "abc123" { + t.Errorf("InstallID = %q, want %q", tr.InstallID, "abc123") + } + + tr.Record(TokenMetadata{TokensSaved: 200}) + totalTokens, _ := tr.Snapshot() + if totalTokens != 5200 { + t.Errorf("TotalTokensSaved = %d, want 5200", totalTokens) + } +} + +func TestTracker_MissingDir(t *testing.T) { + path := filepath.Join(t.TempDir(), "nonexistent-subdir", "savings.json") + + tr := NewTracker(path) + totalTokens, totalCost := tr.Snapshot() + if totalTokens != 0 || totalCost != 0 { + t.Errorf("expected zero snapshot for missing dir tracker, got (%d, %v)", totalTokens, totalCost) + } + + // Record should not panic even if dir doesn't exist + tr.Record(TokenMetadata{TokensSaved: 100}) + totalTokens, _ = tr.Snapshot() + if totalTokens != 100 { + t.Errorf("TotalTokensSaved after Record = %d, want 100", totalTokens) + } +} From 15c191ea2e862b0c17ec22894edcb2e2129e4445 Mon Sep 17 00:00:00 2001 From: Shane McCarron Date: Sun, 15 Mar 2026 12:08:59 -0500 Subject: [PATCH 3/7] feat(tools): add resultWithMeta wrapper and metrics tracker to Server Co-Authored-By: Claude Sonnet 4.6 --- internal/tools/tools.go | 53 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/internal/tools/tools.go b/internal/tools/tools.go index 5f1fcfd..c83fa3f 100644 --- a/internal/tools/tools.go +++ b/internal/tools/tools.go @@ -18,6 +18,7 @@ import ( "time" "github.com/DeusData/codebase-memory-mcp/internal/discover" + "github.com/DeusData/codebase-memory-mcp/internal/metrics" "github.com/DeusData/codebase-memory-mcp/internal/pipeline" "github.com/DeusData/codebase-memory-mcp/internal/store" "github.com/DeusData/codebase-memory-mcp/internal/watcher" @@ -44,6 +45,8 @@ type Server struct { indexMu sync.Mutex handlers map[string]mcp.ToolHandler + metricsTracker *metrics.Tracker // nil when metrics_enabled=false + // Session-aware fields (set once via sync.Once, then immutable) sessionOnce sync.Once sessionRoot string // absolute path from client @@ -72,6 +75,9 @@ func NewServer(r *store.StoreRouter, opts ...ServerOption) *Server { opt(srv) } + // Initialize metrics tracker if enabled (default true). + srv.initMetricsTracker() + srv.mcp = mcp.NewServer( &mcp.Implementation{ Name: "codebase-memory-mcp", @@ -788,6 +794,53 @@ func (s *Server) registerProjectTools() { }, s.handleIndexStatus) } +// initMetricsTracker initializes the metrics tracker if metrics_enabled=true (default). +func (s *Server) initMetricsTracker() { + enabled := true + if s.config != nil { + enabled = s.config.GetBool(store.ConfigMetricsEnabled, true) + } + if !enabled { + return + } + home, err := os.UserHomeDir() + if err != nil { + slog.Warn("metrics: cannot determine home dir, metrics disabled", "err", err) + return + } + savingsPath := filepath.Join(home, ".cache", "codebase-memory-mcp", "savings.json") + s.metricsTracker = metrics.NewTracker(savingsPath) +} + +// resultWithMeta wraps data with a _meta field containing token savings estimation. +// If tracker is non-nil, also records the savings cumulatively. +func resultWithMeta(data map[string]any, meta metrics.TokenMetadata, tracker *metrics.Tracker) *mcp.CallToolResult { + data["_meta"] = meta + if tracker != nil { + tracker.Record(meta) + } + return jsonResult(data) +} + +// priceForConfig returns the USD price-per-token for the configured pricing model. +// Defaults to Claude Sonnet pricing ($15/M output tokens). +func priceForConfig(cfg *store.ConfigStore) float64 { + if cfg == nil { + return 0.000015 // claude-sonnet default + } + model := cfg.Get(store.ConfigPricingModel, "claude-sonnet") + switch model { + case "claude-opus": + return 0.000075 // $75/M output tokens + case "gpt-4o": + return 0.000010 // $10/M output tokens + case "custom": + return cfg.GetFloat64(store.ConfigCustomPricePerToken, 0.000015) + default: // "claude-sonnet" + return 0.000015 + } +} + // --- Helpers --- // jsonResult marshals data to JSON and returns as tool result. From 1f44cbc3538798ce3d906c5dedfc10cb5418d1c1 Mon Sep 17 00:00:00 2001 From: Shane McCarron Date: Sun, 15 Mar 2026 12:09:19 -0500 Subject: [PATCH 4/7] feat(snippet): instrument get_code_snippet with token savings _meta Co-Authored-By: Claude Sonnet 4.6 --- internal/tools/snippet.go | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/internal/tools/snippet.go b/internal/tools/snippet.go index 6da31d5..2bfe9fa 100644 --- a/internal/tools/snippet.go +++ b/internal/tools/snippet.go @@ -3,12 +3,14 @@ package tools import ( "bufio" "context" + "encoding/json" "fmt" "log/slog" "os" "path/filepath" "strings" + "github.com/DeusData/codebase-memory-mcp/internal/metrics" "github.com/DeusData/codebase-memory-mcp/internal/store" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -154,6 +156,15 @@ func (s *Server) buildSnippetResponse(match *snippetMatch, includeNeighbors bool responseData["alternatives"] = alternatives } + // Token savings: baseline = full source file size, response = marshaled JSON. + if s.config == nil || s.config.GetBool(store.ConfigMetricsEnabled, true) { + if fi, statErr := os.Stat(absPath); statErr == nil { + price := priceForConfig(s.config) + responseJSON, _ := json.Marshal(responseData) + meta := metrics.CalculateSavings(int(fi.Size()), len(responseJSON), price) + return resultWithMeta(responseData, meta, s.metricsTracker), nil + } + } return jsonResult(responseData), nil } From cf2c280c8803d07f875737790baf61fce9b8c8eb Mon Sep 17 00:00:00 2001 From: Shane McCarron Date: Sun, 15 Mar 2026 12:09:51 -0500 Subject: [PATCH 5/7] feat(search): instrument search_graph with token savings _meta Co-Authored-By: Claude Sonnet 4.6 --- internal/tools/search.go | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/internal/tools/search.go b/internal/tools/search.go index 980a4f4..b0ac6d8 100644 --- a/internal/tools/search.go +++ b/internal/tools/search.go @@ -2,8 +2,12 @@ package tools import ( "context" + "encoding/json" "fmt" + "os" + "path/filepath" + "github.com/DeusData/codebase-memory-mcp/internal/metrics" "github.com/DeusData/codebase-memory-mcp/internal/store" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -101,6 +105,31 @@ func (s *Server) handleSearchGraph(_ context.Context, req *mcp.CallToolRequest) s.addIndexStatus(responseData) result := jsonResult(responseData) + // Token savings: baseline = sum of unique source file sizes in results page + // (files the user would have read to find these symbols manually). + if s.config == nil || s.config.GetBool(store.ConfigMetricsEnabled, true) { + proj, _ := st.GetProject(projName) + if proj != nil { + seenFiles := make(map[string]struct{}) + baselineBytes := 0 + for _, r := range output.Results { + if r.Node.FilePath == "" { + continue + } + if _, seen := seenFiles[r.Node.FilePath]; !seen { + seenFiles[r.Node.FilePath] = struct{}{} + absPath := filepath.Join(proj.RootPath, r.Node.FilePath) + if fi, statErr := os.Stat(absPath); statErr == nil { + baselineBytes += int(fi.Size()) + } + } + } + price := priceForConfig(s.config) + responseJSON, _ := json.Marshal(responseData) + meta := metrics.CalculateSavings(baselineBytes, len(responseJSON), price) + result = resultWithMeta(responseData, meta, s.metricsTracker) + } + } s.addUpdateNotice(result) return result, nil } From a3096f2d0f3bdc1a7e69ad7cb45cc7edb0a912f4 Mon Sep 17 00:00:00 2001 From: Shane McCarron Date: Sun, 15 Mar 2026 12:11:44 -0500 Subject: [PATCH 6/7] feat(search): refactor searchResultWithMeta helper, add _meta test Extract metrics block into searchResultWithMeta to reduce cognitive complexity. Add TestGetCodeSnippet_MetaField to snippet_test.go. Co-Authored-By: Claude Sonnet 4.6 --- internal/tools/search.go | 65 ++++++++++++++++++++-------------- internal/tools/snippet_test.go | 43 ++++++++++++++++++++++ 2 files changed, 82 insertions(+), 26 deletions(-) diff --git a/internal/tools/search.go b/internal/tools/search.go index b0ac6d8..6e44c3e 100644 --- a/internal/tools/search.go +++ b/internal/tools/search.go @@ -104,32 +104,45 @@ func (s *Server) handleSearchGraph(_ context.Context, req *mcp.CallToolRequest) } s.addIndexStatus(responseData) - result := jsonResult(responseData) - // Token savings: baseline = sum of unique source file sizes in results page - // (files the user would have read to find these symbols manually). - if s.config == nil || s.config.GetBool(store.ConfigMetricsEnabled, true) { - proj, _ := st.GetProject(projName) - if proj != nil { - seenFiles := make(map[string]struct{}) - baselineBytes := 0 - for _, r := range output.Results { - if r.Node.FilePath == "" { - continue - } - if _, seen := seenFiles[r.Node.FilePath]; !seen { - seenFiles[r.Node.FilePath] = struct{}{} - absPath := filepath.Join(proj.RootPath, r.Node.FilePath) - if fi, statErr := os.Stat(absPath); statErr == nil { - baselineBytes += int(fi.Size()) - } - } - } - price := priceForConfig(s.config) - responseJSON, _ := json.Marshal(responseData) - meta := metrics.CalculateSavings(baselineBytes, len(responseJSON), price) - result = resultWithMeta(responseData, meta, s.metricsTracker) - } - } + result := s.searchResultWithMeta(responseData, output.Results, st, projName) s.addUpdateNotice(result) return result, nil } + +// searchResultWithMeta computes token savings for search_graph and returns a wrapped result. +// Baseline = sum of unique source file sizes referenced in results. +// Falls back to jsonResult when metrics are disabled or project root is unavailable. +func (s *Server) searchResultWithMeta(responseData map[string]any, results []*store.SearchResult, st *store.Store, projName string) *mcp.CallToolResult { + if s.config != nil && !s.config.GetBool(store.ConfigMetricsEnabled, true) { + return jsonResult(responseData) + } + proj, _ := st.GetProject(projName) + if proj == nil { + return jsonResult(responseData) + } + baselineBytes := uniqueFileBytes(results, proj.RootPath) + price := priceForConfig(s.config) + responseJSON, _ := json.Marshal(responseData) + meta := metrics.CalculateSavings(baselineBytes, len(responseJSON), price) + return resultWithMeta(responseData, meta, s.metricsTracker) +} + +// uniqueFileBytes sums the sizes of unique source files referenced in search results. +func uniqueFileBytes(results []*store.SearchResult, rootPath string) int { + seen := make(map[string]struct{}, len(results)) + total := 0 + for _, r := range results { + if r.Node.FilePath == "" { + continue + } + if _, ok := seen[r.Node.FilePath]; ok { + continue + } + seen[r.Node.FilePath] = struct{}{} + absPath := filepath.Join(rootPath, r.Node.FilePath) + if fi, err := os.Stat(absPath); err == nil { + total += int(fi.Size()) + } + } + return total +} diff --git a/internal/tools/snippet_test.go b/internal/tools/snippet_test.go index 9252e7c..662010d 100644 --- a/internal/tools/snippet_test.go +++ b/internal/tools/snippet_test.go @@ -7,6 +7,7 @@ import ( "path/filepath" "testing" + "github.com/DeusData/codebase-memory-mcp/internal/metrics" "github.com/DeusData/codebase-memory-mcp/internal/store" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -488,3 +489,45 @@ func TestSnippet_IncludeNeighbors_Enabled(t *testing.T) { t.Error("expected Run in callee_names") } } + +func TestGetCodeSnippet_MetaField(t *testing.T) { + srv := testSnippetServer(t) + // Attach a metricsTracker backed by a temp file. + savingsPath := filepath.Join(t.TempDir(), "savings.json") + srv.metricsTracker = metrics.NewTracker(savingsPath) + + data := callSnippet(t, srv, "test-project.cmd.server.main.HandleRequest") + + // Source should still be present. + if data["source"] == nil || data["source"] == "" { + t.Fatal("expected non-empty source") + } + + // _meta must exist. + metaRaw, ok := data["_meta"] + if !ok { + t.Fatal("expected _meta field in response") + } + meta, ok := metaRaw.(map[string]any) + if !ok { + t.Fatalf("expected _meta to be a map, got %T", metaRaw) + } + + tokensSaved, _ := meta["tokens_saved"].(float64) + baselineTokens, _ := meta["baseline_tokens"].(float64) + responseTokens, _ := meta["response_tokens"].(float64) + compressionRatio, _ := meta["compression_ratio"].(float64) + + if tokensSaved < 0 { + t.Errorf("tokens_saved should be >= 0, got %v", tokensSaved) + } + if baselineTokens <= 0 { + t.Errorf("baseline_tokens should be > 0, got %v", baselineTokens) + } + if responseTokens <= 0 { + t.Errorf("response_tokens should be > 0, got %v", responseTokens) + } + if compressionRatio <= 0 { + t.Errorf("compression_ratio should be > 0, got %v", compressionRatio) + } +} From 9528dd9a80665e3d20e3d4b0ac09db4a9949eccf Mon Sep 17 00:00:00 2001 From: Shane McCarron Date: Sun, 15 Mar 2026 12:18:43 -0500 Subject: [PATCH 7/7] test(search): add _meta field integration test for search_graph - Adds TestSearchGraph_MetaField in internal/tools/search_test.go - Verifies _meta field is present in search_graph response when metricsTracker is attached - Asserts tokens_saved >= 0, baseline_tokens >= 0 (may be 0 for test fixtures), response_tokens > 0 Co-Authored-By: Claude Sonnet 4.6 --- internal/tools/search_test.go | 83 +++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 internal/tools/search_test.go diff --git a/internal/tools/search_test.go b/internal/tools/search_test.go new file mode 100644 index 0000000..489dea8 --- /dev/null +++ b/internal/tools/search_test.go @@ -0,0 +1,83 @@ +package tools + +import ( + "context" + "encoding/json" + "path/filepath" + "testing" + + "github.com/DeusData/codebase-memory-mcp/internal/metrics" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +func callSearchGraph(t *testing.T, srv *Server, namePattern string) map[string]any { + t.Helper() + args := map[string]any{"name_pattern": namePattern} + rawArgs, _ := json.Marshal(args) + + req := &mcp.CallToolRequest{ + Params: &mcp.CallToolParamsRaw{ + Name: "search_graph", + Arguments: rawArgs, + }, + } + + result, err := srv.handleSearchGraph(context.TODO(), req) + if err != nil { + t.Fatalf("handleSearchGraph error: %v", err) + } + if len(result.Content) == 0 { + t.Fatal("empty result content") + } + tc, ok := result.Content[0].(*mcp.TextContent) + if !ok { + t.Fatalf("expected TextContent, got %T", result.Content[0]) + } + var data map[string]any + if err := json.Unmarshal([]byte(tc.Text), &data); err != nil { + t.Fatalf("unmarshal result: %v (text: %s)", err, tc.Text) + } + return data +} + +func TestSearchGraph_MetaField(t *testing.T) { + srv := testSnippetServer(t) + // Attach a metricsTracker backed by a temp file. + savingsPath := filepath.Join(t.TempDir(), "savings.json") + srv.metricsTracker = metrics.NewTracker(savingsPath) + + // Search for "Handle" which matches HandleRequest in the fixture. + data := callSearchGraph(t, srv, "Handle") + + // Results should be present. + results, ok := data["results"].([]any) + if !ok || len(results) == 0 { + t.Fatal("expected at least one result from search_graph") + } + + // _meta must exist. + metaRaw, ok := data["_meta"] + if !ok { + t.Fatal("expected _meta field in response") + } + meta, ok := metaRaw.(map[string]any) + if !ok { + t.Fatalf("expected _meta to be a map, got %T", metaRaw) + } + + tokensSaved, _ := meta["tokens_saved"].(float64) + baselineTokens, _ := meta["baseline_tokens"].(float64) + responseTokens, _ := meta["response_tokens"].(float64) + + if tokensSaved < 0 { + t.Errorf("tokens_saved should be >= 0, got %v", tokensSaved) + } + // baseline_tokens may be 0 if the fixture file is not accessible on disk + // (test uses a temp dir, not the real source tree), so only assert >= 0. + if baselineTokens < 0 { + t.Errorf("baseline_tokens should be >= 0, got %v", baselineTokens) + } + if responseTokens <= 0 { + t.Errorf("response_tokens should be > 0, got %v", responseTokens) + } +}