feat: docker mcp integration (#2026)

Kujtim Hoxha , Andrey Nering , and Christian Rocha created

Co-authored-by: Andrey Nering <andreynering@users.noreply.github.com>
Co-authored-by: Christian Rocha <christian@rocha.is>

Change summary

internal/agent/agent.go                |   3 
internal/agent/coordinator.go          |  16 +
internal/agent/tools/mcp-tools.go      |  48 ++-
internal/agent/tools/mcp/init.go       | 122 +++++++---
internal/agent/tools/mcp/tools.go      |  38 +++
internal/agent/tools/mcp/tools_test.go | 102 +++++++++
internal/config/docker_mcp.go          |  75 +++++++
internal/config/docker_mcp_test.go     | 168 ++++++++++++++++
internal/ui/AGENTS.md                  |   1 
internal/ui/chat/docker_mcp.go         | 290 ++++++++++++++++++++++++++++
internal/ui/chat/tools.go              |   4 
internal/ui/dialog/actions.go          |   4 
internal/ui/dialog/commands.go         |  10 
internal/ui/model/mcp.go               |   8 
internal/ui/model/ui.go                |  36 +++
internal/ui/styles/styles.go           |   8 
16 files changed, 870 insertions(+), 63 deletions(-)

Detailed changes

internal/agent/agent.go 🔗

@@ -266,6 +266,9 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
 				prepared.Messages[i].ProviderOptions = nil
 			}
 
+			// Use latest tools (updated by SetTools when MCP tools change).
+			prepared.Tools = a.tools.Copy()
+
 			queuedCalls, _ := a.messageQueue.Get(call.SessionID)
 			a.messageQueue.Del(call.SessionID)
 			for _, queued := range queuedCalls {

internal/agent/coordinator.go 🔗

@@ -71,6 +71,7 @@ type Coordinator interface {
 	Summarize(context.Context, string) error
 	Model() Model
 	UpdateModels(ctx context.Context) error
+	RefreshTools(ctx context.Context) error
 }
 
 type coordinator struct {
@@ -904,6 +905,21 @@ func (c *coordinator) UpdateModels(ctx context.Context) error {
 	return nil
 }
 
+func (c *coordinator) RefreshTools(ctx context.Context) error {
+	agentCfg, ok := c.cfg.Config().Agents[config.AgentCoder]
+	if !ok {
+		return errors.New("coder agent not configured")
+	}
+
+	tools, err := c.buildTools(ctx, agentCfg)
+	if err != nil {
+		return err
+	}
+	c.currentAgent.SetTools(tools)
+	slog.Debug("refreshed agent tools", "count", len(tools))
+	return nil
+}
+
 func (c *coordinator) QueuedPrompts(sessionID string) int {
 	return c.currentAgent.QueuedPrompts(sessionID)
 }

internal/agent/tools/mcp-tools.go 🔗

@@ -3,6 +3,7 @@ package tools
 import (
 	"context"
 	"fmt"
+	"slices"
 
 	"charm.land/fantasy"
 	"github.com/charmbracelet/crush/internal/agent/tools/mcp"
@@ -10,6 +11,15 @@ import (
 	"github.com/charmbracelet/crush/internal/permission"
 )
 
+// whitelistDockerTools contains Docker MCP tools that don't require permission.
+var whitelistDockerTools = []string{
+	"mcp_docker_mcp-find",
+	"mcp_docker_mcp-add",
+	"mcp_docker_mcp-remove",
+	"mcp_docker_mcp-config-set",
+	"mcp_docker_code-mode",
+}
+
 // GetMCPTools gets all the currently available MCP tools.
 func GetMCPTools(permissions permission.Service, cfg *config.ConfigStore, wd string) []*Tool {
 	var result []*Tool
@@ -91,23 +101,27 @@ func (m *Tool) Run(ctx context.Context, params fantasy.ToolCall) (fantasy.ToolRe
 	if sessionID == "" {
 		return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for creating a new file")
 	}
-	permissionDescription := fmt.Sprintf("execute %s with the following parameters:", m.Info().Name)
-	p, err := m.permissions.Request(ctx,
-		permission.CreatePermissionRequest{
-			SessionID:   sessionID,
-			ToolCallID:  params.ID,
-			Path:        m.workingDir,
-			ToolName:    m.Info().Name,
-			Action:      "execute",
-			Description: permissionDescription,
-			Params:      params.Input,
-		},
-	)
-	if err != nil {
-		return fantasy.ToolResponse{}, err
-	}
-	if !p {
-		return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
+
+	// Skip permission for whitelisted Docker MCP tools.
+	if !slices.Contains(whitelistDockerTools, params.Name) {
+		permissionDescription := fmt.Sprintf("execute %s with the following parameters:", m.Info().Name)
+		p, err := m.permissions.Request(ctx,
+			permission.CreatePermissionRequest{
+				SessionID:   sessionID,
+				ToolCallID:  params.ID,
+				Path:        m.workingDir,
+				ToolName:    m.Info().Name,
+				Action:      "execute",
+				Description: permissionDescription,
+				Params:      params.Input,
+			},
+		)
+		if err != nil {
+			return fantasy.ToolResponse{}, err
+		}
+		if !p {
+			return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
+		}
 	}
 
 	result, err := mcp.RunTool(ctx, m.cfg, m.mcpName, m.tool.Name, params.Input)

internal/agent/tools/mcp/init.go 🔗

@@ -175,8 +175,6 @@ func Initialize(ctx context.Context, permissions permission.Service, cfg *config
 		}
 
 		// Set initial starting state
-		updateState(name, StateStarting, nil, nil, Counts{})
-
 		wg.Add(1)
 		go func(name string, m config.MCPConfig) {
 			defer func() {
@@ -196,46 +194,9 @@ func Initialize(ctx context.Context, permissions permission.Service, cfg *config
 				}
 			}()
 
-			// createSession handles its own timeout internally.
-			session, err := createSession(ctx, name, m, cfg.Resolver())
-			if err != nil {
-				return
-			}
-
-			tools, err := getTools(ctx, session)
-			if err != nil {
-				slog.Error("Error listing tools", "error", err)
-				updateState(name, StateError, err, nil, Counts{})
-				session.Close()
-				return
-			}
-
-			prompts, err := getPrompts(ctx, session)
-			if err != nil {
-				slog.Error("Error listing prompts", "error", err)
-				updateState(name, StateError, err, nil, Counts{})
-				session.Close()
-				return
-			}
-
-			resources, err := getResources(ctx, session)
-			if err != nil {
-				slog.Error("Error listing resources", "error", err)
-				updateState(name, StateError, err, nil, Counts{})
-				session.Close()
-				return
+			if err := initClient(ctx, cfg, name, m, cfg.Resolver()); err != nil {
+				slog.Debug("failed to initialize mcp client", "name", name, "error", err)
 			}
-
-			toolCount := updateTools(cfg, name, tools)
-			updatePrompts(name, prompts)
-			resourceCount := updateResources(name, resources)
-			sessions.Set(name, session)
-
-			updateState(name, StateConnected, nil, session, Counts{
-				Tools:     toolCount,
-				Prompts:   len(prompts),
-				Resources: resourceCount,
-			})
 		}(name, m)
 	}
 	wg.Wait()
@@ -253,6 +214,85 @@ func WaitForInit(ctx context.Context) error {
 	}
 }
 
+// InitializeSingle initializes a single MCP client by name.
+func InitializeSingle(ctx context.Context, name string, cfg *config.ConfigStore) error {
+	m, exists := cfg.Config().MCP[name]
+	if !exists {
+		return fmt.Errorf("mcp '%s' not found in configuration", name)
+	}
+
+	if m.Disabled {
+		updateState(name, StateDisabled, nil, nil, Counts{})
+		slog.Debug("skipping disabled mcp", "name", name)
+		return nil
+	}
+
+	return initClient(ctx, cfg, name, m, cfg.Resolver())
+}
+
+// initClient initializes a single MCP client with the given configuration.
+func initClient(ctx context.Context, cfg *config.ConfigStore, name string, m config.MCPConfig, resolver config.VariableResolver) error {
+	// Set initial starting state.
+	updateState(name, StateStarting, nil, nil, Counts{})
+
+	// createSession handles its own timeout internally.
+	session, err := createSession(ctx, name, m, resolver)
+	if err != nil {
+		return err
+	}
+
+	tools, err := getTools(ctx, session)
+	if err != nil {
+		slog.Error("Error listing tools", "error", err)
+		updateState(name, StateError, err, nil, Counts{})
+		session.Close()
+		return err
+	}
+
+	prompts, err := getPrompts(ctx, session)
+	if err != nil {
+		slog.Error("Error listing prompts", "error", err)
+		updateState(name, StateError, err, nil, Counts{})
+		session.Close()
+		return err
+	}
+
+	toolCount := updateTools(cfg, name, tools)
+	updatePrompts(name, prompts)
+	sessions.Set(name, session)
+
+	updateState(name, StateConnected, nil, session, Counts{
+		Tools:   toolCount,
+		Prompts: len(prompts),
+	})
+
+	return nil
+}
+
+// DisableSingle disables and closes a single MCP client by name.
+func DisableSingle(cfg *config.ConfigStore, name string) error {
+	session, ok := sessions.Get(name)
+	if ok {
+		if err := session.Close(); err != nil &&
+			!errors.Is(err, io.EOF) &&
+			!errors.Is(err, context.Canceled) &&
+			err.Error() != "signal: killed" {
+			slog.Warn("error closing mcp session", "name", name, "error", err)
+		}
+		sessions.Del(name)
+	}
+
+	// Clear tools and prompts for this MCP.
+	updateTools(cfg, name, nil)
+	updatePrompts(name, nil)
+
+	// Update state to disabled.
+	updateState(name, StateDisabled, nil, nil, Counts{})
+
+	slog.Info("Disabled mcp client", "name", name)
+	return nil
+}
+
 func getOrRenewClient(ctx context.Context, cfg *config.ConfigStore, name string) (*ClientSession, error) {
 	sess, ok := sessions.Get(name)
 	if !ok {

internal/agent/tools/mcp/tools.go 🔗

@@ -2,6 +2,7 @@ package mcp
 
 import (
 	"context"
+	"encoding/base64"
 	"encoding/json"
 	"fmt"
 	"iter"
@@ -81,12 +82,13 @@ func RunTool(ctx context.Context, cfg *config.ConfigStore, name, toolName string
 
 	textContent := strings.Join(textParts, "\n")
 
-	// MCP SDK returns Data as already base64-encoded, so we use it directly.
+	// We need to make sure the data is base64
+	// when using something like docker + playwright the data was not returned correctly.
 	if imageData != nil {
 		return ToolResult{
 			Type:      "image",
 			Content:   textContent,
-			Data:      imageData,
+			Data:      ensureBase64(imageData),
 			MediaType: imageMimeType,
 		}, nil
 	}
@@ -95,7 +97,7 @@ func RunTool(ctx context.Context, cfg *config.ConfigStore, name, toolName string
 		return ToolResult{
 			Type:      "media",
 			Content:   textContent,
-			Data:      audioData,
+			Data:      ensureBase64(audioData),
 			MediaType: audioMimeType,
 		}, nil
 	}
@@ -164,3 +166,33 @@ func filterDisabledTools(cfg *config.ConfigStore, mcpName string, tools []*Tool)
 	}
 	return filtered
 }
+
+// ensureBase64 checks if data is valid base64 and returns it as-is if so,
+// otherwise encodes the raw binary data to base64.
+func ensureBase64(data []byte) []byte {
+	// Check if the data is already valid base64 by attempting to decode it.
+	// Valid base64 should only contain ASCII characters (A-Z, a-z, 0-9, +, /, =).
+	if isValidBase64(data) {
+		return data
+	}
+	// Data is raw binary, encode it to base64.
+	encoded := make([]byte, base64.StdEncoding.EncodedLen(len(data)))
+	base64.StdEncoding.Encode(encoded, data)
+	return encoded
+}
+
+// isValidBase64 checks if the data appears to be valid base64-encoded content.
+func isValidBase64(data []byte) bool {
+	if len(data) == 0 {
+		return true
+	}
+	// Base64 strings should only contain ASCII characters.
+	for _, b := range data {
+		if b > 127 {
+			return false
+		}
+	}
+	// Try to decode to verify it's valid base64.
+	_, err := base64.StdEncoding.DecodeString(string(data))
+	return err == nil
+}

internal/agent/tools/mcp/tools_test.go 🔗

@@ -0,0 +1,102 @@
+package mcp
+
+import (
+	"encoding/base64"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+)
+
+func TestEnsureBase64(t *testing.T) {
+	t.Parallel()
+
+	tests := []struct {
+		name     string
+		input    []byte
+		wantData []byte // expected output
+	}{
+		{
+			name:     "already base64 encoded",
+			input:    []byte("SGVsbG8gV29ybGQh"), // "Hello World!" in base64
+			wantData: []byte("SGVsbG8gV29ybGQh"),
+		},
+		{
+			name:     "raw binary data (PNG header)",
+			input:    []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A},
+			wantData: []byte(base64.StdEncoding.EncodeToString([]byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A})),
+		},
+		{
+			name:     "raw binary with high bytes",
+			input:    []byte{0xFF, 0xD8, 0xFF, 0xE0}, // JPEG header
+			wantData: []byte(base64.StdEncoding.EncodeToString([]byte{0xFF, 0xD8, 0xFF, 0xE0})),
+		},
+		{
+			name:     "empty data",
+			input:    []byte{},
+			wantData: []byte{},
+		},
+		{
+			name:     "base64 with padding",
+			input:    []byte("YQ=="), // "a" in base64
+			wantData: []byte("YQ=="),
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+			result := ensureBase64(tt.input)
+			require.Equal(t, tt.wantData, result)
+
+			// Verify the result is valid base64 that can be decoded.
+			if len(result) > 0 {
+				_, err := base64.StdEncoding.DecodeString(string(result))
+				require.NoError(t, err, "result should be valid base64")
+			}
+		})
+	}
+}
+
+func TestIsValidBase64(t *testing.T) {
+	t.Parallel()
+
+	tests := []struct {
+		name  string
+		input []byte
+		want  bool
+	}{
+		{
+			name:  "valid base64",
+			input: []byte("SGVsbG8gV29ybGQh"),
+			want:  true,
+		},
+		{
+			name:  "valid base64 with padding",
+			input: []byte("YQ=="),
+			want:  true,
+		},
+		{
+			name:  "raw binary with high bytes",
+			input: []byte{0xFF, 0xD8, 0xFF},
+			want:  false,
+		},
+		{
+			name:  "empty",
+			input: []byte{},
+			want:  true,
+		},
+		{
+			name:  "invalid base64 characters",
+			input: []byte("SGVsbG8!@#$"),
+			want:  false,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+			got := isValidBase64(tt.input)
+			require.Equal(t, tt.want, got)
+		})
+	}
+}

internal/config/docker_mcp.go 🔗

@@ -0,0 +1,75 @@
+package config
+
+import (
+	"context"
+	"fmt"
+	"os/exec"
+	"time"
+)
+
+// DockerMCPName is the name of the Docker MCP configuration.
+const DockerMCPName = "docker"
+
+// IsDockerMCPAvailable checks if Docker MCP is available by running
+// 'docker mcp version'.
+func IsDockerMCPAvailable() bool {
+	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+	defer cancel()
+
+	cmd := exec.CommandContext(ctx, "docker", "mcp", "version")
+	err := cmd.Run()
+	return err == nil
+}
+
+// IsDockerMCPEnabled checks if Docker MCP is already configured.
+func (c *Config) IsDockerMCPEnabled() bool {
+	if c.MCP == nil {
+		return false
+	}
+	_, exists := c.MCP[DockerMCPName]
+	return exists
+}
+
+// EnableDockerMCP adds Docker MCP configuration and persists it.
+func (s *ConfigStore) EnableDockerMCP() error {
+	if !IsDockerMCPAvailable() {
+		return fmt.Errorf("docker mcp is not available, please ensure docker is installed and 'docker mcp version' succeeds")
+	}
+
+	mcpConfig := MCPConfig{
+		Type:     MCPStdio,
+		Command:  "docker",
+		Args:     []string{"mcp", "gateway", "run"},
+		Disabled: false,
+	}
+
+	// Add to in-memory config.
+	if s.config.MCP == nil {
+		s.config.MCP = make(map[string]MCPConfig)
+	}
+	s.config.MCP[DockerMCPName] = mcpConfig
+
+	// Persist to config file.
+	if err := s.SetConfigField(ScopeGlobal, "mcp."+DockerMCPName, mcpConfig); err != nil {
+		return fmt.Errorf("failed to persist docker mcp configuration: %w", err)
+	}
+
+	return nil
+}
+
+// DisableDockerMCP removes Docker MCP configuration and persists the change.
+func (s *ConfigStore) DisableDockerMCP() error {
+	if s.config.MCP == nil {
+		return nil
+	}
+
+	// Remove from in-memory config.
+	delete(s.config.MCP, DockerMCPName)
+
+	// Persist the updated MCP map to the config file.
+	if err := s.SetConfigField(ScopeGlobal, "mcp", s.config.MCP); err != nil {
+		return fmt.Errorf("failed to persist docker mcp removal: %w", err)
+	}
+
+	return nil
+}

internal/config/docker_mcp_test.go 🔗

@@ -0,0 +1,168 @@
+package config
+
+import (
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/charmbracelet/crush/internal/env"
+	"github.com/stretchr/testify/require"
+)
+
+func TestIsDockerMCPEnabled(t *testing.T) {
+	t.Parallel()
+
+	t.Run("returns false when MCP is nil", func(t *testing.T) {
+		t.Parallel()
+		cfg := &Config{
+			MCP: nil,
+		}
+		require.False(t, cfg.IsDockerMCPEnabled())
+	})
+
+	t.Run("returns false when docker mcp not configured", func(t *testing.T) {
+		t.Parallel()
+		cfg := &Config{
+			MCP: make(map[string]MCPConfig),
+		}
+		require.False(t, cfg.IsDockerMCPEnabled())
+	})
+
+	t.Run("returns true when docker mcp is configured", func(t *testing.T) {
+		t.Parallel()
+		cfg := &Config{
+			MCP: map[string]MCPConfig{
+				DockerMCPName: {
+					Type:    MCPStdio,
+					Command: "docker",
+				},
+			},
+		}
+		require.True(t, cfg.IsDockerMCPEnabled())
+	})
+}
+
+func TestEnableDockerMCP(t *testing.T) {
+	t.Parallel()
+
+	t.Run("adds docker mcp to config", func(t *testing.T) {
+		t.Parallel()
+
+		// Create a temporary directory for config.
+		tmpDir := t.TempDir()
+		configPath := filepath.Join(tmpDir, "crush.json")
+
+		cfg := &Config{
+			MCP: make(map[string]MCPConfig),
+		}
+		store := &ConfigStore{
+			config:         cfg,
+			globalDataPath: configPath,
+			resolver:       NewShellVariableResolver(env.New()),
+		}
+
+		// Only run this test if docker mcp is available.
+		if !IsDockerMCPAvailable() {
+			t.Skip("Docker MCP not available, skipping test")
+		}
+
+		err := store.EnableDockerMCP()
+		require.NoError(t, err)
+
+		// Check in-memory config.
+		require.True(t, cfg.IsDockerMCPEnabled())
+		mcpConfig, exists := cfg.MCP[DockerMCPName]
+		require.True(t, exists)
+		require.Equal(t, MCPStdio, mcpConfig.Type)
+		require.Equal(t, "docker", mcpConfig.Command)
+		require.Equal(t, []string{"mcp", "gateway", "run"}, mcpConfig.Args)
+		require.False(t, mcpConfig.Disabled)
+
+		// Check persisted config.
+		data, err := os.ReadFile(configPath)
+		require.NoError(t, err)
+		require.Contains(t, string(data), "docker")
+		require.Contains(t, string(data), "gateway")
+	})
+
+	t.Run("fails when docker mcp not available", func(t *testing.T) {
+		t.Parallel()
+
+		// Create a temporary directory for config.
+		tmpDir := t.TempDir()
+		configPath := filepath.Join(tmpDir, "crush.json")
+
+		cfg := &Config{
+			MCP: make(map[string]MCPConfig),
+		}
+		store := &ConfigStore{
+			config:         cfg,
+			globalDataPath: configPath,
+			resolver:       NewShellVariableResolver(env.New()),
+		}
+
+		// Skip if docker mcp is actually available.
+		if IsDockerMCPAvailable() {
+			t.Skip("Docker MCP is available, skipping unavailable test")
+		}
+
+		err := store.EnableDockerMCP()
+		require.Error(t, err)
+		require.Contains(t, err.Error(), "docker mcp is not available")
+	})
+}
+
+func TestDisableDockerMCP(t *testing.T) {
+	t.Parallel()
+
+	t.Run("removes docker mcp from config", func(t *testing.T) {
+		t.Parallel()
+
+		// Create a temporary directory for config.
+		tmpDir := t.TempDir()
+		configPath := filepath.Join(tmpDir, "crush.json")
+
+		cfg := &Config{
+			MCP: map[string]MCPConfig{
+				DockerMCPName: {
+					Type:     MCPStdio,
+					Command:  "docker",
+					Args:     []string{"mcp", "gateway", "run"},
+					Disabled: false,
+				},
+			},
+		}
+		store := &ConfigStore{
+			config:         cfg,
+			globalDataPath: configPath,
+			resolver:       NewShellVariableResolver(env.New()),
+		}
+
+		// Verify it's enabled first.
+		require.True(t, cfg.IsDockerMCPEnabled())
+
+		err := store.DisableDockerMCP()
+		require.NoError(t, err)
+
+		// Check in-memory config.
+		require.False(t, cfg.IsDockerMCPEnabled())
+		_, exists := cfg.MCP[DockerMCPName]
+		require.False(t, exists)
+	})
+
+	t.Run("does nothing when MCP is nil", func(t *testing.T) {
+		t.Parallel()
+
+		cfg := &Config{
+			MCP: nil,
+		}
+		store := &ConfigStore{
+			config:         cfg,
+			globalDataPath: filepath.Join(t.TempDir(), "crush.json"),
+			resolver:       NewShellVariableResolver(env.New()),
+		}
+
+		err := store.DisableDockerMCP()
+		require.NoError(t, err)
+	})
+}

internal/ui/AGENTS.md 🔗

@@ -191,6 +191,7 @@ through all components that need access to app state or styles.
 - Always account for padding/borders in width calculations.
 - Use `tea.Batch()` when returning multiple commands.
 - Pass `*common.Common` to components that need styles or app access.
+- When writing tea.Cmd's prefer creating methods in the model instead of writing inline functions.
 - The `list.List` only renders visible items (lazy). No render cache exists
   at the list level — items should cache internally if rendering is
   expensive.

internal/ui/chat/docker_mcp.go 🔗

@@ -0,0 +1,290 @@
+package chat
+
+import (
+	"encoding/json"
+	"fmt"
+	"strings"
+
+	"charm.land/lipgloss/v2"
+	"charm.land/lipgloss/v2/table"
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/message"
+	"github.com/charmbracelet/crush/internal/stringext"
+	"github.com/charmbracelet/crush/internal/ui/styles"
+)
+
+// DockerMCPToolMessageItem is a message item that represents a Docker MCP tool call.
+type DockerMCPToolMessageItem struct {
+	*baseToolMessageItem
+}
+
+var _ ToolMessageItem = (*DockerMCPToolMessageItem)(nil)
+
+// NewDockerMCPToolMessageItem creates a new [DockerMCPToolMessageItem].
+func NewDockerMCPToolMessageItem(
+	sty *styles.Styles,
+	toolCall message.ToolCall,
+	result *message.ToolResult,
+	canceled bool,
+) ToolMessageItem {
+	return newBaseToolMessageItem(sty, toolCall, result, &DockerMCPToolRenderContext{}, canceled)
+}
+
+// DockerMCPToolRenderContext renders Docker MCP tool messages.
+type DockerMCPToolRenderContext struct{}
+
+// RenderTool implements the [ToolRenderer] interface.
+func (d *DockerMCPToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
+	cappedWidth := cappedMessageWidth(width)
+
+	var params map[string]any
+	if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
+		params = make(map[string]any)
+	}
+
+	tool := strings.TrimPrefix(opts.ToolCall.Name, "mcp_"+config.DockerMCPName+"_")
+
+	mainParam := opts.ToolCall.Input
+	extraArgs := map[string]string{}
+	switch tool {
+	case "mcp-find":
+		if query, ok := params["query"]; ok {
+			if qStr, ok := query.(string); ok {
+				mainParam = qStr
+			}
+		}
+		for k, v := range params {
+			if k == "query" {
+				continue
+			}
+			data, _ := json.Marshal(v)
+			extraArgs[k] = string(data)
+		}
+	case "mcp-add":
+		if name, ok := params["name"]; ok {
+			if nStr, ok := name.(string); ok {
+				mainParam = nStr
+			}
+		}
+		for k, v := range params {
+			if k == "name" {
+				continue
+			}
+			data, _ := json.Marshal(v)
+			extraArgs[k] = string(data)
+		}
+	case "mcp-remove":
+		if name, ok := params["name"]; ok {
+			if nStr, ok := name.(string); ok {
+				mainParam = nStr
+			}
+		}
+		for k, v := range params {
+			if k == "name" {
+				continue
+			}
+			data, _ := json.Marshal(v)
+			extraArgs[k] = string(data)
+		}
+	case "mcp-exec":
+		if name, ok := params["name"]; ok {
+			if nStr, ok := name.(string); ok {
+				mainParam = nStr
+			}
+		}
+	case "mcp-config-set":
+		if server, ok := params["server"]; ok {
+			if sStr, ok := server.(string); ok {
+				mainParam = sStr
+			}
+		}
+	}
+
+	var toolParams []string
+	toolParams = append(toolParams, mainParam)
+	for k, v := range extraArgs {
+		toolParams = append(toolParams, k, v)
+	}
+
+	if opts.IsPending() {
+		return pendingTool(sty, d.formatToolName(sty, tool), opts.Anim, false)
+	}
+
+	header := d.makeHeader(sty, tool, cappedWidth, opts, toolParams...)
+	if opts.Compact {
+		return header
+	}
+
+	if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok {
+		return joinToolParts(header, earlyState)
+	}
+
+	if tool == "mcp-find" {
+		return joinToolParts(header, d.renderMCPServers(sty, opts, cappedWidth))
+	}
+
+	if !opts.HasResult() {
+		return header
+	}
+
+	bodyWidth := cappedWidth - toolBodyLeftPaddingTotal
+	var parts []string
+
+	// Handle text content.
+	if opts.Result.Content != "" {
+		var body string
+		var result json.RawMessage
+		if err := json.Unmarshal([]byte(opts.Result.Content), &result); err == nil {
+			prettyResult, err := json.MarshalIndent(result, "", "  ")
+			if err == nil {
+				body = sty.Tool.Body.Render(toolOutputCodeContent(sty, "result.json", string(prettyResult), 0, bodyWidth, opts.ExpandedContent))
+			} else {
+				body = sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent))
+			}
+		} else if looksLikeMarkdown(opts.Result.Content) {
+			body = sty.Tool.Body.Render(toolOutputCodeContent(sty, "result.md", opts.Result.Content, 0, bodyWidth, opts.ExpandedContent))
+		} else {
+			body = sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent))
+		}
+		parts = append(parts, body)
+	}
+
+	// Handle image content.
+	if opts.Result.Data != "" && strings.HasPrefix(opts.Result.MIMEType, "image/") {
+		parts = append(parts, "", toolOutputImageContent(sty, opts.Result.Data, opts.Result.MIMEType))
+	}
+
+	if len(parts) == 0 {
+		return header
+	}
+
+	return joinToolParts(header, strings.Join(parts, "\n"))
+}
+
+// FindMCPResponse represents the response from mcp-find.
+type FindMCPResponse struct {
+	Servers []struct {
+		Name        string `json:"name"`
+		Description string `json:"description"`
+	} `json:"servers"`
+}
+
+func (d *DockerMCPToolRenderContext) renderMCPServers(sty *styles.Styles, opts *ToolRenderOpts, width int) string {
+	if !opts.HasResult() || opts.Result.Content == "" {
+		return ""
+	}
+
+	var result FindMCPResponse
+	if err := json.Unmarshal([]byte(opts.Result.Content), &result); err != nil {
+		return toolOutputPlainContent(sty, opts.Result.Content, width-toolBodyLeftPaddingTotal, opts.ExpandedContent)
+	}
+
+	if len(result.Servers) == 0 {
+		return sty.Subtle.Render("No MCP servers found.")
+	}
+
+	bodyWidth := min(120, width) - toolBodyLeftPaddingTotal
+	rows := [][]string{}
+	moreServers := ""
+	for i, server := range result.Servers {
+		if i > 9 {
+			moreServers = sty.Subtle.Render(fmt.Sprintf("... and %d more", len(result.Servers)-10))
+			break
+		}
+		rows = append(rows, []string{sty.Base.Render(server.Name), sty.Subtle.Render(server.Description)})
+	}
+	serverTable := table.New().
+		Wrap(false).
+		BorderTop(false).
+		BorderBottom(false).
+		BorderRight(false).
+		BorderLeft(false).
+		BorderColumn(false).
+		BorderRow(false).
+		StyleFunc(func(row, col int) lipgloss.Style {
+			if row == table.HeaderRow {
+				return lipgloss.NewStyle()
+			}
+			switch col {
+			case 0:
+				return lipgloss.NewStyle().PaddingRight(1)
+			}
+			return lipgloss.NewStyle()
+		}).Rows(rows...).Width(bodyWidth)
+	if moreServers != "" {
+		return sty.Tool.Body.Render(serverTable.Render() + "\n" + moreServers)
+	}
+	return sty.Tool.Body.Render(serverTable.Render())
+}
+
+func (d *DockerMCPToolRenderContext) makeHeader(sty *styles.Styles, tool string, width int, opts *ToolRenderOpts, params ...string) string {
+	if opts.Compact {
+		return d.makeCompactHeader(sty, tool, width, params...)
+	}
+
+	icon := toolIcon(sty, opts.Status)
+	if opts.IsPending() {
+		icon = sty.Tool.IconPending.Render()
+	}
+	prefix := fmt.Sprintf("%s %s ", icon, d.formatToolName(sty, tool))
+	return prefix + toolParamList(sty, params, width-lipgloss.Width(prefix))
+}
+
+func (d *DockerMCPToolRenderContext) formatToolName(sty *styles.Styles, tool string) string {
+	mainTool := "Docker MCP"
+	action := tool
+	actionStyle := sty.Tool.MCPToolName
+	switch tool {
+	case "mcp-exec":
+		action = "Exec"
+	case "mcp-config-set":
+		action = "Config Set"
+	case "mcp-find":
+		action = "Find"
+	case "mcp-add":
+		action = "Add"
+		actionStyle = sty.Tool.DockerMCPActionAdd
+	case "mcp-remove":
+		action = "Remove"
+		actionStyle = sty.Tool.DockerMCPActionDel
+	case "code-mode":
+		action = "Code Mode"
+	default:
+		action = strings.ReplaceAll(tool, "-", " ")
+		action = strings.ReplaceAll(action, "_", " ")
+		action = stringext.Capitalize(action)
+	}
+
+	toolNameStyled := sty.Tool.MCPName.Render(mainTool)
+	arrow := sty.Tool.MCPArrow.String()
+	return fmt.Sprintf("%s %s %s", toolNameStyled, arrow, actionStyle.Render(action))
+}
+
+func (d *DockerMCPToolRenderContext) makeCompactHeader(sty *styles.Styles, tool string, width int, params ...string) string {
+	action := tool
+	switch tool {
+	case "mcp-exec":
+		action = "exec"
+	case "mcp-config-set":
+		action = "config-set"
+	case "mcp-find":
+		action = "find"
+	case "mcp-add":
+		action = "add"
+	case "mcp-remove":
+		action = "remove"
+	case "code-mode":
+		action = "code-mode"
+	default:
+		action = strings.ReplaceAll(tool, "-", " ")
+		action = strings.ReplaceAll(action, "_", " ")
+	}
+
+	name := fmt.Sprintf("Docker MCP: %s", action)
+	return toolHeader(sty, ToolStatusSuccess, name, width, true, params...)
+}
+
+// IsDockerMCPTool returns true if the tool name is a Docker MCP tool.
+func IsDockerMCPTool(name string) bool {
+	return strings.HasPrefix(name, "mcp_"+config.DockerMCPName+"_")
+}

internal/ui/chat/tools.go 🔗

@@ -255,7 +255,9 @@ func NewToolMessageItem(
 	case tools.LSPRestartToolName:
 		item = NewLSPRestartToolMessageItem(sty, toolCall, result, canceled)
 	default:
-		if strings.HasPrefix(toolCall.Name, "mcp_") {
+		if IsDockerMCPTool(toolCall.Name) {
+			item = NewDockerMCPToolMessageItem(sty, toolCall, result, canceled)
+		} else if strings.HasPrefix(toolCall.Name, "mcp_") {
 			item = NewMCPToolMessageItem(sty, toolCall, result, canceled)
 		} else {
 			item = NewGenericToolMessageItem(sty, toolCall, result, canceled)

internal/ui/dialog/actions.go 🔗

@@ -81,6 +81,10 @@ type (
 		Arguments   []commands.Argument
 		Args        map[string]string // Actual argument values
 	}
+	// ActionEnableDockerMCP is a message to enable Docker MCP.
+	ActionEnableDockerMCP struct{}
+	// ActionDisableDockerMCP is a message to disable Docker MCP.
+	ActionDisableDockerMCP struct{}
 )
 
 // Messages for API key input dialog.

internal/ui/dialog/commands.go 🔗

@@ -446,6 +446,16 @@ func (c *Commands) defaultCommands() []*CommandItem {
 		commands = append(commands, NewCommandItem(c.com.Styles, "open_external_editor", "Open External Editor", "ctrl+o", ActionExternalEditor{}))
 	}
 
+	// Add Docker MCP command if available and not already enabled
+	if config.IsDockerMCPAvailable() && !cfg.IsDockerMCPEnabled() {
+		commands = append(commands, NewCommandItem(c.com.Styles, "enable_docker_mcp", "Enable Docker MCP Catalog", "", ActionEnableDockerMCP{}))
+	}
+
+	// Add disable Docker MCP command if it's currently enabled
+	if cfg.IsDockerMCPEnabled() {
+		commands = append(commands, NewCommandItem(c.com.Styles, "disable_docker_mcp", "Disable Docker MCP Catalog", "", ActionDisableDockerMCP{}))
+	}
+
 	if c.hasTodos || c.hasQueue {
 		var label string
 		switch {

internal/ui/model/mcp.go 🔗

@@ -6,6 +6,7 @@ import (
 
 	"charm.land/lipgloss/v2"
 	"github.com/charmbracelet/crush/internal/agent/tools/mcp"
+	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/ui/common"
 	"github.com/charmbracelet/crush/internal/ui/styles"
 )
@@ -59,7 +60,12 @@ func mcpList(t *styles.Styles, mcps []mcp.ClientInfo, width, maxItems int) strin
 
 	for _, m := range mcps {
 		var icon string
-		title := t.ResourceName.Render(m.Name)
+		title := m.Name
+		// Show "Docker MCP" instead of the config name for Docker MCP.
+		if m.Name == config.DockerMCPName {
+			title = "Docker MCP"
+		}
+		title = t.ResourceName.Render(title)
 		var description string
 		var extraContent string
 

internal/ui/model/ui.go 🔗

@@ -1383,6 +1383,12 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
 		m.dialog.CloseDialog(dialog.CommandsID)
 	case dialog.ActionQuit:
 		cmds = append(cmds, tea.Quit)
+	case dialog.ActionEnableDockerMCP:
+		m.dialog.CloseDialog(dialog.CommandsID)
+		cmds = append(cmds, m.enableDockerMCP)
+	case dialog.ActionDisableDockerMCP:
+		m.dialog.CloseDialog(dialog.CommandsID)
+		cmds = append(cmds, m.disableDockerMCP)
 	case dialog.ActionInitializeProject:
 		if m.isAgentBusy() {
 			cmds = append(cmds, util.ReportWarn("Agent is busy, please wait before summarizing session..."))
@@ -3457,6 +3463,36 @@ func (m *UI) copyChatHighlight() tea.Cmd {
 	)
 }
 
+func (m *UI) enableDockerMCP() tea.Msg {
+	store := m.com.Store()
+	if err := store.EnableDockerMCP(); err != nil {
+		return util.ReportError(err)()
+	}
+
+	// Initialize the Docker MCP client immediately.
+	ctx := context.Background()
+	if err := mcp.InitializeSingle(ctx, config.DockerMCPName, store); err != nil {
+		return util.ReportError(fmt.Errorf("docker MCP enabled but failed to start: %w", err))()
+	}
+
+	return util.NewInfoMsg("Docker MCP enabled and started successfully")
+}
+
+func (m *UI) disableDockerMCP() tea.Msg {
+	store := m.com.Store()
+	// Close the Docker MCP client.
+	if err := mcp.DisableSingle(store, config.DockerMCPName); err != nil {
+		return util.ReportError(fmt.Errorf("failed to disable docker MCP: %w", err))()
+	}
+
+	// Remove from config and persist.
+	if err := store.DisableDockerMCP(); err != nil {
+		return util.ReportError(err)()
+	}
+
+	return util.NewInfoMsg("Docker MCP disabled successfully")
+}
+
 // renderLogo renders the Crush logo with the given styles and dimensions.
 func renderLogo(t *styles.Styles, compact bool, width int) string {
 	return logo.Render(t, version.Version, compact, logo.Opts{

internal/ui/styles/styles.go 🔗

@@ -328,6 +328,10 @@ type Styles struct {
 		ResourceName            lipgloss.Style
 		ResourceSize            lipgloss.Style
 		MediaType               lipgloss.Style
+
+		// Docker MCP tools
+		DockerMCPActionAdd lipgloss.Style // Docker MCP add action (green)
+		DockerMCPActionDel lipgloss.Style // Docker MCP remove action (red)
 	}
 
 	// Dialog styles
@@ -1182,6 +1186,10 @@ func DefaultStyles() Styles {
 	s.Tool.MediaType = base
 	s.Tool.ResourceSize = base.Foreground(fgMuted)
 
+	// Docker MCP styles
+	s.Tool.DockerMCPActionAdd = base.Foreground(greenLight)
+	s.Tool.DockerMCPActionDel = base.Foreground(red)
+
 	// Buttons
 	s.ButtonFocus = lipgloss.NewStyle().Foreground(white).Background(secondary)
 	s.ButtonBlur = s.Base.Background(bgSubtle)