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		body := renderToolResultTextContent(sty, opts.Result.Content, toolResultContentWidths{Body: bodyWidth, Diff: cappedWidth}, opts.ExpandedContent)
142		parts = append(parts, body)
143	}
144
145	// Handle image content.
146	if opts.Result.Data != "" && strings.HasPrefix(opts.Result.MIMEType, "image/") {
147		parts = append(parts, "", toolOutputImageContent(sty, opts.Result.Data, opts.Result.MIMEType))
148	}
149
150	if len(parts) == 0 {
151		return header
152	}
153
154	return joinToolParts(header, strings.Join(parts, "\n"))
155}
156
157// FindMCPResponse represents the response from mcp-find.
158type FindMCPResponse struct {
159	Servers []struct {
160		Name        string `json:"name"`
161		Description string `json:"description"`
162	} `json:"servers"`
163}
164
165func (d *DockerMCPToolRenderContext) renderMCPServers(sty *styles.Styles, opts *ToolRenderOpts, width int) string {
166	if !opts.HasResult() || opts.Result.Content == "" {
167		return ""
168	}
169
170	var result FindMCPResponse
171	if err := json.Unmarshal([]byte(opts.Result.Content), &result); err != nil {
172		return toolOutputPlainContent(sty, opts.Result.Content, width-toolBodyLeftPaddingTotal, opts.ExpandedContent)
173	}
174
175	if len(result.Servers) == 0 {
176		return sty.Tool.ResultEmpty.Render("No MCP servers found.")
177	}
178
179	bodyWidth := min(120, width) - toolBodyLeftPaddingTotal
180	rows := [][]string{}
181	moreServers := ""
182	for i, server := range result.Servers {
183		if i > 9 {
184			moreServers = sty.Tool.ResultTruncation.Render(fmt.Sprintf("... and %d more", len(result.Servers)-10))
185			break
186		}
187		rows = append(rows, []string{sty.Tool.ResultItemName.Render(server.Name), sty.Tool.ResultItemDesc.Render(server.Description)})
188	}
189	serverTable := table.New().
190		Wrap(false).
191		BorderTop(false).
192		BorderBottom(false).
193		BorderRight(false).
194		BorderLeft(false).
195		BorderColumn(false).
196		BorderRow(false).
197		StyleFunc(func(row, col int) lipgloss.Style {
198			if row == table.HeaderRow {
199				return lipgloss.NewStyle()
200			}
201			switch col {
202			case 0:
203				return lipgloss.NewStyle().PaddingRight(1)
204			}
205			return lipgloss.NewStyle()
206		}).Rows(rows...).Width(bodyWidth)
207	if moreServers != "" {
208		return sty.Tool.Body.Render(serverTable.Render() + "\n" + moreServers)
209	}
210	return sty.Tool.Body.Render(serverTable.Render())
211}
212
213func (d *DockerMCPToolRenderContext) makeHeader(sty *styles.Styles, tool string, width int, opts *ToolRenderOpts, params ...string) string {
214	if opts.Compact {
215		return d.makeCompactHeader(sty, tool, width, params...)
216	}
217
218	icon := toolIcon(sty, opts.Status)
219	if opts.IsPending() {
220		icon = sty.Tool.IconPending.Render()
221	}
222	prefix := fmt.Sprintf("%s %s ", icon, d.formatToolName(sty, tool))
223	return prefix + toolParamList(sty, params, width-lipgloss.Width(prefix))
224}
225
226func (d *DockerMCPToolRenderContext) formatToolName(sty *styles.Styles, tool string) string {
227	mainTool := "Docker MCP"
228	action := tool
229	actionStyle := sty.Tool.MCPToolName
230	switch tool {
231	case "mcp-exec":
232		action = "Exec"
233	case "mcp-config-set":
234		action = "Config Set"
235	case "mcp-find":
236		action = "Find"
237	case "mcp-add":
238		action = "Add"
239		actionStyle = sty.Tool.ActionCreate
240	case "mcp-remove":
241		action = "Remove"
242		actionStyle = sty.Tool.ActionDestroy
243	case "code-mode":
244		action = "Code Mode"
245	default:
246		action = strings.ReplaceAll(tool, "-", " ")
247		action = strings.ReplaceAll(action, "_", " ")
248		action = stringext.Capitalize(action)
249	}
250
251	toolNameStyled := sty.Tool.MCPName.Render(mainTool)
252	arrow := sty.Tool.MCPArrow.String()
253	return fmt.Sprintf("%s %s %s", toolNameStyled, arrow, actionStyle.Render(action))
254}
255
256func (d *DockerMCPToolRenderContext) makeCompactHeader(sty *styles.Styles, tool string, width int, params ...string) string {
257	action := tool
258	switch tool {
259	case "mcp-exec":
260		action = "exec"
261	case "mcp-config-set":
262		action = "config-set"
263	case "mcp-find":
264		action = "find"
265	case "mcp-add":
266		action = "add"
267	case "mcp-remove":
268		action = "remove"
269	case "code-mode":
270		action = "code-mode"
271	default:
272		action = strings.ReplaceAll(tool, "-", " ")
273		action = strings.ReplaceAll(action, "_", " ")
274	}
275
276	name := fmt.Sprintf("Docker MCP: %s", action)
277	return toolHeader(sty, ToolStatusSuccess, name, width, true, params...)
278}
279
280// IsDockerMCPTool returns true if the tool name is a Docker MCP tool.
281func IsDockerMCPTool(name string) bool {
282	return strings.HasPrefix(name, "mcp_"+config.DockerMCPName+"_")
283}