diff --git a/cmd/mcptools/main.go b/cmd/mcptools/main.go index 2a5d51d..182a53a 100644 --- a/cmd/mcptools/main.go +++ b/cmd/mcptools/main.go @@ -152,7 +152,7 @@ func newToolsCmd() *cobra.Command { mcpClient, err := createClient(parsedArgs) if err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) - fmt.Fprintf(os.Stderr, "Example: mcp tools npx -y @modelcontextprotocol/server-filesystem ~/Code\n") + fmt.Fprintf(os.Stderr, "Example: mcp tools npx -y @modelcontextprotocol/server-filesystem ~\n") os.Exit(1) } @@ -182,7 +182,7 @@ func newResourcesCmd() *cobra.Command { mcpClient, err := createClient(parsedArgs) if err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) - fmt.Fprintf(os.Stderr, "Example: mcp resources npx -y @modelcontextprotocol/server-filesystem ~/Code\n") + fmt.Fprintf(os.Stderr, "Example: mcp resources npx -y @modelcontextprotocol/server-filesystem ~\n") os.Exit(1) } @@ -212,7 +212,7 @@ func newPromptsCmd() *cobra.Command { mcpClient, err := createClient(parsedArgs) if err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) - fmt.Fprintf(os.Stderr, "Example: mcp prompts npx -y @modelcontextprotocol/server-filesystem ~/Code\n") + fmt.Fprintf(os.Stderr, "Example: mcp prompts npx -y @modelcontextprotocol/server-filesystem ~\n") os.Exit(1) } @@ -241,7 +241,7 @@ func newCallCmd() *cobra.Command { fmt.Fprintln(os.Stderr, "Error: entity name is required") fmt.Fprintln( os.Stderr, - "Example: mcp call read_file npx -y @modelcontextprotocol/server-filesystem ~/Code", + "Example: mcp call read_file npx -y @modelcontextprotocol/server-filesystem ~", ) os.Exit(1) } @@ -275,7 +275,7 @@ func newCallCmd() *cobra.Command { fmt.Fprintln(os.Stderr, "Error: entity name is required") fmt.Fprintln( os.Stderr, - "Example: mcp call read_file npx -y @modelcontextprotocol/server-filesystem ~/Code", + "Example: mcp call read_file npx -y @modelcontextprotocol/server-filesystem ~", ) os.Exit(1) } @@ -292,7 +292,7 @@ func newCallCmd() *cobra.Command { fmt.Fprintln(os.Stderr, "Error: command to execute is required when using stdio transport") fmt.Fprintln( os.Stderr, - "Example: mcp call read_file npx -y @modelcontextprotocol/server-filesystem ~/Code", + "Example: mcp call read_file npx -y @modelcontextprotocol/server-filesystem ~", ) os.Exit(1) } @@ -350,7 +350,7 @@ func newGetPromptCmd() *cobra.Command { fmt.Fprintln(os.Stderr, "Error: prompt name is required") fmt.Fprintln( os.Stderr, - "Example: mcp get-prompt read_file npx -y @modelcontextprotocol/server-filesystem ~/Code", + "Example: mcp get-prompt read_file npx -y @modelcontextprotocol/server-filesystem ~", ) os.Exit(1) } @@ -384,7 +384,7 @@ func newGetPromptCmd() *cobra.Command { fmt.Fprintln(os.Stderr, "Error: prompt name is required") fmt.Fprintln( os.Stderr, - "Example: mcp get-prompt read_file npx -y @modelcontextprotocol/server-filesystem ~/Code", + "Example: mcp get-prompt read_file npx -y @modelcontextprotocol/server-filesystem ~", ) os.Exit(1) } @@ -428,7 +428,7 @@ func newReadResourceCmd() *cobra.Command { fmt.Fprintln(os.Stderr, "Error: resource name is required") fmt.Fprintln( os.Stderr, - "Example: mcp read-resource npx -y @modelcontextprotocol/server-filesystem ~/Code", + "Example: mcp read-resource npx -y @modelcontextprotocol/server-filesystem ~", ) os.Exit(1) } @@ -462,7 +462,7 @@ func newReadResourceCmd() *cobra.Command { fmt.Fprintln(os.Stderr, "Error: resource name is required") fmt.Fprintln( os.Stderr, - "Example: mcp read-resource npx -y @modelcontextprotocol/server-filesystem ~/Code", + "Example: mcp read-resource npx -y @modelcontextprotocol/server-filesystem ~", ) os.Exit(1) } @@ -519,7 +519,7 @@ func newShellCmd() *cobra.Command { //nolint:gocyclo if len(parsedArgs) == 0 { fmt.Fprintln(os.Stderr, "Error: command to execute is required when using the shell") - fmt.Fprintln(os.Stderr, "Example: mcp shell npx -y @modelcontextprotocol/server-filesystem ~/Code") + fmt.Fprintln(os.Stderr, "Example: mcp shell npx -y @modelcontextprotocol/server-filesystem ~") os.Exit(1) } @@ -535,8 +535,9 @@ func newShellCmd() *cobra.Command { //nolint:gocyclo os.Exit(1) } - fmt.Println("mcp > connected to MCP server over stdio") - fmt.Println("mcp > Type '/h' for help or '/q' to quit") + fmt.Println("mcp tools shell") + fmt.Println("connected to:", strings.Join(parsedArgs, " ")) + fmt.Println("\nmcp > Type '/h' for help or '/q' to quit") line := liner.NewLiner() defer func() { _ = line.Close() }() diff --git a/go.mod b/go.mod index d3599bf..6093fc1 100644 --- a/go.mod +++ b/go.mod @@ -2,12 +2,18 @@ module github.com/f/mcptools go 1.24.1 -require github.com/spf13/cobra v1.9.1 +require ( + github.com/jedib0t/go-pretty/v6 v6.6.7 + github.com/peterh/liner v1.2.2 + github.com/spf13/cobra v1.9.1 +) require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/mattn/go-runewidth v0.0.3 // indirect - github.com/peterh/liner v1.2.2 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/pflag v1.0.6 // indirect - golang.org/x/sys v0.0.0-20211117180635-dee7805ff2e1 // indirect + golang.org/x/sys v0.31.0 // indirect + golang.org/x/term v0.30.0 // indirect + golang.org/x/text v0.23.0 // indirect ) diff --git a/go.sum b/go.sum index 6fae2fb..ab5d87c 100644 --- a/go.sum +++ b/go.sum @@ -1,16 +1,36 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/mattn/go-runewidth v0.0.3 h1:a+kO+98RDGEfo6asOGMmpodZq4FNtnGP54yps8BzLR4= +github.com/jedib0t/go-pretty/v6 v6.6.7 h1:m+LbHpm0aIAPLzLbMfn8dc3Ht8MW7lsSO4MPItz/Uuo= +github.com/jedib0t/go-pretty/v6 v6.6.7/go.mod h1:YwC5CE4fJ1HFUDeivSV1r//AmANFHyqczZk+U6BDALU= github.com/mattn/go-runewidth v0.0.3/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/peterh/liner v1.2.2 h1:aJ4AOodmL+JxOZZEL2u9iJf8omNRpqHc/EbrK+3mAXw= github.com/peterh/liner v1.2.2/go.mod h1:xFwJyiKIXJZUKItq5dGHZSTBRAuG/CpeNpWLyiNRNwI= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -golang.org/x/sys v0.0.0-20211117180635-dee7805ff2e1 h1:kwrAHlwJ0DUBZwQ238v+Uod/3eZ8B2K5rYsUHBQvzmI= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= golang.org/x/sys v0.0.0-20211117180635-dee7805ff2e1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= +golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= +golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/pkg/jsonutils/jsonutils.go b/pkg/jsonutils/jsonutils.go index b0e8e0c..9454049 100644 --- a/pkg/jsonutils/jsonutils.go +++ b/pkg/jsonutils/jsonutils.go @@ -7,10 +7,13 @@ import ( "bytes" "encoding/json" "fmt" + "os" "reflect" "sort" "strings" "text/tabwriter" + + "golang.org/x/term" ) // OutputFormat represents the available output format options. @@ -108,6 +111,7 @@ func formatTable(data any) (string, error) { return formatGenericMap(mapVal) } +// formatToolsList formats a list of tools as a table. func formatToolsList(tools any) (string, error) { toolsSlice, ok := tools.([]any) if !ok { @@ -119,11 +123,19 @@ func formatToolsList(tools any) (string, error) { } var buf bytes.Buffer - w := tabwriter.NewWriter(&buf, 0, 0, 2, ' ', 0) + w := tabwriter.NewWriter(&buf, 0, 0, 2, ' ', tabwriter.StripEscape) fmt.Fprintln(w, "NAME\tDESCRIPTION") fmt.Fprintln(w, "----\t-----------") + termWidth := getTermWidth() + nameColWidth := 20 // Default name column width + descColWidth := termWidth - nameColWidth - 5 // Leave some margin + + if descColWidth < 10 { + descColWidth = max(10, termWidth-nameColWidth-5) // Adaptive minimum width + } + for _, t := range toolsSlice { tool, ok1 := t.(map[string]any) if !ok1 { @@ -133,17 +145,77 @@ func formatToolsList(tools any) (string, error) { name, _ := tool["name"].(string) desc, _ := tool["description"].(string) - if len(desc) > 70 { - desc = desc[:67] + "..." + // Handle multiline description + lines := wrapText(desc, descColWidth) + + if len(lines) == 0 { + fmt.Fprintf(w, "%s\t\n", name) + continue + } + + // First line with name + fmt.Fprintf(w, "%s\t%s\n", name, lines[0]) + + // Remaining lines with empty name column + for _, line := range lines[1:] { + fmt.Fprintf(w, "\t%s\n", line) } - fmt.Fprintf(w, "%s\t%s\n", name, desc) + // Add a blank line between entries + if len(lines) > 1 { + fmt.Fprintln(w, "\t") + } } _ = w.Flush() return buf.String(), nil } +// getTermWidth returns the terminal width or a default value if detection fails. +func getTermWidth() int { + width, _, err := term.GetSize(int(os.Stdout.Fd())) + if err != nil || width <= 0 { + return 80 // Default width if terminal width cannot be determined + } + return width +} + +// wrapText wraps text to fit within a specified width. +func wrapText(text string, width int) []string { + if text == "" { + return []string{} + } + + words := strings.Fields(text) + if len(words) == 0 { + return []string{} + } + + var lines []string + var currentLine string + + for _, word := range words { + switch { + case len(currentLine) == 0: + currentLine = word + case len(currentLine)+len(word)+1 > width: + // Add current line to lines and start a new line + lines = append(lines, currentLine) + currentLine = word + default: + currentLine += " " + word + } + } + + // Add the last line + if len(currentLine) > 0 { + lines = append(lines, currentLine) + } + + return lines +} + +// formatResourcesList formats a list of resources as a table. func formatResourcesList(resources any) (string, error) { resourcesSlice, ok := resources.([]any) if !ok { @@ -170,6 +242,7 @@ func formatResourcesList(resources any) (string, error) { resType, _ := resource["type"].(string) uri, _ := resource["uri"].(string) + // Use the entire URI instead of truncating fmt.Fprintf(w, "%s\t%s\t%s\n", name, resType, uri) } @@ -177,6 +250,7 @@ func formatResourcesList(resources any) (string, error) { return buf.String(), nil } +// formatPromptsList formats a list of prompts as a table. func formatPromptsList(prompts any) (string, error) { promptsSlice, ok := prompts.([]any) if !ok { @@ -188,11 +262,19 @@ func formatPromptsList(prompts any) (string, error) { } var buf bytes.Buffer - w := tabwriter.NewWriter(&buf, 0, 0, 2, ' ', 0) + w := tabwriter.NewWriter(&buf, 0, 0, 2, ' ', tabwriter.StripEscape) fmt.Fprintln(w, "NAME\tDESCRIPTION") fmt.Fprintln(w, "----\t-----------") + termWidth := getTermWidth() + nameColWidth := 20 // Default name column width + descColWidth := termWidth - nameColWidth - 5 // Leave some margin + + if descColWidth < 10 { + descColWidth = 40 // Minimum width if terminal is too narrow + } + for _, p := range promptsSlice { prompt, ok1 := p.(map[string]any) if !ok1 { @@ -202,11 +284,26 @@ func formatPromptsList(prompts any) (string, error) { name, _ := prompt["name"].(string) desc, _ := prompt["description"].(string) - if len(desc) > 70 { - desc = desc[:67] + "..." + // Handle multiline description + lines := wrapText(desc, descColWidth) + + if len(lines) == 0 { + fmt.Fprintf(w, "%s\t\n", name) + continue } - fmt.Fprintf(w, "%s\t%s\n", name, desc) + // First line with name + fmt.Fprintf(w, "%s\t%s\n", name, lines[0]) + + // Remaining lines with empty name column + for _, line := range lines[1:] { + fmt.Fprintf(w, "\t%s\n", line) + } + + // Add a blank line between entries + if len(lines) > 1 { + fmt.Fprintln(w, "\t") + } } _ = w.Flush() diff --git a/pkg/jsonutils/jsonutils_test.go b/pkg/jsonutils/jsonutils_test.go index d2f266a..f040b5f 100644 --- a/pkg/jsonutils/jsonutils_test.go +++ b/pkg/jsonutils/jsonutils_test.go @@ -1,10 +1,89 @@ package jsonutils import ( + "os" "strings" "testing" ) +// TestWrapText tests the text wrapping functionality. +func TestWrapText(t *testing.T) { + testCases := []struct { + name string // String + text string // String + expected []string // Slice pointer (largest) + width int // Integer (smallest) + }{ + { + name: "empty text", + text: "", + width: 10, + expected: []string{}, + }, + { + name: "single word", + text: "hello", + width: 10, + expected: []string{"hello"}, + }, + { + name: "multiple words fitting in one line", + text: "hello world", + width: 20, + expected: []string{"hello world"}, + }, + { + name: "multiple words requiring wrapping", + text: "this is a longer text that needs to be wrapped", + width: 15, + expected: []string{"this is a", "longer text", "that needs to", "be wrapped"}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result := wrapText(tc.text, tc.width) + + if len(result) != len(tc.expected) { + t.Fatalf("Expected %d lines, got %d", len(tc.expected), len(result)) + } + + for i, line := range result { + if line != tc.expected[i] { + t.Errorf("Line %d: expected '%s', got '%s'", i, tc.expected[i], line) + } + } + }) + } +} + +// TestGetTermWidth tests the terminal width detection. +func TestGetTermWidth(t *testing.T) { + // Save original stdout and restore it after the test + origStdout := os.Stdout + defer func() { os.Stdout = origStdout }() + + // Non-terminal case should return default width + r, w, _ := os.Pipe() + os.Stdout = w + + width := getTermWidth() + + // Close pipe + if err := w.Close(); err != nil { + t.Errorf("Error closing pipe: %v", err) + } + os.Stdout = origStdout + + // Read and discard pipe content + _, _ = r.Read(make([]byte, 1024)) + + // Should return default width for non-terminal + if width != 80 { + t.Errorf("Expected default width 80 for non-terminal, got %d", width) + } +} + func TestFormat(t *testing.T) { testCases := []struct { name string @@ -91,16 +170,16 @@ func TestFormat(t *testing.T) { func TestParseFormat(t *testing.T) { testCases := []struct { - input string expected OutputFormat + input string }{ - {"json", FormatJSON}, - {"J", FormatJSON}, - {"pretty", FormatPretty}, - {"P", FormatPretty}, - {"table", FormatTable}, - {"T", FormatTable}, - {"unknown", FormatTable}, // Default is table + {FormatJSON, "json"}, + {FormatJSON, "J"}, + {FormatPretty, "pretty"}, + {FormatPretty, "P"}, + {FormatTable, "table"}, + {FormatTable, "T"}, + {FormatTable, "unknown"}, } for _, tc := range testCases { @@ -112,3 +191,40 @@ func TestParseFormat(t *testing.T) { }) } } + +// TestToolsListFormatting tests the table formatting for tools list. +func TestToolsListFormatting(t *testing.T) { + tools := []any{ + map[string]any{ + "name": "tool1", + "description": "This is a short description", + }, + map[string]any{ + "name": "tool2", + "description": "This is a longer description that should wrap across multiple lines in the table output when displayed to the user", + }, + } + + // Convert to expected structure + toolsData := map[string]any{ + "tools": tools, + } + + output, err := formatTable(toolsData) + if err != nil { + t.Fatalf("Error formatting tools list: %v", err) + } + + // Basic verification + if !strings.Contains(output, "NAME") || !strings.Contains(output, "DESCRIPTION") { + t.Errorf("Missing table headers in output: %s", output) + } + + if !strings.Contains(output, "tool1") || !strings.Contains(output, "tool2") { + t.Errorf("Missing tool names in output: %s", output) + } + + if !strings.Contains(output, "This is a short description") { + t.Errorf("Missing tool description in output: %s", output) + } +}