From f39d5a1df3d2308fdcaa30e12f086b08a547496a Mon Sep 17 00:00:00 2001 From: Yves Junqueira Date: Thu, 23 Apr 2026 23:22:32 -0300 Subject: [PATCH] feat(mcp): auto-refresh and retry on Tool not found When a tools/call returns isError with "Tool not found", fetch tools/list once and retry if the tool now appears. If it is still missing, fail with up to 5 did-you-mean suggestions ranked by shared underscore tokens. --- cmd/mcp.go | 86 +++++++++++++++++++++++++++++++++++++++++++++++++ cmd/mcp_test.go | 39 ++++++++++++++++++++++ 2 files changed, 125 insertions(+) diff --git a/cmd/mcp.go b/cmd/mcp.go index 4e57f2e..f5e1e88 100644 --- a/cmd/mcp.go +++ b/cmd/mcp.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "os" + "sort" "strings" "github.com/pipeboard-co/pipeboard-cli/internal/client" @@ -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) } @@ -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 { diff --git a/cmd/mcp_test.go b/cmd/mcp_test.go index d99c773..ef11e0e 100644 --- a/cmd/mcp_test.go +++ b/cmd/mcp_test.go @@ -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 @@ -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