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
64 changes: 61 additions & 3 deletions packages/loopover-mcp/bin/loopover-mcp.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ const CLI_COMMAND_SPEC = {
changelog: [],
completion: [],
version: [],
tools: [],
tools: ["search"],
doctor: [],
"init-client": [],
"decision-pack": [],
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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 <query>` — 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 <query> [--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("--")));
Expand Down Expand Up @@ -2678,6 +2735,7 @@ function printHelp() {
loopover-mcp --stdio
loopover-mcp version [--json]
loopover-mcp tools [--json]
loopover-mcp tools search <query> [--json]
loopover-mcp completion bash|zsh|fish|powershell [--json]
loopover-mcp login [--profile name] [--github-token <token>] [--json]
loopover-mcp logout [--profile name] [--all] [--json]
Expand Down
77 changes: 77 additions & 0 deletions test/unit/mcp-cli-tools-search.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { describe, expect, it } from "vitest";

import { run, runExpectingFailure } from "./support/mcp-cli-harness";

// #6300: `tools search <query>` 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 <query> [--json]");

const help = run(["--help"]);
expect(help).toContain("loopover-mcp tools search <query> [--json]");
});
});