diff --git a/docs/superpowers/plans/2026-03-29-phase3-mcp-server-cli.md b/docs/superpowers/plans/2026-03-29-phase3-mcp-server-cli.md new file mode 100644 index 0000000..b257bc7 --- /dev/null +++ b/docs/superpowers/plans/2026-03-29-phase3-mcp-server-cli.md @@ -0,0 +1,1374 @@ +# Phase 3: MCP Server + CLI — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build an MCP server that reads Zod schemas and Meta objects from core/ and exposes 5 tools for AI agents (discover, inspect, validate, add, compose), plus a CLI wrapper for humans. + +**Architecture:** The MCP server imports component schemas/meta at startup, builds a component registry in memory, and exposes tools via `@modelcontextprotocol/sdk`. Each tool is a separate module. The CLI (`pettyui`) is a thin wrapper that calls the same registry logic without MCP transport. + +**Tech Stack:** @modelcontextprotocol/sdk, Zod v4 (with `z.toJsonSchema()`), TypeScript, Vitest, Node.js + +**Spec:** `docs/superpowers/specs/2026-03-29-pettyui-ai-first-architecture.md` + +**Parallelism:** Tasks 1-2 sequential (foundation). Tasks 3-7 parallel (each tool is independent). Task 8 (CLI) after tools. Task 9 last. + +``` +Wave 1 (sequential): Task 1 (package scaffold) + Task 2 (component registry) + +Wave 2 (parallel): Task 3 (pettyui.discover) + Task 4 (pettyui.inspect) + Task 5 (pettyui.validate) + Task 6 (pettyui.add) + Task 7 (pettyui.compose) + +Wave 3 (sequential): Task 8 (CLI wrapper) + Task 9 (build config + verification) +``` + +--- + +### Task 1: MCP Package Scaffold + +**Files:** +- Create: `packages/mcp/package.json` +- Create: `packages/mcp/tsconfig.json` +- Create: `packages/mcp/src/index.ts` +- Create: `packages/mcp/src/server.ts` + +- [ ] **Step 1: Create package.json** + +Create `packages/mcp/package.json`: + +```json +{ + "name": "pettyui-mcp", + "version": "0.1.0", + "description": "MCP server for PettyUI — AI's interface to the component library", + "type": "module", + "bin": { + "pettyui": "./dist/cli.js", + "pettyui-mcp": "./dist/index.js" + }, + "exports": { + ".": { + "import": "./dist/index.js" + }, + "./registry": { + "import": "./dist/registry.js" + } + }, + "scripts": { + "build": "tsc", + "dev": "tsc --watch", + "test": "vitest run", + "start": "node dist/index.js", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.12.0", + "pettyui": "workspace:*", + "zod": "^4.3.6" + }, + "devDependencies": { + "typescript": "^6.0.2", + "vitest": "^4.1.2" + } +} +``` + +- [ ] **Step 2: Create tsconfig.json** + +Create `packages/mcp/tsconfig.json`: + +```json +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "module": "Node16", + "moduleResolution": "Node16", + "declaration": true, + "emitDeclarationMaps": true + }, + "include": ["src"] +} +``` + +- [ ] **Step 3: Create server.ts — MCP server setup** + +Create `packages/mcp/src/server.ts`: + +```typescript +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; + +/** Creates and configures the PettyUI MCP server with all tools registered. */ +export function createPettyUIServer(): McpServer { + const server = new McpServer({ + name: "pettyui", + version: "0.1.0", + }); + + return server; +} +``` + +- [ ] **Step 4: Create index.ts — entry point with stdio transport** + +Create `packages/mcp/src/index.ts`: + +```typescript +#!/usr/bin/env node +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { createPettyUIServer } from "./server.js"; + +const server = createPettyUIServer(); +const transport = new StdioServerTransport(); +await server.connect(transport); +``` + +- [ ] **Step 5: Install dependencies** + +```bash +cd packages/mcp && pnpm install +``` + +- [ ] **Step 6: Build and verify** + +```bash +cd packages/mcp && pnpm build +``` + +Expected: Compiles without errors. + +- [ ] **Step 7: Commit** + +```bash +git add packages/mcp/ +git commit -m "feat(mcp): scaffold MCP server package with stdio transport" +``` + +--- + +### Task 2: Component Registry + +The registry is the brain — it loads all component schemas and meta objects into an in-memory index that the tools query. + +**Files:** +- Create: `packages/mcp/src/registry.ts` +- Create: `packages/mcp/tests/registry.test.ts` + +- [ ] **Step 1: Write failing test** + +Create `packages/mcp/tests/registry.test.ts`: + +```typescript +import { describe, expect, it } from "vitest"; +import { ComponentRegistry } from "../src/registry.js"; + +describe("ComponentRegistry", () => { + it("loads all components", () => { + const registry = new ComponentRegistry(); + const components = registry.listComponents(); + expect(components.length).toBeGreaterThan(30); + }); + + it("finds component by exact name", () => { + const registry = new ComponentRegistry(); + const dialog = registry.getComponent("Dialog"); + expect(dialog).toBeDefined(); + expect(dialog!.meta.name).toBe("Dialog"); + expect(dialog!.meta.parts).toContain("Content"); + expect(dialog!.meta.requiredParts).toContain("Title"); + }); + + it("finds component by case-insensitive name", () => { + const registry = new ComponentRegistry(); + const dialog = registry.getComponent("dialog"); + expect(dialog).toBeDefined(); + expect(dialog!.meta.name).toBe("Dialog"); + }); + + it("returns undefined for unknown component", () => { + const registry = new ComponentRegistry(); + expect(registry.getComponent("NonExistent")).toBeUndefined(); + }); + + it("searches by description text", () => { + const registry = new ComponentRegistry(); + const results = registry.search("modal overlay"); + expect(results.length).toBeGreaterThan(0); + expect(results[0].meta.name).toBe("Dialog"); + }); + + it("searches by intent matching", () => { + const registry = new ComponentRegistry(); + const results = registry.search("searchable dropdown"); + const names = results.map((r) => r.meta.name); + expect(names).toContain("Combobox"); + }); + + it("gets JSON schema for a component", () => { + const registry = new ComponentRegistry(); + const dialog = registry.getComponent("Dialog"); + expect(dialog!.jsonSchemas).toBeDefined(); + expect(dialog!.jsonSchemas.root).toBeDefined(); + }); + + it("has styled registry info when available", () => { + const registry = new ComponentRegistry(); + const dialog = registry.getComponent("Dialog"); + expect(dialog!.hasStyledVersion).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +cd packages/mcp && pnpm vitest run tests/registry.test.ts +``` + +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement ComponentRegistry** + +Create `packages/mcp/src/registry.ts`: + +```typescript +import { z } from "zod/v4"; +import type { ComponentMeta } from "pettyui/dialog"; +import * as fs from "node:fs"; +import * as path from "node:path"; + +/** A registered component with schema, meta, and optional styled version info. */ +export interface RegisteredComponent { + meta: ComponentMeta; + schemas: Record; + jsonSchemas: Record; + hasStyledVersion: boolean; + exportPath: string; +} + +/** Search result with relevance score. */ +export interface SearchResult extends RegisteredComponent { + score: number; +} + +/** Registry of all PettyUI components — loaded at startup, queried by tools. */ +export class ComponentRegistry { + private components = new Map(); + + constructor() { + this.loadComponents(); + } + + private loadComponents(): void { + const componentImports = this.getComponentImports(); + for (const entry of componentImports) { + this.registerComponent(entry); + } + } + + private getComponentImports(): Array<{ name: string; mod: Record; exportPath: string }> { + const entries: Array<{ name: string; mod: Record; exportPath: string }> = []; + const componentsDir = this.resolveComponentsDir(); + if (!componentsDir) return entries; + + const dirs = fs.readdirSync(componentsDir, { withFileTypes: true }) + .filter((d) => d.isDirectory()) + .map((d) => d.name); + + for (const dir of dirs) { + const propsFile = path.join(componentsDir, dir, `${dir}.props.ts`); + const propsFileAlt = path.join(componentsDir, dir, `${dir}.props.js`); + if (!fs.existsSync(propsFile) && !fs.existsSync(propsFileAlt)) continue; + + try { + const mod = this.requireComponent(dir); + if (mod) { + const kebabName = dir; + entries.push({ name: kebabName, mod, exportPath: `pettyui/${kebabName}` }); + } + } catch { + // Skip components that fail to load + } + } + return entries; + } + + private resolveComponentsDir(): string | null { + try { + const corePkgPath = require.resolve("pettyui/dialog"); + const coreDir = path.dirname(path.dirname(corePkgPath)); + const srcDir = path.join(coreDir, "src", "components"); + if (fs.existsSync(srcDir)) return srcDir; + return null; + } catch { + const fallback = path.resolve(import.meta.dirname, "../../core/src/components"); + if (fs.existsSync(fallback)) return fallback; + return null; + } + } + + private requireComponent(name: string): Record | null { + try { + const componentsDir = this.resolveComponentsDir(); + if (!componentsDir) return null; + const propsPath = path.join(componentsDir, name, `${name}.props.ts`); + if (!fs.existsSync(propsPath)) return null; + // Dynamic import would be async — for now, we statically register known components + return null; + } catch { + return null; + } + } + + private registerComponent(entry: { name: string; mod: Record; exportPath: string }): void { + const meta = this.findMeta(entry.mod); + if (!meta) return; + + const schemas = this.findSchemas(entry.mod); + const jsonSchemas: Record = {}; + for (const [key, schema] of Object.entries(schemas)) { + try { + jsonSchemas[key] = z.toJsonSchema(schema); + } catch { + jsonSchemas[key] = { type: "object", description: "Schema conversion failed" }; + } + } + + const hasStyledVersion = this.checkStyledVersion(entry.name); + + this.components.set(meta.name.toLowerCase(), { + meta, + schemas, + jsonSchemas, + hasStyledVersion, + exportPath: entry.exportPath, + }); + } + + private findMeta(mod: Record): ComponentMeta | undefined { + for (const value of Object.values(mod)) { + if (value && typeof value === "object" && "name" in value && "description" in value && "parts" in value && "requiredParts" in value) { + return value as ComponentMeta; + } + } + return undefined; + } + + private findSchemas(mod: Record): Record { + const schemas: Record = {}; + for (const [key, value] of Object.entries(mod)) { + if (key.endsWith("Schema") && value instanceof z.ZodType) { + const shortKey = key.replace(/PropsSchema$/, "").replace(/Schema$/, ""); + schemas[shortKey.charAt(0).toLowerCase() + shortKey.slice(1)] = value; + } + } + return schemas; + } + + private checkStyledVersion(kebabName: string): boolean { + try { + const registryDir = path.resolve(import.meta.dirname, "../../registry/src/components"); + return fs.existsSync(path.join(registryDir, `${kebabName}.tsx`)); + } catch { + return false; + } + } + + /** Get all registered components. */ + listComponents(): RegisteredComponent[] { + return Array.from(this.components.values()); + } + + /** Get a component by name (case-insensitive). */ + getComponent(name: string): RegisteredComponent | undefined { + return this.components.get(name.toLowerCase()); + } + + /** Search components by intent/description text. Simple word-match scoring. */ + search(query: string): SearchResult[] { + const terms = query.toLowerCase().split(/\s+/); + const results: SearchResult[] = []; + + for (const component of this.components.values()) { + const searchText = `${component.meta.name} ${component.meta.description}`.toLowerCase(); + let score = 0; + for (const term of terms) { + if (searchText.includes(term)) score += 1; + if (component.meta.name.toLowerCase() === term) score += 3; + if (component.meta.name.toLowerCase().includes(term)) score += 1; + } + if (score > 0) { + results.push({ ...component, score }); + } + } + + return results.sort((a, b) => b.score - a.score); + } +} +``` + +**NOTE TO IMPLEMENTER:** The dynamic import approach above won't work directly with TypeScript source files. The actual implementation should use a **static registration approach** — explicitly importing each component's props module at build time. Create a `packages/mcp/src/component-imports.ts` file that does: + +```typescript +import { DialogRootPropsSchema, DialogContentPropsSchema, DialogMeta } from "pettyui/dialog"; +import { ButtonPropsSchema, ButtonMeta } from "pettyui/button"; +// ... all 39 components + +export const COMPONENT_REGISTRY = [ + { exportPath: "pettyui/dialog", meta: DialogMeta, schemas: { dialogRoot: DialogRootPropsSchema, dialogContent: DialogContentPropsSchema } }, + { exportPath: "pettyui/button", meta: ButtonMeta, schemas: { button: ButtonPropsSchema } }, + // ... all components +]; +``` + +Then `ComponentRegistry` loads from this static list instead of filesystem scanning. + +- [ ] **Step 4: Create component-imports.ts with static imports** + +Create `packages/mcp/src/component-imports.ts` that statically imports all 39 component schemas and meta objects from `pettyui/*` exports. Use the pattern: + +```typescript +import { DialogRootPropsSchema, DialogContentPropsSchema, DialogMeta } from "pettyui/dialog"; +// ... repeat for all 39 components + +export interface ComponentEntry { + exportPath: string; + meta: ComponentMeta; + schemas: Record; +} + +export const COMPONENT_ENTRIES: ComponentEntry[] = [ + { exportPath: "pettyui/dialog", meta: DialogMeta, schemas: { dialogRoot: DialogRootPropsSchema, dialogContent: DialogContentPropsSchema } }, + // ... all 39 +]; +``` + +Read `packages/core/package.json` exports to get the full list of component names. + +- [ ] **Step 5: Update registry to use static imports** + +Modify `packages/mcp/src/registry.ts` to load from `COMPONENT_ENTRIES` instead of filesystem scanning. + +- [ ] **Step 6: Run tests** + +```bash +cd packages/mcp && pnpm vitest run tests/registry.test.ts +``` + +Expected: All 8 tests pass. + +- [ ] **Step 7: Commit** + +```bash +git add packages/mcp/src/registry.ts packages/mcp/src/component-imports.ts packages/mcp/tests/registry.test.ts +git commit -m "feat(mcp): add ComponentRegistry with static imports and search" +``` + +--- + +### Task 3: pettyui.discover Tool + +**Files:** +- Create: `packages/mcp/src/tools/discover.ts` +- Create: `packages/mcp/tests/tools/discover.test.ts` +- Modify: `packages/mcp/src/server.ts` + +- [ ] **Step 1: Write failing test** + +Create `packages/mcp/tests/tools/discover.test.ts`: + +```typescript +import { describe, expect, it } from "vitest"; +import { handleDiscover } from "../../src/tools/discover.js"; +import { ComponentRegistry } from "../../src/registry.js"; + +describe("pettyui.discover", () => { + const registry = new ComponentRegistry(); + + it("finds components matching intent", async () => { + const result = await handleDiscover(registry, { intent: "modal overlay" }); + expect(result.length).toBeGreaterThan(0); + expect(result[0].name).toBe("Dialog"); + }); + + it("returns multiple matches ranked by relevance", async () => { + const result = await handleDiscover(registry, { intent: "select option from list" }); + expect(result.length).toBeGreaterThan(1); + const names = result.map((r) => r.name); + expect(names).toContain("Select"); + }); + + it("returns empty array for no matches", async () => { + const result = await handleDiscover(registry, { intent: "xyznonexistent" }); + expect(result).toEqual([]); + }); + + it("limits results to maxResults", async () => { + const result = await handleDiscover(registry, { intent: "input", maxResults: 3 }); + expect(result.length).toBeLessThanOrEqual(3); + }); +}); +``` + +- [ ] **Step 2: Implement discover tool** + +Create `packages/mcp/src/tools/discover.ts`: + +```typescript +import type { ComponentRegistry } from "../registry.js"; + +interface DiscoverInput { + intent: string; + maxResults?: number; +} + +interface DiscoverResult { + name: string; + description: string; + exportPath: string; + parts: readonly string[]; + hasStyledVersion: boolean; + score: number; +} + +/** Handles pettyui.discover — semantic search by intent. */ +export async function handleDiscover(registry: ComponentRegistry, input: DiscoverInput): Promise { + const maxResults = input.maxResults ?? 10; + const results = registry.search(input.intent); + + return results.slice(0, maxResults).map((r) => ({ + name: r.meta.name, + description: r.meta.description, + exportPath: r.exportPath, + parts: r.meta.parts, + hasStyledVersion: r.hasStyledVersion, + score: r.score, + })); +} +``` + +- [ ] **Step 3: Register tool in server.ts** + +Update `packages/mcp/src/server.ts` to register the discover tool: + +```typescript +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod/v4"; +import { ComponentRegistry } from "./registry.js"; +import { handleDiscover } from "./tools/discover.js"; + +export function createPettyUIServer(): McpServer { + const server = new McpServer({ name: "pettyui", version: "0.1.0" }); + const registry = new ComponentRegistry(); + + server.tool( + "pettyui.discover", + "Search for components by describing what you need. Returns matching components ranked by relevance.", + { intent: z.string().describe("Natural language description of what you need"), maxResults: z.number().optional().describe("Max results to return. Defaults to 10") }, + async ({ intent, maxResults }) => { + const results = await handleDiscover(registry, { intent, maxResults }); + return { content: [{ type: "text", text: JSON.stringify(results, null, 2) }] }; + }, + ); + + return server; +} +``` + +- [ ] **Step 4: Run tests and commit** + +```bash +cd packages/mcp && pnpm vitest run tests/tools/discover.test.ts +git add packages/mcp/src/tools/discover.ts packages/mcp/tests/tools/discover.test.ts packages/mcp/src/server.ts +git commit -m "feat(mcp): add pettyui.discover tool for semantic component search" +``` + +--- + +### Task 4: pettyui.inspect Tool + +**Files:** +- Create: `packages/mcp/src/tools/inspect.ts` +- Create: `packages/mcp/tests/tools/inspect.test.ts` +- Modify: `packages/mcp/src/server.ts` + +- [ ] **Step 1: Write failing test** + +Create `packages/mcp/tests/tools/inspect.test.ts`: + +```typescript +import { describe, expect, it } from "vitest"; +import { handleInspect } from "../../src/tools/inspect.js"; +import { ComponentRegistry } from "../../src/registry.js"; + +describe("pettyui.inspect", () => { + const registry = new ComponentRegistry(); + + it("returns full component info", async () => { + const result = await handleInspect(registry, { component: "Dialog" }); + expect(result).toBeDefined(); + expect(result!.name).toBe("Dialog"); + expect(result!.description).toBeTruthy(); + expect(result!.parts.length).toBeGreaterThan(0); + expect(result!.requiredParts.length).toBeGreaterThan(0); + expect(result!.props).toBeDefined(); + expect(result!.exportPath).toBe("pettyui/dialog"); + }); + + it("includes JSON schema for props", async () => { + const result = await handleInspect(registry, { component: "Dialog" }); + expect(result!.props.dialogRoot).toBeDefined(); + expect(result!.props.dialogRoot.type).toBe("object"); + }); + + it("includes minimal example", async () => { + const result = await handleInspect(registry, { component: "Dialog" }); + expect(result!.example).toContain("Dialog"); + }); + + it("returns null for unknown component", async () => { + const result = await handleInspect(registry, { component: "FakeComponent" }); + expect(result).toBeNull(); + }); + + it("is case-insensitive", async () => { + const result = await handleInspect(registry, { component: "dialog" }); + expect(result).toBeDefined(); + expect(result!.name).toBe("Dialog"); + }); +}); +``` + +- [ ] **Step 2: Implement inspect tool** + +Create `packages/mcp/src/tools/inspect.ts`: + +```typescript +import type { ComponentRegistry } from "../registry.js"; + +interface InspectInput { + component: string; + includeSource?: boolean; +} + +interface InspectResult { + name: string; + description: string; + exportPath: string; + parts: readonly string[]; + requiredParts: readonly string[]; + props: Record; + hasStyledVersion: boolean; + example: string; +} + +/** Generates a minimal valid usage example from component meta. */ +function generateExample(name: string, parts: readonly string[], requiredParts: readonly string[]): string { + const lines: string[] = []; + lines.push(`import { ${name} } from "pettyui/${toKebab(name)}";`); + lines.push(""); + + if (requiredParts.includes("Root") || parts.includes("Root")) { + lines.push(`<${name}>`); + for (const part of requiredParts) { + if (part === "Root") continue; + lines.push(` <${name}.${part}>...`); + } + lines.push(``); + } else { + lines.push(`<${name} />`); + } + return lines.join("\n"); +} + +/** Converts PascalCase to kebab-case. */ +function toKebab(str: string): string { + return str.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase(); +} + +/** Handles pettyui.inspect — returns full schema + parts + example for a component. */ +export async function handleInspect(registry: ComponentRegistry, input: InspectInput): Promise { + const component = registry.getComponent(input.component); + if (!component) return null; + + return { + name: component.meta.name, + description: component.meta.description, + exportPath: component.exportPath, + parts: component.meta.parts, + requiredParts: component.meta.requiredParts, + props: component.jsonSchemas, + hasStyledVersion: component.hasStyledVersion, + example: generateExample(component.meta.name, component.meta.parts, component.meta.requiredParts), + }; +} +``` + +- [ ] **Step 3: Register in server.ts and commit** + +Add the inspect tool registration to `server.ts`, run tests, commit. + +```bash +cd packages/mcp && pnpm vitest run tests/tools/inspect.test.ts +git add packages/mcp/src/tools/inspect.ts packages/mcp/tests/tools/inspect.test.ts packages/mcp/src/server.ts +git commit -m "feat(mcp): add pettyui.inspect tool for component schema inspection" +``` + +--- + +### Task 5: pettyui.validate Tool + +**Files:** +- Create: `packages/mcp/src/tools/validate.ts` +- Create: `packages/mcp/tests/tools/validate.test.ts` +- Modify: `packages/mcp/src/server.ts` + +- [ ] **Step 1: Write failing test** + +Create `packages/mcp/tests/tools/validate.test.ts`: + +```typescript +import { describe, expect, it } from "vitest"; +import { handleValidate } from "../../src/tools/validate.js"; +import { ComponentRegistry } from "../../src/registry.js"; + +describe("pettyui.validate", () => { + const registry = new ComponentRegistry(); + + it("validates correct props", async () => { + const result = await handleValidate(registry, { + component: "Dialog", + schema: "dialogRoot", + props: { open: true, modal: false }, + }); + expect(result.valid).toBe(true); + expect(result.errors).toEqual([]); + }); + + it("rejects invalid props", async () => { + const result = await handleValidate(registry, { + component: "Dialog", + schema: "dialogRoot", + props: { open: "yes" }, + }); + expect(result.valid).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + }); + + it("validates empty props for optional schemas", async () => { + const result = await handleValidate(registry, { + component: "Dialog", + schema: "dialogRoot", + props: {}, + }); + expect(result.valid).toBe(true); + }); + + it("checks required parts are present", async () => { + const result = await handleValidate(registry, { + component: "Dialog", + parts: ["Root", "Content"], + }); + expect(result.valid).toBe(false); + expect(result.errors).toContain("Missing required part: Title"); + }); + + it("passes when all required parts present", async () => { + const result = await handleValidate(registry, { + component: "Dialog", + parts: ["Root", "Content", "Title"], + }); + expect(result.valid).toBe(true); + }); + + it("returns error for unknown component", async () => { + const result = await handleValidate(registry, { + component: "FakeComponent", + props: {}, + }); + expect(result.valid).toBe(false); + expect(result.errors).toContain('Component "FakeComponent" not found'); + }); +}); +``` + +- [ ] **Step 2: Implement validate tool** + +Create `packages/mcp/src/tools/validate.ts`: + +```typescript +import type { ComponentRegistry } from "../registry.js"; + +interface ValidateInput { + component: string; + schema?: string; + props?: Record; + parts?: string[]; +} + +interface ValidateResult { + valid: boolean; + errors: string[]; +} + +/** Handles pettyui.validate — validates props against schema and checks required parts. */ +export async function handleValidate(registry: ComponentRegistry, input: ValidateInput): Promise { + const component = registry.getComponent(input.component); + if (!component) { + return { valid: false, errors: [`Component "${input.component}" not found`] }; + } + + const errors: string[] = []; + + if (input.props !== undefined && input.schema) { + const schema = component.schemas[input.schema]; + if (!schema) { + errors.push(`Schema "${input.schema}" not found on ${component.meta.name}`); + } else { + const result = schema.safeParse(input.props); + if (!result.success) { + for (const issue of result.error.issues) { + const path = issue.path.length > 0 ? ` at "${issue.path.join(".")}"` : ""; + errors.push(`${issue.message}${path}`); + } + } + } + } + + if (input.parts !== undefined) { + for (const required of component.meta.requiredParts) { + if (!input.parts.includes(required)) { + errors.push(`Missing required part: ${required}`); + } + } + } + + return { valid: errors.length === 0, errors }; +} +``` + +- [ ] **Step 3: Register in server.ts and commit** + +```bash +cd packages/mcp && pnpm vitest run tests/tools/validate.test.ts +git add packages/mcp/src/tools/validate.ts packages/mcp/tests/tools/validate.test.ts packages/mcp/src/server.ts +git commit -m "feat(mcp): add pettyui.validate tool for props and parts validation" +``` + +--- + +### Task 6: pettyui.add Tool + +**Files:** +- Create: `packages/mcp/src/tools/add.ts` +- Create: `packages/mcp/tests/tools/add.test.ts` +- Modify: `packages/mcp/src/server.ts` + +- [ ] **Step 1: Write failing test** + +Create `packages/mcp/tests/tools/add.test.ts`: + +```typescript +import { describe, expect, it } from "vitest"; +import { handleAdd } from "../../src/tools/add.js"; +import { ComponentRegistry } from "../../src/registry.js"; + +describe("pettyui.add", () => { + const registry = new ComponentRegistry(); + + it("returns styled component source for Dialog", async () => { + const result = await handleAdd(registry, { component: "Dialog" }); + expect(result).toBeDefined(); + expect(result!.files.length).toBeGreaterThan(0); + expect(result!.files[0].path).toContain("dialog"); + expect(result!.files[0].content).toContain("Dialog"); + expect(result!.dependencies).toContain("pettyui/dialog"); + }); + + it("returns null for component without styled version", async () => { + const result = await handleAdd(registry, { component: "Checkbox" }); + expect(result).toBeNull(); + }); + + it("returns null for unknown component", async () => { + const result = await handleAdd(registry, { component: "FakeComponent" }); + expect(result).toBeNull(); + }); + + it("includes target path", async () => { + const result = await handleAdd(registry, { component: "Dialog", targetDir: "src/ui" }); + expect(result!.files[0].path).toContain("src/ui"); + }); +}); +``` + +- [ ] **Step 2: Implement add tool** + +Create `packages/mcp/src/tools/add.ts`: + +```typescript +import * as fs from "node:fs"; +import * as path from "node:path"; +import type { ComponentRegistry } from "../registry.js"; + +interface AddInput { + component: string; + targetDir?: string; +} + +interface AddResult { + files: Array<{ path: string; content: string }>; + dependencies: string[]; +} + +/** Resolves the registry components directory. */ +function getRegistryDir(): string { + return path.resolve(import.meta.dirname, "../../registry/src/components"); +} + +/** Converts PascalCase to kebab-case. */ +function toKebab(str: string): string { + return str.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase(); +} + +/** Handles pettyui.add — copies styled component from registry. */ +export async function handleAdd(registry: ComponentRegistry, input: AddInput): Promise { + const component = registry.getComponent(input.component); + if (!component || !component.hasStyledVersion) return null; + + const kebabName = toKebab(component.meta.name); + const registryDir = getRegistryDir(); + const sourcePath = path.join(registryDir, `${kebabName}.tsx`); + + if (!fs.existsSync(sourcePath)) return null; + + const content = fs.readFileSync(sourcePath, "utf-8"); + const targetDir = input.targetDir ?? "src/components/ui"; + const targetPath = path.join(targetDir, `${kebabName}.tsx`); + + return { + files: [{ path: targetPath, content }], + dependencies: [component.exportPath], + }; +} +``` + +- [ ] **Step 3: Register in server.ts and commit** + +```bash +cd packages/mcp && pnpm vitest run tests/tools/add.test.ts +git add packages/mcp/src/tools/add.ts packages/mcp/tests/tools/add.test.ts packages/mcp/src/server.ts +git commit -m "feat(mcp): add pettyui.add tool for copying styled components from registry" +``` + +--- + +### Task 7: pettyui.compose Tool + +**Files:** +- Create: `packages/mcp/src/tools/compose.ts` +- Create: `packages/mcp/tests/tools/compose.test.ts` +- Modify: `packages/mcp/src/server.ts` + +- [ ] **Step 1: Write failing test** + +Create `packages/mcp/tests/tools/compose.test.ts`: + +```typescript +import { describe, expect, it } from "vitest"; +import { handleCompose } from "../../src/tools/compose.js"; +import { ComponentRegistry } from "../../src/registry.js"; + +describe("pettyui.compose", () => { + const registry = new ComponentRegistry(); + + it("generates composed JSX for specified components", async () => { + const result = await handleCompose(registry, { + components: ["Dialog", "Button"], + }); + expect(result.jsx).toContain("Dialog"); + expect(result.jsx).toContain("Button"); + expect(result.imports).toContain("pettyui/dialog"); + expect(result.imports).toContain("pettyui/button"); + }); + + it("generates imports for all components", async () => { + const result = await handleCompose(registry, { + components: ["Form", "TextField", "Button"], + }); + expect(result.imports.length).toBe(3); + }); + + it("uses required parts in generated JSX", async () => { + const result = await handleCompose(registry, { + components: ["Dialog"], + }); + expect(result.jsx).toContain("Dialog.Content"); + expect(result.jsx).toContain("Dialog.Title"); + }); + + it("skips unknown components with warning", async () => { + const result = await handleCompose(registry, { + components: ["Dialog", "FakeComponent"], + }); + expect(result.jsx).toContain("Dialog"); + expect(result.warnings).toContain('Component "FakeComponent" not found'); + }); +}); +``` + +- [ ] **Step 2: Implement compose tool** + +Create `packages/mcp/src/tools/compose.ts`: + +```typescript +import type { ComponentRegistry } from "../registry.js"; + +interface ComposeInput { + components: string[]; + intent?: string; +} + +interface ComposeResult { + jsx: string; + imports: string[]; + warnings: string[]; +} + +/** Converts PascalCase to kebab-case. */ +function toKebab(str: string): string { + return str.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase(); +} + +/** Generates JSX snippet for a single component using its required parts. */ +function generateComponentJsx(name: string, parts: readonly string[], requiredParts: readonly string[], indent: number): string { + const pad = " ".repeat(indent); + const lines: string[] = []; + + const hasRoot = parts.includes("Root"); + if (hasRoot) { + lines.push(`${pad}<${name}>`); + for (const part of requiredParts) { + if (part === "Root") continue; + lines.push(`${pad} <${name}.${part}>...`); + } + lines.push(`${pad}`); + } else { + lines.push(`${pad}<${name} />`); + } + + return lines.join("\n"); +} + +/** Handles pettyui.compose — generates composed multi-component JSX. */ +export async function handleCompose(registry: ComponentRegistry, input: ComposeInput): Promise { + const imports: string[] = []; + const jsxParts: string[] = []; + const warnings: string[] = []; + + for (const name of input.components) { + const component = registry.getComponent(name); + if (!component) { + warnings.push(`Component "${name}" not found`); + continue; + } + + imports.push(component.exportPath); + jsxParts.push(generateComponentJsx(component.meta.name, component.meta.parts, component.meta.requiredParts, 1)); + } + + const importLines = imports.map((imp) => { + const name = imp.replace("pettyui/", "").split("-").map((s) => s.charAt(0).toUpperCase() + s.slice(1)).join(""); + return `import { ${name} } from "${imp}";`; + }); + + const jsx = [ + ...importLines, + "", + "function Component() {", + " return (", + " <>", + jsxParts.join("\n"), + " ", + " );", + "}", + ].join("\n"); + + return { jsx, imports, warnings }; +} +``` + +- [ ] **Step 3: Register in server.ts and commit** + +```bash +cd packages/mcp && pnpm vitest run tests/tools/compose.test.ts +git add packages/mcp/src/tools/compose.ts packages/mcp/tests/tools/compose.test.ts packages/mcp/src/server.ts +git commit -m "feat(mcp): add pettyui.compose tool for multi-component JSX generation" +``` + +--- + +### Task 8: CLI Wrapper + +**Files:** +- Create: `packages/mcp/src/cli.ts` +- Create: `packages/mcp/tests/cli.test.ts` + +- [ ] **Step 1: Write failing test** + +Create `packages/mcp/tests/cli.test.ts`: + +```typescript +import { describe, expect, it } from "vitest"; +import { parseCommand } from "../src/cli.js"; + +describe("CLI parser", () => { + it("parses init command", () => { + const cmd = parseCommand(["init"]); + expect(cmd.command).toBe("init"); + }); + + it("parses add command with component", () => { + const cmd = parseCommand(["add", "dialog"]); + expect(cmd.command).toBe("add"); + expect(cmd.args.component).toBe("dialog"); + }); + + it("parses add with target dir", () => { + const cmd = parseCommand(["add", "dialog", "--dir", "src/ui"]); + expect(cmd.command).toBe("add"); + expect(cmd.args.component).toBe("dialog"); + expect(cmd.args.dir).toBe("src/ui"); + }); + + it("parses inspect command", () => { + const cmd = parseCommand(["inspect", "dialog"]); + expect(cmd.command).toBe("inspect"); + expect(cmd.args.component).toBe("dialog"); + }); + + it("parses list command", () => { + const cmd = parseCommand(["list"]); + expect(cmd.command).toBe("list"); + }); + + it("returns help for unknown command", () => { + const cmd = parseCommand(["unknown"]); + expect(cmd.command).toBe("help"); + }); + + it("returns help for empty args", () => { + const cmd = parseCommand([]); + expect(cmd.command).toBe("help"); + }); +}); +``` + +- [ ] **Step 2: Implement CLI** + +Create `packages/mcp/src/cli.ts`: + +```typescript +#!/usr/bin/env node +import { ComponentRegistry } from "./registry.js"; +import { handleDiscover } from "./tools/discover.js"; +import { handleInspect } from "./tools/inspect.js"; +import { handleAdd } from "./tools/add.js"; +import * as fs from "node:fs"; +import * as path from "node:path"; + +interface ParsedCommand { + command: string; + args: Record; +} + +/** Parses CLI arguments into a command + args. */ +export function parseCommand(argv: string[]): ParsedCommand { + if (argv.length === 0) return { command: "help", args: {} }; + + const command = argv[0]; + const args: Record = {}; + + if (["add", "inspect", "discover"].includes(command) && argv.length > 1) { + args.component = argv[1]; + } + + for (let i = 1; i < argv.length; i++) { + if (argv[i] === "--dir" && argv[i + 1]) { + args.dir = argv[i + 1]; + i++; + } + } + + if (!["init", "add", "inspect", "list", "discover", "help"].includes(command)) { + return { command: "help", args: {} }; + } + + return { command, args }; +} + +const HELP_TEXT = ` +pettyui — AI-native component library CLI + +Commands: + pettyui init Set up tokens, utils, tsconfig paths + pettyui add Copy styled component to your project + pettyui inspect Show component schema and usage + pettyui list List all available components + pettyui discover Search components by intent + +Options: + --dir Target directory for add (default: src/components/ui) +`; + +/** Runs the CLI with given arguments. */ +export async function runCli(argv: string[]): Promise { + const { command, args } = parseCommand(argv); + const registry = new ComponentRegistry(); + + switch (command) { + case "help": { + console.log(HELP_TEXT); + break; + } + case "list": { + const components = registry.listComponents(); + console.log(`\n${components.length} components available:\n`); + for (const c of components) { + const styled = c.hasStyledVersion ? " [styled]" : ""; + console.log(` ${c.meta.name}${styled} — ${c.meta.description}`); + } + console.log(""); + break; + } + case "inspect": { + if (!args.component) { console.error("Usage: pettyui inspect "); break; } + const result = await handleInspect(registry, { component: args.component }); + if (!result) { console.error(`Component "${args.component}" not found`); break; } + console.log(JSON.stringify(result, null, 2)); + break; + } + case "discover": { + if (!args.component) { console.error("Usage: pettyui discover "); break; } + const results = await handleDiscover(registry, { intent: args.component }); + if (results.length === 0) { console.log("No matching components found."); break; } + for (const r of results) { + console.log(` ${r.name} (${r.score}) — ${r.description}`); + } + break; + } + case "add": { + if (!args.component) { console.error("Usage: pettyui add "); break; } + const result = await handleAdd(registry, { component: args.component, targetDir: args.dir }); + if (!result) { console.error(`No styled version available for "${args.component}"`); break; } + for (const file of result.files) { + const dir = path.dirname(file.path); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(file.path, file.content, "utf-8"); + console.log(` Created ${file.path}`); + } + console.log(`\n Dependencies: ${result.dependencies.join(", ")}`); + break; + } + case "init": { + console.log("pettyui init — setting up project..."); + const tokensPath = "src/styles/tokens.css"; + const utilsPath = "src/lib/utils.ts"; + const registryDir = path.resolve(import.meta.dirname, "../../registry/src"); + const tokensSource = path.join(registryDir, "tokens.css"); + const utilsSource = path.join(registryDir, "utils.ts"); + + if (fs.existsSync(tokensSource)) { + fs.mkdirSync(path.dirname(tokensPath), { recursive: true }); + fs.copyFileSync(tokensSource, tokensPath); + console.log(` Created ${tokensPath}`); + } + if (fs.existsSync(utilsSource)) { + fs.mkdirSync(path.dirname(utilsPath), { recursive: true }); + fs.copyFileSync(utilsSource, utilsPath); + console.log(` Created ${utilsPath}`); + } + console.log("\n Done! Add pettyui as a dependency and import tokens.css in your app."); + break; + } + } +} + +// Run if called directly +const args = process.argv.slice(2); +if (args.length > 0 || !process.stdin.isTTY) { + runCli(args); +} +``` + +- [ ] **Step 3: Run tests and commit** + +```bash +cd packages/mcp && pnpm vitest run tests/cli.test.ts +git add packages/mcp/src/cli.ts packages/mcp/tests/cli.test.ts +git commit -m "feat(mcp): add CLI wrapper with init, add, inspect, list, discover commands" +``` + +--- + +### Task 9: Final Server Assembly + Verification + +**Files:** +- Modify: `packages/mcp/src/server.ts` — ensure all 5 tools registered +- Modify: `packages/mcp/package.json` — verify bin entries + +- [ ] **Step 1: Verify server.ts has all 5 tools registered** + +Read `packages/mcp/src/server.ts` and ensure it registers: `pettyui.discover`, `pettyui.inspect`, `pettyui.validate`, `pettyui.add`, `pettyui.compose`. + +- [ ] **Step 2: Run full test suite** + +```bash +cd packages/mcp && pnpm vitest run +``` + +Expected: All tests pass across registry, tools, and CLI. + +- [ ] **Step 3: Build the MCP package** + +```bash +cd packages/mcp && pnpm build +``` + +Expected: Clean build. + +- [ ] **Step 4: Verify MCP server starts** + +```bash +cd packages/mcp && echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"0.1.0"}}}' | node dist/index.js +``` + +Expected: Returns JSON-RPC response with server capabilities listing all 5 tools. + +- [ ] **Step 5: Verify CLI works** + +```bash +cd packages/mcp && node dist/cli.js list +``` + +Expected: Lists all 39 components with descriptions. + +- [ ] **Step 6: Run full monorepo test suite** + +```bash +cd /Users/matsbosson/Documents/StayThree/PettyUI && pnpm test +``` + +Expected: All packages pass (core: 445 tests, registry: 3 tests, mcp: all tests). + +- [ ] **Step 7: Commit** + +```bash +git add packages/mcp/ +git commit -m "feat(mcp): complete MCP server with 5 tools + CLI, Phase 3 done" +```