From e0dcaba820b1a5f391fad9b89590e8c8932b6404 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Thu, 19 Mar 2026 15:59:19 +0100 Subject: [PATCH] feat: docker mcp integration (#2026) Co-authored-by: Andrey Nering Co-authored-by: Christian Rocha --- 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(-) create mode 100644 internal/agent/tools/mcp/tools_test.go create mode 100644 internal/config/docker_mcp.go create mode 100644 internal/config/docker_mcp_test.go create mode 100644 internal/ui/chat/docker_mcp.go diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 2b09be75f4882c44869428008156772c3cc4ad99..11a87f58554729048cd7a0629979a0c6bb3babd2 100644 --- a/internal/agent/agent.go +++ b/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 { diff --git a/internal/agent/coordinator.go b/internal/agent/coordinator.go index 04a49eebe2aeb110cd0cd55421d9b632480e7461..53fecb0388628caa8da1eed43e39b2898529e768 100644 --- a/internal/agent/coordinator.go +++ b/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) } diff --git a/internal/agent/tools/mcp-tools.go b/internal/agent/tools/mcp-tools.go index e1184118552ee62e75f60c6943f59ecca2868563..a7e9dc5d3de24944a91856250e9c6d0920222a00 100644 --- a/internal/agent/tools/mcp-tools.go +++ b/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) diff --git a/internal/agent/tools/mcp/init.go b/internal/agent/tools/mcp/init.go index cba9a51c717b1866b823762f85bfadf90e1a7a10..7ad802bae6963540a616bd4cc1576c7b7d7bb0a4 100644 --- a/internal/agent/tools/mcp/init.go +++ b/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 { diff --git a/internal/agent/tools/mcp/tools.go b/internal/agent/tools/mcp/tools.go index 8d1d2649ba4381e14fa8d99933f1dfb3b42d27ae..ce85e591e55139343e43179bdd33c88b49c274be 100644 --- a/internal/agent/tools/mcp/tools.go +++ b/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 +} diff --git a/internal/agent/tools/mcp/tools_test.go b/internal/agent/tools/mcp/tools_test.go new file mode 100644 index 0000000000000000000000000000000000000000..3795381ebd2a6a54540a6905ef39c97b8ce59575 --- /dev/null +++ b/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) + }) + } +} diff --git a/internal/config/docker_mcp.go b/internal/config/docker_mcp.go new file mode 100644 index 0000000000000000000000000000000000000000..e3b352ec275d5cb5fb588367c16774b127e6e6cb --- /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 = "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 +} diff --git a/internal/config/docker_mcp_test.go b/internal/config/docker_mcp_test.go new file mode 100644 index 0000000000000000000000000000000000000000..93438777ce8d735a13fb6f14d25dc93ac31e17ac --- /dev/null +++ b/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) + }) +} diff --git a/internal/ui/AGENTS.md b/internal/ui/AGENTS.md index f3720d7b8867d60b30d00089b3b567ac70fd61ac..6395187ac9ca500d42456abb46aaafad1ed85ee2 100644 --- a/internal/ui/AGENTS.md +++ b/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. diff --git a/internal/ui/chat/docker_mcp.go b/internal/ui/chat/docker_mcp.go new file mode 100644 index 0000000000000000000000000000000000000000..73d10b4803ff7a2e559ce4aba753fbb8e7ebb264 --- /dev/null +++ b/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), ¶ms); 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+"_") +} diff --git a/internal/ui/chat/tools.go b/internal/ui/chat/tools.go index 1342cfba2f6cc1c608298e9695578ae351726160..f91ad8ebd8725c2e2c15a4b9968bb51226c4db12 100644 --- a/internal/ui/chat/tools.go +++ b/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) diff --git a/internal/ui/dialog/actions.go b/internal/ui/dialog/actions.go index 09755417a5a12e4bdb7df1e5f932ade18016fb8f..a2de6513c13a9d00febd8ca510472542e687ce4a 100644 --- a/internal/ui/dialog/actions.go +++ b/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. diff --git a/internal/ui/dialog/commands.go b/internal/ui/dialog/commands.go index a5b555f2b345f51d9624ce87a2dcc6aaa3c1f70e..b29711742ff540434465b9d4d30fa452860c4efc 100644 --- a/internal/ui/dialog/commands.go +++ b/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 { diff --git a/internal/ui/model/mcp.go b/internal/ui/model/mcp.go index c5c94268d2985fff3c79590d3f432872439962b2..edcdfa6ea840b897a208875348484e3f9d77d2b4 100644 --- a/internal/ui/model/mcp.go +++ b/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 diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index f126020d32f2c3c974a0a9435bc69ae56380330d..7e7c36348337bf0d8299fa9d9eba7e52176be284 100644 --- a/internal/ui/model/ui.go +++ b/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{ diff --git a/internal/ui/styles/styles.go b/internal/ui/styles/styles.go index bc6d7099f66a1e4c0a9d8e7e1c05c32d6974dcec..12c5c99e0e2b9619777d64b746c053e0bd3e165b 100644 --- a/internal/ui/styles/styles.go +++ b/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)