mirror of
https://github.com/wagoodman/dive
synced 2026-03-14 14:25:50 +01:00
feat(mcp): implement structured JSON output, layer diffing, LRU cache, and security sandbox (Phases 2 & 3)
This commit is contained in:
parent
03a6b9f2d7
commit
eb4ca70fc2
7 changed files with 365 additions and 114 deletions
|
|
@ -19,7 +19,7 @@ func MCP(app clio.Application, id clio.Identification) *cobra.Command {
|
|||
Use: "mcp",
|
||||
Short: "Start the Model Context Protocol (MCP) server.",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
s := mcp.NewServer(id)
|
||||
s := mcp.NewServer(id, opts.MCP)
|
||||
return mcp.Run(s, opts.MCP)
|
||||
},
|
||||
}, opts)
|
||||
|
|
|
|||
|
|
@ -2,39 +2,113 @@ package mcp
|
|||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
lru "github.com/hashicorp/golang-lru/v2"
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
"github.com/wagoodman/dive/cmd/dive/cli/internal/command/adapter"
|
||||
"github.com/wagoodman/dive/cmd/dive/cli/internal/options"
|
||||
"github.com/wagoodman/dive/dive"
|
||||
"github.com/wagoodman/dive/dive/filetree"
|
||||
"github.com/wagoodman/dive/dive/image"
|
||||
"github.com/wagoodman/dive/internal/log"
|
||||
)
|
||||
|
||||
type toolHandlers struct {
|
||||
mu sync.RWMutex
|
||||
analyses map[string]*image.Analysis
|
||||
opts options.MCP
|
||||
analyses *lru.Cache[string, *image.Analysis]
|
||||
}
|
||||
|
||||
func newToolHandlers() *toolHandlers {
|
||||
return &toolHandlers{
|
||||
analyses: make(map[string]*image.Analysis),
|
||||
func newToolHandlers(opts options.MCP) *toolHandlers {
|
||||
cacheSize := opts.CacheSize
|
||||
if cacheSize <= 0 {
|
||||
cacheSize = 10
|
||||
}
|
||||
cache, _ := lru.New[string, *image.Analysis](cacheSize)
|
||||
return &toolHandlers{
|
||||
opts: opts,
|
||||
analyses: cache,
|
||||
}
|
||||
}
|
||||
|
||||
// --- Data Models for Structured Output ---
|
||||
|
||||
type ImageSummary struct {
|
||||
Image string `json:"image"`
|
||||
TotalSize uint64 `json:"total_size_bytes"`
|
||||
EfficiencyScore float64 `json:"efficiency_score"`
|
||||
WastedSpace uint64 `json:"wasted_space_bytes"`
|
||||
LayerCount int `json:"layer_count"`
|
||||
Layers []LayerSummary `json:"layers"`
|
||||
}
|
||||
|
||||
type LayerSummary struct {
|
||||
Index int `json:"index"`
|
||||
ID string `json:"id"`
|
||||
Size uint64 `json:"size_bytes"`
|
||||
Command string `json:"command"`
|
||||
}
|
||||
|
||||
type WastedSpaceResult struct {
|
||||
Image string `json:"image"`
|
||||
Inefficiencies []InefficiencyItem `json:"inefficiencies"`
|
||||
}
|
||||
|
||||
type InefficiencyItem struct {
|
||||
Path string `json:"path"`
|
||||
CumulativeSize int64 `json:"cumulative_size_bytes"`
|
||||
Occurrences int `json:"occurrences"`
|
||||
}
|
||||
|
||||
type FileNodeInfo struct {
|
||||
Path string `json:"path"`
|
||||
Type string `json:"type"` // "file" or "directory"
|
||||
Size uint64 `json:"size_bytes"`
|
||||
DiffType string `json:"diff_type,omitempty"` // "added", "modified", "removed", "unmodified"
|
||||
}
|
||||
|
||||
type DiffResult struct {
|
||||
Image string `json:"image"`
|
||||
BaseLayer int `json:"base_layer_index"`
|
||||
TargetLayer int `json:"target_layer_index"`
|
||||
Changes []FileNodeInfo `json:"changes"`
|
||||
}
|
||||
|
||||
// --- Helper Functions ---
|
||||
|
||||
func (h *toolHandlers) validateSandbox(path string) (string, error) {
|
||||
if h.opts.Sandbox == "" {
|
||||
return path, nil
|
||||
}
|
||||
|
||||
absSandbox, err := filepath.Abs(h.opts.Sandbox)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid sandbox path: %v", err)
|
||||
}
|
||||
|
||||
absPath, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid image path: %v", err)
|
||||
}
|
||||
|
||||
rel, err := filepath.Rel(absSandbox, absPath)
|
||||
if err != nil || strings.HasPrefix(rel, "..") {
|
||||
return "", fmt.Errorf("security: path '%s' is outside of sandbox '%s'", path, h.opts.Sandbox)
|
||||
}
|
||||
|
||||
return absPath, nil
|
||||
}
|
||||
|
||||
func (h *toolHandlers) getAnalysis(ctx context.Context, imageName string, sourceStr string) (*image.Analysis, error) {
|
||||
// Heuristic: if imageName ends in .tar and source is docker, assume docker-archive
|
||||
if strings.HasSuffix(imageName, ".tar") && sourceStr == "docker" {
|
||||
sourceStr = "docker-archive"
|
||||
// If the file doesn't exist at the given path, check .data/
|
||||
if _, err := os.Stat(imageName); os.IsNotExist(err) {
|
||||
wd, _ := os.Getwd()
|
||||
// Navigate up from cmd/dive/cli/internal/mcp to root if needed
|
||||
// (During real runs, Getwd is project root)
|
||||
root := wd
|
||||
for i := 0; i < 5; i++ {
|
||||
if _, err := os.Stat(filepath.Join(root, "go.mod")); err == nil {
|
||||
|
|
@ -54,36 +128,51 @@ func (h *toolHandlers) getAnalysis(ctx context.Context, imageName string, source
|
|||
return nil, fmt.Errorf("unknown image source: %s", sourceStr)
|
||||
}
|
||||
|
||||
// Security Sandbox check for archives
|
||||
if source == dive.SourceDockerArchive {
|
||||
var err error
|
||||
imageName, err = h.validateSandbox(imageName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
cacheKey := fmt.Sprintf("%s:%s", sourceStr, imageName)
|
||||
|
||||
h.mu.RLock()
|
||||
analysis, ok := h.analyses[cacheKey]
|
||||
h.mu.RUnlock()
|
||||
|
||||
if !ok {
|
||||
log.Infof("Image %s not in cache, analyzing...", imageName)
|
||||
resolver, err := dive.GetImageResolver(source)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot get image resolver: %v", err)
|
||||
}
|
||||
|
||||
img, err := adapter.ImageResolver(resolver).Fetch(ctx, imageName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot fetch image: %v", err)
|
||||
}
|
||||
|
||||
analysis, err = adapter.NewAnalyzer().Analyze(ctx, img)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot analyze image: %v", err)
|
||||
}
|
||||
|
||||
h.mu.Lock()
|
||||
h.analyses[cacheKey] = analysis
|
||||
h.mu.Unlock()
|
||||
if analysis, ok := h.analyses.Get(cacheKey); ok {
|
||||
return analysis, nil
|
||||
}
|
||||
|
||||
log.Infof("Image %s not in cache, analyzing...", imageName)
|
||||
resolver, err := dive.GetImageResolver(source)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot get image resolver: %v", err)
|
||||
}
|
||||
|
||||
img, err := adapter.ImageResolver(resolver).Fetch(ctx, imageName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot fetch image: %v", err)
|
||||
}
|
||||
|
||||
analysis, err := adapter.NewAnalyzer().Analyze(ctx, img)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot analyze image: %v", err)
|
||||
}
|
||||
|
||||
h.analyses.Add(cacheKey, analysis)
|
||||
return analysis, nil
|
||||
}
|
||||
|
||||
func (h *toolHandlers) jsonResponse(v interface{}) (*mcp.CallToolResult, error) {
|
||||
b, err := json.MarshalIndent(v, "", " ")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return mcp.NewToolResultText(string(b)), nil
|
||||
}
|
||||
|
||||
// --- Tool Handlers ---
|
||||
|
||||
func (h *toolHandlers) analyzeImageHandler(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
imageName, err := request.RequireString("image")
|
||||
if err != nil {
|
||||
|
|
@ -97,21 +186,25 @@ func (h *toolHandlers) analyzeImageHandler(ctx context.Context, request mcp.Call
|
|||
return mcp.NewToolResultError(err.Error()), nil
|
||||
}
|
||||
|
||||
summary := h.formatSummary(analysis)
|
||||
return mcp.NewToolResultText(summary), nil
|
||||
}
|
||||
|
||||
func (h *toolHandlers) formatSummary(analysis *image.Analysis) string {
|
||||
summary := fmt.Sprintf("Image: %s\n", analysis.Image)
|
||||
summary += fmt.Sprintf("Total Size: %d bytes\n", analysis.SizeBytes)
|
||||
summary += fmt.Sprintf("Efficiency Score: %.2f%%\n", analysis.Efficiency*100)
|
||||
summary += fmt.Sprintf("Wasted Space: %d bytes\n", analysis.WastedBytes)
|
||||
summary += fmt.Sprintf("Layers: %d\n", len(analysis.Layers))
|
||||
summary := ImageSummary{
|
||||
Image: analysis.Image,
|
||||
TotalSize: analysis.SizeBytes,
|
||||
EfficiencyScore: analysis.Efficiency,
|
||||
WastedSpace: analysis.WastedBytes,
|
||||
LayerCount: len(analysis.Layers),
|
||||
Layers: make([]LayerSummary, len(analysis.Layers)),
|
||||
}
|
||||
|
||||
for i, layer := range analysis.Layers {
|
||||
summary += fmt.Sprintf(" Layer %d: %s (Size: %d bytes, Command: %s)\n", i, layer.ShortId(), layer.Size, layer.Command)
|
||||
summary.Layers[i] = LayerSummary{
|
||||
Index: i,
|
||||
ID: layer.ShortId(),
|
||||
Size: layer.Size,
|
||||
Command: layer.Command,
|
||||
}
|
||||
}
|
||||
return summary
|
||||
|
||||
return h.jsonResponse(summary)
|
||||
}
|
||||
|
||||
func (h *toolHandlers) getWastedSpaceHandler(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
|
|
@ -127,16 +220,11 @@ func (h *toolHandlers) getWastedSpaceHandler(ctx context.Context, request mcp.Ca
|
|||
return mcp.NewToolResultError(err.Error()), nil
|
||||
}
|
||||
|
||||
if len(analysis.Inefficiencies) == 0 {
|
||||
return mcp.NewToolResultText("No wasted space detected in this image."), nil
|
||||
result := WastedSpaceResult{
|
||||
Image: analysis.Image,
|
||||
Inefficiencies: make([]InefficiencyItem, 0),
|
||||
}
|
||||
|
||||
summary := h.formatWastedSpace(analysis)
|
||||
return mcp.NewToolResultText(summary), nil
|
||||
}
|
||||
|
||||
func (h *toolHandlers) formatWastedSpace(analysis *image.Analysis) string {
|
||||
summary := fmt.Sprintf("Top Inefficient Files for %s:\n", analysis.Image)
|
||||
limit := 20
|
||||
if len(analysis.Inefficiencies) < limit {
|
||||
limit = len(analysis.Inefficiencies)
|
||||
|
|
@ -144,9 +232,14 @@ func (h *toolHandlers) formatWastedSpace(analysis *image.Analysis) string {
|
|||
|
||||
for i := 0; i < limit; i++ {
|
||||
inef := analysis.Inefficiencies[i]
|
||||
summary += fmt.Sprintf("- %s (Cumulative Size: %d bytes, occurrences: %d)\n", inef.Path, inef.CumulativeSize, len(inef.Nodes))
|
||||
result.Inefficiencies = append(result.Inefficiencies, InefficiencyItem{
|
||||
Path: inef.Path,
|
||||
CumulativeSize: inef.CumulativeSize,
|
||||
Occurrences: len(inef.Nodes),
|
||||
})
|
||||
}
|
||||
return summary
|
||||
|
||||
return h.jsonResponse(result)
|
||||
}
|
||||
|
||||
func (h *toolHandlers) inspectLayerHandler(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
|
|
@ -183,34 +276,117 @@ func (h *toolHandlers) inspectLayerHandler(ctx context.Context, request mcp.Call
|
|||
startNode = node
|
||||
}
|
||||
|
||||
summary := fmt.Sprintf("Contents of layer %d at %s:\n", layerIdx, pathStr)
|
||||
files := make([]FileNodeInfo, 0)
|
||||
count := 0
|
||||
limit := 100
|
||||
limit := 500 // Higher limit for JSON
|
||||
|
||||
for name, child := range startNode.Children {
|
||||
if count >= limit {
|
||||
summary += "... (truncated)\n"
|
||||
break
|
||||
}
|
||||
typeChar := "F"
|
||||
typeStr := "file"
|
||||
if child.Data.FileInfo.IsDir {
|
||||
typeChar = "D"
|
||||
typeStr = "directory"
|
||||
}
|
||||
summary += fmt.Sprintf("[%s] %s (%d bytes)\n", typeChar, name, child.Data.FileInfo.Size)
|
||||
files = append(files, FileNodeInfo{
|
||||
Path: filepath.Join(pathStr, name),
|
||||
Type: typeStr,
|
||||
Size: uint64(child.Data.FileInfo.Size),
|
||||
})
|
||||
count++
|
||||
}
|
||||
|
||||
if count == 0 {
|
||||
summary += "(Empty or no children found at this path)\n"
|
||||
return h.jsonResponse(files)
|
||||
}
|
||||
|
||||
func (h *toolHandlers) diffLayersHandler(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
imageName, err := request.RequireString("image")
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(err.Error()), nil
|
||||
}
|
||||
|
||||
return mcp.NewToolResultText(summary), nil
|
||||
baseIdx, err := request.RequireInt("base_layer_index")
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError("base_layer_index is required"), nil
|
||||
}
|
||||
|
||||
targetIdx, err := request.RequireInt("target_layer_index")
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError("target_layer_index is required"), nil
|
||||
}
|
||||
|
||||
sourceStr := request.GetString("source", "docker")
|
||||
|
||||
analysis, err := h.getAnalysis(ctx, imageName, sourceStr)
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(err.Error()), nil
|
||||
}
|
||||
|
||||
if baseIdx < 0 || baseIdx >= len(analysis.RefTrees) || targetIdx < 0 || targetIdx >= len(analysis.RefTrees) {
|
||||
return mcp.NewToolResultError("layer index out of bounds"), nil
|
||||
}
|
||||
|
||||
baseTree, _, err := filetree.StackTreeRange(analysis.RefTrees, 0, baseIdx)
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(fmt.Sprintf("failed to stack base tree: %v", err)), nil
|
||||
}
|
||||
|
||||
targetTree, _, err := filetree.StackTreeRange(analysis.RefTrees, 0, targetIdx)
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(fmt.Sprintf("failed to stack target tree: %v", err)), nil
|
||||
}
|
||||
|
||||
_, err = baseTree.CompareAndMark(targetTree)
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(fmt.Sprintf("failed to compare trees: %v", err)), nil
|
||||
}
|
||||
|
||||
changes := make([]FileNodeInfo, 0)
|
||||
|
||||
err = baseTree.VisitDepthParentFirst(func(node *filetree.FileNode) error {
|
||||
if node.Data.DiffType != filetree.Unmodified {
|
||||
diffStr := ""
|
||||
switch node.Data.DiffType {
|
||||
case filetree.Added:
|
||||
diffStr = "added"
|
||||
case filetree.Modified:
|
||||
diffStr = "modified"
|
||||
case filetree.Removed:
|
||||
diffStr = "removed"
|
||||
}
|
||||
|
||||
typeStr := "file"
|
||||
if node.Data.FileInfo.IsDir {
|
||||
typeStr = "directory"
|
||||
}
|
||||
|
||||
changes = append(changes, FileNodeInfo{
|
||||
Path: node.Path(),
|
||||
Type: typeStr,
|
||||
Size: uint64(node.Data.FileInfo.Size),
|
||||
DiffType: diffStr,
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}, nil)
|
||||
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(fmt.Sprintf("failed to visit tree: %v", err)), nil
|
||||
}
|
||||
|
||||
result := DiffResult{
|
||||
Image: imageName,
|
||||
BaseLayer: baseIdx,
|
||||
TargetLayer: targetIdx,
|
||||
Changes: changes,
|
||||
}
|
||||
|
||||
return h.jsonResponse(result)
|
||||
}
|
||||
|
||||
// --- Resource Handlers ---
|
||||
|
||||
func (h *toolHandlers) resourceSummaryHandler(ctx context.Context, request mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) {
|
||||
// URI pattern: dive://image/{name}/summary
|
||||
parts := strings.Split(request.Params.URI, "/")
|
||||
if len(parts) < 5 {
|
||||
return nil, fmt.Errorf("invalid resource URI: %s", request.Params.URI)
|
||||
|
|
@ -222,18 +398,35 @@ func (h *toolHandlers) resourceSummaryHandler(ctx context.Context, request mcp.R
|
|||
return nil, err
|
||||
}
|
||||
|
||||
content := h.formatSummary(analysis)
|
||||
summary := ImageSummary{
|
||||
Image: analysis.Image,
|
||||
TotalSize: analysis.SizeBytes,
|
||||
EfficiencyScore: analysis.Efficiency,
|
||||
WastedSpace: analysis.WastedBytes,
|
||||
LayerCount: len(analysis.Layers),
|
||||
Layers: make([]LayerSummary, len(analysis.Layers)),
|
||||
}
|
||||
|
||||
for i, layer := range analysis.Layers {
|
||||
summary.Layers[i] = LayerSummary{
|
||||
Index: i,
|
||||
ID: layer.ShortId(),
|
||||
Size: layer.Size,
|
||||
Command: layer.Command,
|
||||
}
|
||||
}
|
||||
|
||||
b, _ := json.MarshalIndent(summary, "", " ")
|
||||
return []mcp.ResourceContents{
|
||||
mcp.TextResourceContents{
|
||||
URI: request.Params.URI,
|
||||
MIMEType: "text/plain",
|
||||
Text: content,
|
||||
MIMEType: "application/json",
|
||||
Text: string(b),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (h *toolHandlers) resourceEfficiencyHandler(ctx context.Context, request mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) {
|
||||
// URI pattern: dive://image/{name}/efficiency
|
||||
parts := strings.Split(request.Params.URI, "/")
|
||||
if len(parts) < 5 {
|
||||
return nil, fmt.Errorf("invalid resource URI: %s", request.Params.URI)
|
||||
|
|
@ -245,12 +438,18 @@ func (h *toolHandlers) resourceEfficiencyHandler(ctx context.Context, request mc
|
|||
return nil, err
|
||||
}
|
||||
|
||||
content := fmt.Sprintf("Efficiency Score: %.2f%%\nWasted Space: %d bytes\n", analysis.Efficiency*100, analysis.WastedBytes)
|
||||
result := map[string]interface{}{
|
||||
"image": analysis.Image,
|
||||
"efficiency_score": analysis.Efficiency,
|
||||
"wasted_bytes": analysis.WastedBytes,
|
||||
}
|
||||
|
||||
b, _ := json.MarshalIndent(result, "", " ")
|
||||
return []mcp.ResourceContents{
|
||||
mcp.TextResourceContents{
|
||||
URI: request.Params.URI,
|
||||
MIMEType: "text/plain",
|
||||
Text: content,
|
||||
MIMEType: "application/json",
|
||||
Text: string(b),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
|
@ -268,8 +467,8 @@ func (h *toolHandlers) promptOptimizeDockerfileHandler(ctx context.Context, requ
|
|||
return nil, err
|
||||
}
|
||||
|
||||
wasted := h.formatWastedSpace(analysis)
|
||||
summary := h.formatSummary(analysis)
|
||||
wastedB, _ := json.MarshalIndent(analysis.Inefficiencies, "", " ")
|
||||
summaryB, _ := json.MarshalIndent(analysis, "", " ")
|
||||
|
||||
return &mcp.GetPromptResult{
|
||||
Description: "Optimize Dockerfile based on Dive analysis",
|
||||
|
|
@ -278,7 +477,7 @@ func (h *toolHandlers) promptOptimizeDockerfileHandler(ctx context.Context, requ
|
|||
Role: mcp.RoleUser,
|
||||
Content: mcp.TextContent{
|
||||
Type: "text",
|
||||
Text: fmt.Sprintf("You are an expert in Docker and OCI image optimization. Your findings for image '%s':\n\n%s\n\n%s\n\nPlease suggest optimizations for the Dockerfile.", imageName, summary, wasted),
|
||||
Text: fmt.Sprintf("You are an expert in Docker and OCI image optimization. Your findings for image '%s':\n\nSummary:\n%s\n\nWasted Space:\n%s\n\nPlease suggest optimizations for the Dockerfile.", imageName, string(summaryB), string(wastedB)),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -2,17 +2,19 @@ package mcp
|
|||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/wagoodman/dive/cmd/dive/cli/internal/options"
|
||||
"github.com/wagoodman/dive/dive/image"
|
||||
)
|
||||
|
||||
func TestHandlers_AnalyzeImage_MissingImage(t *testing.T) {
|
||||
h := newToolHandlers()
|
||||
h := newToolHandlers(options.DefaultMCP())
|
||||
ctx := context.Background()
|
||||
req := mcp.CallToolRequest{}
|
||||
req.Params.Name = "analyze_image"
|
||||
|
|
@ -35,7 +37,7 @@ func TestHandlers_GetWastedSpace_AutoAnalysis(t *testing.T) {
|
|||
|
||||
imagePath := filepath.Join(root, ".data/test-docker-image.tar")
|
||||
|
||||
h := newToolHandlers()
|
||||
h := newToolHandlers(options.DefaultMCP())
|
||||
ctx := context.Background()
|
||||
req := mcp.CallToolRequest{}
|
||||
req.Params.Name = "get_wasted_space"
|
||||
|
|
@ -47,37 +49,40 @@ func TestHandlers_GetWastedSpace_AutoAnalysis(t *testing.T) {
|
|||
result, err := h.getWastedSpaceHandler(ctx, req)
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, result.IsError)
|
||||
assert.Contains(t, result.Content[0].(mcp.TextContent).Text, "Top Inefficient Files")
|
||||
|
||||
var wasted WastedSpaceResult
|
||||
err = json.Unmarshal([]byte(result.Content[0].(mcp.TextContent).Text), &wasted)
|
||||
assert.NoError(t, err)
|
||||
assert.Contains(t, wasted.Image, "test-docker-image.tar")
|
||||
assert.NotEmpty(t, wasted.Inefficiencies)
|
||||
}
|
||||
|
||||
func TestHandlers_GetWastedSpace_WithCache(t *testing.T) {
|
||||
h := newToolHandlers()
|
||||
h.analyses["docker:ubuntu:latest"] = &image.Analysis{
|
||||
Image: "ubuntu:latest",
|
||||
WastedBytes: 0,
|
||||
}
|
||||
|
||||
func TestHandlers_SandboxViolation(t *testing.T) {
|
||||
opts := options.DefaultMCP()
|
||||
opts.Sandbox = "/tmp/nothing"
|
||||
h := newToolHandlers(opts)
|
||||
ctx := context.Background()
|
||||
req := mcp.CallToolRequest{}
|
||||
req.Params.Name = "get_wasted_space"
|
||||
req.Params.Name = "analyze_image"
|
||||
req.Params.Arguments = map[string]interface{}{
|
||||
"image": "ubuntu:latest",
|
||||
"image": ".data/test-docker-image.tar",
|
||||
"source": "docker-archive",
|
||||
}
|
||||
|
||||
result, err := h.getWastedSpaceHandler(ctx, req)
|
||||
result, err := h.analyzeImageHandler(ctx, req)
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, result.IsError)
|
||||
assert.Contains(t, result.Content[0].(mcp.TextContent).Text, "No wasted space detected")
|
||||
assert.True(t, result.IsError)
|
||||
assert.Contains(t, result.Content[0].(mcp.TextContent).Text, "security: path")
|
||||
}
|
||||
|
||||
func TestHandlers_ResourceSummary(t *testing.T) {
|
||||
h := newToolHandlers()
|
||||
h.analyses["docker:ubuntu:latest"] = &image.Analysis{
|
||||
h := newToolHandlers(options.DefaultMCP())
|
||||
h.analyses.Add("docker:ubuntu:latest", &image.Analysis{
|
||||
Image: "ubuntu:latest",
|
||||
WastedBytes: 0,
|
||||
SizeBytes: 100,
|
||||
Efficiency: 1.0,
|
||||
}
|
||||
})
|
||||
|
||||
ctx := context.Background()
|
||||
req := mcp.ReadResourceRequest{}
|
||||
|
|
@ -88,28 +93,44 @@ func TestHandlers_ResourceSummary(t *testing.T) {
|
|||
assert.Len(t, result, 1)
|
||||
textRes, ok := result[0].(mcp.TextResourceContents)
|
||||
assert.True(t, ok)
|
||||
assert.Contains(t, textRes.Text, "Image: ubuntu:latest")
|
||||
}
|
||||
|
||||
func TestHandlers_PromptOptimize(t *testing.T) {
|
||||
h := newToolHandlers()
|
||||
h.analyses["docker:ubuntu:latest"] = &image.Analysis{
|
||||
Image: "ubuntu:latest",
|
||||
WastedBytes: 0,
|
||||
SizeBytes: 100,
|
||||
Efficiency: 1.0,
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
req := mcp.GetPromptRequest{}
|
||||
req.Params.Name = "optimize-dockerfile"
|
||||
req.Params.Arguments = map[string]string{
|
||||
"image": "ubuntu:latest",
|
||||
}
|
||||
|
||||
result, err := h.promptOptimizeDockerfileHandler(ctx, req)
|
||||
|
||||
var summary ImageSummary
|
||||
err = json.Unmarshal([]byte(textRes.Text), &summary)
|
||||
assert.NoError(t, err)
|
||||
assert.Contains(t, result.Description, "Optimize Dockerfile")
|
||||
assert.Len(t, result.Messages, 1)
|
||||
assert.Contains(t, result.Messages[0].Content.(mcp.TextContent).Text, "ubuntu:latest")
|
||||
assert.Equal(t, "ubuntu:latest", summary.Image)
|
||||
}
|
||||
|
||||
func TestHandlers_DiffLayers(t *testing.T) {
|
||||
wd, _ := os.Getwd()
|
||||
root := wd
|
||||
for i := 0; i < 5; i++ {
|
||||
if _, err := os.Stat(filepath.Join(root, "go.mod")); err == nil {
|
||||
break
|
||||
}
|
||||
root = filepath.Dir(root)
|
||||
}
|
||||
|
||||
imagePath := filepath.Join(root, ".data/test-docker-image.tar")
|
||||
|
||||
h := newToolHandlers(options.DefaultMCP())
|
||||
ctx := context.Background()
|
||||
req := mcp.CallToolRequest{}
|
||||
req.Params.Name = "diff_layers"
|
||||
req.Params.Arguments = map[string]interface{}{
|
||||
"image": imagePath,
|
||||
"source": "docker-archive",
|
||||
"base_layer_index": 0,
|
||||
"target_layer_index": 1,
|
||||
}
|
||||
|
||||
result, err := h.diffLayersHandler(ctx, req)
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, result.IsError)
|
||||
|
||||
var diff DiffResult
|
||||
err = json.Unmarshal([]byte(result.Content[0].(mcp.TextContent).Text), &diff)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 0, diff.BaseLayer)
|
||||
assert.Equal(t, 1, diff.TargetLayer)
|
||||
assert.NotEmpty(t, diff.Changes)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import (
|
|||
"github.com/wagoodman/dive/internal/log"
|
||||
)
|
||||
|
||||
func NewServer(id clio.Identification) *server.MCPServer {
|
||||
func NewServer(id clio.Identification, opts options.MCP) *server.MCPServer {
|
||||
s := server.NewMCPServer(
|
||||
id.Name,
|
||||
id.Version,
|
||||
|
|
@ -20,13 +20,13 @@ func NewServer(id clio.Identification) *server.MCPServer {
|
|||
server.WithPromptCapabilities(true),
|
||||
)
|
||||
|
||||
h := newToolHandlers()
|
||||
h := newToolHandlers(opts)
|
||||
|
||||
// --- Tools ---
|
||||
|
||||
// 1. analyze_image tool
|
||||
analyzeTool := mcp.NewTool("analyze_image",
|
||||
mcp.WithDescription("Analyze a docker image and return efficiency metrics and layer details"),
|
||||
mcp.WithDescription("Analyze a docker image and return efficiency metrics and layer details (JSON)"),
|
||||
mcp.WithString("image",
|
||||
mcp.Required(),
|
||||
mcp.Description("The name of the image to analyze (e.g., 'ubuntu:latest')"),
|
||||
|
|
@ -39,10 +39,10 @@ func NewServer(id clio.Identification) *server.MCPServer {
|
|||
|
||||
// 2. get_wasted_space tool
|
||||
wastedSpaceTool := mcp.NewTool("get_wasted_space",
|
||||
mcp.WithDescription("Get the list of inefficient files that contribute to wasted space in the image"),
|
||||
mcp.WithDescription("Get the list of inefficient files that contribute to wasted space in the image (JSON)"),
|
||||
mcp.WithString("image",
|
||||
mcp.Required(),
|
||||
mcp.Description("The name of the image to get wasted space for (must be analyzed first)"),
|
||||
mcp.Description("The name of the image to get wasted space for"),
|
||||
),
|
||||
mcp.WithString("source",
|
||||
mcp.Description("The container engine to fetch the image from (docker, podman, docker-archive). Defaults to 'docker'."),
|
||||
|
|
@ -52,7 +52,7 @@ func NewServer(id clio.Identification) *server.MCPServer {
|
|||
|
||||
// 3. inspect_layer tool
|
||||
inspectLayerTool := mcp.NewTool("inspect_layer",
|
||||
mcp.WithDescription("Inspect the contents of a specific layer in an image"),
|
||||
mcp.WithDescription("Inspect the contents of a specific layer in an image (JSON)"),
|
||||
mcp.WithString("image",
|
||||
mcp.Required(),
|
||||
mcp.Description("The name of the image to inspect"),
|
||||
|
|
@ -70,17 +70,38 @@ func NewServer(id clio.Identification) *server.MCPServer {
|
|||
)
|
||||
s.AddTool(inspectLayerTool, h.inspectLayerHandler)
|
||||
|
||||
// 4. diff_layers tool
|
||||
diffLayersTool := mcp.NewTool("diff_layers",
|
||||
mcp.WithDescription("Compare two layers in an image and return file changes (JSON)"),
|
||||
mcp.WithString("image",
|
||||
mcp.Required(),
|
||||
mcp.Description("The name of the image"),
|
||||
),
|
||||
mcp.WithNumber("base_layer_index",
|
||||
mcp.Required(),
|
||||
mcp.Description("The index of the base layer for comparison"),
|
||||
),
|
||||
mcp.WithNumber("target_layer_index",
|
||||
mcp.Required(),
|
||||
mcp.Description("The index of the target layer to compare against the base"),
|
||||
),
|
||||
mcp.WithString("source",
|
||||
mcp.Description("The container engine to fetch the image from (docker, podman, docker-archive). Defaults to 'docker'."),
|
||||
),
|
||||
)
|
||||
s.AddTool(diffLayersTool, h.diffLayersHandler)
|
||||
|
||||
// --- Resources ---
|
||||
|
||||
// 1. Summary resource template
|
||||
summaryTemplate := mcp.NewResourceTemplate("dive://image/{name}/summary", "Image Summary",
|
||||
mcp.WithTemplateDescription("Get a text summary of the image analysis"),
|
||||
mcp.WithTemplateDescription("Get a JSON summary of the image analysis"),
|
||||
)
|
||||
s.AddResourceTemplate(summaryTemplate, h.resourceSummaryHandler)
|
||||
|
||||
// 2. Efficiency resource template
|
||||
efficiencyTemplate := mcp.NewResourceTemplate("dive://image/{name}/efficiency", "Image Efficiency",
|
||||
mcp.WithTemplateDescription("Get the efficiency score and wasted bytes for an image"),
|
||||
mcp.WithTemplateDescription("Get the efficiency score and wasted bytes for an image (JSON)"),
|
||||
)
|
||||
s.AddResourceTemplate(efficiencyTemplate, h.resourceEfficiencyHandler)
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,10 @@ type MCP struct {
|
|||
Host string `yaml:"host" json:"host" mapstructure:"host"`
|
||||
// Port is the port for the MCP HTTP/SSE server
|
||||
Port int `yaml:"port" json:"port" mapstructure:"port"`
|
||||
// Sandbox is a directory to restrict docker-archive lookups
|
||||
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"`
|
||||
}
|
||||
|
||||
func DefaultMCP() MCP {
|
||||
|
|
@ -23,6 +27,7 @@ func DefaultMCP() MCP {
|
|||
Transport: "stdio",
|
||||
Host: "localhost",
|
||||
Port: 8080,
|
||||
CacheSize: 10,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -30,4 +35,6 @@ func (o *MCP) AddFlags(flags clio.FlagSet) {
|
|||
flags.StringVarP(&o.Transport, "transport", "t", "The transport to use for the MCP server (stdio, sse).")
|
||||
flags.StringVarP(&o.Host, "host", "", "The host to listen on for the MCP HTTP/SSE server.")
|
||||
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.")
|
||||
}
|
||||
|
|
|
|||
1
go.mod
1
go.mod
|
|
@ -69,6 +69,7 @@ require (
|
|||
github.com/gookit/color v1.5.4 // indirect
|
||||
github.com/hashicorp/errwrap v1.0.0 // indirect
|
||||
github.com/hashicorp/go-multierror v1.1.0 // indirect
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||
github.com/iancoleman/strcase v0.3.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/invopop/jsonschema v0.13.0 // indirect
|
||||
|
|
|
|||
2
go.sum
2
go.sum
|
|
@ -119,6 +119,8 @@ github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/U
|
|||
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/go-multierror v1.1.0 h1:B9UzwGQJehnUY1yNrnwREHc3fGbC2xefo8g4TbElacI=
|
||||
github.com/hashicorp/go-multierror v1.1.0/go.mod h1:spPvp8C1qA32ftKqdAHm4hHTbPw+vmowP0z+KUhOZdA=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/iancoleman/strcase v0.3.0 h1:nTXanmYxhfFAMjZL34Ov6gkzEsSJZ5DbhxWjvSASxEI=
|
||||
github.com/iancoleman/strcase v0.3.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho=
|
||||
github.com/ianlancetaylor/demangle v0.0.0-20210905161508-09a460cdf81d/go.mod h1:aYm2/VgdVmcIU8iMfdMvDMsRAQjcfZSKFby6HOFvi/w=
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue