From e05e3bce685a604d05b8d4085f25085cb87e4597 Mon Sep 17 00:00:00 2001 From: Daoud AbdelMonem Faleh Date: Tue, 3 Mar 2026 00:20:05 +0100 Subject: [PATCH] feat(mcp): implement cache TTL and finalize Phase 3 roadmap --- cmd/dive/cli/internal/mcp/handlers.go | 25 +++++++++++---- cmd/dive/cli/internal/mcp/handlers_test.go | 37 +++++++++++++++++++--- cmd/dive/cli/internal/options/mcp.go | 4 +++ 3 files changed, 55 insertions(+), 11 deletions(-) diff --git a/cmd/dive/cli/internal/mcp/handlers.go b/cmd/dive/cli/internal/mcp/handlers.go index 1110c81..74c2af8 100644 --- a/cmd/dive/cli/internal/mcp/handlers.go +++ b/cmd/dive/cli/internal/mcp/handlers.go @@ -7,6 +7,7 @@ import ( "os" "path/filepath" "strings" + "time" lru "github.com/hashicorp/golang-lru/v2" "github.com/mark3labs/mcp-go/mcp" @@ -18,9 +19,14 @@ import ( "github.com/wagoodman/dive/internal/log" ) +type cachedAnalysis struct { + Analysis *image.Analysis + Timestamp time.Time +} + type toolHandlers struct { opts options.MCP - analyses *lru.Cache[string, *image.Analysis] + analyses *lru.Cache[string, cachedAnalysis] } func newToolHandlers(opts options.MCP) *toolHandlers { @@ -28,7 +34,7 @@ func newToolHandlers(opts options.MCP) *toolHandlers { if cacheSize <= 0 { cacheSize = 10 } - cache, _ := lru.New[string, *image.Analysis](cacheSize) + cache, _ := lru.New[string, cachedAnalysis](cacheSize) return &toolHandlers{ opts: opts, analyses: cache, @@ -139,11 +145,15 @@ func (h *toolHandlers) getAnalysis(ctx context.Context, imageName string, source cacheKey := fmt.Sprintf("%s:%s", sourceStr, imageName) - if analysis, ok := h.analyses.Get(cacheKey); ok { - return analysis, nil + if cached, ok := h.analyses.Get(cacheKey); ok { + ttl, err := time.ParseDuration(h.opts.CacheTTL) + if err == nil && time.Since(cached.Timestamp) < ttl { + return cached.Analysis, nil + } + h.analyses.Remove(cacheKey) } - log.Infof("Image %s not in cache, analyzing...", imageName) + log.Infof("Image %s not in cache or expired, analyzing...", imageName) resolver, err := dive.GetImageResolver(source) if err != nil { return nil, fmt.Errorf("cannot get image resolver: %v", err) @@ -159,7 +169,10 @@ func (h *toolHandlers) getAnalysis(ctx context.Context, imageName string, source return nil, fmt.Errorf("cannot analyze image: %v", err) } - h.analyses.Add(cacheKey, analysis) + h.analyses.Add(cacheKey, cachedAnalysis{ + Analysis: analysis, + Timestamp: time.Now(), + }) return analysis, nil } diff --git a/cmd/dive/cli/internal/mcp/handlers_test.go b/cmd/dive/cli/internal/mcp/handlers_test.go index 8b112d4..83a0c27 100644 --- a/cmd/dive/cli/internal/mcp/handlers_test.go +++ b/cmd/dive/cli/internal/mcp/handlers_test.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" "testing" + "time" "github.com/mark3labs/mcp-go/mcp" "github.com/stretchr/testify/assert" @@ -77,11 +78,14 @@ func TestHandlers_SandboxViolation(t *testing.T) { func TestHandlers_ResourceSummary(t *testing.T) { h := newToolHandlers(options.DefaultMCP()) - h.analyses.Add("docker:ubuntu:latest", &image.Analysis{ - Image: "ubuntu:latest", - WastedBytes: 0, - SizeBytes: 100, - Efficiency: 1.0, + h.analyses.Add("docker:ubuntu:latest", cachedAnalysis{ + Analysis: &image.Analysis{ + Image: "ubuntu:latest", + WastedBytes: 0, + SizeBytes: 100, + Efficiency: 1.0, + }, + Timestamp: time.Now(), }) ctx := context.Background() @@ -100,6 +104,29 @@ func TestHandlers_ResourceSummary(t *testing.T) { assert.Equal(t, "ubuntu:latest", summary.Image) } +func TestHandlers_CacheTTL(t *testing.T) { + opts := options.DefaultMCP() + opts.CacheTTL = "1ms" // Very short TTL + h := newToolHandlers(opts) + + h.analyses.Add("docker-archive:invalid.tar", cachedAnalysis{ + Analysis: &image.Analysis{ + Image: "invalid.tar", + }, + Timestamp: time.Now().Add(-1 * time.Hour), // Way in the past + }) + + ctx := context.Background() + // This should trigger a real analysis attempts because cache is expired + imageName := "invalid.tar" + sourceStr := "docker-archive" + + _, err := h.getAnalysis(ctx, imageName, sourceStr) + // It should fail to find the file, proving it didn't use the cache + assert.Error(t, err) + assert.Contains(t, err.Error(), "no such file or directory") +} + func TestHandlers_DiffLayers(t *testing.T) { wd, _ := os.Getwd() root := wd diff --git a/cmd/dive/cli/internal/options/mcp.go b/cmd/dive/cli/internal/options/mcp.go index 6d391ff..9caf256 100644 --- a/cmd/dive/cli/internal/options/mcp.go +++ b/cmd/dive/cli/internal/options/mcp.go @@ -20,6 +20,8 @@ type MCP struct { Sandbox string `yaml:"sandbox" json:"sandbox" mapstructure:"sandbox"` // CacheSize is the maximum number of analysis results to cache CacheSize int `yaml:"cache-size" json:"cache-size" mapstructure:"cache-size"` + // CacheTTL is the time analysis results stay in cache before being considered stale + CacheTTL string `yaml:"cache-ttl" json:"cache-ttl" mapstructure:"cache-ttl"` } func DefaultMCP() MCP { @@ -28,6 +30,7 @@ func DefaultMCP() MCP { Host: "localhost", Port: 8080, CacheSize: 10, + CacheTTL: "24h", } } @@ -37,4 +40,5 @@ func (o *MCP) AddFlags(flags clio.FlagSet) { flags.IntVarP(&o.Port, "port", "", "The port to listen on for the MCP HTTP/SSE server.") flags.StringVarP(&o.Sandbox, "mcp-sandbox", "", "A directory to restrict docker-archive lookups to.") flags.IntVarP(&o.CacheSize, "mcp-cache-size", "", "The maximum number of analysis results to cache.") + flags.StringVarP(&o.CacheTTL, "mcp-cache-ttl", "", "The duration to keep analysis results in cache (e.g. 1h, 30m).") }