|
4 | 4 | "encoding/json" |
5 | 5 | "fmt" |
6 | 6 | "os" |
| 7 | + "sort" |
7 | 8 | "strings" |
8 | 9 |
|
9 | 10 | "github.com/pipeboard-co/pipeboard-cli/internal/client" |
@@ -167,6 +168,28 @@ func runMCPToolsCall(cmd *cobra.Command, args []string) error { |
167 | 168 | return emitJSONError(fmt.Errorf("tool call failed: %w", err)) |
168 | 169 | } |
169 | 170 |
|
| 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 | + |
170 | 193 | if mcpJSON { |
171 | 194 | return emitToolResultJSON(result) |
172 | 195 | } |
@@ -254,6 +277,69 @@ func emitToolResultJSON(raw json.RawMessage) error { |
254 | 277 | return emitJSON(out) |
255 | 278 | } |
256 | 279 |
|
| 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 | + |
257 | 343 | func truncate(s string, max int) string { |
258 | 344 | s = strings.ReplaceAll(s, "\n", " ") |
259 | 345 | if len(s) <= max { |
|
0 commit comments