Skip to content

Commit db89ec0

Browse files
authored
Merge pull request #2 from OpenSystemsLab/feature/resource-tab
Feature/resource tab
2 parents 2fedc3d + 655b271 commit db89ec0

File tree

2 files changed

+247
-25
lines changed

2 files changed

+247
-25
lines changed

README.md

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,11 @@ MCP-CLI is a command-line tool for interacting with and testing Model Context Pr
77
- **Multiple Transports:** Connect to MCP servers using `stdio`, `sse` (Server-Sent Events), or `http` (streamable HTTP).
88
- **Interactive TUI:** A terminal user interface built with `bubbletea` that allows you to:
99
- Select a tool from a list of available tools.
10+
- Browse and query MCP resources.
1011
- Enter arguments for the selected tool in a form.
1112
- View the results of the tool execution.
13+
- **Resource Browser:** A new tab in the TUI for listing and querying MCP resources.
14+
- **Prompt Browser:** A new tab in the TUI for listing MCP prompts.
1215
- **Debug Panel:** A scrollable debug panel on the right side of the TUI that shows:
1316
- Informational logs (key presses, state changes).
1417
- The arguments sent to the tool in a pretty-printed JSON format.
@@ -98,10 +101,15 @@ mcp-cli http -H "Authorization: Bearer my-token" http://localhost:8080/mcp
98101

99102
When you connect to an MCP server, you will be presented with a terminal user interface.
100103

101-
- **Tool Selection View:** A list of available tools. Use the arrow keys to navigate and press `Enter` to select a tool.
104+
- **Tool Selection View:** A list of available tools. Use the arrow keys to navigate and press `Enter` to select a tool. Press `r` to switch to the resource browser or `p` to switch to the prompt browser.
105+
- **Resource Browser View:** A list of available resources. Use the arrow keys to navigate and press `Enter` to view the resource details. Press `t` to switch back to the tool selection view or `p` to switch to the prompt browser.
106+
- **Prompt Browser View:** A list of available prompts. Use the arrow keys to navigate. Press `t` to switch back to the tool selection view or `r` to switch to the resource browser.
102107
- **Argument Input View:** A form for entering the arguments for the selected tool. Use `Tab` to switch between fields and `Enter` to submit the tool call.
103-
- **Result View:** Shows the result of the tool execution. Press `Enter` to return to the argument input view for the same tool, allowing you to easily call it again with different arguments.
108+
- **Resource Detail View:** Shows the content of the selected resource. Press `Esc` to return to the resource list.
104109
- **Debug Panel:** The right-hand panel shows a scrollable log of events, tool calls, and results. Use the up and down arrow keys to scroll through the log.
105110
- **Navigation:**
106-
- `Esc`: Return to the tool selection view.
111+
- - `t`: Switch to the tool selection view.
112+
- - `r`: Switch to the resource browser view.
113+
- - `p`: Switch to the prompt browser view.
114+
- - `Esc`: Return to the previous view.
107115
- `Ctrl+C`: Exit the application.

main.go

Lines changed: 236 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"strconv"
1313
"strings"
1414

15+
"github.com/charmbracelet/bubbles/key"
1516
"github.com/charmbracelet/bubbles/list"
1617
"github.com/charmbracelet/bubbles/textinput"
1718
"github.com/charmbracelet/bubbles/viewport"
@@ -190,29 +191,39 @@ type viewState int
190191
const (
191192
toolSelectionView viewState = iota
192193
argumentInputView
194+
resourceListView
195+
resourceDetailView
196+
promptListView
193197
)
194198

195199
type AppModel struct {
196-
state viewState
197-
ctx context.Context
198-
session *mcp.ClientSession
199-
toolList list.Model
200-
argInputs []textinput.Model
201-
argOrder []string
202-
argFocus int
203-
selectedTool *mcp.Tool
204-
tools []*mcp.Tool
205-
result string
206-
err error
207-
log []string
208-
width int
209-
height int
210-
debugViewport viewport.Model
200+
state viewState
201+
ctx context.Context
202+
session *mcp.ClientSession
203+
toolList list.Model
204+
resourceList list.Model
205+
promptList list.Model
206+
argInputs []textinput.Model
207+
argOrder []string
208+
argFocus int
209+
selectedTool *mcp.Tool
210+
tools []*mcp.Tool
211+
resources []*mcp.Resource
212+
prompts []*mcp.Prompt
213+
selectedResource *mcp.Resource
214+
result string
215+
resourceResult string
216+
err error
217+
log []string
218+
width int
219+
height int
220+
debugViewport viewport.Model
211221
}
212222

213223
func initialModel(ctx context.Context, session *mcp.ClientSession) *AppModel {
214224
var err error
215225
var tools []*mcp.Tool
226+
var resources []*mcp.Resource
216227

217228
// Iterate over the tools using range
218229
for tool, iterErr := range session.Tools(ctx, nil) {
@@ -227,13 +238,72 @@ func initialModel(ctx context.Context, session *mcp.ClientSession) *AppModel {
227238
return &AppModel{err: err}
228239
}
229240

230-
items := []list.Item{}
241+
var prompts []*mcp.Prompt
242+
for prompt, iterErr := range session.Prompts(ctx, nil) {
243+
if iterErr != nil {
244+
err = iterErr
245+
break
246+
}
247+
prompts = append(prompts, prompt)
248+
}
249+
250+
if err != nil {
251+
return &AppModel{err: err}
252+
}
253+
254+
for resource, iterErr := range session.Resources(ctx, nil) {
255+
if iterErr != nil {
256+
err = iterErr
257+
break
258+
}
259+
resources = append(resources, resource)
260+
}
261+
262+
if err != nil {
263+
return &AppModel{err: err}
264+
}
265+
266+
toolItems := []list.Item{}
231267
for _, tool := range tools {
232-
items = append(items, item{title: tool.Name, desc: tool.Description, tool: tool})
268+
toolItems = append(toolItems, item{title: tool.Name, desc: tool.Description, tool: tool})
269+
}
270+
271+
resourceItems := []list.Item{}
272+
for _, resource := range resources {
273+
resourceItems = append(resourceItems, resourceItem{title: resource.Name, desc: resource.Description, resource: resource})
274+
}
275+
276+
promptItems := []list.Item{}
277+
for _, prompt := range prompts {
278+
promptItems = append(promptItems, promptItem{title: prompt.Name, desc: prompt.Description, prompt: prompt})
279+
}
280+
281+
toolList := list.New(toolItems, list.NewDefaultDelegate(), 0, 0)
282+
toolList.Title = "Select a tool to execute"
283+
toolList.AdditionalShortHelpKeys = func() []key.Binding {
284+
return []key.Binding{
285+
key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "resources")),
286+
key.NewBinding(key.WithKeys("p"), key.WithHelp("p", "prompts")),
287+
}
233288
}
234289

235-
l := list.New(items, list.NewDefaultDelegate(), 0, 0)
236-
l.Title = "Select a tool to execute"
290+
resourceList := list.New(resourceItems, list.NewDefaultDelegate(), 0, 0)
291+
resourceList.Title = "Select a resource"
292+
resourceList.AdditionalShortHelpKeys = func() []key.Binding {
293+
return []key.Binding{
294+
key.NewBinding(key.WithKeys("t"), key.WithHelp("t", "tools")),
295+
key.NewBinding(key.WithKeys("p"), key.WithHelp("p", "prompts")),
296+
}
297+
}
298+
299+
promptList := list.New(promptItems, list.NewDefaultDelegate(), 0, 0)
300+
promptList.Title = "Select a prompt"
301+
promptList.AdditionalShortHelpKeys = func() []key.Binding {
302+
return []key.Binding{
303+
key.NewBinding(key.WithKeys("t"), key.WithHelp("t", "tools")),
304+
key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "resources")),
305+
}
306+
}
237307

238308
vp := viewport.New(1, 1) // Initial size, will be updated on WindowSizeMsg
239309
vp.SetContent("Debug log will appear here...")
@@ -242,8 +312,12 @@ func initialModel(ctx context.Context, session *mcp.ClientSession) *AppModel {
242312
state: toolSelectionView,
243313
ctx: ctx,
244314
session: session,
245-
toolList: l,
315+
toolList: toolList,
316+
resourceList: resourceList,
317+
promptList: promptList,
246318
tools: tools,
319+
resources: resources,
320+
prompts: prompts,
247321
debugViewport: vp,
248322
}
249323
}
@@ -257,6 +331,24 @@ func (i item) Title() string { return i.title }
257331
func (i item) Description() string { return i.desc }
258332
func (i item) FilterValue() string { return i.title }
259333

334+
type resourceItem struct {
335+
title, desc string
336+
resource *mcp.Resource
337+
}
338+
339+
func (i resourceItem) Title() string { return i.title }
340+
func (i resourceItem) Description() string { return i.desc }
341+
func (i resourceItem) FilterValue() string { return i.title }
342+
343+
type promptItem struct {
344+
title, desc string
345+
prompt *mcp.Prompt
346+
}
347+
348+
func (i promptItem) Title() string { return i.title }
349+
func (i promptItem) Description() string { return i.desc }
350+
func (i promptItem) FilterValue() string { return i.title }
351+
260352
func (m *AppModel) logf(format string, a ...any) {
261353
m.log = append(m.log, fmt.Sprintf(format, a...))
262354
m.debugViewport.SetContent(strings.Join(m.log, "\n"))
@@ -282,13 +374,27 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
282374
}
283375
m.logf("Result:\n========\n%s", msg.result)
284376
m.result = msg.result
377+
case resourceResult:
378+
if msg.err != nil {
379+
m.err = msg.err
380+
return m, nil
381+
}
382+
if verbose {
383+
m.logf("Resource result received")
384+
}
385+
m.logf("Result:\n========\n%s", msg.result)
386+
m.resourceResult = msg.result
285387
case tea.KeyMsg:
286388
if verbose {
287389
m.logf("Key pressed: %s", msg.String())
288390
}
289391
switch msg.Type {
290392
case tea.KeyEsc:
291-
m.state = toolSelectionView
393+
if m.state == resourceDetailView {
394+
m.state = resourceListView
395+
} else {
396+
m.state = toolSelectionView
397+
}
292398
return m, nil
293399
case tea.KeyCtrlC:
294400
return m, tea.Quit
@@ -302,13 +408,31 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
302408
m.debugViewport, cmd = m.debugViewport.Update(msg)
303409
cmds = append(cmds, cmd)
304410
return model, tea.Batch(cmds...)
411+
case resourceListView:
412+
var model tea.Model
413+
model, cmd = m.updateResourceListView(msg)
414+
cmds = append(cmds, cmd)
415+
m.debugViewport, cmd = m.debugViewport.Update(msg)
416+
cmds = append(cmds, cmd)
417+
return model, tea.Batch(cmds...)
418+
case promptListView:
419+
var model tea.Model
420+
model, cmd = m.updatePromptListView(msg)
421+
cmds = append(cmds, cmd)
422+
m.debugViewport, cmd = m.debugViewport.Update(msg)
423+
cmds = append(cmds, cmd)
424+
return model, tea.Batch(cmds...)
305425
case argumentInputView:
306426
var model tea.Model
307427
model, cmd = m.updateArgumentInputView(msg)
308428
cmds = append(cmds, cmd)
309429
m.debugViewport, cmd = m.debugViewport.Update(msg)
310430
cmds = append(cmds, cmd)
311431
return model, tea.Batch(cmds...)
432+
case resourceDetailView:
433+
m.debugViewport, cmd = m.debugViewport.Update(msg)
434+
cmds = append(cmds, cmd)
435+
return m, tea.Batch(cmds...)
312436
}
313437

314438
case tea.WindowSizeMsg:
@@ -330,7 +454,14 @@ func (m *AppModel) updateToolSelectionView(msg tea.Msg) (tea.Model, tea.Cmd) {
330454
m.toolList, cmd = m.toolList.Update(msg)
331455

332456
if keyMsg, ok := msg.(tea.KeyMsg); ok {
333-
if keyMsg.Type == tea.KeyEnter {
457+
switch keyMsg.String() {
458+
case "r":
459+
m.state = resourceListView
460+
return m, nil
461+
case "p":
462+
m.state = promptListView
463+
return m, nil
464+
case "enter":
334465
selectedItem := m.toolList.SelectedItem().(item)
335466
m.selectedTool = selectedItem.tool
336467

@@ -368,6 +499,47 @@ func (m *AppModel) updateToolSelectionView(msg tea.Msg) (tea.Model, tea.Cmd) {
368499
return m, cmd
369500
}
370501

502+
func (m *AppModel) updatePromptListView(msg tea.Msg) (tea.Model, tea.Cmd) {
503+
var cmd tea.Cmd
504+
m.promptList, cmd = m.promptList.Update(msg)
505+
506+
if keyMsg, ok := msg.(tea.KeyMsg); ok {
507+
switch keyMsg.String() {
508+
case "t":
509+
m.state = toolSelectionView
510+
return m, nil
511+
case "r":
512+
m.state = resourceListView
513+
return m, nil
514+
}
515+
}
516+
517+
return m, cmd
518+
}
519+
520+
func (m *AppModel) updateResourceListView(msg tea.Msg) (tea.Model, tea.Cmd) {
521+
var cmd tea.Cmd
522+
m.resourceList, cmd = m.resourceList.Update(msg)
523+
524+
if keyMsg, ok := msg.(tea.KeyMsg); ok {
525+
switch keyMsg.String() {
526+
case "t":
527+
m.state = toolSelectionView
528+
return m, nil
529+
case "p":
530+
m.state = promptListView
531+
return m, nil
532+
case "enter":
533+
selectedItem := m.resourceList.SelectedItem().(resourceItem)
534+
m.selectedResource = selectedItem.resource
535+
m.state = resourceDetailView
536+
return m, m.readResourceCmd()
537+
}
538+
}
539+
540+
return m, cmd
541+
}
542+
371543
func (m *AppModel) updateArgumentInputView(msg tea.Msg) (tea.Model, tea.Cmd) {
372544
keyMsg, ok := msg.(tea.KeyMsg)
373545
if !ok {
@@ -422,6 +594,18 @@ func (m AppModel) View() string {
422594
case toolSelectionView:
423595
m.toolList.SetSize(mainWidth-2, m.height-2)
424596
mainContent.WriteString(m.toolList.View())
597+
case resourceListView:
598+
m.resourceList.SetSize(mainWidth-2, m.height-2)
599+
mainContent.WriteString(m.resourceList.View())
600+
case promptListView:
601+
m.promptList.SetSize(mainWidth-2, m.height-2)
602+
mainContent.WriteString(m.promptList.View())
603+
case resourceDetailView:
604+
var b strings.Builder
605+
b.WriteString(fmt.Sprintf("Details for %s:\n\n", m.selectedResource.Name))
606+
b.WriteString(m.resourceResult)
607+
b.WriteString("\n\nPress Esc to go back to resource list.")
608+
mainContent.WriteString(b.String())
425609
case argumentInputView:
426610
var b strings.Builder
427611
b.WriteString(fmt.Sprintf("Enter arguments for %s:\n\n", m.selectedTool.Name))
@@ -456,6 +640,12 @@ type toolResult struct {
456640
err error
457641
}
458642

643+
// resourceResult represents the result of a resource read
644+
type resourceResult struct {
645+
result string
646+
err error
647+
}
648+
459649
// callToolCmd returns a tea.Cmd that calls the tool
460650
func (m *AppModel) callToolCmd() tea.Cmd {
461651
return func() tea.Msg {
@@ -554,6 +744,30 @@ func (m *AppModel) callTool() (tea.Model, tea.Cmd) {
554744
return m, m.callToolCmd()
555745
}
556746

747+
func (m *AppModel) readResourceCmd() tea.Cmd {
748+
return func() tea.Msg {
749+
params := &mcp.ReadResourceParams{
750+
URI: m.selectedResource.URI,
751+
}
752+
result, err := m.session.ReadResource(m.ctx, params)
753+
if err != nil {
754+
return resourceResult{err: err}
755+
}
756+
757+
var resultStr strings.Builder
758+
for _, content := range result.Contents {
759+
prettyJSON, err := json.MarshalIndent(content, "", " ")
760+
if err != nil {
761+
resultStr.WriteString(fmt.Sprintf("Error marshalling content: %v\n", err))
762+
} else {
763+
resultStr.WriteString(string(prettyJSON))
764+
}
765+
}
766+
767+
return resourceResult{result: resultStr.String()}
768+
}
769+
}
770+
557771
func handleSession(ctx context.Context, session *mcp.ClientSession) {
558772
if verbose {
559773
f, err := tea.LogToFile("debug.log", "debug")

0 commit comments

Comments
 (0)