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