diff --git a/internal/agent/tools/mcp/init.go b/internal/agent/tools/mcp/init.go index 734c95e6a80fac4d2ed211bcb886b8c4377e9052..3059c62abbb5d26eb1dbc33961184544fcb448e6 100644 --- a/internal/agent/tools/mcp/init.go +++ b/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 { diff --git a/internal/config/docker_mcp.go b/internal/config/docker_mcp.go new file mode 100644 index 0000000000000000000000000000000000000000..1a2218b4aa31f4baaf93f7b15da42207981001f4 --- /dev/null +++ b/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 +} diff --git a/internal/config/docker_mcp_test.go b/internal/config/docker_mcp_test.go new file mode 100644 index 0000000000000000000000000000000000000000..5191ceded60f52662c199ced2fad2dd93a8fbc76 --- /dev/null +++ b/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) + }) +} diff --git a/internal/tui/components/dialogs/commands/commands.go b/internal/tui/components/dialogs/commands/commands.go index 1e6bfd9fc0791ba45b8c76edc3ca745e0fa53528..06769c61a2bc2726137142cc72408e7dd39eadc4 100644 --- a/internal/tui/components/dialogs/commands/commands.go +++ b/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", diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 2fbd9f612934511d18e5d8c0a35f042ebbb8d82a..e5640a6ae5e19752aa7df900229dd72fc7ad7d05 100644 --- a/internal/tui/tui.go +++ b/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: