From eb4ca70fc2c57f5200bfeba692004a5a0419594c Mon Sep 17 00:00:00 2001 From: Daoud AbdelMonem Faleh Date: Tue, 3 Mar 2026 00:13:00 +0100 Subject: [PATCH] feat(mcp): implement structured JSON output, layer diffing, LRU cache, and security sandbox (Phases 2 & 3) --- cmd/dive/cli/internal/command/mcp.go | 2 +- cmd/dive/cli/internal/mcp/handlers.go | 349 ++++++++++++++++----- cmd/dive/cli/internal/mcp/handlers_test.go | 103 +++--- cmd/dive/cli/internal/mcp/server.go | 37 ++- cmd/dive/cli/internal/options/mcp.go | 7 + go.mod | 1 + go.sum | 2 + 7 files changed, 376 insertions(+), 125 deletions(-) diff --git a/cmd/dive/cli/internal/command/mcp.go b/cmd/dive/cli/internal/command/mcp.go index e73e300..a1d9b19 100644 --- a/cmd/dive/cli/internal/command/mcp.go +++ b/cmd/dive/cli/internal/command/mcp.go @@ -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) diff --git a/cmd/dive/cli/internal/mcp/handlers.go b/cmd/dive/cli/internal/mcp/handlers.go index 5bb62b6..1110c81 100644 --- a/cmd/dive/cli/internal/mcp/handlers.go +++ b/cmd/dive/cli/internal/mcp/handlers.go @@ -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)), }, }, }, diff --git a/cmd/dive/cli/internal/mcp/handlers_test.go b/cmd/dive/cli/internal/mcp/handlers_test.go index fa17984..8b112d4 100644 --- a/cmd/dive/cli/internal/mcp/handlers_test.go +++ b/cmd/dive/cli/internal/mcp/handlers_test.go @@ -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) } diff --git a/cmd/dive/cli/internal/mcp/server.go b/cmd/dive/cli/internal/mcp/server.go index f6e0bff..ae912e8 100644 --- a/cmd/dive/cli/internal/mcp/server.go +++ b/cmd/dive/cli/internal/mcp/server.go @@ -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) diff --git a/cmd/dive/cli/internal/options/mcp.go b/cmd/dive/cli/internal/options/mcp.go index 48cd43d..6d391ff 100644 --- a/cmd/dive/cli/internal/options/mcp.go +++ b/cmd/dive/cli/internal/options/mcp.go @@ -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.") } diff --git a/go.mod b/go.mod index 4e4a719..b48046d 100644 --- a/go.mod +++ b/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 diff --git a/go.sum b/go.sum index d2799b0..03cb215 100644 --- a/go.sum +++ b/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=