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
86 changes: 86 additions & 0 deletions cmd/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"os"
"sort"
"strings"

"github.com/pipeboard-co/pipeboard-cli/internal/client"
Expand Down Expand Up @@ -167,6 +168,28 @@ func runMCPToolsCall(cmd *cobra.Command, args []string) error {
return emitJSONError(fmt.Errorf("tool call failed: %w", err))
}

if isToolNotFound(result) {
if !mcpJSON {
fmt.Fprintf(os.Stderr, "Tool not found; refreshing tool list and retrying...\n")
}
tools, listErr := c.ListTools(mcpServer)
if listErr != nil {
return emitJSONError(fmt.Errorf("tool not found and tools/list failed: %w", listErr))
}
if toolExists(tools.Tools, mcpToolName) {
result, err = c.CallTool(mcpServer, mcpToolName, toolArgs)
if err != nil {
return emitJSONError(fmt.Errorf("tool call failed: %w", err))
}
} else {
msg := fmt.Sprintf("tool %q not found on server %q", mcpToolName, mcpServer)
if suggestions := similarTools(tools.Tools, mcpToolName, 5); len(suggestions) > 0 {
msg += " (did you mean: " + strings.Join(suggestions, ", ") + "?)"
}
return emitJSONError(fmt.Errorf("%s", msg))
}
}

if mcpJSON {
return emitToolResultJSON(result)
}
Expand Down Expand Up @@ -254,6 +277,69 @@ func emitToolResultJSON(raw json.RawMessage) error {
return emitJSON(out)
}

// isToolNotFound reports whether a tools/call result is an isError envelope
// whose text begins with "Tool not found". That signal is what triggers an
// implicit tools/list refresh and one retry.
func isToolNotFound(raw json.RawMessage) bool {
var env struct {
Content []struct {
Text string `json:"text"`
} `json:"content"`
IsError bool `json:"isError"`
}
if err := json.Unmarshal(raw, &env); err != nil {
return false
}
if !env.IsError || len(env.Content) == 0 {
return false
}
return strings.HasPrefix(env.Content[0].Text, "Tool not found")
}

func toolExists(tools []client.ToolDefinition, name string) bool {
for _, t := range tools {
if t.Name == name {
return true
}
}
return false
}

// similarTools returns up to max tool names that share underscore-separated
// tokens with name, ranked by shared-token count. Used to produce a
// "did you mean" hint when a tool is genuinely missing.
func similarTools(tools []client.ToolDefinition, name string, max int) []string {
tokens := strings.Split(strings.ToLower(name), "_")
type scored struct {
name string
score int
}
var candidates []scored
for _, t := range tools {
lower := strings.ToLower(t.Name)
score := 0
for _, tok := range tokens {
if len(tok) > 1 && strings.Contains(lower, tok) {
score++
}
}
if score > 0 {
candidates = append(candidates, scored{t.Name, score})
}
}
sort.SliceStable(candidates, func(i, j int) bool {
return candidates[i].score > candidates[j].score
})
if len(candidates) > max {
candidates = candidates[:max]
}
out := make([]string, len(candidates))
for i, c := range candidates {
out[i] = c.name
}
return out
}

func truncate(s string, max int) string {
s = strings.ReplaceAll(s, "\n", " ")
if len(s) <= max {
Expand Down
39 changes: 39 additions & 0 deletions cmd/mcp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import (
"io"
"os"
"testing"

"github.com/pipeboard-co/pipeboard-cli/internal/client"
)

// captureStdout runs fn with os.Stdout redirected to a pipe and returns the
Expand Down Expand Up @@ -125,6 +127,43 @@ func TestEmitToolResultJSON_PassthroughWhenNotEnvelope(t *testing.T) {
}
}

func TestIsToolNotFound(t *testing.T) {
tests := []struct {
name string
raw string
want bool
}{
{"tool not found envelope", `{"content":[{"type":"text","text":"Tool not found: foo"}],"isError":true}`, true},
{"isError but different message", `{"content":[{"type":"text","text":"auth expired"}],"isError":true}`, false},
{"not an error", `{"content":[{"type":"text","text":"Tool not found: foo"}],"isError":false}`, false},
{"no content", `{"content":[],"isError":true}`, false},
{"garbage", `not json`, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := isToolNotFound(json.RawMessage(tt.raw)); got != tt.want {
t.Errorf("isToolNotFound = %v, want %v", got, tt.want)
}
})
}
}

func TestSimilarTools(t *testing.T) {
tools := []client.ToolDefinition{
{Name: "list_snap_audiences"},
{Name: "list_snap_campaigns"},
{Name: "list_meta_campaigns"},
{Name: "get_snap_segment_details"},
}
got := similarTools(tools, "list_snap_segments", 3)
if len(got) == 0 {
t.Fatalf("expected suggestions, got none")
}
if got[0] != "list_snap_audiences" && got[0] != "list_snap_campaigns" {
t.Errorf("top suggestion should be a list_snap_* tool, got %q", got[0])
}
}

func TestEmitJSONError_InJSONMode(t *testing.T) {
prev := mcpJSON
mcpJSON = true
Expand Down
Loading