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
5 changes: 5 additions & 0 deletions .changeset/index-mcp-search.md
Original file line number Diff line number Diff line change
@@ -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.
83 changes: 82 additions & 1 deletion packages/ui/bin/ca-ui-mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -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<ManifestDemo[]>("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 }] });
Expand Down Expand Up @@ -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",
{
Expand Down
195 changes: 195 additions & 0 deletions packages/ui/scripts/index-search.ts
Original file line number Diff line number Diff line change
@@ -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<Record<FilterFacet, string[]>>;

/** 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<string, string>;
}

/**
* 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<FilterFacet, Record<string, number>> {
const words = query.split(/\s+/).map(squash).filter(Boolean);
const out = {} as Record<FilterFacet, Record<string, number>>;
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);
}
Loading