feat(mcp): implement structured JSON output, layer diffing, LRU cache, and security sandbox (Phases 2 & 3)

This commit is contained in:
Daoud AbdelMonem Faleh 2026-03-03 00:13:00 +01:00
commit eb4ca70fc2
7 changed files with 365 additions and 114 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

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

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