From 9f7875010536883be3f1565dc0b1477383e9cb10 Mon Sep 17 00:00:00 2001 From: Mats Bosson Date: Sun, 29 Mar 2026 23:48:03 +0700 Subject: [PATCH] MCP component registry --- packages/mcp/src/component-imports.ts | 30 +++++ packages/mcp/src/registry.ts | 172 ++++++++++++++++++++++++++ packages/mcp/tests/registry.test.ts | 58 +++++++++ 3 files changed, 260 insertions(+) create mode 100644 packages/mcp/src/component-imports.ts create mode 100644 packages/mcp/src/registry.ts create mode 100644 packages/mcp/tests/registry.test.ts diff --git a/packages/mcp/src/component-imports.ts b/packages/mcp/src/component-imports.ts new file mode 100644 index 0000000..f4c4b56 --- /dev/null +++ b/packages/mcp/src/component-imports.ts @@ -0,0 +1,30 @@ +export interface ComponentMeta { + readonly name: string; + readonly description: string; + readonly parts: readonly string[]; + readonly requiredParts: readonly string[]; +} + +export interface ComponentEntry { + exportPath: string; + meta: ComponentMeta; + schemas: Record; +} + +/** Converts a PascalCase component name to its pettyui/ kebab-case export path. */ +export function toExportPath(name: string): string { + return "pettyui/" + name.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase(); +} + +/** Looks up a single entry by component name (case-insensitive). Returns undefined if not found. */ +export function findEntry(name: string, entries: ComponentEntry[]): ComponentEntry | undefined { + const lower = name.toLowerCase(); + return entries.find((e) => e.meta.name.toLowerCase() === lower); +} + +/** Returns all registered pettyui/ export paths from a given entry list. */ +export function allExportPaths(entries: ComponentEntry[]): string[] { + return entries.map((e) => e.exportPath); +} + +export { COMPONENT_ENTRIES } from "./registry.js"; diff --git a/packages/mcp/src/registry.ts b/packages/mcp/src/registry.ts new file mode 100644 index 0000000..1c48892 --- /dev/null +++ b/packages/mcp/src/registry.ts @@ -0,0 +1,172 @@ +import { z } from "zod/v4"; +import { accessSync } from "node:fs"; +import { resolve, join } from "node:path"; +import { type ComponentEntry, type ComponentMeta, toExportPath } from "./component-imports.js"; +import { DialogRootPropsSchema, DialogContentPropsSchema, DialogMeta } from "../../core/src/components/dialog/dialog.props.js"; +import { ButtonPropsSchema, ButtonMeta } from "../../core/src/components/button/button.props.js"; +import { BadgePropsSchema, BadgeMeta } from "../../core/src/components/badge/badge.props.js"; +import { AlertPropsSchema, AlertMeta } from "../../core/src/components/alert/alert.props.js"; +import { LinkPropsSchema, LinkMeta } from "../../core/src/components/link/link.props.js"; +import { SelectRootPropsSchema, SelectItemPropsSchema, SelectMeta } from "../../core/src/components/select/select.props.js"; +import { TogglePropsSchema, ToggleMeta } from "../../core/src/components/toggle/toggle.props.js"; +import { ComboboxRootPropsSchema, ComboboxItemPropsSchema, ComboboxMeta } from "../../core/src/components/combobox/combobox.props.js"; +import { DropdownMenuRootPropsSchema, DropdownMenuItemPropsSchema, DropdownMenuMeta } from "../../core/src/components/dropdown-menu/dropdown-menu.props.js"; +import { TextFieldRootPropsSchema, TextFieldMeta } from "../../core/src/components/text-field/text-field.props.js"; +import { ListboxRootPropsSchema, ListboxItemPropsSchema, ListboxMeta } from "../../core/src/components/listbox/listbox.props.js"; +import { CheckboxRootPropsSchema, CheckboxMeta } from "../../core/src/components/checkbox/checkbox.props.js"; +import { SwitchRootPropsSchema, SwitchMeta } from "../../core/src/components/switch/switch.props.js"; +import { TooltipRootPropsSchema, TooltipMeta } from "../../core/src/components/tooltip/tooltip.props.js"; +import { PopoverRootPropsSchema, PopoverMeta } from "../../core/src/components/popover/popover.props.js"; +import { RadioGroupRootPropsSchema, RadioGroupItemPropsSchema, RadioGroupMeta } from "../../core/src/components/radio-group/radio-group.props.js"; +import { HoverCardRootPropsSchema, HoverCardMeta } from "../../core/src/components/hover-card/hover-card.props.js"; +import { DrawerRootPropsSchema, DrawerMeta } from "../../core/src/components/drawer/drawer.props.js"; +import { ToastRegionPropsSchema, ToastMeta } from "../../core/src/components/toast/toast.props.js"; +import { SkeletonPropsSchema, SkeletonMeta } from "../../core/src/components/skeleton/skeleton.props.js"; +import { SliderRootPropsSchema, SliderMeta } from "../../core/src/components/slider/slider.props.js"; +import { NumberFieldRootPropsSchema as NumFieldRootSchema, NumberFieldMeta as NumFieldMeta } from "../../core/src/components/number-field/number-field.props.js"; +import { ToggleGroupRootPropsSchema, ToggleGroupItemPropsSchema, ToggleGroupMeta } from "../../core/src/components/toggle-group/toggle-group.props.js"; +import { ProgressRootPropsSchema, ProgressMeta } from "../../core/src/components/progress/progress.props.js"; +import { CollapsibleRootPropsSchema, CollapsibleMeta } from "../../core/src/components/collapsible/collapsible.props.js"; +import { AlertDialogRootPropsSchema, AlertDialogMeta } from "../../core/src/components/alert-dialog/alert-dialog.props.js"; +import { BreadcrumbsRootPropsSchema, BreadcrumbsMeta } from "../../core/src/components/breadcrumbs/breadcrumbs.props.js"; +import { TabsRootPropsSchema, TabsMeta } from "../../core/src/components/tabs/tabs.props.js"; +import { AccordionRootPropsSchema, AccordionItemPropsSchema, AccordionMeta } from "../../core/src/components/accordion/accordion.props.js"; +import { CardPropsSchema, CardMeta } from "../../core/src/components/card/card.props.js"; +import { AvatarRootPropsSchema, AvatarImagePropsSchema, AvatarMeta } from "../../core/src/components/avatar/avatar.props.js"; +import { NavigationMenuRootPropsSchema, NavigationMenuMeta } from "../../core/src/components/navigation-menu/navigation-menu.props.js"; +import { CommandPaletteRootPropsSchema, CommandPaletteMeta } from "../../core/src/components/command-palette/command-palette.props.js"; +import { FormRootPropsSchema, FormFieldPropsSchema, FormMeta } from "../../core/src/components/form/form.props.js"; +import { CalendarRootPropsSchema, CalendarMeta } from "../../core/src/components/calendar/calendar.props.js"; +import { WizardRootPropsSchema, WizardStepPropsSchema, WizardMeta } from "../../core/src/components/wizard/wizard.props.js"; +import { DataTableRootPropsSchema, DataTableMeta } from "../../core/src/components/data-table/data-table.props.js"; +import { VirtualListRootPropsSchema, VirtualListMeta } from "../../core/src/components/virtual-list/virtual-list.props.js"; +import { DatePickerRootPropsSchema, DatePickerMeta } from "../../core/src/components/date-picker/date-picker.props.js"; +import { SeparatorPropsSchema, SeparatorMeta } from "../../core/src/components/separator/separator.props.js"; +import { PaginationRootPropsSchema, PaginationMeta } from "../../core/src/components/pagination/pagination.props.js"; + +export interface RegisteredComponent { meta: ComponentMeta; schemas: Record; jsonSchemas: Record; hasStyledVersion: boolean; exportPath: string; } +export interface SearchResult extends RegisteredComponent { score: number; } + +function buildEntries(): ComponentEntry[] { + const ep = toExportPath; + const out: ComponentEntry[] = []; + out[out.length] = { exportPath: ep("Dialog"), meta: DialogMeta, schemas: { dialogRoot: DialogRootPropsSchema, dialogContent: DialogContentPropsSchema } }; + out[out.length] = { exportPath: ep("Button"), meta: ButtonMeta, schemas: { button: ButtonPropsSchema } }; + out[out.length] = { exportPath: ep("Badge"), meta: BadgeMeta, schemas: { badge: BadgePropsSchema } }; + out[out.length] = { exportPath: ep("Alert"), meta: AlertMeta, schemas: { alert: AlertPropsSchema } }; + out[out.length] = { exportPath: ep("Link"), meta: LinkMeta, schemas: { link: LinkPropsSchema } }; + out[out.length] = { exportPath: ep("Select"), meta: SelectMeta, schemas: { selectRoot: SelectRootPropsSchema, selectItem: SelectItemPropsSchema } }; + out[out.length] = { exportPath: ep("Toggle"), meta: ToggleMeta, schemas: { toggle: TogglePropsSchema } }; + out[out.length] = { exportPath: ep("Combobox"), meta: ComboboxMeta, schemas: { comboboxRoot: ComboboxRootPropsSchema, comboboxItem: ComboboxItemPropsSchema } }; + out[out.length] = { exportPath: ep("DropdownMenu"), meta: DropdownMenuMeta, schemas: { dropdownMenuRoot: DropdownMenuRootPropsSchema, dropdownMenuItem: DropdownMenuItemPropsSchema } }; + out[out.length] = { exportPath: ep("TextField"), meta: TextFieldMeta, schemas: { textFieldRoot: TextFieldRootPropsSchema } }; + out[out.length] = { exportPath: ep("Listbox"), meta: ListboxMeta, schemas: { listboxRoot: ListboxRootPropsSchema, listboxItem: ListboxItemPropsSchema } }; + out[out.length] = { exportPath: ep("Checkbox"), meta: CheckboxMeta, schemas: { checkboxRoot: CheckboxRootPropsSchema } }; + out[out.length] = { exportPath: ep("Switch"), meta: SwitchMeta, schemas: { switchRoot: SwitchRootPropsSchema } }; + out[out.length] = { exportPath: ep("Tooltip"), meta: TooltipMeta, schemas: { tooltipRoot: TooltipRootPropsSchema } }; + out[out.length] = { exportPath: ep("Popover"), meta: PopoverMeta, schemas: { popoverRoot: PopoverRootPropsSchema } }; + out[out.length] = { exportPath: ep("RadioGroup"), meta: RadioGroupMeta, schemas: { radioGroupRoot: RadioGroupRootPropsSchema, radioGroupItem: RadioGroupItemPropsSchema } }; + out[out.length] = { exportPath: ep("HoverCard"), meta: HoverCardMeta, schemas: { hoverCardRoot: HoverCardRootPropsSchema } }; + out[out.length] = { exportPath: ep("Drawer"), meta: DrawerMeta, schemas: { drawerRoot: DrawerRootPropsSchema } }; + out[out.length] = { exportPath: ep("Toast"), meta: ToastMeta, schemas: { toastRegion: ToastRegionPropsSchema } }; + out[out.length] = { exportPath: ep("Skeleton"), meta: SkeletonMeta, schemas: { skeleton: SkeletonPropsSchema } }; + out[out.length] = { exportPath: ep("Slider"), meta: SliderMeta, schemas: { sliderRoot: SliderRootPropsSchema } }; + out[out.length] = { exportPath: ep("NumField"), meta: NumFieldMeta, schemas: { numFieldRoot: NumFieldRootSchema } }; + out[out.length] = { exportPath: ep("ToggleGroup"), meta: ToggleGroupMeta, schemas: { toggleGroupRoot: ToggleGroupRootPropsSchema, toggleGroupItem: ToggleGroupItemPropsSchema } }; + out[out.length] = { exportPath: ep("Progress"), meta: ProgressMeta, schemas: { progressRoot: ProgressRootPropsSchema } }; + out[out.length] = { exportPath: ep("Collapsible"), meta: CollapsibleMeta, schemas: { collapsibleRoot: CollapsibleRootPropsSchema } }; + out[out.length] = { exportPath: ep("AlertDialog"), meta: AlertDialogMeta, schemas: { alertDialogRoot: AlertDialogRootPropsSchema } }; + out[out.length] = { exportPath: ep("Breadcrumbs"), meta: BreadcrumbsMeta, schemas: { breadcrumbsRoot: BreadcrumbsRootPropsSchema } }; + out[out.length] = { exportPath: ep("Tabs"), meta: TabsMeta, schemas: { tabsRoot: TabsRootPropsSchema } }; + out[out.length] = { exportPath: ep("Accordion"), meta: AccordionMeta, schemas: { accordionRoot: AccordionRootPropsSchema, accordionItem: AccordionItemPropsSchema } }; + out[out.length] = { exportPath: ep("Card"), meta: CardMeta, schemas: { card: CardPropsSchema } }; + out[out.length] = { exportPath: ep("Avatar"), meta: AvatarMeta, schemas: { avatarRoot: AvatarRootPropsSchema, avatarImage: AvatarImagePropsSchema } }; + out[out.length] = { exportPath: ep("NavigationMenu"), meta: NavigationMenuMeta, schemas: { navigationMenuRoot: NavigationMenuRootPropsSchema } }; + out[out.length] = { exportPath: ep("CommandPalette"), meta: CommandPaletteMeta, schemas: { commandPaletteRoot: CommandPaletteRootPropsSchema } }; + out[out.length] = { exportPath: ep("Form"), meta: FormMeta, schemas: { formRoot: FormRootPropsSchema, formField: FormFieldPropsSchema } }; + out[out.length] = { exportPath: ep("Calendar"), meta: CalendarMeta, schemas: { calendarRoot: CalendarRootPropsSchema } }; + out[out.length] = { exportPath: ep("Wizard"), meta: WizardMeta, schemas: { wizardRoot: WizardRootPropsSchema, wizardStep: WizardStepPropsSchema } }; + out[out.length] = { exportPath: ep("DataTable"), meta: DataTableMeta, schemas: { dataTableRoot: DataTableRootPropsSchema } }; + out[out.length] = { exportPath: ep("VirtualList"), meta: VirtualListMeta, schemas: { virtualListRoot: VirtualListRootPropsSchema } }; + out[out.length] = { exportPath: ep("DatePicker"), meta: DatePickerMeta, schemas: { datePickerRoot: DatePickerRootPropsSchema } }; + out[out.length] = { exportPath: ep("Separator"), meta: SeparatorMeta, schemas: { separator: SeparatorPropsSchema } }; + out[out.length] = { exportPath: ep("Pagination"), meta: PaginationMeta, schemas: { paginationRoot: PaginationRootPropsSchema } }; + return out; +} +export const COMPONENT_ENTRIES: ComponentEntry[] = buildEntries(); + +function toJsonSchema(schema: unknown): unknown { + try { + return z.toJsonSchema(schema as z.ZodType); + } catch { + return { type: "object" }; + } +} + +function styledFileExists(name: string): boolean { + const kebab = name.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase(); + const styledPath = join(resolve(import.meta.dirname, "../../registry/src/components"), `${kebab}.tsx`); + try { + accessSync(styledPath); + return true; + } catch { + return false; + } +} + +function toRegistered(entry: ComponentEntry): RegisteredComponent { + const jsonSchemas: Record = {}; + for (const [key, schema] of Object.entries(entry.schemas)) { + jsonSchemas[key] = toJsonSchema(schema); + } + return { meta: entry.meta, schemas: entry.schemas, jsonSchemas, hasStyledVersion: styledFileExists(entry.meta.name), exportPath: entry.exportPath }; +} + +/** Registry of all PettyUI components with search, lookup, and JSON Schema conversion. */ +export class ComponentRegistry { + private readonly components: Map; + + constructor() { + this.components = new Map(); + this.loadComponents(); + } + + private loadComponents(): void { + for (const entry of COMPONENT_ENTRIES) { + this.components.set(entry.meta.name.toLowerCase(), toRegistered(entry)); + } + } + + /** Returns all registered components. */ + listComponents(): RegisteredComponent[] { + return Array.from(this.components.values()); + } + + /** Finds a component by name (case-insensitive). Returns undefined if not found. */ + getComponent(name: string): RegisteredComponent | undefined { + return this.components.get(name.toLowerCase()); + } + + /** Searches components by name and description, returning ranked results. */ + search(query: string): SearchResult[] { + const terms = query.toLowerCase().split(/\s+/); + const results: SearchResult[] = []; + for (const comp of this.components.values()) { + const score = this.scoreComponent(comp, terms); + if (score > 0) results.push({ ...comp, score }); + } + return results.sort((a, b) => b.score - a.score); + } + + private scoreComponent(comp: RegisteredComponent, terms: string[]): number { + const name = comp.meta.name.toLowerCase(); + const text = `${name} ${comp.meta.description.toLowerCase()}`; + let score = 0; + for (const term of terms) { + if (text.includes(term)) score += 1; + if (name === term) score += 3; + if (name.includes(term)) score += 1; + } + return score; + } +} diff --git a/packages/mcp/tests/registry.test.ts b/packages/mcp/tests/registry.test.ts new file mode 100644 index 0000000..b8a9627 --- /dev/null +++ b/packages/mcp/tests/registry.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect } from "vitest"; +import { ComponentRegistry } from "../src/registry.js"; + +describe("ComponentRegistry", () => { + const registry = new ComponentRegistry(); + + it("loads all components", () => { + const all = registry.listComponents(); + expect(all.length).toBeGreaterThan(30); + }); + + it("finds by exact name", () => { + const comp = registry.getComponent("Dialog"); + expect(comp).toBeDefined(); + expect(comp?.meta.name).toBe("Dialog"); + }); + + it("finds case-insensitive", () => { + const lower = registry.getComponent("dialog"); + const upper = registry.getComponent("DIALOG"); + expect(lower).toBeDefined(); + expect(upper).toBeDefined(); + expect(lower?.meta.name).toBe("Dialog"); + }); + + it("returns undefined for unknown component", () => { + const comp = registry.getComponent("NonExistentWidget"); + expect(comp).toBeUndefined(); + }); + + it("searches by description keyword", () => { + const results = registry.search("modal overlay"); + expect(results.length).toBeGreaterThan(0); + const names = results.map((r) => r.meta.name); + expect(names).toContain("Dialog"); + }); + + it("searches by intent — searchable dropdown finds Combobox", () => { + const results = registry.search("searchable dropdown"); + expect(results.length).toBeGreaterThan(0); + const names = results.map((r) => r.meta.name); + expect(names).toContain("Combobox"); + }); + + it("returns json schemas for dialog", () => { + const comp = registry.getComponent("dialog"); + expect(comp).toBeDefined(); + expect(comp?.jsonSchemas).toBeDefined(); + expect(Object.keys(comp?.jsonSchemas ?? {}).length).toBeGreaterThan(0); + }); + + it("reports hasStyledVersion correctly for dialog", () => { + const comp = registry.getComponent("dialog"); + expect(comp).toBeDefined(); + expect(typeof comp?.hasStyledVersion).toBe("boolean"); + expect(comp?.hasStyledVersion).toBe(true); + }); +});