Skip to content

Commit f39d5a1

Browse files
committed
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.
1 parent aa18be7 commit f39d5a1

2 files changed

Lines changed: 125 additions & 0 deletions

File tree

‎cmd/mcp.go‎

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"encoding/json"
55
"fmt"
66
"os"
7+
"sort"
78
"strings"
89

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

171+
if isToolNotFound(result) {
172+
if !mcpJSON {
173+
fmt.Fprintf(os.Stderr, "Tool not found; refreshing tool list and retrying...\n")
174+
}
175+
tools, listErr := c.ListTools(mcpServer)
176+
if listErr != nil {
177+
return emitJSONError(fmt.Errorf("tool not found and tools/list failed: %w", listErr))
178+
}
179+
if toolExists(tools.Tools, mcpToolName) {
180+
result, err = c.CallTool(mcpServer, mcpToolName, toolArgs)
181+
if err != nil {
182+
return emitJSONError(fmt.Errorf("tool call failed: %w", err))
183+
}
184+
} else {
185+
msg := fmt.Sprintf("tool %q not found on server %q", mcpToolName, mcpServer)
186+
if suggestions := similarTools(tools.Tools, mcpToolName, 5); len(suggestions) > 0 {
187+
msg += " (did you mean: " + strings.Join(suggestions, ", ") + "?)"
188+
}
189+
return emitJSONError(fmt.Errorf("%s", msg))
190+
}
191+
}
192+
170193
if mcpJSON {
171194
return emitToolResultJSON(result)
172195
}
@@ -254,6 +277,69 @@ func emitToolResultJSON(raw json.RawMessage) error {
254277
return emitJSON(out)
255278
}
256279

280+
// isToolNotFound reports whether a tools/call result is an isError envelope
281+
// whose text begins with "Tool not found". That signal is what triggers an
282+
// implicit tools/list refresh and one retry.
283+
func isToolNotFound(raw json.RawMessage) bool {
284+
var env struct {
285+
Content []struct {
286+
Text string `json:"text"`
287+
} `json:"content"`
288+
IsError bool `json:"isError"`
289+
}
290+
if err := json.Unmarshal(raw, &env); err != nil {
291+
return false
292+
}
293+
if !env.IsError || len(env.Content) == 0 {
294+
return false
295+
}
296+
return strings.HasPrefix(env.Content[0].Text, "Tool not found")
297+
}
298+
299+
func toolExists(tools []client.ToolDefinition, name string) bool {
300+
for _, t := range tools {
301+
if t.Name == name {
302+
return true
303+
}
304+
}
305+
return false
306+
}
307+
308+
// similarTools returns up to max tool names that share underscore-separated
309+
// tokens with name, ranked by shared-token count. Used to produce a
310+
// "did you mean" hint when a tool is genuinely missing.
311+
func similarTools(tools []client.ToolDefinition, name string, max int) []string {
312+
tokens := strings.Split(strings.ToLower(name), "_")
313+
type scored struct {
314+
name string
315+
score int
316+
}
317+
var candidates []scored
318+
for _, t := range tools {
319+
lower := strings.ToLower(t.Name)
320+
score := 0
321+
for _, tok := range tokens {
322+
if len(tok) > 1 && strings.Contains(lower, tok) {
323+
score++
324+
}
325+
}
326+
if score > 0 {
327+
candidates = append(candidates, scored{t.Name, score})
328+
}
329+
}
330+
sort.SliceStable(candidates, func(i, j int) bool {
331+
return candidates[i].score > candidates[j].score
332+
})
333+
if len(candidates) > max {
334+
candidates = candidates[:max]
335+
}
336+
out := make([]string, len(candidates))
337+
for i, c := range candidates {
338+
out[i] = c.name
339+
}
340+
return out
341+
}
342+
257343
func truncate(s string, max int) string {
258344
s = strings.ReplaceAll(s, "\n", " ")
259345
if len(s) <= max {

‎cmd/mcp_test.go‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import (
55
"io"
66
"os"
77
"testing"
8+
9+
"github.com/pipeboard-co/pipeboard-cli/internal/client"
810
)
911

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

130+
func TestIsToolNotFound(t *testing.T) {
131+
tests := []struct {
132+
name string
133+
raw string
134+
want bool
135+
}{
136+
{"tool not found envelope", `{"content":[{"type":"text","text":"Tool not found: foo"}],"isError":true}`, true},
137+
{"isError but different message", `{"content":[{"type":"text","text":"auth expired"}],"isError":true}`, false},
138+
{"not an error", `{"content":[{"type":"text","text":"Tool not found: foo"}],"isError":false}`, false},
139+
{"no content", `{"content":[],"isError":true}`, false},
140+
{"garbage", `not json`, false},
141+
}
142+
for _, tt := range tests {
143+
t.Run(tt.name, func(t *testing.T) {
144+
if got := isToolNotFound(json.RawMessage(tt.raw)); got != tt.want {
145+
t.Errorf("isToolNotFound = %v, want %v", got, tt.want)
146+
}
147+
})
148+
}
149+
}
150+
151+
func TestSimilarTools(t *testing.T) {
152+
tools := []client.ToolDefinition{
153+
{Name: "list_snap_audiences"},
154+
{Name: "list_snap_campaigns"},
155+
{Name: "list_meta_campaigns"},
156+
{Name: "get_snap_segment_details"},
157+
}
158+
got := similarTools(tools, "list_snap_segments", 3)
159+
if len(got) == 0 {
160+
t.Fatalf("expected suggestions, got none")
161+
}
162+
if got[0] != "list_snap_audiences" && got[0] != "list_snap_campaigns" {
163+
t.Errorf("top suggestion should be a list_snap_* tool, got %q", got[0])
164+
}
165+
}
166+
128167
func TestEmitJSONError_InJSONMode(t *testing.T) {
129168
prev := mcpJSON
130169
mcpJSON = true

0 commit comments

Comments
 (0)