Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

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

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

- **Tool Selection View:** A list of available tools. Use the arrow keys to navigate and press `Enter` to select a tool.
- **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.
- **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.
- **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.
- **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.
- **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.
- **Resource Detail View:** Shows the content of the selected resource. Press `Esc` to return to the resource list.
- **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.
- **Navigation:**
- `Esc`: Return to the tool selection view.
- - `t`: Switch to the tool selection view.
- - `r`: Switch to the resource browser view.
- - `p`: Switch to the prompt browser view.
- - `Esc`: Return to the previous view.
- `Ctrl+C`: Exit the application.
258 changes: 236 additions & 22 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"strconv"
"strings"

"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/list"
"github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/bubbles/viewport"
Expand Down Expand Up @@ -190,29 +191,39 @@ type viewState int
const (
toolSelectionView viewState = iota
argumentInputView
resourceListView
resourceDetailView
promptListView
)

type AppModel struct {
state viewState
ctx context.Context
session *mcp.ClientSession
toolList list.Model
argInputs []textinput.Model
argOrder []string
argFocus int
selectedTool *mcp.Tool
tools []*mcp.Tool
result string
err error
log []string
width int
height int
debugViewport viewport.Model
state viewState
ctx context.Context
session *mcp.ClientSession
toolList list.Model
resourceList list.Model
promptList list.Model
argInputs []textinput.Model
argOrder []string
argFocus int
selectedTool *mcp.Tool
tools []*mcp.Tool
resources []*mcp.Resource
prompts []*mcp.Prompt
selectedResource *mcp.Resource
result string
resourceResult string
err error
log []string
width int
height int
debugViewport viewport.Model
}

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

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

items := []list.Item{}
var prompts []*mcp.Prompt
for prompt, iterErr := range session.Prompts(ctx, nil) {
if iterErr != nil {
err = iterErr
break
}
prompts = append(prompts, prompt)
}

if err != nil {
return &AppModel{err: err}
}

for resource, iterErr := range session.Resources(ctx, nil) {
if iterErr != nil {
err = iterErr
break
}
resources = append(resources, resource)
}

if err != nil {
return &AppModel{err: err}
}

toolItems := []list.Item{}
for _, tool := range tools {
items = append(items, item{title: tool.Name, desc: tool.Description, tool: tool})
toolItems = append(toolItems, item{title: tool.Name, desc: tool.Description, tool: tool})
}

resourceItems := []list.Item{}
for _, resource := range resources {
resourceItems = append(resourceItems, resourceItem{title: resource.Name, desc: resource.Description, resource: resource})
}

promptItems := []list.Item{}
for _, prompt := range prompts {
promptItems = append(promptItems, promptItem{title: prompt.Name, desc: prompt.Description, prompt: prompt})
}

toolList := list.New(toolItems, list.NewDefaultDelegate(), 0, 0)
toolList.Title = "Select a tool to execute"
toolList.AdditionalShortHelpKeys = func() []key.Binding {
return []key.Binding{
key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "resources")),
key.NewBinding(key.WithKeys("p"), key.WithHelp("p", "prompts")),
}
}

l := list.New(items, list.NewDefaultDelegate(), 0, 0)
l.Title = "Select a tool to execute"
resourceList := list.New(resourceItems, list.NewDefaultDelegate(), 0, 0)
resourceList.Title = "Select a resource"
resourceList.AdditionalShortHelpKeys = func() []key.Binding {
return []key.Binding{
key.NewBinding(key.WithKeys("t"), key.WithHelp("t", "tools")),
key.NewBinding(key.WithKeys("p"), key.WithHelp("p", "prompts")),
}
}

promptList := list.New(promptItems, list.NewDefaultDelegate(), 0, 0)
promptList.Title = "Select a prompt"
promptList.AdditionalShortHelpKeys = func() []key.Binding {
return []key.Binding{
key.NewBinding(key.WithKeys("t"), key.WithHelp("t", "tools")),
key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "resources")),
}
}

vp := viewport.New(1, 1) // Initial size, will be updated on WindowSizeMsg
vp.SetContent("Debug log will appear here...")
Expand All @@ -242,8 +312,12 @@ func initialModel(ctx context.Context, session *mcp.ClientSession) *AppModel {
state: toolSelectionView,
ctx: ctx,
session: session,
toolList: l,
toolList: toolList,
resourceList: resourceList,
promptList: promptList,
tools: tools,
resources: resources,
prompts: prompts,
debugViewport: vp,
}
}
Expand All @@ -257,6 +331,24 @@ func (i item) Title() string { return i.title }
func (i item) Description() string { return i.desc }
func (i item) FilterValue() string { return i.title }

type resourceItem struct {
title, desc string
resource *mcp.Resource
}

func (i resourceItem) Title() string { return i.title }
func (i resourceItem) Description() string { return i.desc }
func (i resourceItem) FilterValue() string { return i.title }

type promptItem struct {
title, desc string
prompt *mcp.Prompt
}

func (i promptItem) Title() string { return i.title }
func (i promptItem) Description() string { return i.desc }
func (i promptItem) FilterValue() string { return i.title }

func (m *AppModel) logf(format string, a ...any) {
m.log = append(m.log, fmt.Sprintf(format, a...))
m.debugViewport.SetContent(strings.Join(m.log, "\n"))
Expand All @@ -282,13 +374,27 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
m.logf("Result:\n========\n%s", msg.result)
m.result = msg.result
case resourceResult:
if msg.err != nil {
m.err = msg.err
return m, nil
}
if verbose {
m.logf("Resource result received")
}
m.logf("Result:\n========\n%s", msg.result)
m.resourceResult = msg.result
case tea.KeyMsg:
if verbose {
m.logf("Key pressed: %s", msg.String())
}
switch msg.Type {
case tea.KeyEsc:
m.state = toolSelectionView
if m.state == resourceDetailView {
m.state = resourceListView
} else {
m.state = toolSelectionView
}
return m, nil
case tea.KeyCtrlC:
return m, tea.Quit
Expand All @@ -302,13 +408,31 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.debugViewport, cmd = m.debugViewport.Update(msg)
cmds = append(cmds, cmd)
return model, tea.Batch(cmds...)
case resourceListView:
var model tea.Model
model, cmd = m.updateResourceListView(msg)
cmds = append(cmds, cmd)
m.debugViewport, cmd = m.debugViewport.Update(msg)
cmds = append(cmds, cmd)
return model, tea.Batch(cmds...)
case promptListView:
var model tea.Model
model, cmd = m.updatePromptListView(msg)
cmds = append(cmds, cmd)
m.debugViewport, cmd = m.debugViewport.Update(msg)
cmds = append(cmds, cmd)
return model, tea.Batch(cmds...)
case argumentInputView:
var model tea.Model
model, cmd = m.updateArgumentInputView(msg)
cmds = append(cmds, cmd)
m.debugViewport, cmd = m.debugViewport.Update(msg)
cmds = append(cmds, cmd)
return model, tea.Batch(cmds...)
case resourceDetailView:
m.debugViewport, cmd = m.debugViewport.Update(msg)
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
}

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

if keyMsg, ok := msg.(tea.KeyMsg); ok {
if keyMsg.Type == tea.KeyEnter {
switch keyMsg.String() {
case "r":
m.state = resourceListView
return m, nil
case "p":
m.state = promptListView
return m, nil
case "enter":
selectedItem := m.toolList.SelectedItem().(item)
m.selectedTool = selectedItem.tool

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

func (m *AppModel) updatePromptListView(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
m.promptList, cmd = m.promptList.Update(msg)

if keyMsg, ok := msg.(tea.KeyMsg); ok {
switch keyMsg.String() {
case "t":
m.state = toolSelectionView
return m, nil
case "r":
m.state = resourceListView
return m, nil
}
}

return m, cmd
}

func (m *AppModel) updateResourceListView(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
m.resourceList, cmd = m.resourceList.Update(msg)

if keyMsg, ok := msg.(tea.KeyMsg); ok {
switch keyMsg.String() {
case "t":
m.state = toolSelectionView
return m, nil
case "p":
m.state = promptListView
return m, nil
case "enter":
selectedItem := m.resourceList.SelectedItem().(resourceItem)
m.selectedResource = selectedItem.resource
m.state = resourceDetailView
return m, m.readResourceCmd()
}
}

return m, cmd
}

func (m *AppModel) updateArgumentInputView(msg tea.Msg) (tea.Model, tea.Cmd) {
keyMsg, ok := msg.(tea.KeyMsg)
if !ok {
Expand Down Expand Up @@ -422,6 +594,18 @@ func (m AppModel) View() string {
case toolSelectionView:
m.toolList.SetSize(mainWidth-2, m.height-2)
mainContent.WriteString(m.toolList.View())
case resourceListView:
m.resourceList.SetSize(mainWidth-2, m.height-2)
mainContent.WriteString(m.resourceList.View())
case promptListView:
m.promptList.SetSize(mainWidth-2, m.height-2)
mainContent.WriteString(m.promptList.View())
case resourceDetailView:
var b strings.Builder
b.WriteString(fmt.Sprintf("Details for %s:\n\n", m.selectedResource.Name))
b.WriteString(m.resourceResult)
b.WriteString("\n\nPress Esc to go back to resource list.")
mainContent.WriteString(b.String())
case argumentInputView:
var b strings.Builder
b.WriteString(fmt.Sprintf("Enter arguments for %s:\n\n", m.selectedTool.Name))
Expand Down Expand Up @@ -456,6 +640,12 @@ type toolResult struct {
err error
}

// resourceResult represents the result of a resource read
type resourceResult struct {
result string
err error
}

// callToolCmd returns a tea.Cmd that calls the tool
func (m *AppModel) callToolCmd() tea.Cmd {
return func() tea.Msg {
Expand Down Expand Up @@ -554,6 +744,30 @@ func (m *AppModel) callTool() (tea.Model, tea.Cmd) {
return m, m.callToolCmd()
}

func (m *AppModel) readResourceCmd() tea.Cmd {
return func() tea.Msg {
params := &mcp.ReadResourceParams{
URI: m.selectedResource.URI,
}
result, err := m.session.ReadResource(m.ctx, params)
if err != nil {
return resourceResult{err: err}
}

var resultStr strings.Builder
for _, content := range result.Contents {
prettyJSON, err := json.MarshalIndent(content, "", " ")
if err != nil {
resultStr.WriteString(fmt.Sprintf("Error marshalling content: %v\n", err))
} else {
resultStr.WriteString(string(prettyJSON))
}
}

return resourceResult{result: resultStr.String()}
}
}

func handleSession(ctx context.Context, session *mcp.ClientSession) {
if verbose {
f, err := tea.LogToFile("debug.log", "debug")
Expand Down
Loading