1package mcp
2
3import (
4 "cmp"
5 "context"
6 "fmt"
7 "strings"
8
9 "github.com/charmbracelet/bubbles/v2/help"
10 "github.com/charmbracelet/bubbles/v2/key"
11 "github.com/charmbracelet/bubbles/v2/list"
12 tea "github.com/charmbracelet/bubbletea/v2"
13 "github.com/charmbracelet/crush/internal/llm/agent"
14 "github.com/charmbracelet/crush/internal/message"
15 "github.com/charmbracelet/crush/internal/tui/components/core"
16 "github.com/charmbracelet/crush/internal/tui/components/dialogs"
17 "github.com/charmbracelet/crush/internal/tui/styles"
18 "github.com/charmbracelet/crush/internal/tui/util"
19 "github.com/charmbracelet/lipgloss/v2"
20 "github.com/modelcontextprotocol/go-sdk/mcp"
21)
22
23const (
24 ResourcePickerID = "resourcepicker"
25 listHeight = 15
26)
27
28type ResourcePickedMsg struct {
29 Attachment message.Attachment
30}
31
32type ResourcePicker interface {
33 dialogs.DialogModel
34}
35
36type resourceItem struct {
37 clientName string
38 resource *mcp.Resource
39}
40
41func (i resourceItem) Title() string {
42 return i.resource.URI
43}
44
45func (i resourceItem) Description() string {
46 return cmp.Or(i.resource.Description, i.resource.Title, i.resource.Name, "(no description)")
47}
48
49func (i resourceItem) FilterValue() string {
50 return i.Title() + " " + i.Description()
51}
52
53type model struct {
54 wWidth int
55 wHeight int
56 width int
57 list list.Model
58 keyMap KeyMap
59 help help.Model
60 loading bool
61}
62
63func NewResourcePickerCmp() ResourcePicker {
64 t := styles.CurrentTheme()
65
66 delegate := list.NewDefaultDelegate()
67 delegate.Styles.SelectedTitle = delegate.Styles.SelectedTitle.Foreground(t.Secondary)
68 delegate.Styles.SelectedDesc = delegate.Styles.SelectedDesc.Foreground(t.FgMuted)
69
70 l := list.New([]list.Item{}, delegate, 0, listHeight)
71 l.SetShowStatusBar(false)
72 l.SetShowTitle(false)
73 l.SetFilteringEnabled(true)
74 l.DisableQuitKeybindings()
75
76 help := help.New()
77 help.Styles = t.S().Help
78
79 return &model{
80 list: l,
81 keyMap: DefaultKeyMap(),
82 help: help,
83 loading: true,
84 }
85}
86
87func (m *model) Init() tea.Cmd {
88 return m.loadResources
89}
90
91func (m *model) loadResources() tea.Msg {
92 resources := agent.GetMCPResources()
93 items := make([]list.Item, 0, len(resources))
94
95 for key, resource := range resources {
96 parts := strings.SplitN(key, ":", 2)
97 if len(parts) != 2 {
98 continue
99 }
100 clientName := parts[0]
101 items = append(items, resourceItem{
102 clientName: clientName,
103 resource: resource,
104 })
105 }
106
107 return resourcesLoadedMsg{items: items}
108}
109
110type resourcesLoadedMsg struct {
111 items []list.Item
112}
113
114func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
115 switch msg := msg.(type) {
116 case tea.WindowSizeMsg:
117 m.wWidth = msg.Width
118 m.wHeight = msg.Height
119 m.width = min(80, m.wWidth-4)
120 h := min(listHeight+4, m.wHeight-4)
121 m.list.SetSize(m.width-4, h)
122 return m, nil
123
124 case resourcesLoadedMsg:
125 m.loading = false
126 cmd := m.list.SetItems(msg.items)
127 if len(msg.items) == 0 {
128 return m, tea.Batch(
129 cmd,
130 util.ReportWarn("No MCP resources available"),
131 util.CmdHandler(dialogs.CloseDialogMsg{}),
132 )
133 }
134 return m, cmd
135
136 case tea.KeyPressMsg:
137 if key.Matches(msg, m.keyMap.Close) {
138 return m, util.CmdHandler(dialogs.CloseDialogMsg{})
139 }
140 if key.Matches(msg, m.keyMap.Select) {
141 if item, ok := m.list.SelectedItem().(resourceItem); ok {
142 return m, tea.Sequence(
143 util.CmdHandler(dialogs.CloseDialogMsg{}),
144 m.fetchResource(item),
145 )
146 }
147 }
148 }
149
150 var cmd tea.Cmd
151 m.list, cmd = m.list.Update(msg)
152 return m, cmd
153}
154
155func (m *model) fetchResource(item resourceItem) tea.Cmd {
156 return func() tea.Msg {
157 ctx := context.Background()
158 content, err := agent.GetMCPResourceContent(ctx, item.clientName, item.resource.URI)
159 if err != nil {
160 return util.ReportError(fmt.Errorf("failed to fetch resource: %w", err))
161 }
162
163 var textContent strings.Builder
164 for _, c := range content.Contents {
165 if c.Text != "" {
166 textContent.WriteString(c.Text)
167 } else if len(c.Blob) > 0 {
168 textContent.WriteString(string(c.Blob))
169 }
170 }
171
172 fileName := item.resource.Name
173 if item.resource.Title != "" {
174 fileName = item.resource.Title
175 }
176
177 mimeType := item.resource.MIMEType
178 if mimeType == "" {
179 mimeType = "text/plain"
180 }
181
182 attachment := message.Attachment{
183 FileName: fileName,
184 FilePath: fileName,
185 MimeType: mimeType,
186 Content: []byte(textContent.String()),
187 }
188
189 return ResourcePickedMsg{Attachment: attachment}
190 }
191}
192
193func (m *model) View() string {
194 t := styles.CurrentTheme()
195
196 if m.loading {
197 return m.style().Render(
198 lipgloss.JoinVertical(
199 lipgloss.Left,
200 t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Loading MCP Resources...", m.width-4)),
201 ),
202 )
203 }
204
205 content := lipgloss.JoinVertical(
206 lipgloss.Left,
207 t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Add MCP Resource", m.width-4)),
208 m.list.View(),
209 t.S().Base.Width(m.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(m.help.View(m.keyMap)),
210 )
211
212 return m.style().Render(content)
213}
214
215func (m *model) style() lipgloss.Style {
216 t := styles.CurrentTheme()
217 return t.S().Base.
218 Width(m.width).
219 Border(lipgloss.RoundedBorder()).
220 BorderForeground(t.BorderFocus)
221}
222
223func (m *model) Position() (int, int) {
224 x := (m.wWidth - m.width) / 2
225 y := (m.wHeight - listHeight - 6) / 2
226 return y, x
227}
228
229func (m *model) ID() dialogs.DialogID {
230 return ResourcePickerID
231}