mirror of
https://github.com/wagoodman/dive
synced 2026-03-14 14:25:50 +01:00
feat: add MCP server for AI-assisted image analysis
This commit is contained in:
parent
d6c691947f
commit
07c1bcf089
12 changed files with 613 additions and 0 deletions
71
ARCHITECTURE.md
Normal file
71
ARCHITECTURE.md
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
# Dive Project Architecture & Development Guide
|
||||
|
||||
## Overview
|
||||
`dive` is a tool for exploring a docker image, layer contents, and discovering ways to shrink the size of your Docker/OCI image. It analyzes each layer of an image, showing the changes in the file system, and calculates an "efficiency" score based on wasted space (duplicated or deleted files across layers).
|
||||
|
||||
## Architecture
|
||||
|
||||
### 1. High-Level Components
|
||||
The project follows a decoupled, event-driven architecture to separate the CLI logic, image analysis, and the Terminal User Interface (TUI).
|
||||
|
||||
* **CLI (cmd/dive/cli):** Managed by `clio` and `cobra`. It handles configuration, flag parsing, and orchestrates the analysis flow.
|
||||
* **Image Domain (dive/image):** Core models for `Image`, `Layer`, and `Analysis`. It includes resolvers for different sources (Docker, Podman, Archives).
|
||||
* **Filetree Domain (dive/filetree):** The "brain" of the application. It manages the representation of file systems, handles "stacking" layers, and calculates efficiency.
|
||||
* **Internal Bus (internal/bus):** Powered by `go-partybus`. It allows the CLI and Analysis components to communicate with the UI via events (e.g., `ExploreAnalysis`, `TaskStarted`).
|
||||
* **TUI (cmd/dive/cli/internal/ui):** Built with `gocui` (and `lipgloss` for styling). It consumes events from the bus to render the interactive explorer.
|
||||
|
||||
### 2. Analysis Flow
|
||||
1. **Resolve:** The `GetImageResolver` determines the source (Docker Engine, Podman, or Tarball).
|
||||
2. **Fetch:** The resolver extracts the image and builds a list of `Layer` objects, each containing a `FileTree`.
|
||||
3. **Analyze:** The `Analyze` function (in `dive/image/analysis.go`) triggers the `Efficiency` calculation.
|
||||
4. **Efficiency Calculation:**
|
||||
* Iterates through all layer trees.
|
||||
* Tracks file paths across layers.
|
||||
* Identifies "wasted space" (files rewritten in subsequent layers or deleted files that still occupy space in lower layers).
|
||||
5. **Explore:** If not in CI mode, the `Analysis` results are published to the bus, triggering the TUI.
|
||||
|
||||
### 3. TUI Structure (V1)
|
||||
The TUI uses an MVC-like pattern:
|
||||
* **App/Controller:** Manages the `gocui` main loop and coordinate views.
|
||||
* **Views:** Specialized components for `Layer`, `FileTree`, `ImageDetails`, etc.
|
||||
* **ViewModel:** Buffers and formats data for presentation.
|
||||
|
||||
## Engineering Standards
|
||||
|
||||
### Coding Standards
|
||||
* **Go Version:** 1.24.
|
||||
* **Linting:** Enforced via `golangci-lint` with a specific configuration in `.golangci.yaml`.
|
||||
* **Formatting:** Standard `gofmt -s` and `go mod tidy`.
|
||||
* **CLI Framework:** Uses `github.com/anchore/clio` for standardized application setup (logging, configuration, versioning).
|
||||
|
||||
### Testing Infrastructure
|
||||
* **Unit Tests:** Standard `go test`. Coverage is tracked and enforced (threshold: 25%) via `.github/scripts/coverage.py`.
|
||||
* **CLI/Integration Tests:** Located in `cmd/dive/cli/`, using `go-snaps` for snapshot testing of CLI output and configuration.
|
||||
* **Acceptance Tests:** Automated cross-platform tests (Linux, Mac, Windows) that run the built binary against real test images (located in `.data/`).
|
||||
|
||||
### Quality Gates
|
||||
* **Static Analysis:** Gofmt check, file name validation (no `:`), and `golangci-lint`.
|
||||
* **License Compliance:** Checked via `bouncer`.
|
||||
* **CI Pipeline:** GitHub Actions (`validations.yaml`) runs on every PR, executing:
|
||||
* Static Analysis.
|
||||
* Unit Tests (with coverage check).
|
||||
* Snapshot builds.
|
||||
* Acceptance tests on multiple platforms.
|
||||
|
||||
## Build and Release
|
||||
* **Taskfile.yaml:** The primary entry point for development tasks (`task test`, `task build`, `task lint`).
|
||||
* **Makefile:** A shim for `Taskfile` for users accustomed to `make`.
|
||||
* **Goreleaser:** Manages the build matrix (Linux, Darwin, Windows across various archs), generates `.deb`, `.rpm`, Homebrew formulas, and Docker images.
|
||||
* **CI Release:** Automated via `.github/workflows/release.yaml` on tag pushes.
|
||||
|
||||
## Key Dependencies
|
||||
* `github.com/awesome-gocui/gocui`: TUI framework.
|
||||
* `github.com/wagoodman/go-partybus`: Event bus.
|
||||
* `github.com/anchore/clio`: Application framework.
|
||||
* `github.com/docker/docker`: Docker API integration.
|
||||
* `github.com/gkampitakis/go-snaps`: Snapshot testing.
|
||||
|
||||
## Future Development Notes
|
||||
* **Windows Support:** While present, acceptance tests for Windows are noted as "todo" in some areas or require specific runners.
|
||||
* **Performance:** The filetree stacking and efficiency calculation are CPU and memory intensive for very large images; optimizations should focus on `dive/filetree`.
|
||||
* **UI Modernization:** Styling is increasingly moving towards `lipgloss`, though the core remains `gocui`.
|
||||
71
MCP_DESIGN.md
Normal file
71
MCP_DESIGN.md
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
# Dive MCP Server: High-Level Design & Implementation Plan
|
||||
|
||||
## 1. Overview
|
||||
This document outlines the design and architecture for integrating Model Context Protocol (MCP) support into the `dive` project. The goal is to allow AI agents (via MCP clients) to programmatically analyze Docker/OCI images, inspect layer contents, and identify optimization opportunities using `dive`'s existing analysis engine.
|
||||
|
||||
## 2. Architecture
|
||||
|
||||
### 2.1 Component Integration
|
||||
The MCP server will be implemented as a new subcommand within the existing `clio`/`cobra` CLI framework. It will act as a "headless" consumer of the core `dive` domain, similar to the CI evaluator or the JSON exporter.
|
||||
|
||||
* **Command Layer:** `cmd/dive/cli/internal/command/mcp.go`
|
||||
* **MCP Logic Layer:** `cmd/dive/cli/internal/mcp/`
|
||||
* **Domain Re-use:** Leverages `dive/image`, `dive/filetree`, and `cmd/dive/cli/internal/command/adapter`.
|
||||
|
||||
### 2.2 System Diagram
|
||||
```mermaid
|
||||
graph TD
|
||||
Client[MCP Client] -- stdio/SSE --> Server[Dive MCP Server]
|
||||
Server -- Tool Call --> Handler[MCP Handlers]
|
||||
Handler -- Execute --> Analysis[Adapter: Analyzer/Resolver]
|
||||
Analysis -- Data --> Handler
|
||||
Handler -- Transform --> JSON[MCP JSON-RPC]
|
||||
JSON --> Server
|
||||
Server --> Client
|
||||
```
|
||||
|
||||
### 2.3 MCP Protocol (Version 2025-11-25) Implementation
|
||||
The server will support the following capabilities:
|
||||
|
||||
#### Tools
|
||||
* `analyze_image(image_path, source)`: Returns high-level efficiency metrics and layer metadata.
|
||||
* `inspect_layer(image_path, layer_id)`: Returns file tree changes for a specific layer.
|
||||
* `get_wasted_space(image_path)`: Returns a list of inefficient files and cumulative size.
|
||||
|
||||
#### Resources
|
||||
* `dive://image/{name}/summary`: Provides the latest analysis summary.
|
||||
* `dive://image/{name}/efficiency`: Provides the efficiency score and metrics.
|
||||
|
||||
#### Prompts
|
||||
* `optimize-dockerfile`: A template that assists the AI in rewriting a Dockerfile based on `dive`'s findings.
|
||||
|
||||
## 3. Implementation Plan
|
||||
|
||||
### Phase 1: Foundation (Research & Scaffolding)
|
||||
* **Dependency Management:** Introduce `github.com/mark3labs/mcp-go` (lightweight MCP SDK for Go).
|
||||
* **Subcommand Registration:** Add `mcp` command to `cmd/dive/cli/cli.go`.
|
||||
* **Options:** Implement `options.MCP` for configuration (e.g., transport type, timeouts).
|
||||
|
||||
### Phase 2: Server & Transport Logic
|
||||
* **Stdio Transport:** Implement the primary communication channel for local MCP clients.
|
||||
* **HTTP/SSE Transport:** Implement an optional `--http` mode for streamable MCP over network/web containers.
|
||||
* **Session Management:** Implement a simple in-memory cache to prevent re-analyzing the same image across multiple tool calls in a single session.
|
||||
|
||||
### Phase 3: Tool Handlers & Data Mapping
|
||||
* **Handler Logic:** Map MCP tool requests to `adapter.NewAnalyzer().Analyze()`.
|
||||
* **Data Pruning:** Implement depth-limited tree serialization to ensure MCP responses stay within protocol/client token limits.
|
||||
* **Progress Notifications:** Integrate with `internal/bus` to stream analysis progress back to the MCP client.
|
||||
|
||||
### Phase 4: Validation & Quality Control
|
||||
* **Unit Testing:** Add tests for MCP handlers in `cmd/dive/cli/internal/mcp/`.
|
||||
* **Snapshot Testing:** Use `go-snaps` to verify MCP JSON-RPC outputs.
|
||||
* **Quality Gates:** Ensure compliance with `golangci-lint` and the 25% coverage threshold via `Taskfile`.
|
||||
|
||||
### Phase 5: Release
|
||||
* **Release Process:** No changes required to `goreleaser`; the `mcp` subcommand will be included in the standard `dive` binary.
|
||||
* **Documentation:** Update `README.md` and provide a `dive-mcp-config.json` example for Claude Desktop.
|
||||
|
||||
## 4. Design Constraints
|
||||
* **Zero Impact on TUI:** The MCP server will not initialize `gocui` or the terminal UI.
|
||||
* **Dependency Minimalization:** Only one new dependency (`mcp-go`) will be added.
|
||||
* **Standards Compliance:** Strict adherence to Go 1.24 and existing error handling patterns.
|
||||
32
README.md
32
README.md
|
|
@ -49,6 +49,38 @@ CI=true dive <your-image>
|
|||
|
||||
**This is beta quality!** *Feel free to submit an issue if you want a new feature or find a bug :)*
|
||||
|
||||
## Model Context Protocol (MCP) Server
|
||||
|
||||
`dive` can act as an MCP server, allowing AI agents (like Claude Desktop or IDE extensions) to programmatically analyze images and identify optimization opportunities.
|
||||
|
||||
To start the MCP server:
|
||||
```bash
|
||||
dive mcp
|
||||
```
|
||||
|
||||
By default, it uses the `stdio` transport. You can also run it as an SSE server:
|
||||
```bash
|
||||
dive mcp --transport sse --port 8080
|
||||
```
|
||||
|
||||
### Available Tools
|
||||
- `analyze_image(image, source)`: Returns efficiency metrics and layer details.
|
||||
- `get_wasted_space(image, source)`: Returns the list of top inefficient files.
|
||||
- `inspect_layer(image, layer_index, source, path)`: Lists files within a specific layer and path.
|
||||
|
||||
### Configuration for Claude Desktop
|
||||
Add the following to your `claude_desktop_config.json`:
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"dive": {
|
||||
"command": "dive",
|
||||
"args": ["mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Basic Features
|
||||
|
||||
**Show Docker image contents broken down by layer**
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ func create(id clio.Identification) (clio.Application, *cobra.Command) {
|
|||
clio.VersionCommand(id),
|
||||
clio.ConfigCommand(app, nil),
|
||||
command.Build(app),
|
||||
command.MCP(app, id),
|
||||
)
|
||||
|
||||
return app, rootCmd
|
||||
|
|
|
|||
26
cmd/dive/cli/internal/command/mcp.go
Normal file
26
cmd/dive/cli/internal/command/mcp.go
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
package command
|
||||
|
||||
import (
|
||||
"github.com/anchore/clio"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/wagoodman/dive/cmd/dive/cli/internal/mcp"
|
||||
"github.com/wagoodman/dive/cmd/dive/cli/internal/options"
|
||||
)
|
||||
|
||||
type mcpOptions struct {
|
||||
options.Application `yaml:",inline" mapstructure:",squash"`
|
||||
}
|
||||
|
||||
func MCP(app clio.Application, id clio.Identification) *cobra.Command {
|
||||
opts := &mcpOptions{
|
||||
Application: options.DefaultApplication(),
|
||||
}
|
||||
return app.SetupCommand(&cobra.Command{
|
||||
Use: "mcp",
|
||||
Short: "Start the Model Context Protocol (MCP) server.",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
s := mcp.NewServer(id)
|
||||
return mcp.Run(s, opts.MCP)
|
||||
},
|
||||
}, opts)
|
||||
}
|
||||
183
cmd/dive/cli/internal/mcp/handlers.go
Normal file
183
cmd/dive/cli/internal/mcp/handlers.go
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
"github.com/wagoodman/dive/cmd/dive/cli/internal/command/adapter"
|
||||
"github.com/wagoodman/dive/dive"
|
||||
"github.com/wagoodman/dive/dive/image"
|
||||
"github.com/wagoodman/dive/internal/log"
|
||||
)
|
||||
|
||||
type toolHandlers struct {
|
||||
mu sync.RWMutex
|
||||
analyses map[string]*image.Analysis
|
||||
}
|
||||
|
||||
func newToolHandlers() *toolHandlers {
|
||||
return &toolHandlers{
|
||||
analyses: make(map[string]*image.Analysis),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *toolHandlers) getAnalysis(ctx context.Context, imageName string, sourceStr string) (*image.Analysis, error) {
|
||||
source := dive.ParseImageSource(sourceStr)
|
||||
if source == dive.SourceUnknown {
|
||||
return nil, fmt.Errorf("unknown image source: %s", sourceStr)
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
return analysis, nil
|
||||
}
|
||||
|
||||
func (h *toolHandlers) analyzeImageHandler(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
imageName, err := request.RequireString("image")
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(err.Error()), nil
|
||||
}
|
||||
|
||||
sourceStr := request.GetString("source", "docker")
|
||||
|
||||
analysis, err := h.getAnalysis(ctx, imageName, sourceStr)
|
||||
if err != nil {
|
||||
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))
|
||||
|
||||
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)
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
func (h *toolHandlers) getWastedSpaceHandler(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
imageName, err := request.RequireString("image")
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(err.Error()), nil
|
||||
}
|
||||
|
||||
sourceStr := request.GetString("source", "docker")
|
||||
|
||||
analysis, err := h.getAnalysis(ctx, imageName, sourceStr)
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(err.Error()), nil
|
||||
}
|
||||
|
||||
if len(analysis.Inefficiencies) == 0 {
|
||||
return mcp.NewToolResultText("No wasted space detected in this image.")
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
func (h *toolHandlers) inspectLayerHandler(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
imageName, err := request.RequireString("image")
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(err.Error()), nil
|
||||
}
|
||||
|
||||
layerIdx, err := request.RequireInt("layer_index")
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError("layer_index is required and must be an integer"), nil
|
||||
}
|
||||
|
||||
sourceStr := request.GetString("source", "docker")
|
||||
pathStr := request.GetString("path", "/")
|
||||
|
||||
analysis, err := h.getAnalysis(ctx, imageName, sourceStr)
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(err.Error()), nil
|
||||
}
|
||||
|
||||
if layerIdx < 0 || layerIdx >= len(analysis.RefTrees) {
|
||||
return mcp.NewToolResultError(fmt.Sprintf("layer index out of bounds: %d (total layers: %d)", layerIdx, len(analysis.RefTrees))), nil
|
||||
}
|
||||
|
||||
tree := analysis.RefTrees[layerIdx]
|
||||
|
||||
startNode := tree.Root
|
||||
if pathStr != "/" {
|
||||
node, err := tree.GetNode(pathStr)
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(fmt.Sprintf("path not found in layer: %s", pathStr)), nil
|
||||
}
|
||||
startNode = node
|
||||
}
|
||||
|
||||
summary := fmt.Sprintf("Contents of layer %d at %s:\n", layerIdx, pathStr)
|
||||
count := 0
|
||||
limit := 100
|
||||
|
||||
for name, child := range startNode.Children {
|
||||
if count >= limit {
|
||||
summary += "... (truncated)\n"
|
||||
break
|
||||
}
|
||||
typeChar := "F"
|
||||
if child.Data.FileInfo.IsDir {
|
||||
typeChar = "D"
|
||||
}
|
||||
summary += fmt.Sprintf("[%s] %s (%d bytes)\n", typeChar, name, child.Data.FileInfo.Size)
|
||||
count++
|
||||
}
|
||||
|
||||
if count == 0 {
|
||||
summary += "(Empty or no children found at this path)\n"
|
||||
}
|
||||
|
||||
return mcp.NewToolResultText(summary), nil
|
||||
}
|
||||
71
cmd/dive/cli/internal/mcp/handlers_test.go
Normal file
71
cmd/dive/cli/internal/mcp/handlers_test.go
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/wagoodman/dive/dive/image"
|
||||
)
|
||||
|
||||
func TestHandlers_AnalyzeImage_MissingImage(t *testing.T) {
|
||||
h := newToolHandlers()
|
||||
ctx := context.Background()
|
||||
req := mcp.CallToolRequest{}
|
||||
req.Params.Name = "analyze_image"
|
||||
req.Params.Arguments = map[string]interface{}{}
|
||||
|
||||
result, err := h.analyzeImageHandler(ctx, req)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, result.IsError)
|
||||
}
|
||||
|
||||
func TestHandlers_GetWastedSpace_AutoAnalysis(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()
|
||||
ctx := context.Background()
|
||||
req := mcp.CallToolRequest{}
|
||||
req.Params.Name = "get_wasted_space"
|
||||
req.Params.Arguments = map[string]interface{}{
|
||||
"image": imagePath,
|
||||
"source": "docker-archive",
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
func TestHandlers_GetWastedSpace_WithCache(t *testing.T) {
|
||||
h := newToolHandlers()
|
||||
h.analyses["docker:ubuntu:latest"] = &image.Analysis{
|
||||
Image: "ubuntu:latest",
|
||||
WastedBytes: 0,
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
req := mcp.CallToolRequest{}
|
||||
req.Params.Name = "get_wasted_space"
|
||||
req.Params.Arguments = map[string]interface{}{
|
||||
"image": "ubuntu:latest",
|
||||
}
|
||||
|
||||
result, err := h.getWastedSpaceHandler(ctx, req)
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, result.IsError)
|
||||
assert.Contains(t, result.Content[0].(mcp.TextContent).Text, "No wasted space detected")
|
||||
}
|
||||
101
cmd/dive/cli/internal/mcp/server.go
Normal file
101
cmd/dive/cli/internal/mcp/server.go
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
package mcp
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/anchore/clio"
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
"github.com/mark3labs/mcp-go/server"
|
||||
"github.com/wagoodman/dive/cmd/dive/cli/internal/options"
|
||||
"github.com/wagoodman/dive/internal/log"
|
||||
)
|
||||
|
||||
func NewServer(id clio.Identification) *server.MCPServer {
|
||||
s := server.NewMCPServer(
|
||||
id.Name,
|
||||
id.Version,
|
||||
server.WithResourceCapabilities(true, true),
|
||||
server.WithToolCapabilities(true),
|
||||
server.WithPromptCapabilities(true),
|
||||
)
|
||||
|
||||
h := newToolHandlers()
|
||||
|
||||
// --- Tools ---
|
||||
|
||||
// 1. analyze_image tool
|
||||
analyzeTool := mcp.NewTool("analyze_image",
|
||||
mcp.WithDescription("Analyze a docker image and return efficiency metrics and layer details"),
|
||||
mcp.WithString("image",
|
||||
mcp.Required(),
|
||||
mcp.Description("The name of the image to analyze (e.g., 'ubuntu:latest')"),
|
||||
),
|
||||
mcp.WithString("source",
|
||||
mcp.Description("The container engine to fetch the image from (docker, podman, docker-archive). Defaults to 'docker'."),
|
||||
),
|
||||
)
|
||||
s.AddTool(analyzeTool, h.analyzeImageHandler)
|
||||
|
||||
// 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.WithString("image",
|
||||
mcp.Required(),
|
||||
mcp.Description("The name of the image to get wasted space for (must be analyzed first)"),
|
||||
),
|
||||
mcp.WithString("source",
|
||||
mcp.Description("The container engine to fetch the image from (docker, podman, docker-archive). Defaults to 'docker'."),
|
||||
),
|
||||
)
|
||||
s.AddTool(wastedSpaceTool, h.getWastedSpaceHandler)
|
||||
|
||||
// 3. inspect_layer tool
|
||||
inspectLayerTool := mcp.NewTool("inspect_layer",
|
||||
mcp.WithDescription("Inspect the contents of a specific layer in an image"),
|
||||
mcp.WithString("image",
|
||||
mcp.Required(),
|
||||
mcp.Description("The name of the image to inspect"),
|
||||
),
|
||||
mcp.WithNumber("layer_index",
|
||||
mcp.Required(),
|
||||
mcp.Description("The index of the layer to inspect (starting from 0)"),
|
||||
),
|
||||
mcp.WithString("source",
|
||||
mcp.Description("The container engine to fetch the image from (docker, podman, docker-archive). Defaults to 'docker'."),
|
||||
),
|
||||
mcp.WithString("path",
|
||||
mcp.Description("The path within the layer to inspect. Defaults to '/'."),
|
||||
),
|
||||
)
|
||||
s.AddTool(inspectLayerTool, h.inspectLayerHandler)
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
func Run(s *server.MCPServer, opts options.MCP) error {
|
||||
switch opts.Transport {
|
||||
case "sse":
|
||||
addr := fmt.Sprintf("%s:%d", opts.Host, opts.Port)
|
||||
sseServer := server.NewSSEServer(s, server.WithBaseURL(fmt.Sprintf("http://%s", addr)))
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("/sse", sseServer.SSEHandler())
|
||||
mux.Handle("/messages", sseServer.MessageHandler())
|
||||
|
||||
log.Infof("Starting MCP SSE server on %s", addr)
|
||||
fmt.Printf("Starting MCP SSE server on %s
|
||||
", addr)
|
||||
fmt.Printf("- SSE endpoint: http://%s/sse
|
||||
", addr)
|
||||
fmt.Printf("- Message endpoint: http://%s/messages
|
||||
", addr)
|
||||
|
||||
return http.ListenAndServe(addr, mux)
|
||||
case "stdio":
|
||||
log.Infof("Starting MCP Stdio server")
|
||||
return server.ServeStdio(s)
|
||||
default:
|
||||
return fmt.Errorf("unsupported transport: %s", opts.Transport)
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ type Application struct {
|
|||
Analysis Analysis `yaml:",inline" mapstructure:",squash"`
|
||||
CI CI `yaml:",inline" mapstructure:",squash"`
|
||||
Export Export `yaml:",inline" mapstructure:",squash"`
|
||||
MCP MCP `yaml:",inline" mapstructure:",squash"`
|
||||
UI UI `yaml:",inline" mapstructure:",squash"`
|
||||
}
|
||||
|
||||
|
|
@ -16,6 +17,7 @@ func DefaultApplication() Application {
|
|||
Analysis: DefaultAnalysis(),
|
||||
CI: DefaultCI(),
|
||||
Export: DefaultExport(),
|
||||
MCP: DefaultMCP(),
|
||||
UI: DefaultUI(),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
33
cmd/dive/cli/internal/options/mcp.go
Normal file
33
cmd/dive/cli/internal/options/mcp.go
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
package options
|
||||
|
||||
import (
|
||||
"github.com/anchore/clio"
|
||||
)
|
||||
|
||||
var _ interface {
|
||||
clio.FlagAdder
|
||||
} = (*MCP)(nil)
|
||||
|
||||
// MCP provides configuration for the Model Context Protocol server
|
||||
type MCP struct {
|
||||
// Transport is the transport to use for the MCP server (stdio, sse)
|
||||
Transport string `yaml:"transport" json:"transport" mapstructure:"transport"`
|
||||
// Host is the host for the MCP HTTP/SSE server
|
||||
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"`
|
||||
}
|
||||
|
||||
func DefaultMCP() MCP {
|
||||
return MCP{
|
||||
Transport: "stdio",
|
||||
Host: "localhost",
|
||||
Port: 8080,
|
||||
}
|
||||
}
|
||||
|
||||
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.")
|
||||
}
|
||||
7
go.mod
7
go.mod
|
|
@ -19,6 +19,7 @@ require (
|
|||
github.com/google/uuid v1.6.0
|
||||
github.com/klauspost/compress v1.18.0
|
||||
github.com/lunixbochs/vtclean v1.0.0
|
||||
github.com/mark3labs/mcp-go v0.44.1
|
||||
github.com/muesli/termenv v0.16.0
|
||||
github.com/phayes/permbits v0.0.0-20190612203442-39d7c581d2ee
|
||||
github.com/scylladb/go-set v1.0.2
|
||||
|
|
@ -39,6 +40,8 @@ require (
|
|||
github.com/anchore/fangs v0.0.0-20250402135612-96e29e45f3fe // indirect
|
||||
github.com/anchore/go-homedir v0.0.0-20250319154043-c29668562e4d // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/bahlo/generic-list-go v0.2.0 // indirect
|
||||
github.com/buger/jsonparser v1.1.1 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
|
||||
github.com/charmbracelet/x/ansi v0.8.0 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
|
||||
|
|
@ -68,9 +71,11 @@ require (
|
|||
github.com/hashicorp/go-multierror v1.1.0 // 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
|
||||
github.com/kr/pretty v0.3.1 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||
github.com/mailru/easyjson v0.7.7 // indirect
|
||||
github.com/maruel/natural v1.1.1 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
|
|
@ -102,7 +107,9 @@ require (
|
|||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.1 // indirect
|
||||
github.com/tidwall/sjson v1.2.5 // indirect
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 // indirect
|
||||
go.opentelemetry.io/otel v1.31.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.31.0 // indirect
|
||||
|
|
|
|||
15
go.sum
15
go.sum
|
|
@ -23,6 +23,10 @@ github.com/awesome-gocui/keybinding v1.0.1-0.20211011072933-86029037a63f/go.mod
|
|||
github.com/awesome-gocui/termbox-go v0.0.0-20190427202837-c0aef3d18bcc/go.mod h1:tOy3o5Nf1bA17mnK4W41gD7PS3u4Cv0P0pqFcoWMy8s=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
|
||||
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
|
||||
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
|
||||
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
|
|
@ -120,6 +124,9 @@ github.com/iancoleman/strcase v0.3.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47
|
|||
github.com/ianlancetaylor/demangle v0.0.0-20210905161508-09a460cdf81d/go.mod h1:aYm2/VgdVmcIU8iMfdMvDMsRAQjcfZSKFby6HOFvi/w=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E=
|
||||
github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
|
|
@ -137,6 +144,10 @@ github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69
|
|||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/lunixbochs/vtclean v1.0.0 h1:xu2sLAri4lGiovBDQKxl5mrXyESr3gUr5m5SM5+LVb8=
|
||||
github.com/lunixbochs/vtclean v1.0.0/go.mod h1:pHhQNgMf3btfWnGBVipUOjRYhoOsdGqdm/+2c2E2WMI=
|
||||
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
||||
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
github.com/mark3labs/mcp-go v0.44.1 h1:2PKppYlT9X2fXnE8SNYQLAX4hNjfPB0oNLqQVcN6mE8=
|
||||
github.com/mark3labs/mcp-go v0.44.1/go.mod h1:YnJfOL382MIWDx1kMY+2zsRHU/q78dBg9aFb8W6Thdw=
|
||||
github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo=
|
||||
github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
|
|
@ -240,8 +251,12 @@ github.com/wagoodman/go-partybus v0.0.0-20230516145632-8ccac152c651 h1:jIVmlAFIq
|
|||
github.com/wagoodman/go-partybus v0.0.0-20230516145632-8ccac152c651/go.mod h1:b26F2tHLqaoRQf8DywqzVaV1MQ9yvjb0OMcNl7Nxu20=
|
||||
github.com/wagoodman/go-progress v0.0.0-20230925121702-07e42b3cdba0 h1:0KGbf+0SMg+UFy4e1A/CPVvXn21f1qtWdeJwxZFoQG8=
|
||||
github.com/wagoodman/go-progress v0.0.0-20230925121702-07e42b3cdba0/go.mod h1:jLXFoL31zFaHKAAyZUh+sxiTDFe1L1ZHrcK2T1itVKA=
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
|
||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk=
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue