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