diff --git a/.changeset/index-mcp-search.md b/.changeset/index-mcp-search.md new file mode 100644 index 00000000..80e96180 --- /dev/null +++ b/.changeset/index-mcp-search.md @@ -0,0 +1,5 @@ +--- +"@codeatlasdev/ui": minor +--- + +`ca-ui-mcp` searches the gallery index. `search_ui` takes words plus facet filters (kind, subtype, style, motion, interaction, layout, colorMode, density, tech, cost, component), `list_facets` lists every value with its count for the current selection, and `get_related` returns the entries related to a demo. They read the same published facets as `api/v1/index.json`: a facet still under review is never searchable. diff --git a/packages/ui/bin/ca-ui-mcp.ts b/packages/ui/bin/ca-ui-mcp.ts index 6039bc2a..99d64681 100755 --- a/packages/ui/bin/ca-ui-mcp.ts +++ b/packages/ui/bin/ca-ui-mcp.ts @@ -6,7 +6,7 @@ * claude mcp add codeatlas-ui -- npx -y -p @codeatlasdev/ui ca-ui-mcp * * Tools: get_recipe, list_components, get_component, get_export, get_equivalent, list_parts, search_tokens, list_surfaces, get_guide, - * search_demos, get_demo, report_issue (drafts a sanitized GitHub issue from a library problem; it never posts anything). + * search_demos, get_demo, search_ui, list_facets, get_related, report_issue (drafts a sanitized GitHub issue from a library problem; it never posts anything). */ import { existsSync, readFileSync } from "node:fs"; import { dirname, join } from "node:path"; @@ -23,6 +23,15 @@ import type { ManifestToken, PartsFile, } from "../scripts/build-manifest.ts"; +import { indexEntries } from "../scripts/index-build.ts"; +import { + chipCounts, + FILTER_FACETS, + type FilterFacet, + type Filters, + relatedEntries, + searchIndex, +} from "../scripts/index-search.ts"; import { type Equivalent, findEquivalent } from "../src/cli/equivalents.ts"; import { type MigrationFile, migrationMarkdown } from "../src/cli/migrations.ts"; import { buildReport } from "../src/cli/report.ts"; @@ -50,6 +59,8 @@ const recipes = existsSync(join(DIST, "recipes.json")) : []; // A build without site/ (from the published tarball) emits no demos: the other tools must keep working. const demos = existsSync(join(DIST, "demos.json")) ? load("demos.json") : []; +// The published facets only (a facet still under review is never searchable), same entries as api/v1/index.json. +const entries = indexEntries(demos); const noDemos = "No demo catalog in this build (dist/demos.json is missing)."; const text = (s: string) => ({ content: [{ type: "text" as const, text: s }] }); @@ -393,6 +404,76 @@ server.registerTool( } ); +const entryLine = (e: (typeof entries)[number]) => + `#${e.id} ${e.title} — ${e.kind}/${e.subtype} — ${e.summary}`; + +server.registerTool( + "search_ui", + { + description: + "Search the gallery index: words (title, summary, aliases, components) plus facet filters. Several values of one facet are alternatives, different facets all apply. Facets: kind, subtype (`kind/subtype`), style, motion, interaction, layout, colorMode, density, tech, cost, component. Call list_facets for the values that exist. Returns `#id title — kind/subtype — summary`, best match first. Fetch the source with get_demo.", + inputSchema: { + query: z.string().default(""), + filters: z.partialRecord(z.enum(FILTER_FACETS), z.array(z.string())).default({}), + limit: z.number().int().min(1).max(50).default(10), + }, + }, + ({ query, filters, limit }) => { + const hits = searchIndex(entries, { query, filters: filters as Filters }); + if (!hits.length) return fail(entries.length ? "No entry matches." : noDemos); + const shown = hits.slice(0, limit).map(entryLine); + return text(`${hits.length} match${hits.length === 1 ? "" : "es"}\n${shown.join("\n")}`); + } +); + +server.registerTool( + "list_facets", + { + description: + "Every filter facet of the gallery index with its values and entry counts, for search_ui. Pass a query or filters to get the counts for that selection (a facet's own choice is ignored, so its siblings stay listed). A value with count 0 would match nothing.", + inputSchema: { + query: z.string().default(""), + filters: z.partialRecord(z.enum(FILTER_FACETS), z.array(z.string())).default({}), + }, + }, + ({ query, filters }) => { + if (!entries.length) return fail(noDemos); + const counts = chipCounts(entries, { query, filters: filters as Filters }); + return text( + (FILTER_FACETS as readonly FilterFacet[]) + .filter((f) => Object.keys(counts[f]).length) + .map( + (f) => + `${f}: ${Object.entries(counts[f]) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([v, n]) => `${v} (${n})`) + .join(", ")}` + ) + .join("\n") + ); + } +); + +server.registerTool( + "get_related", + { + description: + "Entries related to a numbered demo: the ones its sidecar names first, then those sharing its subtype, styles and components. Ids as `12`, `012` or `#012`. Returns `#id title — kind/subtype — summary`.", + inputSchema: { id: z.string(), limit: z.number().int().min(1).max(20).default(8) }, + }, + ({ id, limit }) => { + const num = id.match(/^#?(\d{1,4})$/)?.[1]; + const want = + num && + (entries.find((e) => e.id === num.padStart(3, "0")) ?? entries.find((e) => e.id === num)); + if (!want) return fail(entries.length ? `Unknown demo id "${id}"; use search_ui.` : noDemos); + const rel = relatedEntries(entries, want.id, limit); + return rel.length + ? text(rel.map(entryLine).join("\n")) + : fail(`No related entries for #${want.id}.`); + } +); + server.registerTool( "report_issue", { diff --git a/packages/ui/scripts/index-search.ts b/packages/ui/scripts/index-search.ts new file mode 100644 index 00000000..47d0d5f4 --- /dev/null +++ b/packages/ui/scripts/index-search.ts @@ -0,0 +1,195 @@ +/** + * Search and facet filtering over the gallery index. Pure and dependency-free: the gallery page (instant client-side + * filtering) and the MCP tools (`search_ui`, `list_facets`, `get_related`) run this one module over the entries of + * `api/v1/index.json`, so a filter means the same thing for a person and for an agent. + * + * Only published facets exist on an entry (see `publishedFacets`); a facet that is absent is never filterable. + */ +import type { IndexEntry } from "./index-build.ts"; + +/** Filter facets, as they are named in the URL (`?kind=section&style=brutalism`) and in `search_ui`. */ +export const FILTER_FACETS = [ + "kind", + "subtype", + "style", + "motion", + "interaction", + "layout", + "colorMode", + "density", + "tech", + "cost", + "component", +] as const; +export type FilterFacet = (typeof FILTER_FACETS)[number]; + +/** Chosen values per facet. Several values of one facet are alternatives (OR); different facets all apply (AND). */ +export type Filters = Partial>; + +/** The values an entry holds for a facet; `undefined` when the facet is not published for it. */ +export function facetValues(e: IndexEntry, facet: FilterFacet): string[] | undefined { + switch (facet) { + case "kind": + return [e.kind as string]; + case "subtype": + return [`${e.kind}/${e.subtype}`]; + case "style": + return e.style; + case "motion": + return e.motion && [e.motion]; + case "interaction": + return e.interaction; + case "layout": + return e.layout && [e.layout]; + case "colorMode": + return e.colorMode && [e.colorMode]; + case "density": + return e.density && [e.density]; + case "tech": + return e.tech; + case "cost": + return [e.cost as string]; + case "component": + return e.components; + } +} + +/** Case-insensitive match that forgives `-`, `_` and spaces ("app list" finds AppList). */ +const squash = (s: string) => s.toLowerCase().replace(/[\s_-]/g, ""); + +/** Everything a word can match, in one squashed string per entry (build it once per entry, not per keystroke). */ +export function haystack(e: IndexEntry): string { + return squash( + [ + e.id, + e.title, + e.summary, + e.use, + ...e.tags, + ...(e.components ?? []), + ...(e.aliases ?? []), + ].join(" ") + ); +} + +function matchesFilters(e: IndexEntry, filters: Filters, skip?: FilterFacet): boolean { + for (const facet of FILTER_FACETS) { + const wanted = filters[facet]; + if (facet === skip || !wanted?.length) continue; + const has = facetValues(e, facet); + if (!has || !wanted.some((w) => has.includes(w))) return false; + } + return true; +} + +export interface SearchOptions { + query?: string; + filters?: Filters; + /** Prebuilt `haystack` per id, so typing does not rebuild every string. */ + haystacks?: ReadonlyMap; +} + +/** + * Entries that match every query word and every chosen facet, best match first. A word in the title or an alias + * outranks one only in the summary; with no query the catalog order is kept (the ids are append-only). + */ +export function searchIndex( + entries: readonly IndexEntry[], + { query = "", filters = {}, haystacks }: SearchOptions = {} +): IndexEntry[] { + const words = query.split(/\s+/).map(squash).filter(Boolean); + const scored: { e: IndexEntry; score: number }[] = []; + for (const e of entries) { + if (!matchesFilters(e, filters)) continue; + let score = 0; + if (words.length) { + const hay = haystacks?.get(e.id) ?? haystack(e); + if (!words.every((w) => hay.includes(w))) continue; + const title = squash(e.title); + const aliases = (e.aliases ?? []).map(squash); + for (const w of words) { + if (title.includes(w)) score += 4; + if (aliases.some((a) => a.includes(w))) score += 3; + if ((e.components ?? []).some((c) => squash(c).includes(w))) score += 2; + } + } + scored.push({ e, score }); + } + if (words.length) scored.sort((a, b) => b.score - a.score); + return scored.map((s) => s.e); +} + +/** + * Chip counts: for each facet, how many entries would match if that value were also chosen, with the other facets' + * choices applied (a facet's own choices are skipped so its siblings stay selectable). A value with count 0 is a + * disabled chip. + */ +export function chipCounts( + entries: readonly IndexEntry[], + { query = "", filters = {}, haystacks }: SearchOptions = {} +): Record> { + const words = query.split(/\s+/).map(squash).filter(Boolean); + const out = {} as Record>; + for (const facet of FILTER_FACETS) out[facet] = {}; + for (const e of entries) { + if (words.length) { + const hay = haystacks?.get(e.id) ?? haystack(e); + if (!words.every((w) => hay.includes(w))) continue; + } + for (const facet of FILTER_FACETS) { + if (!matchesFilters(e, filters, facet)) continue; + for (const v of facetValues(e, facet) ?? []) out[facet][v] = (out[facet][v] ?? 0) + 1; + } + } + return out; +} + +/** `?kind=a,b&style=x` to `Filters` (unknown facets and empty values are dropped). */ +export function parseFilters(params: URLSearchParams): Filters { + const out: Filters = {}; + for (const facet of FILTER_FACETS) { + const raw = params.get(facet); + const values = raw?.split(",").filter(Boolean); + if (values?.length) out[facet] = values; + } + return out; +} + +/** Writes the chosen facets into `params` (and removes the cleared ones), leaving every other parameter alone. */ +export function writeFilters(params: URLSearchParams, filters: Filters): void { + for (const facet of FILTER_FACETS) { + const values = filters[facet]; + if (values?.length) params.set(facet, values.join(",")); + else params.delete(facet); + } +} + +/** + * Entries related to `id`: first the ids the sidecar names, then the ones that share its subtype and the most styles + * and components. Never includes `id` itself. + */ +export function relatedEntries( + entries: readonly IndexEntry[], + id: string, + limit = 8 +): IndexEntry[] { + const self = entries.find((e) => e.id === id); + if (!self) return []; + const named = new Map((self.related ?? []).map((r, i) => [r, i])); + const share = (a: readonly string[] = [], b: readonly string[] = []) => + a.filter((x) => b.includes(x)).length; + return entries + .filter((e) => e.id !== id) + .map((e) => ({ + e, + score: named.has(e.id) + ? 1000 - (named.get(e.id) as number) + : (e.kind === self.kind && e.subtype === self.subtype ? 10 : 0) + + share(e.style, self.style) * 2 + + share(e.components, self.components), + })) + .filter((r) => r.score > 0) + .sort((a, b) => b.score - a.score) + .slice(0, limit) + .map((r) => r.e); +} diff --git a/packages/ui/tests/index-search.test.ts b/packages/ui/tests/index-search.test.ts new file mode 100644 index 00000000..5eec28db --- /dev/null +++ b/packages/ui/tests/index-search.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import type { IndexEntry } from "../scripts/index-build.ts"; +import { + chipCounts, + haystack, + parseFilters, + relatedEntries, + searchIndex, + writeFilters, +} from "../scripts/index-search.ts"; + +const entries: IndexEntry[] = ( + JSON.parse( + readFileSync(join(import.meta.dir, "../../../site/public/api/v1/index.json"), "utf8") + ) as { + entries: IndexEntry[]; + } +).entries; + +describe("index search over the real index.json", () => { + it("never exposes an unreviewed facet", () => { + expect(entries.length).toBeGreaterThan(300); + expect(entries.every((e) => !("industry" in e))).toBe(true); + }); + + it("filters by one facet and ORs its values", () => { + const one = searchIndex(entries, { filters: { kind: ["motion"] } }); + expect(one.length).toBeGreaterThan(0); + expect(one.every((e) => e.kind === "motion")).toBe(true); + const both = searchIndex(entries, { filters: { kind: ["motion", "component"] } }); + expect(both.length).toBe(entries.length); + }); + + it("ANDs different facets and a query", () => { + const hits = searchIndex(entries, { + query: "button", + filters: { kind: ["component"], motion: ["subtle"] }, + }); + expect(hits.length).toBeGreaterThan(0); + for (const e of hits) { + expect(e.kind).toBe("component"); + expect(e.motion).toBe("subtle"); + expect(haystack(e)).toContain("button"); + } + }); + + it("forgives case and separators and ranks a title hit first", () => { + const hits = searchIndex(entries, { query: "APP list" }); + expect(hits.length).toBeGreaterThan(0); + expect(squashTitle(hits[0])).toContain("applist"); + }); + + it("returns nothing for an impossible combination", () => { + expect(searchIndex(entries, { query: "zzzqqq" })).toEqual([]); + }); + + it("chip counts ignore the facet's own choice and match the result size", () => { + const filters = { kind: ["motion"], motion: ["expressive"] }; + const counts = chipCounts(entries, { filters }); + // Siblings of `motion` stay selectable: the count is for kind=motion plus that value. + const motionOnly = searchIndex(entries, { filters: { kind: ["motion"] } }); + expect(counts.motion.subtle ?? 0).toBe(motionOnly.filter((e) => e.motion === "subtle").length); + const total = searchIndex(entries, { filters }); + expect(counts.motion.expressive).toBe(total.length); + // A value that cannot occur with the other choices has no count (a disabled chip). + expect(counts.kind.component ?? 0).toBe( + searchIndex(entries, { filters: { motion: ["expressive"], kind: ["component"] } }).length + ); + }); + + it("round-trips filters through the URL and keeps other params", () => { + const params = new URLSearchParams("pick=001&kind=old"); + writeFilters(params, { kind: ["motion"], style: ["aurora", "bento"] }); + expect(params.get("pick")).toBe("001"); + expect(params.get("kind")).toBe("motion"); + expect(parseFilters(params)).toEqual({ kind: ["motion"], style: ["aurora", "bento"] }); + writeFilters(params, {}); + expect(params.toString()).toBe("pick=001"); + expect(parseFilters(new URLSearchParams("kind=,&bogus=1"))).toEqual({}); + }); + + it("related: named ids first, never itself", () => { + const withRelated = entries.find((e) => (e.related ?? []).length > 0); + if (!withRelated) throw new Error("no entry has related ids"); + const rel = relatedEntries(entries, withRelated.id); + expect(rel.length).toBeGreaterThan(0); + expect(rel.some((e) => e.id === withRelated.id)).toBe(false); + expect(rel[0].id).toBe((withRelated.related as string[])[0]); + expect(relatedEntries(entries, "nope")).toEqual([]); + }); +}); + +function squashTitle(e: IndexEntry) { + return e.title.toLowerCase().replace(/[\s_-]/g, ""); +} diff --git a/packages/ui/tests/mcp-index.test.ts b/packages/ui/tests/mcp-index.test.ts new file mode 100644 index 00000000..e5deaa09 --- /dev/null +++ b/packages/ui/tests/mcp-index.test.ts @@ -0,0 +1,72 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; + +// search_ui, list_facets and get_related over the real stdio server: needs `bun run build` (dist/bin + dist/demos.json). +const bin = join(import.meta.dir, "../dist/bin/ca-ui-mcp.js"); +const built = existsSync(bin) && existsSync(join(import.meta.dir, "../dist/demos.json")); + +describe.skipIf(!built)("ca-ui-mcp index tools", () => { + const client = new Client({ name: "test", version: "0.0.0" }); + beforeAll(async () => { + await client.connect(new StdioClientTransport({ command: "node", args: [bin] })); + }); + afterAll(() => client.close()); + + const call = async (name: string, args: Record = {}) => { + const res = (await client.callTool({ name, arguments: args })) as { + isError?: boolean; + content: { text: string }[]; + }; + return { error: res.isError === true, text: res.content[0]?.text ?? "" }; + }; + + it("registers the three tools", async () => { + const tools = (await client.listTools()).tools.map((t) => t.name); + for (const t of ["search_ui", "list_facets", "get_related"]) expect(tools).toContain(t); + }); + + it("search_ui applies words and filters and states the match count", async () => { + const { error, text } = await call("search_ui", { + query: "fade", + filters: { kind: ["motion"] }, + limit: 3, + }); + expect(error).toBe(false); + const [head, ...lines] = text.split("\n"); + expect(head).toMatch(/^\d+ matche?s?$/); + expect(lines.length).toBeLessThanOrEqual(3); + for (const l of lines) expect(l).toMatch(/^#\d+ .+ — motion\/.+ — /); + }); + + it("search_ui answers no match with an error", async () => { + const { error, text } = await call("search_ui", { query: "zzzqqq" }); + expect(error).toBe(true); + expect(text).toBe("No entry matches."); + }); + + it("list_facets lists values with counts, never industry", async () => { + const { error, text } = await call("list_facets"); + expect(error).toBe(false); + expect(text).toMatch(/^kind: .*component \(\d+\)/m); + expect(text).toMatch(/^style: /m); + expect(text).not.toMatch(/^industry/m); + }); + + it("list_facets narrows with the selection", async () => { + const { text } = await call("list_facets", { filters: { kind: ["motion"] } }); + expect(text).toMatch(/^subtype: .*motion\/text \(\d+\)/m); + expect(text).toMatch(/^kind: .*component \(\d+\)/m); + }); + + it("get_related returns other entries and rejects an unknown id", async () => { + const found = await call("search_ui", { query: "fade", limit: 1 }); + const id = found.text.split("\n")[1].match(/^#(\d+)/)?.[1] as string; + const rel = await call("get_related", { id: `#${id}` }); + expect(rel.error).toBe(false); + expect(rel.text).not.toContain(`#${id} `); + expect((await call("get_related", { id: "nope" })).error).toBe(true); + }); +}); diff --git a/site/src/content/docs/getting-started/ai-agents.mdx b/site/src/content/docs/getting-started/ai-agents.mdx index 2434b92f..022d57d4 100644 --- a/site/src/content/docs/getting-started/ai-agents.mdx +++ b/site/src/content/docs/getting-started/ai-agents.mdx @@ -48,6 +48,9 @@ Every page is also linked with `