From 2480dd740c97ab94067483db087993fda4e0ffa1 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Fri, 10 Oct 2025 00:31:42 -0300 Subject: [PATCH] feat(mcp): select resource Signed-off-by: Carlos Alexandro Becker --- internal/llm/agent/agent.go | 13 +- internal/llm/agent/mcp-tools.go | 27 ++ internal/llm/provider/anthropic.go | 13 +- internal/tui/components/chat/editor/editor.go | 21 +- .../components/dialogs/commands/commands.go | 1 + internal/tui/components/dialogs/mcp/keys.go | 33 +++ .../components/dialogs/mcp/resourcepicker.go | 237 ++++++++++++++++++ internal/tui/page/chat/chat.go | 11 + internal/tui/page/chat/keys.go | 15 +- internal/tui/styles/icons.go | 3 +- internal/tui/tui.go | 9 + 11 files changed, 364 insertions(+), 19 deletions(-) create mode 100644 internal/tui/components/dialogs/mcp/keys.go create mode 100644 internal/tui/components/dialogs/mcp/resourcepicker.go diff --git a/internal/llm/agent/agent.go b/internal/llm/agent/agent.go index 02ac1da5e4d29b86fd668ad12d6e55ab917ee2df..a0ed18a85642ef19c6731475d4a55d21780c56a1 100644 --- a/internal/llm/agent/agent.go +++ b/internal/llm/agent/agent.go @@ -346,9 +346,6 @@ func (a *agent) err(err error) AgentEvent { } func (a *agent) Run(ctx context.Context, sessionID string, content string, attachments ...message.Attachment) (<-chan AgentEvent, error) { - if !a.Model().SupportsImages && attachments != nil { - attachments = nil - } events := make(chan AgentEvent, 1) if a.IsSessionBusy(sessionID) { existing, ok := a.promptQueue.Get(sessionID) @@ -371,7 +368,15 @@ func (a *agent) Run(ctx context.Context, sessionID string, content string, attac }) var attachmentParts []message.ContentPart for _, attachment := range attachments { - attachmentParts = append(attachmentParts, message.BinaryContent{Path: attachment.FilePath, MIMEType: attachment.MimeType, Data: attachment.Content}) + if !a.Model().SupportsImages && strings.HasPrefix(attachment.MimeType, "image/") { + slog.Warn("Model does not support images, skipping attachment", "mimeType", attachment.MimeType, "fileName", attachment.FileName) + continue + } + attachmentParts = append(attachmentParts, message.BinaryContent{ + Path: attachment.FilePath, + MIMEType: attachment.MimeType, + Data: attachment.Content, + }) } result := a.processGeneration(genCtx, sessionID, content, attachmentParts) if result.Error != nil { diff --git a/internal/llm/agent/mcp-tools.go b/internal/llm/agent/mcp-tools.go index 743fbe989d6ebacf8187fde3d90a9b0534240d5e..243b3bc14794eb53a9c93f972d93b117a7b3a8cc 100644 --- a/internal/llm/agent/mcp-tools.go +++ b/internal/llm/agent/mcp-tools.go @@ -562,3 +562,30 @@ func GetMCPPromptContent(ctx context.Context, clientName, promptName string, arg Arguments: args, }) } + +// GetMCPResources returns all available MCP resources. +func GetMCPResources() map[string]*mcp.Resource { + return maps.Collect(mcpResources.Seq2()) +} + +// GetMCPResource returns a specific MCP resource by name. +func GetMCPResource(name string) (*mcp.Resource, bool) { + return mcpResources.Get(name) +} + +// GetMCPResourcesByClient returns all resources for a specific MCP client. +func GetMCPResourcesByClient(clientName string) ([]*mcp.Resource, bool) { + return mcpClient2Resources.Get(clientName) +} + +// GetMCPResourceContent retrieves the content of an MCP resource. +func GetMCPResourceContent(ctx context.Context, clientName, uri string) (*mcp.ReadResourceResult, error) { + c, err := getOrRenewClient(ctx, clientName) + if err != nil { + return nil, err + } + + return c.ReadResource(ctx, &mcp.ReadResourceParams{ + URI: uri, + }) +} diff --git a/internal/llm/provider/anthropic.go b/internal/llm/provider/anthropic.go index 981ff4590fd7db92288ff11b3d8f607e594cb0fd..be9ec8454526cc2d4319594cef6e2b8429195ec5 100644 --- a/internal/llm/provider/anthropic.go +++ b/internal/llm/provider/anthropic.go @@ -126,9 +126,16 @@ func (a *anthropicClient) convertMessages(messages []message.Message) (anthropic var contentBlocks []anthropic.ContentBlockParamUnion contentBlocks = append(contentBlocks, content) for _, binaryContent := range msg.BinaryContent() { - base64Image := binaryContent.String(catwalk.InferenceProviderAnthropic) - imageBlock := anthropic.NewImageBlockBase64(binaryContent.MIMEType, base64Image) - contentBlocks = append(contentBlocks, imageBlock) + if strings.HasPrefix(binaryContent.MIMEType, "image/") { + base64Image := binaryContent.String(catwalk.InferenceProviderAnthropic) + imageBlock := anthropic.NewImageBlockBase64(binaryContent.MIMEType, base64Image) + contentBlocks = append(contentBlocks, imageBlock) + continue + } + blk := anthropic.NewDocumentBlock(anthropic.PlainTextSourceParam{ + Data: string(binaryContent.Data), + }) + contentBlocks = append(contentBlocks, blk) } anthropicMessages = append(anthropicMessages, anthropic.NewUserMessage(contentBlocks...)) diff --git a/internal/tui/components/chat/editor/editor.go b/internal/tui/components/chat/editor/editor.go index f70a0a3dbe63a9473f552efa233e03bd4efc0ee1..51277929d75e2723da8b50e7251768028b3c7544 100644 --- a/internal/tui/components/chat/editor/editor.go +++ b/internal/tui/components/chat/editor/editor.go @@ -26,6 +26,7 @@ import ( "github.com/charmbracelet/crush/internal/tui/components/dialogs" "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands" "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker" + "github.com/charmbracelet/crush/internal/tui/components/dialogs/mcp" "github.com/charmbracelet/crush/internal/tui/components/dialogs/quit" "github.com/charmbracelet/crush/internal/tui/styles" "github.com/charmbracelet/crush/internal/tui/util" @@ -179,10 +180,13 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.repositionCompletions case filepicker.FilePickedMsg: if len(m.attachments) >= maxAttachments { - return m, util.ReportError(fmt.Errorf("cannot add more than %d images", maxAttachments)) + // TODO: check if this still needed + return m, util.ReportError(fmt.Errorf("cannot add more than %d attachments", maxAttachments)) } m.attachments = append(m.attachments, msg.Attachment) return m, nil + case mcp.ResourcePickedMsg: + m.attachments = append(m.attachments, msg.Attachment) case completions.CompletionsOpenedMsg: m.isCompletionsOpen = true case completions.CompletionsClosedMsg: @@ -458,16 +462,21 @@ func (m *editorCmp) attachmentsContent() string { Background(t.FgMuted). Foreground(t.FgBase) for i, attachment := range m.attachments { - var filename string + icon := styles.DocumentIcon + if strings.HasPrefix(attachment.MimeType, "image/") { + icon = styles.ImageIcon + } + + var item string if len(attachment.FileName) > 10 { - filename = fmt.Sprintf(" %s %s...", styles.DocumentIcon, attachment.FileName[0:7]) + item = fmt.Sprintf(" %s %s...", icon, attachment.FileName[0:7]) } else { - filename = fmt.Sprintf(" %s %s", styles.DocumentIcon, attachment.FileName) + item = fmt.Sprintf(" %s %s", icon, attachment.FileName) } if m.deleteMode { - filename = fmt.Sprintf("%d%s", i, filename) + item = fmt.Sprintf("%d%s", i, item) } - styledAttachments = append(styledAttachments, attachmentStyles.Render(filename)) + styledAttachments = append(styledAttachments, attachmentStyles.Render(item)) } content := lipgloss.JoinHorizontal(lipgloss.Left, styledAttachments...) return content diff --git a/internal/tui/components/dialogs/commands/commands.go b/internal/tui/components/dialogs/commands/commands.go index dc8289dc53eb5a17f8a4a3e22485de176ad16dd0..98e95eaa7930b040ba0b6a22e59a8c9d3ac31513 100644 --- a/internal/tui/components/dialogs/commands/commands.go +++ b/internal/tui/components/dialogs/commands/commands.go @@ -79,6 +79,7 @@ type ( SwitchModelMsg struct{} QuitMsg struct{} OpenFilePickerMsg struct{} + OpenResourcePickerMsg struct{} ToggleHelpMsg struct{} ToggleCompactModeMsg struct{} ToggleThinkingMsg struct{} diff --git a/internal/tui/components/dialogs/mcp/keys.go b/internal/tui/components/dialogs/mcp/keys.go new file mode 100644 index 0000000000000000000000000000000000000000..88021af9f8c86a2343b619d8668f98180c8176fd --- /dev/null +++ b/internal/tui/components/dialogs/mcp/keys.go @@ -0,0 +1,33 @@ +package mcp + +import ( + "github.com/charmbracelet/bubbles/v2/key" +) + +type KeyMap struct { + Close key.Binding + Select key.Binding +} + +func DefaultKeyMap() KeyMap { + return KeyMap{ + Close: key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "close"), + ), + Select: key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "select"), + ), + } +} + +func (k KeyMap) ShortHelp() []key.Binding { + return []key.Binding{k.Select, k.Close} +} + +func (k KeyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{ + {k.Select, k.Close}, + } +} diff --git a/internal/tui/components/dialogs/mcp/resourcepicker.go b/internal/tui/components/dialogs/mcp/resourcepicker.go new file mode 100644 index 0000000000000000000000000000000000000000..da5d34ccead16c14a76919a2521e25559a68664a --- /dev/null +++ b/internal/tui/components/dialogs/mcp/resourcepicker.go @@ -0,0 +1,237 @@ +package mcp + +import ( + "context" + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/v2/help" + "github.com/charmbracelet/bubbles/v2/key" + "github.com/charmbracelet/bubbles/v2/list" + tea "github.com/charmbracelet/bubbletea/v2" + "github.com/charmbracelet/crush/internal/llm/agent" + "github.com/charmbracelet/crush/internal/message" + "github.com/charmbracelet/crush/internal/tui/components/core" + "github.com/charmbracelet/crush/internal/tui/components/dialogs" + "github.com/charmbracelet/crush/internal/tui/styles" + "github.com/charmbracelet/crush/internal/tui/util" + "github.com/charmbracelet/lipgloss/v2" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +const ( + ResourcePickerID = "resourcepicker" + listHeight = 15 +) + +type ResourcePickedMsg struct { + Attachment message.Attachment +} + +type ResourcePicker interface { + dialogs.DialogModel +} + +type resourceItem struct { + clientName string + resource *mcp.Resource +} + +func (i resourceItem) Title() string { + if i.resource.Title != "" { + return i.resource.Title + } + return i.resource.Name +} + +func (i resourceItem) Description() string { + desc := i.resource.Description + if desc == "" { + desc = i.resource.URI + } + return fmt.Sprintf("[%s] %s", i.clientName, desc) +} + +func (i resourceItem) FilterValue() string { + return i.Title() + " " + i.Description() +} + +type model struct { + wWidth int + wHeight int + width int + list list.Model + keyMap KeyMap + help help.Model + loading bool +} + +func NewResourcePickerCmp() ResourcePicker { + t := styles.CurrentTheme() + + delegate := list.NewDefaultDelegate() + delegate.Styles.SelectedTitle = delegate.Styles.SelectedTitle.Foreground(t.Accent) + delegate.Styles.SelectedDesc = delegate.Styles.SelectedDesc.Foreground(t.FgMuted) + + l := list.New([]list.Item{}, delegate, 0, listHeight) + l.Title = "Select MCP Resource" + l.Styles.Title = t.S().Title + l.SetShowStatusBar(false) + l.SetFilteringEnabled(true) + l.DisableQuitKeybindings() + + help := help.New() + help.Styles = t.S().Help + + return &model{ + list: l, + keyMap: DefaultKeyMap(), + help: help, + loading: true, + } +} + +func (m *model) Init() tea.Cmd { + return m.loadResources +} + +func (m *model) loadResources() tea.Msg { + resources := agent.GetMCPResources() + items := make([]list.Item, 0, len(resources)) + + for key, resource := range resources { + parts := strings.SplitN(key, ":", 2) + if len(parts) != 2 { + continue + } + clientName := parts[0] + items = append(items, resourceItem{ + clientName: clientName, + resource: resource, + }) + } + + return resourcesLoadedMsg{items: items} +} + +type resourcesLoadedMsg struct { + items []list.Item +} + +func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.wWidth = msg.Width + m.wHeight = msg.Height + m.width = min(80, m.wWidth-4) + h := min(listHeight+4, m.wHeight-4) + m.list.SetSize(m.width-4, h) + return m, nil + + case resourcesLoadedMsg: + m.loading = false + cmd := m.list.SetItems(msg.items) + if len(msg.items) == 0 { + return m, tea.Batch( + cmd, + util.ReportWarn("No MCP resources available"), + util.CmdHandler(dialogs.CloseDialogMsg{}), + ) + } + return m, cmd + + case tea.KeyPressMsg: + if key.Matches(msg, m.keyMap.Close) { + return m, util.CmdHandler(dialogs.CloseDialogMsg{}) + } + if key.Matches(msg, m.keyMap.Select) { + if item, ok := m.list.SelectedItem().(resourceItem); ok { + return m, tea.Sequence( + util.CmdHandler(dialogs.CloseDialogMsg{}), + m.fetchResource(item), + ) + } + } + } + + var cmd tea.Cmd + m.list, cmd = m.list.Update(msg) + return m, cmd +} + +func (m *model) fetchResource(item resourceItem) tea.Cmd { + return func() tea.Msg { + ctx := context.Background() + content, err := agent.GetMCPResourceContent(ctx, item.clientName, item.resource.URI) + if err != nil { + return util.ReportError(fmt.Errorf("failed to fetch resource: %w", err)) + } + + var textContent strings.Builder + for _, c := range content.Contents { + if c.Text != "" { + textContent.WriteString(c.Text) + } else if len(c.Blob) > 0 { + textContent.WriteString(string(c.Blob)) + } + } + + fileName := item.resource.Name + if item.resource.Title != "" { + fileName = item.resource.Title + } + + mimeType := item.resource.MIMEType + if mimeType == "" { + mimeType = "text/plain" + } + + attachment := message.Attachment{ + FileName: fileName, + FilePath: fileName, + MimeType: mimeType, + Content: []byte(textContent.String()), + } + + return ResourcePickedMsg{Attachment: attachment} + } +} + +func (m *model) View() string { + t := styles.CurrentTheme() + + if m.loading { + return m.style().Render( + lipgloss.JoinVertical( + lipgloss.Left, + t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Loading MCP Resources...", m.width-4)), + ), + ) + } + + content := lipgloss.JoinVertical( + lipgloss.Left, + m.list.View(), + t.S().Base.Width(m.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(m.help.View(m.keyMap)), + ) + + return m.style().Render(content) +} + +func (m *model) style() lipgloss.Style { + t := styles.CurrentTheme() + return t.S().Base. + Width(m.width). + Border(lipgloss.RoundedBorder()). + BorderForeground(t.BorderFocus) +} + +func (m *model) Position() (int, int) { + x := (m.wWidth - m.width) / 2 + y := (m.wHeight - listHeight - 6) / 2 + return y, x +} + +func (m *model) ID() dialogs.DialogID { + return ResourcePickerID +} diff --git a/internal/tui/page/chat/chat.go b/internal/tui/page/chat/chat.go index 2918925068cb2f012bead47bbf44260c6255288c..7c3e3d32d17cf48e49b45f4dc727f24b1f420902 100644 --- a/internal/tui/page/chat/chat.go +++ b/internal/tui/page/chat/chat.go @@ -3,6 +3,7 @@ package chat import ( "context" "fmt" + "log/slog" "time" "github.com/charmbracelet/bubbles/v2/help" @@ -30,6 +31,7 @@ import ( "github.com/charmbracelet/crush/internal/tui/components/dialogs" "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands" "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker" + "github.com/charmbracelet/crush/internal/tui/components/dialogs/mcp" "github.com/charmbracelet/crush/internal/tui/components/dialogs/models" "github.com/charmbracelet/crush/internal/tui/components/dialogs/reasoning" "github.com/charmbracelet/crush/internal/tui/page" @@ -286,6 +288,7 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, cmd) return p, tea.Batch(cmds...) case filepicker.FilePickedMsg, + mcp.ResourcePickedMsg, completions.CompletionsClosedMsg, completions.SelectCompletionMsg: u, cmd := p.editor.Update(msg) @@ -378,6 +381,9 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } else { return p, util.ReportWarn("File attachments are not supported by the current model: " + model.Name) } + case key.Matches(msg, p.keyMap.AddMCPResource): + slog.Warn("AQUI") + return p, util.CmdHandler(commands.OpenResourcePickerMsg{}) case key.Matches(msg, p.keyMap.Tab): if p.session.ID == "" { u, cmd := p.splash.Update(msg) @@ -761,6 +767,7 @@ func (p *chatPage) Bindings() []key.Binding { bindings := []key.Binding{ p.keyMap.NewSession, p.keyMap.AddAttachment, + p.keyMap.AddMCPResource, } if p.app.CoderAgent != nil && p.app.CoderAgent.IsBusy() { cancelBinding := p.keyMap.Cancel @@ -1021,6 +1028,10 @@ func (p *chatPage) Help() help.KeyMap { key.WithKeys("ctrl+f"), key.WithHelp("ctrl+f", "add image"), ), + key.NewBinding( + key.WithKeys("ctrl+m"), + key.WithHelp("ctrl+m", "add mcp resource"), + ), key.NewBinding( key.WithKeys("/"), key.WithHelp("/", "add file"), diff --git a/internal/tui/page/chat/keys.go b/internal/tui/page/chat/keys.go index 679a97c69522c0e831e59bddc7b0c1ddcc55fbb9..74f065646a3f8a248584424a98abe77583dd8d8b 100644 --- a/internal/tui/page/chat/keys.go +++ b/internal/tui/page/chat/keys.go @@ -5,11 +5,12 @@ import ( ) type KeyMap struct { - NewSession key.Binding - AddAttachment key.Binding - Cancel key.Binding - Tab key.Binding - Details key.Binding + NewSession key.Binding + AddAttachment key.Binding + AddMCPResource key.Binding + Cancel key.Binding + Tab key.Binding + Details key.Binding } func DefaultKeyMap() KeyMap { @@ -22,6 +23,10 @@ func DefaultKeyMap() KeyMap { key.WithKeys("ctrl+f"), key.WithHelp("ctrl+f", "add attachment"), ), + AddMCPResource: key.NewBinding( + key.WithKeys("ctrl+r"), + key.WithHelp("ctrl+r", "add mcp resource"), + ), Cancel: key.NewBinding( key.WithKeys("esc", "alt+esc"), key.WithHelp("esc", "cancel"), diff --git a/internal/tui/styles/icons.go b/internal/tui/styles/icons.go index d9d1ab06f96ff64f8772e1b0f4b099a0ebed2b0a..a53ee09bc7f157a26b8f4445459c9e7471ce7896 100644 --- a/internal/tui/styles/icons.go +++ b/internal/tui/styles/icons.go @@ -8,7 +8,8 @@ const ( HintIcon string = "∵" SpinnerIcon string = "..." LoadingIcon string = "⟳" - DocumentIcon string = "🖼" + DocumentIcon string = "📄 " + ImageIcon string = "🖼" ModelIcon string = "◇" // Tool call icons diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 8cd754fc1f8999bcc8db98f333d2d4790e3b99ff..0d3b0ecfee305e1bbbea809ea017c9ceb1a7d8c1 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -25,6 +25,7 @@ import ( "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands" "github.com/charmbracelet/crush/internal/tui/components/dialogs/compact" "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker" + "github.com/charmbracelet/crush/internal/tui/components/dialogs/mcp" "github.com/charmbracelet/crush/internal/tui/components/dialogs/models" "github.com/charmbracelet/crush/internal/tui/components/dialogs/permissions" "github.com/charmbracelet/crush/internal/tui/components/dialogs/quit" @@ -254,6 +255,14 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return a, util.CmdHandler(dialogs.OpenDialogMsg{ Model: filepicker.NewFilePickerCmp(a.app.Config().WorkingDir()), }) + // Resource Picker + case commands.OpenResourcePickerMsg: + if a.dialog.ActiveDialogID() == mcp.ResourcePickerID { + return a, util.CmdHandler(dialogs.CloseDialogMsg{}) + } + return a, util.CmdHandler(dialogs.OpenDialogMsg{ + Model: mcp.NewResourcePickerCmp(), + }) // Permissions case pubsub.Event[permission.PermissionNotification]: item, ok := a.pages[a.currentPage]