feat(mcp): implement cache TTL and finalize Phase 3 roadmap

This commit is contained in:
Daoud AbdelMonem Faleh 2026-03-03 00:20:05 +01:00
commit e05e3bce68
3 changed files with 55 additions and 11 deletions

View file

@ -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
}

View file

@ -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

View file

@ -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).")
}