feat: add docker mcp config

Kujtim Hoxha created

Change summary

internal/agent/tools/mcp/init.go                     |  74 ++++++
internal/config/docker_mcp.go                        |  75 ++++++
internal/config/docker_mcp_test.go                   | 162 ++++++++++++++
internal/tui/components/dialogs/commands/commands.go |  26 ++
internal/tui/tui.go                                  |  30 ++
5 files changed, 367 insertions(+)

Detailed changes

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

@@ -189,6 +189,80 @@ func Initialize(ctx context.Context, permissions permission.Service, cfg *config
 	wg.Wait()
 }
 
+// InitializeSingle initializes a single MCP client by name.
+func InitializeSingle(ctx context.Context, name string, cfg *config.Config) error {
+	m, exists := cfg.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
+	}
+
+	// Set initial starting state.
+	updateState(name, StateStarting, nil, nil, Counts{})
+
+	// createSession handles its own timeout internally.
+	session, err := createSession(ctx, name, m, cfg.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
+	}
+
+	updateTools(name, tools)
+	updatePrompts(name, prompts)
+	sessions.Set(name, session)
+
+	updateState(name, StateConnected, nil, session, Counts{
+		Tools:   len(tools),
+		Prompts: len(prompts),
+	})
+
+	return nil
+}
+
+// DisableSingle disables and closes a single MCP client by name.
+func DisableSingle(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(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, name string) (*mcp.ClientSession, error) {
 	sess, ok := sessions.Get(name)
 	if !ok {

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 = "crush_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 (c *Config) 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 c.MCP == nil {
+		c.MCP = make(map[string]MCPConfig)
+	}
+	c.MCP[DockerMCPName] = mcpConfig
+
+	// Persist to config file.
+	if err := c.SetConfigField("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 (c *Config) DisableDockerMCP() error {
+	if c.MCP == nil {
+		return nil
+	}
+
+	// Remove from in-memory config.
+	delete(c.MCP, DockerMCPName)
+
+	// Persist to config file by setting to null.
+	if err := c.SetConfigField("mcp", c.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,162 @@
+package config
+
+import (
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/charmbracelet/catwalk/pkg/catwalk"
+	"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),
+			dataConfigDir:  configPath,
+			resolver:       NewShellVariableResolver(env.New()),
+			knownProviders: []catwalk.Provider{},
+		}
+
+		// Only run this test if docker mcp is available.
+		if !IsDockerMCPAvailable() {
+			t.Skip("Docker MCP not available, skipping test")
+		}
+
+		err := cfg.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), "crush_docker")
+		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),
+			dataConfigDir:  configPath,
+			resolver:       NewShellVariableResolver(env.New()),
+			knownProviders: []catwalk.Provider{},
+		}
+
+		// Skip if docker mcp is actually available.
+		if IsDockerMCPAvailable() {
+			t.Skip("Docker MCP is available, skipping unavailable test")
+		}
+
+		err := cfg.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,
+				},
+			},
+			dataConfigDir:  configPath,
+			resolver:       NewShellVariableResolver(env.New()),
+			knownProviders: []catwalk.Provider{},
+		}
+
+		// Verify it's enabled first.
+		require.True(t, cfg.IsDockerMCPEnabled())
+
+		err := cfg.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,
+			dataConfigDir:  t.TempDir() + "/crush.json",
+			resolver:       NewShellVariableResolver(env.New()),
+			knownProviders: []catwalk.Provider{},
+		}
+
+		err := cfg.DisableDockerMCP()
+		require.NoError(t, err)
+	})
+}

internal/tui/components/dialogs/commands/commands.go 🔗

@@ -83,6 +83,8 @@ type (
 	OpenReasoningDialogMsg struct{}
 	OpenExternalEditorMsg  struct{}
 	ToggleYoloModeMsg      struct{}
+	EnableDockerMCPMsg     struct{}
+	DisableDockerMCPMsg    struct{}
 	CompactMsg             struct {
 		SessionID string
 	}
@@ -434,6 +436,30 @@ func (c *commandDialogCmp) defaultCommands() []Command {
 		})
 	}
 
+	// Add Docker MCP command if available and not already enabled
+	if config.IsDockerMCPAvailable() && !cfg.IsDockerMCPEnabled() {
+		commands = append(commands, Command{
+			ID:          "enable_docker_mcp",
+			Title:       "Enable Docker MCP",
+			Description: "Enable Docker MCP integration",
+			Handler: func(cmd Command) tea.Cmd {
+				return util.CmdHandler(EnableDockerMCPMsg{})
+			},
+		})
+	}
+
+	// Add disable Docker MCP command if it's currently enabled
+	if cfg.IsDockerMCPEnabled() {
+		commands = append(commands, Command{
+			ID:          "disable_docker_mcp",
+			Title:       "Disable Docker MCP",
+			Description: "Disable Docker MCP integration",
+			Handler: func(cmd Command) tea.Cmd {
+				return util.CmdHandler(DisableDockerMCPMsg{})
+			},
+		})
+	}
+
 	return append(commands, []Command{
 		{
 			ID:          "toggle_yolo",

internal/tui/tui.go 🔗

@@ -255,6 +255,36 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		return a, util.CmdHandler(dialogs.OpenDialogMsg{
 			Model: quit.NewQuitDialog(),
 		})
+	case commands.EnableDockerMCPMsg:
+		return a, func() tea.Msg {
+			cfg := config.Get()
+			if err := cfg.EnableDockerMCP(); err != nil {
+				return util.ReportError(err)()
+			}
+
+			// Initialize the Docker MCP client immediately.
+			ctx := context.Background()
+			if err := mcp.InitializeSingle(ctx, config.DockerMCPName, cfg); err != nil {
+				return util.ReportError(fmt.Errorf("docker MCP enabled but failed to start: %w", err))()
+			}
+
+			return util.ReportInfo("Docker MCP enabled and started successfully")()
+		}
+	case commands.DisableDockerMCPMsg:
+		return a, func() tea.Msg {
+			// Close the Docker MCP client.
+			if err := mcp.DisableSingle(config.DockerMCPName); err != nil {
+				return util.ReportError(fmt.Errorf("failed to disable docker MCP: %w", err))()
+			}
+
+			// Remove from config and persist.
+			cfg := config.Get()
+			if err := cfg.DisableDockerMCP(); err != nil {
+				return util.ReportError(err)()
+			}
+
+			return util.ReportInfo("Docker MCP disabled successfully")()
+		}
 	case commands.ToggleYoloModeMsg:
 		a.app.Permissions.SetSkipRequests(!a.app.Permissions.SkipRequests())
 	case commands.ToggleHelpMsg: