# 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" ```