feat(mcp): select resource

Carlos Alexandro Becker created

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

Change summary

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 
internal/tui/components/dialogs/commands/commands.go  |   1 
internal/tui/components/dialogs/mcp/keys.go           |  33 +
internal/tui/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(-)

Detailed changes

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 {

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,
+	})
+}

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...))
 

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

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},
+	}
+}

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
+}

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"),

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"),

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

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]