Detailed changes
@@ -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 {
@@ -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
+}
@@ -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)
+ })
+}
@@ -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",
@@ -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: