diff --git a/packages/loopover-mcp/bin/loopover-mcp.js b/packages/loopover-mcp/bin/loopover-mcp.js index 3ee8b776ff..aca8c4d67a 100755 --- a/packages/loopover-mcp/bin/loopover-mcp.js +++ b/packages/loopover-mcp/bin/loopover-mcp.js @@ -42,7 +42,7 @@ const CLI_COMMAND_SPEC = { changelog: [], completion: [], version: [], - tools: [], + tools: ["search"], doctor: [], "init-client": [], "decision-pack": [], @@ -1932,7 +1932,7 @@ async function runCli(args) { if (command === "--help" || command === "help") return printHelp(); if (command === "--version" || command === "-v" || command === "version") return printVersion(parseOptions(args.slice(1))); if (command === "completion") return completionCommand(args.slice(1)); - if (command === "tools") return toolsCommand(parseOptions(args.slice(1))); + if (command === "tools") return toolsCommand(args.slice(1)); if (command === "agent") return runAgentCli(args.slice(1)); if (command === "cache") return runCacheCli(args.slice(1)); if (command === "maintain") return maintainCli(args.slice(1)); @@ -2519,19 +2519,76 @@ function printVersion(options) { process.stdout.write(`${packageName}/${packageVersion} (api ${currentApiVersion}, node ${process.version})\n`); } -function toolsCommand(options) { +function toolsCommand(args) { + const subcommand = args[0]; + if (subcommand === "search") return toolsSearchCommand(args.slice(1)); + const options = parseOptions(args); const tools = STDIO_TOOL_DESCRIPTORS.map(({ name, description }) => ({ name, description })); const payload = { count: tools.length, tools }; if (options.json) { process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); return; } + printToolRows(tools); +} + +// `tools search ` — fuzzy discovery across the ~150-tool combined surface (#6300). Matches the +// query against each registered tool's name AND description (not name-only), so "stake" surfaces +// get_subnet_stake_quote even though "stake" is only in its description. Reuses this CLI's existing +// levenshteinDistance for typo tolerance rather than pulling in a fuzzy-match dependency. +function toolsSearchCommand(args) { + const options = parseOptions(args); + const query = args.find((arg) => !arg.startsWith("--")); + if (!query) throw new Error("Usage: loopover-mcp tools search [--json]"); + const tools = searchTools(query); + const payload = { query, count: tools.length, tools }; + if (options.json) { + process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); + return; + } + if (tools.length === 0) { + process.stdout.write(`No tools match "${query}".\n`); + return; + } + printToolRows(tools); +} + +function printToolRows(tools) { const nameWidth = tools.reduce((width, tool) => Math.max(width, tool.name.length), 0); for (const tool of tools) { process.stdout.write(`${tool.name.padEnd(nameWidth)} ${tool.description}\n`); } } +// Rank registered tools by how well they match the query, best first. A substring hit on the name beats +// a substring hit on the description, which beats a typo-tolerant (Levenshtein) hit on any name/description +// token; tools that match none of these are dropped. Ties break alphabetically for a stable listing. +function searchTools(query) { + const needle = query.toLowerCase(); + const scored = []; + for (const { name, description } of STDIO_TOOL_DESCRIPTORS) { + const score = scoreToolMatch(needle, name.toLowerCase(), description.toLowerCase()); + if (score !== null) scored.push({ name, description, score }); + } + scored.sort((a, b) => a.score - b.score || a.name.localeCompare(b.name)); + return scored.map(({ name, description }) => ({ name, description })); +} + +function scoreToolMatch(needle, name, description) { + if (name.includes(needle)) return 0; + if (description.includes(needle)) return 1; + // Typo tolerance: compare the query to each name/description token, allowing a small edit distance that + // scales with the query length (a longer query tolerates more typos, a very short one stays exact-ish). + const budget = Math.max(1, Math.floor(needle.length / 4)); + let best = Infinity; + for (const token of `${name} ${description}`.split(/[^a-z0-9]+/)) { + if (!token) continue; + const distance = levenshteinDistance(needle, token); + if (distance < best) best = distance; + } + return best <= budget ? 2 + best : null; +} + function completionCommand(args) { const shell = args[0] && !args[0].startsWith("--") ? args[0] : undefined; const options = parseOptions(args.filter((arg) => arg.startsWith("--"))); @@ -2678,6 +2735,7 @@ function printHelp() { loopover-mcp --stdio loopover-mcp version [--json] loopover-mcp tools [--json] + loopover-mcp tools search [--json] loopover-mcp completion bash|zsh|fish|powershell [--json] loopover-mcp login [--profile name] [--github-token ] [--json] loopover-mcp logout [--profile name] [--all] [--json] diff --git a/test/unit/mcp-cli-tools-search.test.ts b/test/unit/mcp-cli-tools-search.test.ts new file mode 100644 index 0000000000..940171f595 --- /dev/null +++ b/test/unit/mcp-cli-tools-search.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; + +import { run, runExpectingFailure } from "./support/mcp-cli-harness"; + +// #6300: `tools search ` lets a user or agent find the right tool among the ~150-tool combined +// surface without reading the full `tools` listing or already knowing the exact name. It fuzzy-matches the +// query against each registered tool's name AND description, so a term that only appears in a description +// still surfaces the tool. +type SearchPayload = { + query: string; + count: number; + tools: Array<{ name: string; description: string }>; +}; + +function search(query: string): SearchPayload { + return JSON.parse(run(["tools", "search", query, "--json"])) as SearchPayload; +} + +describe("loopover-mcp CLI — tools search (#6300)", () => { + it("matches by name and ranks the name hit first", () => { + const payload = search("reviewability"); + expect(payload.query).toBe("reviewability"); + expect(payload.count).toBeGreaterThan(0); + expect(payload.tools).toHaveLength(payload.count); + // A tool whose NAME contains the query is the closest possible match, so it sorts to the top. + expect(payload.tools[0]!.name).toBe("loopover_get_pr_reviewability"); + expect(payload.tools[0]!.name).toContain("reviewability"); + }); + + it("matches by description even when the query is absent from every tool name", () => { + const payload = search("duplicate"); + expect(payload.count).toBeGreaterThan(0); + // No registered tool has "duplicate" in its name, so every hit here is description-driven. + expect(payload.tools.every((tool) => !tool.name.includes("duplicate"))).toBe(true); + const preflight = payload.tools.find((tool) => tool.name === "loopover_preflight_pr"); + expect(preflight, "a description-only match must still surface").toBeTruthy(); + expect(preflight!.description).toContain("duplicate"); + }); + + it("tolerates a typo via the CLI's existing Levenshtein matcher", () => { + const payload = search("reviewabilty"); + expect(payload.count).toBeGreaterThan(0); + const hit = payload.tools.find((tool) => tool.name === "loopover_get_pr_reviewability"); + expect(hit, "a one-character typo should still surface the tool").toBeTruthy(); + // The raw (misspelled) query is a substring of neither the name nor the description — this hit can only + // come from the fuzzy token comparison, not a substring match. + expect(hit!.name.includes("reviewabilty")).toBe(false); + expect(hit!.description.toLowerCase().includes("reviewabilty")).toBe(false); + }); + + it("returns an empty result set for a query that matches nothing", () => { + const payload = search("zzqqxxnope"); + expect(payload.count).toBe(0); + expect(payload.tools).toEqual([]); + }); + + it("prints name + description rows for a human search and a friendly line when nothing matches", () => { + const plain = run(["tools", "search", "reviewability"]); + const payload = search("reviewability"); + for (const tool of payload.tools) { + expect(plain).toContain(tool.name); + expect(plain).toContain(tool.description); + } + + const empty = run(["tools", "search", "zzqqxxnope"]); + expect(empty).toContain('No tools match "zzqqxxnope".'); + }); + + it("rejects a search with no query and documents the subcommand in --help", () => { + const failure = runExpectingFailure(["tools", "search"]); + expect(failure.status).not.toBe(0); + expect(`${failure.stdout}${failure.stderr}`).toContain("Usage: loopover-mcp tools search [--json]"); + + const help = run(["--help"]); + expect(help).toContain("loopover-mcp tools search [--json]"); + }); +});