docker_mcp.go

  1package chat
  2
  3import (
  4	"encoding/json"
  5	"fmt"
  6	"sort"
  7	"strings"
  8
  9	"charm.land/lipgloss/v2"
 10	"charm.land/lipgloss/v2/table"
 11	"github.com/charmbracelet/crush/internal/config"
 12	"github.com/charmbracelet/crush/internal/message"
 13	"github.com/charmbracelet/crush/internal/stringext"
 14	"github.com/charmbracelet/crush/internal/ui/styles"
 15)
 16
 17// DockerMCPToolMessageItem is a message item that represents a Docker MCP tool call.
 18type DockerMCPToolMessageItem struct {
 19	*baseToolMessageItem
 20}
 21
 22var _ ToolMessageItem = (*DockerMCPToolMessageItem)(nil)
 23
 24// NewDockerMCPToolMessageItem creates a new [DockerMCPToolMessageItem].
 25func NewDockerMCPToolMessageItem(
 26	sty *styles.Styles,
 27	toolCall message.ToolCall,
 28	result *message.ToolResult,
 29	canceled bool,
 30) ToolMessageItem {
 31	return newBaseToolMessageItem(sty, toolCall, result, &DockerMCPToolRenderContext{}, canceled)
 32}
 33
 34// DockerMCPToolRenderContext renders Docker MCP tool messages.
 35type DockerMCPToolRenderContext struct{}
 36
 37// RenderTool implements the [ToolRenderer] interface.
 38func (d *DockerMCPToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
 39	cappedWidth := cappedMessageWidth(width)
 40
 41	var params map[string]any
 42	if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
 43		params = make(map[string]any)
 44	}
 45
 46	tool := strings.TrimPrefix(opts.ToolCall.Name, "mcp_"+config.DockerMCPName+"_")
 47
 48	mainParam := opts.ToolCall.Input
 49	extraArgs := map[string]string{}
 50	switch tool {
 51	case "mcp-find":
 52		if query, ok := params["query"]; ok {
 53			if qStr, ok := query.(string); ok {
 54				mainParam = qStr
 55			}
 56		}
 57		for k, v := range params {
 58			if k == "query" {
 59				continue
 60			}
 61			data, _ := json.Marshal(v)
 62			extraArgs[k] = string(data)
 63		}
 64	case "mcp-add":
 65		if name, ok := params["name"]; ok {
 66			if nStr, ok := name.(string); ok {
 67				mainParam = nStr
 68			}
 69		}
 70		for k, v := range params {
 71			if k == "name" {
 72				continue
 73			}
 74			data, _ := json.Marshal(v)
 75			extraArgs[k] = string(data)
 76		}
 77	case "mcp-remove":
 78		if name, ok := params["name"]; ok {
 79			if nStr, ok := name.(string); ok {
 80				mainParam = nStr
 81			}
 82		}
 83		for k, v := range params {
 84			if k == "name" {
 85				continue
 86			}
 87			data, _ := json.Marshal(v)
 88			extraArgs[k] = string(data)
 89		}
 90	case "mcp-exec":
 91		if name, ok := params["name"]; ok {
 92			if nStr, ok := name.(string); ok {
 93				mainParam = nStr
 94			}
 95		}
 96	case "mcp-config-set":
 97		if server, ok := params["server"]; ok {
 98			if sStr, ok := server.(string); ok {
 99				mainParam = sStr
100			}
101		}
102	}
103
104	var toolParams []string
105	toolParams = append(toolParams, mainParam)
106	keys := make([]string, 0, len(extraArgs))
107	for k := range extraArgs {
108		keys = append(keys, k)
109	}
110	sort.Strings(keys)
111	for _, k := range keys {
112		toolParams = append(toolParams, k, extraArgs[k])
113	}
114
115	if opts.IsPending() {
116		return pendingTool(sty, d.formatToolName(sty, tool), opts.Anim, false)
117	}
118
119	header := d.makeHeader(sty, tool, cappedWidth, opts, toolParams...)
120	if opts.Compact {
121		return header
122	}
123
124	if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok {
125		return joinToolParts(header, earlyState)
126	}
127
128	if tool == "mcp-find" {
129		return joinToolParts(header, d.renderMCPServers(sty, opts, cappedWidth))
130	}
131
132	if !opts.HasResult() {
133		return header
134	}
135
136	bodyWidth := cappedWidth - toolBodyLeftPaddingTotal
137	var parts []string
138
139	// Handle text content.
140	if opts.Result.Content != "" {
141		var body string
142		var result json.RawMessage
143		if err := json.Unmarshal([]byte(opts.Result.Content), &result); err == nil {
144			prettyResult, err := json.MarshalIndent(result, "", "  ")
145			if err == nil {
146				body = sty.Tool.Body.Render(toolOutputCodeContent(sty, "result.json", string(prettyResult), 0, bodyWidth, opts.ExpandedContent))
147			} else {
148				body = sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent))
149			}
150		} else if looksLikeMarkdown(opts.Result.Content) {
151			body = sty.Tool.Body.Render(toolOutputCodeContent(sty, "result.md", opts.Result.Content, 0, bodyWidth, opts.ExpandedContent))
152		} else {
153			body = sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent))
154		}
155		parts = append(parts, body)
156	}
157
158	// Handle image content.
159	if opts.Result.Data != "" && strings.HasPrefix(opts.Result.MIMEType, "image/") {
160		parts = append(parts, "", toolOutputImageContent(sty, opts.Result.Data, opts.Result.MIMEType))
161	}
162
163	if len(parts) == 0 {
164		return header
165	}
166
167	return joinToolParts(header, strings.Join(parts, "\n"))
168}
169
170// FindMCPResponse represents the response from mcp-find.
171type FindMCPResponse struct {
172	Servers []struct {
173		Name        string `json:"name"`
174		Description string `json:"description"`
175	} `json:"servers"`
176}
177
178func (d *DockerMCPToolRenderContext) renderMCPServers(sty *styles.Styles, opts *ToolRenderOpts, width int) string {
179	if !opts.HasResult() || opts.Result.Content == "" {
180		return ""
181	}
182
183	var result FindMCPResponse
184	if err := json.Unmarshal([]byte(opts.Result.Content), &result); err != nil {
185		return toolOutputPlainContent(sty, opts.Result.Content, width-toolBodyLeftPaddingTotal, opts.ExpandedContent)
186	}
187
188	if len(result.Servers) == 0 {
189		return sty.Subtle.Render("No MCP servers found.")
190	}
191
192	bodyWidth := min(120, width) - toolBodyLeftPaddingTotal
193	rows := [][]string{}
194	moreServers := ""
195	for i, server := range result.Servers {
196		if i > 9 {
197			moreServers = sty.Subtle.Render(fmt.Sprintf("... and %d more", len(result.Servers)-10))
198			break
199		}
200		rows = append(rows, []string{sty.Base.Render(server.Name), sty.Subtle.Render(server.Description)})
201	}
202	serverTable := table.New().
203		Wrap(false).
204		BorderTop(false).
205		BorderBottom(false).
206		BorderRight(false).
207		BorderLeft(false).
208		BorderColumn(false).
209		BorderRow(false).
210		StyleFunc(func(row, col int) lipgloss.Style {
211			if row == table.HeaderRow {
212				return lipgloss.NewStyle()
213			}
214			switch col {
215			case 0:
216				return lipgloss.NewStyle().PaddingRight(1)
217			}
218			return lipgloss.NewStyle()
219		}).Rows(rows...).Width(bodyWidth)
220	if moreServers != "" {
221		return sty.Tool.Body.Render(serverTable.Render() + "\n" + moreServers)
222	}
223	return sty.Tool.Body.Render(serverTable.Render())
224}
225
226func (d *DockerMCPToolRenderContext) makeHeader(sty *styles.Styles, tool string, width int, opts *ToolRenderOpts, params ...string) string {
227	if opts.Compact {
228		return d.makeCompactHeader(sty, tool, width, params...)
229	}
230
231	icon := toolIcon(sty, opts.Status)
232	if opts.IsPending() {
233		icon = sty.Tool.IconPending.Render()
234	}
235	prefix := fmt.Sprintf("%s %s ", icon, d.formatToolName(sty, tool))
236	return prefix + toolParamList(sty, params, width-lipgloss.Width(prefix))
237}
238
239func (d *DockerMCPToolRenderContext) formatToolName(sty *styles.Styles, tool string) string {
240	mainTool := "Docker MCP"
241	action := tool
242	actionStyle := sty.Tool.MCPToolName
243	switch tool {
244	case "mcp-exec":
245		action = "Exec"
246	case "mcp-config-set":
247		action = "Config Set"
248	case "mcp-find":
249		action = "Find"
250	case "mcp-add":
251		action = "Add"
252		actionStyle = sty.Tool.DockerMCPActionAdd
253	case "mcp-remove":
254		action = "Remove"
255		actionStyle = sty.Tool.DockerMCPActionDel
256	case "code-mode":
257		action = "Code Mode"
258	default:
259		action = strings.ReplaceAll(tool, "-", " ")
260		action = strings.ReplaceAll(action, "_", " ")
261		action = stringext.Capitalize(action)
262	}
263
264	toolNameStyled := sty.Tool.MCPName.Render(mainTool)
265	arrow := sty.Tool.MCPArrow.String()
266	return fmt.Sprintf("%s %s %s", toolNameStyled, arrow, actionStyle.Render(action))
267}
268
269func (d *DockerMCPToolRenderContext) makeCompactHeader(sty *styles.Styles, tool string, width int, params ...string) string {
270	action := tool
271	switch tool {
272	case "mcp-exec":
273		action = "exec"
274	case "mcp-config-set":
275		action = "config-set"
276	case "mcp-find":
277		action = "find"
278	case "mcp-add":
279		action = "add"
280	case "mcp-remove":
281		action = "remove"
282	case "code-mode":
283		action = "code-mode"
284	default:
285		action = strings.ReplaceAll(tool, "-", " ")
286		action = strings.ReplaceAll(action, "_", " ")
287	}
288
289	name := fmt.Sprintf("Docker MCP: %s", action)
290	return toolHeader(sty, ToolStatusSuccess, name, width, true, params...)
291}
292
293// IsDockerMCPTool returns true if the tool name is a Docker MCP tool.
294func IsDockerMCPTool(name string) bool {
295	return strings.HasPrefix(name, "mcp_"+config.DockerMCPName+"_")
296}