on main help sign in
feat: docker mcp integration (#2026)
e0dcabaKujtim Hoxha
internal/agent/agent.go+3
@@ -266,6 +266,9 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
266 prepared.Messages[i].ProviderOptions = nil
266 prepared.Messages[i].ProviderOptions = nil
267 }
267 }
268
268
269+ // Use latest tools (updated by SetTools when MCP tools change).
270+ prepared.Tools = a.tools.Copy()
271+
269 queuedCalls, _ := a.messageQueue.Get(call.SessionID)
272 queuedCalls, _ := a.messageQueue.Get(call.SessionID)
270 a.messageQueue.Del(call.SessionID)
273 a.messageQueue.Del(call.SessionID)
271 for _, queued := range queuedCalls {
274 for _, queued := range queuedCalls {
internal/agent/coordinator.go+16
@@ -71,6 +71,7 @@ type Coordinator interface {
71 Summarize(context.Context, string) error
71 Summarize(context.Context, string) error
72 Model() Model
72 Model() Model
73 UpdateModels(ctx context.Context) error
73 UpdateModels(ctx context.Context) error
74+ RefreshTools(ctx context.Context) error
74}
75}
75
76
76type coordinator struct {
77type coordinator struct {
@@ -904,6 +905,21 @@ func (c *coordinator) UpdateModels(ctx context.Context) error {
904 return nil
905 return nil
905}
906}
906
907
908+func (c *coordinator) RefreshTools(ctx context.Context) error {
909+ agentCfg, ok := c.cfg.Config().Agents[config.AgentCoder]
910+ if !ok {
911+ return errors.New("coder agent not configured")
912+ }
913+
914+ tools, err := c.buildTools(ctx, agentCfg)
915+ if err != nil {
916+ return err
917+ }
918+ c.currentAgent.SetTools(tools)
919+ slog.Debug("refreshed agent tools", "count", len(tools))
920+ return nil
921+}
922+
907func (c *coordinator) QueuedPrompts(sessionID string) int {
923func (c *coordinator) QueuedPrompts(sessionID string) int {
908 return c.currentAgent.QueuedPrompts(sessionID)
924 return c.currentAgent.QueuedPrompts(sessionID)
909}
925}
internal/agent/tools/mcp-tools.go+31−17
@@ -3,6 +3,7 @@ package tools
3import (
3import (
4 "context"
4 "context"
5 "fmt"
5 "fmt"
6+ "slices"
6
7
7 "charm.land/fantasy"
8 "charm.land/fantasy"
8 "github.com/charmbracelet/crush/internal/agent/tools/mcp"
9 "github.com/charmbracelet/crush/internal/agent/tools/mcp"
@@ -10,6 +11,15 @@ import (
10 "github.com/charmbracelet/crush/internal/permission"
11 "github.com/charmbracelet/crush/internal/permission"
11)
12)
12
13
14+// whitelistDockerTools contains Docker MCP tools that don't require permission.
15+var whitelistDockerTools = []string{
16+ "mcp_docker_mcp-find",
17+ "mcp_docker_mcp-add",
18+ "mcp_docker_mcp-remove",
19+ "mcp_docker_mcp-config-set",
20+ "mcp_docker_code-mode",
21+}
22+
13// GetMCPTools gets all the currently available MCP tools.
23// GetMCPTools gets all the currently available MCP tools.
14func GetMCPTools(permissions permission.Service, cfg *config.ConfigStore, wd string) []*Tool {
24func GetMCPTools(permissions permission.Service, cfg *config.ConfigStore, wd string) []*Tool {
15 var result []*Tool
25 var result []*Tool
@@ -91,23 +101,27 @@ func (m *Tool) Run(ctx context.Context, params fantasy.ToolCall) (fantasy.ToolRe
91 if sessionID == "" {
101 if sessionID == "" {
92 return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for creating a new file")
102 return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for creating a new file")
93 }
103 }
104+
105+ // Skip permission for whitelisted Docker MCP tools.
106+ if !slices.Contains(whitelistDockerTools, params.Name) {
94− permissionDescription := fmt.Sprintf("execute %s with the following parameters:", m.Info().Name)
107+ permissionDescription := fmt.Sprintf("execute %s with the following parameters:", m.Info().Name)
95− p, err := m.permissions.Request(ctx,
108+ p, err := m.permissions.Request(ctx,
96− permission.CreatePermissionRequest{
109+ permission.CreatePermissionRequest{
97− SessionID: sessionID,
110+ SessionID: sessionID,
98− ToolCallID: params.ID,
111+ ToolCallID: params.ID,
99− Path: m.workingDir,
112+ Path: m.workingDir,
100− ToolName: m.Info().Name,
113+ ToolName: m.Info().Name,
101− Action: "execute",
114+ Action: "execute",
102− Description: permissionDescription,
115+ Description: permissionDescription,
103− Params: params.Input,
116+ Params: params.Input,
104− },
117+ },
105− )
118+ )
106− if err != nil {
119+ if err != nil {
107− return fantasy.ToolResponse{}, err
120+ return fantasy.ToolResponse{}, err
108− }
121+ }
109− if !p {
122+ if !p {
110− return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
123+ return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
124+ }
111 }
125 }
112
126
113 result, err := mcp.RunTool(ctx, m.cfg, m.mcpName, m.tool.Name, params.Input)
127 result, err := mcp.RunTool(ctx, m.cfg, m.mcpName, m.tool.Name, params.Input)
internal/agent/tools/mcp/init.go+81−41
@@ -175,8 +175,6 @@ func Initialize(ctx context.Context, permissions permission.Service, cfg *config
175 }
175 }
176
176
177 // Set initial starting state
177 // Set initial starting state
178− updateState(name, StateStarting, nil, nil, Counts{})
179−
180 wg.Add(1)
178 wg.Add(1)
181 go func(name string, m config.MCPConfig) {
179 go func(name string, m config.MCPConfig) {
182 defer func() {
180 defer func() {
@@ -196,46 +194,9 @@ func Initialize(ctx context.Context, permissions permission.Service, cfg *config
196 }
194 }
197 }()
195 }()
198
196
199− // createSession handles its own timeout internally.
200− session, err := createSession(ctx, name, m, cfg.Resolver())
197+ if err := initClient(ctx, cfg, name, m, cfg.Resolver()); err != nil {
201− if err != nil {
198+ slog.Debug("failed to initialize mcp client", "name", name, "error", err)
202− return
203− }
204−
205− tools, err := getTools(ctx, session)
206− if err != nil {
207− slog.Error("Error listing tools", "error", err)
208− updateState(name, StateError, err, nil, Counts{})
209− session.Close()
210− return
211− }
212−
213− prompts, err := getPrompts(ctx, session)
214− if err != nil {
215− slog.Error("Error listing prompts", "error", err)
216− updateState(name, StateError, err, nil, Counts{})
217− session.Close()
218− return
219− }
220−
221− resources, err := getResources(ctx, session)
222− if err != nil {
223− slog.Error("Error listing resources", "error", err)
224− updateState(name, StateError, err, nil, Counts{})
225− session.Close()
226− return
227 }
199 }
228−
229− toolCount := updateTools(cfg, name, tools)
230− updatePrompts(name, prompts)
231− resourceCount := updateResources(name, resources)
232− sessions.Set(name, session)
233−
234− updateState(name, StateConnected, nil, session, Counts{
235− Tools: toolCount,
236− Prompts: len(prompts),
237− Resources: resourceCount,
238− })
239 }(name, m)
200 }(name, m)
240 }
201 }
241 wg.Wait()
202 wg.Wait()
@@ -253,6 +214,85 @@ func WaitForInit(ctx context.Context) error {
253 }
214 }
254}
215}
255
216
217+// InitializeSingle initializes a single MCP client by name.
218+func InitializeSingle(ctx context.Context, name string, cfg *config.ConfigStore) error {
219+ m, exists := cfg.Config().MCP[name]
220+ if !exists {
221+ return fmt.Errorf("mcp '%s' not found in configuration", name)
222+ }
223+
224+ if m.Disabled {
225+ updateState(name, StateDisabled, nil, nil, Counts{})
226+ slog.Debug("skipping disabled mcp", "name", name)
227+ return nil
228+ }
229+
230+ return initClient(ctx, cfg, name, m, cfg.Resolver())
231+}
232+
233+// initClient initializes a single MCP client with the given configuration.
234+func initClient(ctx context.Context, cfg *config.ConfigStore, name string, m config.MCPConfig, resolver config.VariableResolver) error {
235+ // Set initial starting state.
236+ updateState(name, StateStarting, nil, nil, Counts{})
237+
238+ // createSession handles its own timeout internally.
239+ session, err := createSession(ctx, name, m, resolver)
240+ if err != nil {
241+ return err
242+ }
243+
244+ tools, err := getTools(ctx, session)
245+ if err != nil {
246+ slog.Error("Error listing tools", "error", err)
247+ updateState(name, StateError, err, nil, Counts{})
248+ session.Close()
249+ return err
250+ }
251+
252+ prompts, err := getPrompts(ctx, session)
253+ if err != nil {
254+ slog.Error("Error listing prompts", "error", err)
255+ updateState(name, StateError, err, nil, Counts{})
256+ session.Close()
257+ return err
258+ }
259+
260+ toolCount := updateTools(cfg, name, tools)
261+ updatePrompts(name, prompts)
262+ sessions.Set(name, session)
263+
264+ updateState(name, StateConnected, nil, session, Counts{
265+ Tools: toolCount,
266+ Prompts: len(prompts),
267+ })
268+
269+ return nil
270+}
271+
272+// DisableSingle disables and closes a single MCP client by name.
273+func DisableSingle(cfg *config.ConfigStore, name string) error {
274+ session, ok := sessions.Get(name)
275+ if ok {
276+ if err := session.Close(); err != nil &&
277+ !errors.Is(err, io.EOF) &&
278+ !errors.Is(err, context.Canceled) &&
279+ err.Error() != "signal: killed" {
280+ slog.Warn("error closing mcp session", "name", name, "error", err)
281+ }
282+ sessions.Del(name)
283+ }
284+
285+ // Clear tools and prompts for this MCP.
286+ updateTools(cfg, name, nil)
287+ updatePrompts(name, nil)
288+
289+ // Update state to disabled.
290+ updateState(name, StateDisabled, nil, nil, Counts{})
291+
292+ slog.Info("Disabled mcp client", "name", name)
293+ return nil
294+}
295+
256func getOrRenewClient(ctx context.Context, cfg *config.ConfigStore, name string) (*ClientSession, error) {
296func getOrRenewClient(ctx context.Context, cfg *config.ConfigStore, name string) (*ClientSession, error) {
257 sess, ok := sessions.Get(name)
297 sess, ok := sessions.Get(name)
258 if !ok {
298 if !ok {
internal/agent/tools/mcp/tools.go+35−3
@@ -2,6 +2,7 @@ package mcp
2
2
3import (
3import (
4 "context"
4 "context"
5+ "encoding/base64"
5 "encoding/json"
6 "encoding/json"
6 "fmt"
7 "fmt"
7 "iter"
8 "iter"
@@ -81,12 +82,13 @@ func RunTool(ctx context.Context, cfg *config.ConfigStore, name, toolName string
81
82
82 textContent := strings.Join(textParts, "\n")
83 textContent := strings.Join(textParts, "\n")
83
84
84− // MCP SDK returns Data as already base64-encoded, so we use it directly.
85+ // We need to make sure the data is base64
86+ // when using something like docker + playwright the data was not returned correctly.
85 if imageData != nil {
87 if imageData != nil {
86 return ToolResult{
88 return ToolResult{
87 Type: "image",
89 Type: "image",
88 Content: textContent,
90 Content: textContent,
89− Data: imageData,
91+ Data: ensureBase64(imageData),
90 MediaType: imageMimeType,
92 MediaType: imageMimeType,
91 }, nil
93 }, nil
92 }
94 }
@@ -95,7 +97,7 @@ func RunTool(ctx context.Context, cfg *config.ConfigStore, name, toolName string
95 return ToolResult{
97 return ToolResult{
96 Type: "media",
98 Type: "media",
97 Content: textContent,
99 Content: textContent,
98− Data: audioData,
100+ Data: ensureBase64(audioData),
99 MediaType: audioMimeType,
101 MediaType: audioMimeType,
100 }, nil
102 }, nil
101 }
103 }
@@ -164,3 +166,33 @@ func filterDisabledTools(cfg *config.ConfigStore, mcpName string, tools []*Tool)
164 }
166 }
165 return filtered
167 return filtered
166}
168}
169+
170+// ensureBase64 checks if data is valid base64 and returns it as-is if so,
171+// otherwise encodes the raw binary data to base64.
172+func ensureBase64(data []byte) []byte {
173+ // Check if the data is already valid base64 by attempting to decode it.
174+ // Valid base64 should only contain ASCII characters (A-Z, a-z, 0-9, +, /, =).
175+ if isValidBase64(data) {
176+ return data
177+ }
178+ // Data is raw binary, encode it to base64.
179+ encoded := make([]byte, base64.StdEncoding.EncodedLen(len(data)))
180+ base64.StdEncoding.Encode(encoded, data)
181+ return encoded
182+}
183+
184+// isValidBase64 checks if the data appears to be valid base64-encoded content.
185+func isValidBase64(data []byte) bool {
186+ if len(data) == 0 {
187+ return true
188+ }
189+ // Base64 strings should only contain ASCII characters.
190+ for _, b := range data {
191+ if b > 127 {
192+ return false
193+ }
194+ }
195+ // Try to decode to verify it's valid base64.
196+ _, err := base64.StdEncoding.DecodeString(string(data))
197+ return err == nil
198+}
internal/agent/tools/mcp/tools_test.gonew file+102
@@ -0,0 +1,102 @@
1+package mcp
2+
3+import (
4+ "encoding/base64"
5+ "testing"
6+
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestEnsureBase64(t *testing.T) {
11+ t.Parallel()
12+
13+ tests := []struct {
14+ name string
15+ input []byte
16+ wantData []byte // expected output
17+ }{
18+ {
19+ name: "already base64 encoded",
20+ input: []byte("SGVsbG8gV29ybGQh"), // "Hello World!" in base64
21+ wantData: []byte("SGVsbG8gV29ybGQh"),
22+ },
23+ {
24+ name: "raw binary data (PNG header)",
25+ input: []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A},
26+ wantData: []byte(base64.StdEncoding.EncodeToString([]byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A})),
27+ },
28+ {
29+ name: "raw binary with high bytes",
30+ input: []byte{0xFF, 0xD8, 0xFF, 0xE0}, // JPEG header
31+ wantData: []byte(base64.StdEncoding.EncodeToString([]byte{0xFF, 0xD8, 0xFF, 0xE0})),
32+ },
33+ {
34+ name: "empty data",
35+ input: []byte{},
36+ wantData: []byte{},
37+ },
38+ {
39+ name: "base64 with padding",
40+ input: []byte("YQ=="), // "a" in base64
41+ wantData: []byte("YQ=="),
42+ },
43+ }
44+
45+ for _, tt := range tests {
46+ t.Run(tt.name, func(t *testing.T) {
47+ t.Parallel()
48+ result := ensureBase64(tt.input)
49+ require.Equal(t, tt.wantData, result)
50+
51+ // Verify the result is valid base64 that can be decoded.
52+ if len(result) > 0 {
53+ _, err := base64.StdEncoding.DecodeString(string(result))
54+ require.NoError(t, err, "result should be valid base64")
55+ }
56+ })
57+ }
58+}
59+
60+func TestIsValidBase64(t *testing.T) {
61+ t.Parallel()
62+
63+ tests := []struct {
64+ name string
65+ input []byte
66+ want bool
67+ }{
68+ {
69+ name: "valid base64",
70+ input: []byte("SGVsbG8gV29ybGQh"),
71+ want: true,
72+ },
73+ {
74+ name: "valid base64 with padding",
75+ input: []byte("YQ=="),
76+ want: true,
77+ },
78+ {
79+ name: "raw binary with high bytes",
80+ input: []byte{0xFF, 0xD8, 0xFF},
81+ want: false,
82+ },
83+ {
84+ name: "empty",
85+ input: []byte{},
86+ want: true,
87+ },
88+ {
89+ name: "invalid base64 characters",
90+ input: []byte("SGVsbG8!@#$"),
91+ want: false,
92+ },
93+ }
94+
95+ for _, tt := range tests {
96+ t.Run(tt.name, func(t *testing.T) {
97+ t.Parallel()
98+ got := isValidBase64(tt.input)
99+ require.Equal(t, tt.want, got)
100+ })
101+ }
102+}
internal/config/docker_mcp.gonew file+75
@@ -0,0 +1,75 @@
1+package config
2+
3+import (
4+ "context"
5+ "fmt"
6+ "os/exec"
7+ "time"
8+)
9+
10+// DockerMCPName is the name of the Docker MCP configuration.
11+const DockerMCPName = "docker"
12+
13+// IsDockerMCPAvailable checks if Docker MCP is available by running
14+// 'docker mcp version'.
15+func IsDockerMCPAvailable() bool {
16+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
17+ defer cancel()
18+
19+ cmd := exec.CommandContext(ctx, "docker", "mcp", "version")
20+ err := cmd.Run()
21+ return err == nil
22+}
23+
24+// IsDockerMCPEnabled checks if Docker MCP is already configured.
25+func (c *Config) IsDockerMCPEnabled() bool {
26+ if c.MCP == nil {
27+ return false
28+ }
29+ _, exists := c.MCP[DockerMCPName]
30+ return exists
31+}
32+
33+// EnableDockerMCP adds Docker MCP configuration and persists it.
34+func (s *ConfigStore) EnableDockerMCP() error {
35+ if !IsDockerMCPAvailable() {
36+ return fmt.Errorf("docker mcp is not available, please ensure docker is installed and 'docker mcp version' succeeds")
37+ }
38+
39+ mcpConfig := MCPConfig{
40+ Type: MCPStdio,
41+ Command: "docker",
42+ Args: []string{"mcp", "gateway", "run"},
43+ Disabled: false,
44+ }
45+
46+ // Add to in-memory config.
47+ if s.config.MCP == nil {
48+ s.config.MCP = make(map[string]MCPConfig)
49+ }
50+ s.config.MCP[DockerMCPName] = mcpConfig
51+
52+ // Persist to config file.
53+ if err := s.SetConfigField(ScopeGlobal, "mcp."+DockerMCPName, mcpConfig); err != nil {
54+ return fmt.Errorf("failed to persist docker mcp configuration: %w", err)
55+ }
56+
57+ return nil
58+}
59+
60+// DisableDockerMCP removes Docker MCP configuration and persists the change.
61+func (s *ConfigStore) DisableDockerMCP() error {
62+ if s.config.MCP == nil {
63+ return nil
64+ }
65+
66+ // Remove from in-memory config.
67+ delete(s.config.MCP, DockerMCPName)
68+
69+ // Persist the updated MCP map to the config file.
70+ if err := s.SetConfigField(ScopeGlobal, "mcp", s.config.MCP); err != nil {
71+ return fmt.Errorf("failed to persist docker mcp removal: %w", err)
72+ }
73+
74+ return nil
75+}
internal/config/docker_mcp_test.gonew file+168
@@ -0,0 +1,168 @@
1+package config
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "testing"
7+
8+ "github.com/charmbracelet/crush/internal/env"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestIsDockerMCPEnabled(t *testing.T) {
13+ t.Parallel()
14+
15+ t.Run("returns false when MCP is nil", func(t *testing.T) {
16+ t.Parallel()
17+ cfg := &Config{
18+ MCP: nil,
19+ }
20+ require.False(t, cfg.IsDockerMCPEnabled())
21+ })
22+
23+ t.Run("returns false when docker mcp not configured", func(t *testing.T) {
24+ t.Parallel()
25+ cfg := &Config{
26+ MCP: make(map[string]MCPConfig),
27+ }
28+ require.False(t, cfg.IsDockerMCPEnabled())
29+ })
30+
31+ t.Run("returns true when docker mcp is configured", func(t *testing.T) {
32+ t.Parallel()
33+ cfg := &Config{
34+ MCP: map[string]MCPConfig{
35+ DockerMCPName: {
36+ Type: MCPStdio,
37+ Command: "docker",
38+ },
39+ },
40+ }
41+ require.True(t, cfg.IsDockerMCPEnabled())
42+ })
43+}
44+
45+func TestEnableDockerMCP(t *testing.T) {
46+ t.Parallel()
47+
48+ t.Run("adds docker mcp to config", func(t *testing.T) {
49+ t.Parallel()
50+
51+ // Create a temporary directory for config.
52+ tmpDir := t.TempDir()
53+ configPath := filepath.Join(tmpDir, "crush.json")
54+
55+ cfg := &Config{
56+ MCP: make(map[string]MCPConfig),
57+ }
58+ store := &ConfigStore{
59+ config: cfg,
60+ globalDataPath: configPath,
61+ resolver: NewShellVariableResolver(env.New()),
62+ }
63+
64+ // Only run this test if docker mcp is available.
65+ if !IsDockerMCPAvailable() {
66+ t.Skip("Docker MCP not available, skipping test")
67+ }
68+
69+ err := store.EnableDockerMCP()
70+ require.NoError(t, err)
71+
72+ // Check in-memory config.
73+ require.True(t, cfg.IsDockerMCPEnabled())
74+ mcpConfig, exists := cfg.MCP[DockerMCPName]
75+ require.True(t, exists)
76+ require.Equal(t, MCPStdio, mcpConfig.Type)
77+ require.Equal(t, "docker", mcpConfig.Command)
78+ require.Equal(t, []string{"mcp", "gateway", "run"}, mcpConfig.Args)
79+ require.False(t, mcpConfig.Disabled)
80+
81+ // Check persisted config.
82+ data, err := os.ReadFile(configPath)
83+ require.NoError(t, err)
84+ require.Contains(t, string(data), "docker")
85+ require.Contains(t, string(data), "gateway")
86+ })
87+
88+ t.Run("fails when docker mcp not available", func(t *testing.T) {
89+ t.Parallel()
90+
91+ // Create a temporary directory for config.
92+ tmpDir := t.TempDir()
93+ configPath := filepath.Join(tmpDir, "crush.json")
94+
95+ cfg := &Config{
96+ MCP: make(map[string]MCPConfig),
97+ }
98+ store := &ConfigStore{
99+ config: cfg,
100+ globalDataPath: configPath,
101+ resolver: NewShellVariableResolver(env.New()),
102+ }
103+
104+ // Skip if docker mcp is actually available.
105+ if IsDockerMCPAvailable() {
106+ t.Skip("Docker MCP is available, skipping unavailable test")
107+ }
108+
109+ err := store.EnableDockerMCP()
110+ require.Error(t, err)
111+ require.Contains(t, err.Error(), "docker mcp is not available")
112+ })
113+}
114+
115+func TestDisableDockerMCP(t *testing.T) {
116+ t.Parallel()
117+
118+ t.Run("removes docker mcp from config", func(t *testing.T) {
119+ t.Parallel()
120+
121+ // Create a temporary directory for config.
122+ tmpDir := t.TempDir()
123+ configPath := filepath.Join(tmpDir, "crush.json")
124+
125+ cfg := &Config{
126+ MCP: map[string]MCPConfig{
127+ DockerMCPName: {
128+ Type: MCPStdio,
129+ Command: "docker",
130+ Args: []string{"mcp", "gateway", "run"},
131+ Disabled: false,
132+ },
133+ },
134+ }
135+ store := &ConfigStore{
136+ config: cfg,
137+ globalDataPath: configPath,
138+ resolver: NewShellVariableResolver(env.New()),
139+ }
140+
141+ // Verify it's enabled first.
142+ require.True(t, cfg.IsDockerMCPEnabled())
143+
144+ err := store.DisableDockerMCP()
145+ require.NoError(t, err)
146+
147+ // Check in-memory config.
148+ require.False(t, cfg.IsDockerMCPEnabled())
149+ _, exists := cfg.MCP[DockerMCPName]
150+ require.False(t, exists)
151+ })
152+
153+ t.Run("does nothing when MCP is nil", func(t *testing.T) {
154+ t.Parallel()
155+
156+ cfg := &Config{
157+ MCP: nil,
158+ }
159+ store := &ConfigStore{
160+ config: cfg,
161+ globalDataPath: filepath.Join(t.TempDir(), "crush.json"),
162+ resolver: NewShellVariableResolver(env.New()),
163+ }
164+
165+ err := store.DisableDockerMCP()
166+ require.NoError(t, err)
167+ })
168+}
internal/ui/AGENTS.md+1
@@ -191,6 +191,7 @@ through all components that need access to app state or styles.
191- Always account for padding/borders in width calculations.
191- Always account for padding/borders in width calculations.
192- Use `tea.Batch()` when returning multiple commands.
192- Use `tea.Batch()` when returning multiple commands.
193- Pass `*common.Common` to components that need styles or app access.
193- Pass `*common.Common` to components that need styles or app access.
194+- When writing tea.Cmd's prefer creating methods in the model instead of writing inline functions.
194- The `list.List` only renders visible items (lazy). No render cache exists
195- The `list.List` only renders visible items (lazy). No render cache exists
195 at the list level — items should cache internally if rendering is
196 at the list level — items should cache internally if rendering is
196 expensive.
197 expensive.
internal/ui/chat/docker_mcp.gonew file+290
@@ -0,0 +1,290 @@
1+package chat
2+
3+import (
4+ "encoding/json"
5+ "fmt"
6+ "strings"
7+
8+ "charm.land/lipgloss/v2"
9+ "charm.land/lipgloss/v2/table"
10+ "github.com/charmbracelet/crush/internal/config"
11+ "github.com/charmbracelet/crush/internal/message"
12+ "github.com/charmbracelet/crush/internal/stringext"
13+ "github.com/charmbracelet/crush/internal/ui/styles"
14+)
15+
16+// DockerMCPToolMessageItem is a message item that represents a Docker MCP tool call.
17+type DockerMCPToolMessageItem struct {
18+ *baseToolMessageItem
19+}
20+
21+var _ ToolMessageItem = (*DockerMCPToolMessageItem)(nil)
22+
23+// NewDockerMCPToolMessageItem creates a new [DockerMCPToolMessageItem].
24+func NewDockerMCPToolMessageItem(
25+ sty *styles.Styles,
26+ toolCall message.ToolCall,
27+ result *message.ToolResult,
28+ canceled bool,
29+) ToolMessageItem {
30+ return newBaseToolMessageItem(sty, toolCall, result, &DockerMCPToolRenderContext{}, canceled)
31+}
32+
33+// DockerMCPToolRenderContext renders Docker MCP tool messages.
34+type DockerMCPToolRenderContext struct{}
35+
36+// RenderTool implements the [ToolRenderer] interface.
37+func (d *DockerMCPToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
38+ cappedWidth := cappedMessageWidth(width)
39+
40+ var params map[string]any
41+ if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
42+ params = make(map[string]any)
43+ }
44+
45+ tool := strings.TrimPrefix(opts.ToolCall.Name, "mcp_"+config.DockerMCPName+"_")
46+
47+ mainParam := opts.ToolCall.Input
48+ extraArgs := map[string]string{}
49+ switch tool {
50+ case "mcp-find":
51+ if query, ok := params["query"]; ok {
52+ if qStr, ok := query.(string); ok {
53+ mainParam = qStr
54+ }
55+ }
56+ for k, v := range params {
57+ if k == "query" {
58+ continue
59+ }
60+ data, _ := json.Marshal(v)
61+ extraArgs[k] = string(data)
62+ }
63+ case "mcp-add":
64+ if name, ok := params["name"]; ok {
65+ if nStr, ok := name.(string); ok {
66+ mainParam = nStr
67+ }
68+ }
69+ for k, v := range params {
70+ if k == "name" {
71+ continue
72+ }
73+ data, _ := json.Marshal(v)
74+ extraArgs[k] = string(data)
75+ }
76+ case "mcp-remove":
77+ if name, ok := params["name"]; ok {
78+ if nStr, ok := name.(string); ok {
79+ mainParam = nStr
80+ }
81+ }
82+ for k, v := range params {
83+ if k == "name" {
84+ continue
85+ }
86+ data, _ := json.Marshal(v)
87+ extraArgs[k] = string(data)
88+ }
89+ case "mcp-exec":
90+ if name, ok := params["name"]; ok {
91+ if nStr, ok := name.(string); ok {
92+ mainParam = nStr
93+ }
94+ }
95+ case "mcp-config-set":
96+ if server, ok := params["server"]; ok {
97+ if sStr, ok := server.(string); ok {
98+ mainParam = sStr
99+ }
100+ }
101+ }
102+
103+ var toolParams []string
104+ toolParams = append(toolParams, mainParam)
105+ for k, v := range extraArgs {
106+ toolParams = append(toolParams, k, v)
107+ }
108+
109+ if opts.IsPending() {
110+ return pendingTool(sty, d.formatToolName(sty, tool), opts.Anim, false)
111+ }
112+
113+ header := d.makeHeader(sty, tool, cappedWidth, opts, toolParams...)
114+ if opts.Compact {
115+ return header
116+ }
117+
118+ if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok {
119+ return joinToolParts(header, earlyState)
120+ }
121+
122+ if tool == "mcp-find" {
123+ return joinToolParts(header, d.renderMCPServers(sty, opts, cappedWidth))
124+ }
125+
126+ if !opts.HasResult() {
127+ return header
128+ }
129+
130+ bodyWidth := cappedWidth - toolBodyLeftPaddingTotal
131+ var parts []string
132+
133+ // Handle text content.
134+ if opts.Result.Content != "" {
135+ var body string
136+ var result json.RawMessage
137+ if err := json.Unmarshal([]byte(opts.Result.Content), &result); err == nil {
138+ prettyResult, err := json.MarshalIndent(result, "", " ")
139+ if err == nil {
140+ body = sty.Tool.Body.Render(toolOutputCodeContent(sty, "result.json", string(prettyResult), 0, bodyWidth, opts.ExpandedContent))
141+ } else {
142+ body = sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent))
143+ }
144+ } else if looksLikeMarkdown(opts.Result.Content) {
145+ body = sty.Tool.Body.Render(toolOutputCodeContent(sty, "result.md", opts.Result.Content, 0, bodyWidth, opts.ExpandedContent))
146+ } else {
147+ body = sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent))
148+ }
149+ parts = append(parts, body)
150+ }
151+
152+ // Handle image content.
153+ if opts.Result.Data != "" && strings.HasPrefix(opts.Result.MIMEType, "image/") {
154+ parts = append(parts, "", toolOutputImageContent(sty, opts.Result.Data, opts.Result.MIMEType))
155+ }
156+
157+ if len(parts) == 0 {
158+ return header
159+ }
160+
161+ return joinToolParts(header, strings.Join(parts, "\n"))
162+}
163+
164+// FindMCPResponse represents the response from mcp-find.
165+type FindMCPResponse struct {
166+ Servers []struct {
167+ Name string `json:"name"`
168+ Description string `json:"description"`
169+ } `json:"servers"`
170+}
171+
172+func (d *DockerMCPToolRenderContext) renderMCPServers(sty *styles.Styles, opts *ToolRenderOpts, width int) string {
173+ if !opts.HasResult() || opts.Result.Content == "" {
174+ return ""
175+ }
176+
177+ var result FindMCPResponse
178+ if err := json.Unmarshal([]byte(opts.Result.Content), &result); err != nil {
179+ return toolOutputPlainContent(sty, opts.Result.Content, width-toolBodyLeftPaddingTotal, opts.ExpandedContent)
180+ }
181+
182+ if len(result.Servers) == 0 {
183+ return sty.Subtle.Render("No MCP servers found.")
184+ }
185+
186+ bodyWidth := min(120, width) - toolBodyLeftPaddingTotal
187+ rows := [][]string{}
188+ moreServers := ""
189+ for i, server := range result.Servers {
190+ if i > 9 {
191+ moreServers = sty.Subtle.Render(fmt.Sprintf("... and %d more", len(result.Servers)-10))
192+ break
193+ }
194+ rows = append(rows, []string{sty.Base.Render(server.Name), sty.Subtle.Render(server.Description)})
195+ }
196+ serverTable := table.New().
197+ Wrap(false).
198+ BorderTop(false).
199+ BorderBottom(false).
200+ BorderRight(false).
201+ BorderLeft(false).
202+ BorderColumn(false).
203+ BorderRow(false).
204+ StyleFunc(func(row, col int) lipgloss.Style {
205+ if row == table.HeaderRow {
206+ return lipgloss.NewStyle()
207+ }
208+ switch col {
209+ case 0:
210+ return lipgloss.NewStyle().PaddingRight(1)
211+ }
212+ return lipgloss.NewStyle()
213+ }).Rows(rows...).Width(bodyWidth)
214+ if moreServers != "" {
215+ return sty.Tool.Body.Render(serverTable.Render() + "\n" + moreServers)
216+ }
217+ return sty.Tool.Body.Render(serverTable.Render())
218+}
219+
220+func (d *DockerMCPToolRenderContext) makeHeader(sty *styles.Styles, tool string, width int, opts *ToolRenderOpts, params ...string) string {
221+ if opts.Compact {
222+ return d.makeCompactHeader(sty, tool, width, params...)
223+ }
224+
225+ icon := toolIcon(sty, opts.Status)
226+ if opts.IsPending() {
227+ icon = sty.Tool.IconPending.Render()
228+ }
229+ prefix := fmt.Sprintf("%s %s ", icon, d.formatToolName(sty, tool))
230+ return prefix + toolParamList(sty, params, width-lipgloss.Width(prefix))
231+}
232+
233+func (d *DockerMCPToolRenderContext) formatToolName(sty *styles.Styles, tool string) string {
234+ mainTool := "Docker MCP"
235+ action := tool
236+ actionStyle := sty.Tool.MCPToolName
237+ switch tool {
238+ case "mcp-exec":
239+ action = "Exec"
240+ case "mcp-config-set":
241+ action = "Config Set"
242+ case "mcp-find":
243+ action = "Find"
244+ case "mcp-add":
245+ action = "Add"
246+ actionStyle = sty.Tool.DockerMCPActionAdd
247+ case "mcp-remove":
248+ action = "Remove"
249+ actionStyle = sty.Tool.DockerMCPActionDel
250+ case "code-mode":
251+ action = "Code Mode"
252+ default:
253+ action = strings.ReplaceAll(tool, "-", " ")
254+ action = strings.ReplaceAll(action, "_", " ")
255+ action = stringext.Capitalize(action)
256+ }
257+
258+ toolNameStyled := sty.Tool.MCPName.Render(mainTool)
259+ arrow := sty.Tool.MCPArrow.String()
260+ return fmt.Sprintf("%s %s %s", toolNameStyled, arrow, actionStyle.Render(action))
261+}
262+
263+func (d *DockerMCPToolRenderContext) makeCompactHeader(sty *styles.Styles, tool string, width int, params ...string) string {
264+ action := tool
265+ switch tool {
266+ case "mcp-exec":
267+ action = "exec"
268+ case "mcp-config-set":
269+ action = "config-set"
270+ case "mcp-find":
271+ action = "find"
272+ case "mcp-add":
273+ action = "add"
274+ case "mcp-remove":
275+ action = "remove"
276+ case "code-mode":
277+ action = "code-mode"
278+ default:
279+ action = strings.ReplaceAll(tool, "-", " ")
280+ action = strings.ReplaceAll(action, "_", " ")
281+ }
282+
283+ name := fmt.Sprintf("Docker MCP: %s", action)
284+ return toolHeader(sty, ToolStatusSuccess, name, width, true, params...)
285+}
286+
287+// IsDockerMCPTool returns true if the tool name is a Docker MCP tool.
288+func IsDockerMCPTool(name string) bool {
289+ return strings.HasPrefix(name, "mcp_"+config.DockerMCPName+"_")
290+}
internal/ui/chat/tools.go+3−1
@@ -255,7 +255,9 @@ func NewToolMessageItem(
255 case tools.LSPRestartToolName:
255 case tools.LSPRestartToolName:
256 item = NewLSPRestartToolMessageItem(sty, toolCall, result, canceled)
256 item = NewLSPRestartToolMessageItem(sty, toolCall, result, canceled)
257 default:
257 default:
258+ if IsDockerMCPTool(toolCall.Name) {
259+ item = NewDockerMCPToolMessageItem(sty, toolCall, result, canceled)
258− if strings.HasPrefix(toolCall.Name, "mcp_") {
260+ } else if strings.HasPrefix(toolCall.Name, "mcp_") {
259 item = NewMCPToolMessageItem(sty, toolCall, result, canceled)
261 item = NewMCPToolMessageItem(sty, toolCall, result, canceled)
260 } else {
262 } else {
261 item = NewGenericToolMessageItem(sty, toolCall, result, canceled)
263 item = NewGenericToolMessageItem(sty, toolCall, result, canceled)
internal/ui/dialog/actions.go+4
@@ -81,6 +81,10 @@ type (
81 Arguments []commands.Argument
81 Arguments []commands.Argument
82 Args map[string]string // Actual argument values
82 Args map[string]string // Actual argument values
83 }
83 }
84+ // ActionEnableDockerMCP is a message to enable Docker MCP.
85+ ActionEnableDockerMCP struct{}
86+ // ActionDisableDockerMCP is a message to disable Docker MCP.
87+ ActionDisableDockerMCP struct{}
84)
88)
85
89
86// Messages for API key input dialog.
90// Messages for API key input dialog.
internal/ui/dialog/commands.go+10
@@ -446,6 +446,16 @@ func (c *Commands) defaultCommands() []*CommandItem {
446 commands = append(commands, NewCommandItem(c.com.Styles, "open_external_editor", "Open External Editor", "ctrl+o", ActionExternalEditor{}))
446 commands = append(commands, NewCommandItem(c.com.Styles, "open_external_editor", "Open External Editor", "ctrl+o", ActionExternalEditor{}))
447 }
447 }
448
448
449+ // Add Docker MCP command if available and not already enabled
450+ if config.IsDockerMCPAvailable() && !cfg.IsDockerMCPEnabled() {
451+ commands = append(commands, NewCommandItem(c.com.Styles, "enable_docker_mcp", "Enable Docker MCP Catalog", "", ActionEnableDockerMCP{}))
452+ }
453+
454+ // Add disable Docker MCP command if it's currently enabled
455+ if cfg.IsDockerMCPEnabled() {
456+ commands = append(commands, NewCommandItem(c.com.Styles, "disable_docker_mcp", "Disable Docker MCP Catalog", "", ActionDisableDockerMCP{}))
457+ }
458+
449 if c.hasTodos || c.hasQueue {
459 if c.hasTodos || c.hasQueue {
450 var label string
460 var label string
451 switch {
461 switch {
internal/ui/model/mcp.go+7−1
@@ -6,6 +6,7 @@ import (
6
6
7 "charm.land/lipgloss/v2"
7 "charm.land/lipgloss/v2"
8 "github.com/charmbracelet/crush/internal/agent/tools/mcp"
8 "github.com/charmbracelet/crush/internal/agent/tools/mcp"
9+ "github.com/charmbracelet/crush/internal/config"
9 "github.com/charmbracelet/crush/internal/ui/common"
10 "github.com/charmbracelet/crush/internal/ui/common"
10 "github.com/charmbracelet/crush/internal/ui/styles"
11 "github.com/charmbracelet/crush/internal/ui/styles"
11)
12)
@@ -59,7 +60,12 @@ func mcpList(t *styles.Styles, mcps []mcp.ClientInfo, width, maxItems int) strin
59
60
60 for _, m := range mcps {
61 for _, m := range mcps {
61 var icon string
62 var icon string
63+ title := m.Name
64+ // Show "Docker MCP" instead of the config name for Docker MCP.
65+ if m.Name == config.DockerMCPName {
66+ title = "Docker MCP"
67+ }
62− title := t.ResourceName.Render(m.Name)
68+ title = t.ResourceName.Render(title)
63 var description string
69 var description string
64 var extraContent string
70 var extraContent string
65
71
internal/ui/model/ui.go+36
@@ -1383,6 +1383,12 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
1383 m.dialog.CloseDialog(dialog.CommandsID)
1383 m.dialog.CloseDialog(dialog.CommandsID)
1384 case dialog.ActionQuit:
1384 case dialog.ActionQuit:
1385 cmds = append(cmds, tea.Quit)
1385 cmds = append(cmds, tea.Quit)
1386+ case dialog.ActionEnableDockerMCP:
1387+ m.dialog.CloseDialog(dialog.CommandsID)
1388+ cmds = append(cmds, m.enableDockerMCP)
1389+ case dialog.ActionDisableDockerMCP:
1390+ m.dialog.CloseDialog(dialog.CommandsID)
1391+ cmds = append(cmds, m.disableDockerMCP)
1386 case dialog.ActionInitializeProject:
1392 case dialog.ActionInitializeProject:
1387 if m.isAgentBusy() {
1393 if m.isAgentBusy() {
1388 cmds = append(cmds, util.ReportWarn("Agent is busy, please wait before summarizing session..."))
1394 cmds = append(cmds, util.ReportWarn("Agent is busy, please wait before summarizing session..."))
@@ -3457,6 +3463,36 @@ func (m *UI) copyChatHighlight() tea.Cmd {
3457 )
3463 )
3458}
3464}
3459
3465
3466+func (m *UI) enableDockerMCP() tea.Msg {
3467+ store := m.com.Store()
3468+ if err := store.EnableDockerMCP(); err != nil {
3469+ return util.ReportError(err)()
3470+ }
3471+
3472+ // Initialize the Docker MCP client immediately.
3473+ ctx := context.Background()
3474+ if err := mcp.InitializeSingle(ctx, config.DockerMCPName, store); err != nil {
3475+ return util.ReportError(fmt.Errorf("docker MCP enabled but failed to start: %w", err))()
3476+ }
3477+
3478+ return util.NewInfoMsg("Docker MCP enabled and started successfully")
3479+}
3480+
3481+func (m *UI) disableDockerMCP() tea.Msg {
3482+ store := m.com.Store()
3483+ // Close the Docker MCP client.
3484+ if err := mcp.DisableSingle(store, config.DockerMCPName); err != nil {
3485+ return util.ReportError(fmt.Errorf("failed to disable docker MCP: %w", err))()
3486+ }
3487+
3488+ // Remove from config and persist.
3489+ if err := store.DisableDockerMCP(); err != nil {
3490+ return util.ReportError(err)()
3491+ }
3492+
3493+ return util.NewInfoMsg("Docker MCP disabled successfully")
3494+}
3495+
3460// renderLogo renders the Crush logo with the given styles and dimensions.
3496// renderLogo renders the Crush logo with the given styles and dimensions.
3461func renderLogo(t *styles.Styles, compact bool, width int) string {
3497func renderLogo(t *styles.Styles, compact bool, width int) string {
3462 return logo.Render(t, version.Version, compact, logo.Opts{
3498 return logo.Render(t, version.Version, compact, logo.Opts{
internal/ui/styles/styles.go+8
@@ -328,6 +328,10 @@ type Styles struct {
328 ResourceName lipgloss.Style
328 ResourceName lipgloss.Style
329 ResourceSize lipgloss.Style
329 ResourceSize lipgloss.Style
330 MediaType lipgloss.Style
330 MediaType lipgloss.Style
331+
332+ // Docker MCP tools
333+ DockerMCPActionAdd lipgloss.Style // Docker MCP add action (green)
334+ DockerMCPActionDel lipgloss.Style // Docker MCP remove action (red)
331 }
335 }
332
336
333 // Dialog styles
337 // Dialog styles
@@ -1182,6 +1186,10 @@ func DefaultStyles() Styles {
1182 s.Tool.MediaType = base
1186 s.Tool.MediaType = base
1183 s.Tool.ResourceSize = base.Foreground(fgMuted)
1187 s.Tool.ResourceSize = base.Foreground(fgMuted)
1184
1188
1189+ // Docker MCP styles
1190+ s.Tool.DockerMCPActionAdd = base.Foreground(greenLight)
1191+ s.Tool.DockerMCPActionDel = base.Foreground(red)
1192+
1185 // Buttons
1193 // Buttons
1186 s.ButtonFocus = lipgloss.NewStyle().Foreground(white).Background(secondary)
1194 s.ButtonFocus = lipgloss.NewStyle().Foreground(white).Background(secondary)
1187 s.ButtonBlur = s.Base.Background(bgSubtle)
1195 s.ButtonBlur = s.Base.Background(bgSubtle)