docker_mcp.go

  1package chat
  2
  3import (
  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.
 17type DockerMCPToolMessageItem struct {
 18	*baseToolMessageItem
 19}
 20
 21var _ ToolMessageItem = (*DockerMCPToolMessageItem)(nil)
 22
 23// NewDockerMCPToolMessageItem creates a new [DockerMCPToolMessageItem].
 24func 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.
 34type DockerMCPToolRenderContext struct{}
 35
 36// RenderTool implements the [ToolRenderer] interface.
 37func (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)
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.
165type FindMCPResponse struct {
166	Servers []struct {
167		Name        string `json:"name"`
168		Description string `json:"description"`
169	} `json:"servers"`
170}
171
172func (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
220func (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
233func (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
263func (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.
288func IsDockerMCPTool(name string) bool {
289	return strings.HasPrefix(name, "mcp_"+config.DockerMCPName+"_")
290}