diff --git a/packages/nimbus-mcp/src/data-loader.ts b/packages/nimbus-mcp/src/data-loader.ts index 738958532..22dcc772f 100644 --- a/packages/nimbus-mcp/src/data-loader.ts +++ b/packages/nimbus-mcp/src/data-loader.ts @@ -154,10 +154,7 @@ export async function getIconData(): Promise { // --------------------------------------------------------------------------- /** Returns the full icon catalog. */ -export async function getIconCatalog(): Promise { - const catalogPath = resolve(getDataDir(), "icons.json"); - return readJson(catalogPath); -} +export const getIconCatalog = lazyJson("icons.json"); // --------------------------------------------------------------------------- // Flattened token data diff --git a/packages/nimbus-mcp/src/server.ts b/packages/nimbus-mcp/src/server.ts index 54cd230e7..782a009ca 100644 --- a/packages/nimbus-mcp/src/server.ts +++ b/packages/nimbus-mcp/src/server.ts @@ -2,6 +2,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { registerGetComponent } from "./tools/get-component.js"; import { registerListComponents } from "./tools/list-components.js"; import { registerSearchDocs } from "./tools/search-docs.js"; +import { registerSearchIcons } from "./tools/search-icons.js"; /** * Creates and configures the Nimbus MCP server. @@ -27,6 +28,7 @@ export function createServer(): McpServer { registerGetComponent(server); registerListComponents(server); registerSearchDocs(server); + registerSearchIcons(server); return server; } diff --git a/packages/nimbus-mcp/src/tools/search-icons.spec.ts b/packages/nimbus-mcp/src/tools/search-icons.spec.ts new file mode 100644 index 000000000..87bbb9756 --- /dev/null +++ b/packages/nimbus-mcp/src/tools/search-icons.spec.ts @@ -0,0 +1,188 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import type { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { createTestClient } from "../test-utils.js"; +import type { IconResult, SearchIconsResponse } from "../types.js"; + +/** + * Behavioral tests for the search_icons tool. + * + * Reads the icon catalog from data/icons.json (populated by the prebuild step). + * Tests assert result shapes, pagination metadata, and known icon matches. + */ + +async function callSearchIcons( + client: Client, + args: { query: string; offset?: number } +): Promise { + const result = await client.callTool({ + name: "search_icons", + arguments: args, + }); + const text = (result.content as Array<{ type: string; text: string }>).find( + (c) => c.type === "text" + )?.text; + + if (!text) { + throw new Error("search_icons returned no text content"); + } + + return JSON.parse(text) as SearchIconsResponse; +} + +describe("search_icons — basic search", () => { + let client: Client; + let close: () => Promise; + + beforeAll(async () => { + const ctx = createTestClient(); + await ctx.connect(); + client = ctx.client; + close = ctx.close; + }); + + afterAll(() => close()); + + it("returns results for a matching query", async () => { + const response = await callSearchIcons(client, { query: "check" }); + expect(response.totalResults).toBeGreaterThan(0); + expect(response.results.length).toBeGreaterThan(0); + }); + + it("every entry has the required fields", async () => { + const response = await callSearchIcons(client, { query: "arrow" }); + expect(response.results.length).toBeGreaterThan(0); + expect(response.importPath).toBe("@commercetools/nimbus-icons"); + for (const icon of response.results) { + expect(typeof icon.name).toBe("string"); + expect(["material", "custom"]).toContain(icon.category); + expect(Array.isArray(icon.keywords)).toBe(true); + } + }); + + it("returns pagination metadata", async () => { + const response = await callSearchIcons(client, { query: "arrow" }); + expect(response.totalResults).toBeGreaterThan(0); + expect(response.offset).toBe(0); + expect(response.pageSize).toBe(10); + expect(typeof response.hasMore).toBe("boolean"); + }); + + it("pages results at 10 per page", async () => { + const response = await callSearchIcons(client, { query: "arrow" }); + expect(response.results.length).toBeLessThanOrEqual(10); + }); + + it("returns all matching results for a broad query", async () => { + // "s" is a very broad query — totalResults should reflect actual matches + const response = await callSearchIcons(client, { query: "s" }); + expect(response.totalResults).toBeGreaterThan(10); + }); + + it("returns zero results for a nonsense query", async () => { + const response = await callSearchIcons(client, { query: "zzqxjwvfk" }); + expect(response.totalResults).toBe(0); + expect(response.results).toEqual([]); + }); + + it("returns zero results for a long nonsense query containing short icon names", async () => { + // A long garbage string that contains substrings matching short icon names + // (e.g. "sd", "in") — should not match via reverse-substring logic + const response = await callSearchIcons(client, { + query: "dsfawedsf;sdklmf klsdjqwin ipwsder y", + }); + expect(response.totalResults).toBe(0); + expect(response.results).toEqual([]); + }); +}); + +describe("search_icons — pagination", () => { + let client: Client; + let close: () => Promise; + + beforeAll(async () => { + const ctx = createTestClient(); + await ctx.connect(); + client = ctx.client; + close = ctx.close; + }); + + afterAll(() => close()); + + it("returns hasMore and hint when more results exist", async () => { + const response = await callSearchIcons(client, { query: "arrow" }); + // "arrow" should match more than 10 icons + expect(response.hasMore).toBe(true); + expect(response.hint).toBeDefined(); + expect(response.hint).toContain("offset"); + }); + + it("retrieves the next page with offset", async () => { + const page1 = await callSearchIcons(client, { query: "arrow" }); + const page2 = await callSearchIcons(client, { + query: "arrow", + offset: 10, + }); + + expect(page2.results.length).toBeGreaterThan(0); + // pages should not overlap + const page1Names = page1.results.map((r) => r.name); + const page2Names = page2.results.map((r) => r.name); + for (const name of page2Names) { + expect(page1Names).not.toContain(name); + } + }); + + it("returns hasMore: false on the last page", async () => { + // Fetch page 1 to learn totalResults, then jump past the end + const page1 = await callSearchIcons(client, { query: "home" }); + const lastPageOffset = page1.totalResults; // offset beyond all results + const response = await callSearchIcons(client, { + query: "home", + offset: lastPageOffset, + }); + expect(response.hasMore).toBe(false); + expect(response.hint).toBeUndefined(); + }); + + it("echoes the requested offset in the response", async () => { + const response = await callSearchIcons(client, { + query: "arrow", + offset: 10, + }); + expect(response.offset).toBe(10); + }); +}); + +describe("search_icons — acceptance criteria", () => { + let client: Client; + let close: () => Promise; + + beforeAll(async () => { + const ctx = createTestClient(); + await ctx.connect(); + client = ctx.client; + close = ctx.close; + }); + + afterAll(() => close()); + + it('search_icons("CheckCircle") returns CheckCircle across pages', async () => { + const page1 = await callSearchIcons(client, { query: "CheckCircle" }); + const page2 = page1.hasMore + ? await callSearchIcons(client, { query: "CheckCircle", offset: 10 }) + : { results: [] as IconResult[] }; + + const names = [...page1.results, ...page2.results].map((r) => r.name); + expect(names).toContain("CheckCircle"); + }); + + it("importPath is hoisted to envelope, not per result", async () => { + const response = await callSearchIcons(client, { query: "checkmark" }); + expect(response.importPath).toBe("@commercetools/nimbus-icons"); + for (const icon of response.results) { + expect( + (icon as unknown as Record).importPath + ).toBeUndefined(); + } + }); +}); diff --git a/packages/nimbus-mcp/src/tools/search-icons.ts b/packages/nimbus-mcp/src/tools/search-icons.ts new file mode 100644 index 000000000..3b10ce4d9 --- /dev/null +++ b/packages/nimbus-mcp/src/tools/search-icons.ts @@ -0,0 +1,175 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import Fuse from "fuse.js"; +import { z } from "zod"; +import { getIconCatalog } from "../data-loader.js"; +import type { + FuseCache, + IconCatalogEntry, + RelevanceFields, + SearchIconsResponse, +} from "../types.js"; +import { rankByRelevance } from "../utils/relevance.js"; + +/** Number of results returned per page. */ +const PAGE_SIZE = 10; + +/** + * Minimum keyword length for substring matching to avoid single-char noise. + * Also used as Fuse.js minMatchCharLength so both passes share the same threshold. + */ +const MIN_KEYWORD_LENGTH = 2; + +let fuseCache: FuseCache | undefined; + +async function getFuse(): Promise { + if (!fuseCache) { + const catalog = await getIconCatalog(); + fuseCache = { + icons: catalog.icons, + fuse: new Fuse(catalog.icons, { + keys: [ + { name: "name", weight: 2 }, + { name: "keywords", weight: 1 }, + ], + // Fuse surfaces candidates up to this threshold; the post-filter + // (score < 0.35) is intentionally tighter to discard borderline matches. + threshold: 0.4, + ignoreLocation: true, + includeScore: true, + minMatchCharLength: MIN_KEYWORD_LENGTH, + }), + }; + } + return fuseCache; +} + +/** Maps an icon entry to RelevanceFields for ranking. */ +function toRelevanceFields(icon: IconCatalogEntry): RelevanceFields { + return { + title: icon.name, + description: "", + tags: icon.keywords.join(" "), + }; +} + +/** + * Two-pass search matching the list_components pattern: + * Pass 1: Substring match — icon name/keyword contains the query. + * Pass 2: Fuse.js fuzzy fallback. + */ +async function searchIcons(query: string): Promise { + const { fuse, icons } = await getFuse(); + const tokens = query.toLowerCase().split(/\s+/).filter(Boolean); + const needle = tokens.join(" "); + + // Pass 1: substring match on name and keywords. + // Only checks if the icon name/keyword contains the needle — not the reverse — + // to avoid short icon names matching inside long or nonsense query strings. + const substringMatches: IconCatalogEntry[] = []; + + for (const icon of icons) { + const nameLower = icon.name.toLowerCase(); + if (nameLower.includes(needle)) { + substringMatches.push(icon); + } else if ( + icon.keywords.some( + (kw) => + kw.length >= MIN_KEYWORD_LENGTH && kw.toLowerCase().includes(needle) + ) + ) { + substringMatches.push(icon); + } + } + + if (substringMatches.length > 0) { + return rankByRelevance(substringMatches, tokens, toRelevanceFields); + } + + // Pass 2: fuzzy fallback — post-filter is tighter than Fuse threshold + // (0.35 vs 0.4) to discard borderline matches that Fuse surfaces. + return fuse + .search(query) + .filter((r) => (r.score ?? 1) < 0.35) + .map((r) => r.item); +} + +/** + * Registers the `search_icons` tool on the given MCP server. + * + * Accepts a `query` param and an optional `offset` for pagination. + * Performs a two-pass search (substring then Fuse.js fuzzy) against icon names + * and keywords from the icon catalog. Returns a page of {@link PAGE_SIZE} + * results with metadata about total matches and how to fetch more. + */ +export function registerSearchIcons(server: McpServer): void { + server.registerTool( + "search_icons", + { + title: "Search Icons", + description: + "Fuzzy-search Nimbus icons by name or keyword. " + + "Returns matching icon names with import paths from @commercetools/nimbus-icons. " + + `Results are paginated (${PAGE_SIZE} per page). Use the offset parameter to retrieve additional results.`, + inputSchema: { + query: z + .string() + .min(1) + .describe( + 'Search query to match against icon names and keywords, e.g. "checkmark", "arrow", "settings".' + ), + offset: z + .number() + .int() + .min(0) + .default(0) + .describe( + "Starting index for paginated results. Omit or pass 0 for the first page." + ), + }, + }, + async ({ query, offset }) => { + try { + const allResults = await searchIcons(query); + const page = allResults.slice(offset, offset + PAGE_SIZE); + const hasMore = offset + PAGE_SIZE < allResults.length; + + const payload: SearchIconsResponse = { + query, + importPath: "@commercetools/nimbus-icons", + totalResults: allResults.length, + offset, + pageSize: PAGE_SIZE, + hasMore, + results: page.map(({ name, category, keywords }) => ({ + name, + category, + keywords, + })), + }; + + if (hasMore) { + payload.hint = `Use \`offset: ${offset + PAGE_SIZE}\` for more results (${allResults.length} total).`; + } + + return { + content: [ + { + type: "text" as const, + text: JSON.stringify(payload), + }, + ], + }; + } catch { + return { + content: [ + { + type: "text" as const, + text: "Icon catalog is not available in this environment.", + }, + ], + isError: true, + }; + } + } + ); +} diff --git a/packages/nimbus-mcp/src/types.ts b/packages/nimbus-mcp/src/types.ts index 76f4fd6fe..28f7e71b0 100644 --- a/packages/nimbus-mcp/src/types.ts +++ b/packages/nimbus-mcp/src/types.ts @@ -2,6 +2,8 @@ * Shared type definitions for the Nimbus MCP server. */ +import type Fuse from "fuse.js"; + // --------------------------------------------------------------------------- // Core types // --------------------------------------------------------------------------- @@ -260,3 +262,28 @@ export interface RelevanceFields { tags: string; content?: string; } + +/** A single icon result returned by search_icons (importPath hoisted to envelope). */ +export interface IconResult { + name: string; + category: "material" | "custom"; + keywords: string[]; +} + +/** Shape returned by the paginated search_icons tool. */ +export interface SearchIconsResponse { + query: string; + importPath: string; + totalResults: number; + offset: number; + pageSize: number; + hasMore: boolean; + hint?: string; + results: IconResult[]; +} + +/** Cached Fuse instance and icon list (created on first call, avoids double catalog load). */ +export interface FuseCache { + fuse: Fuse; + icons: IconCatalogEntry[]; +}