PettyUI/docs/superpowers/plans/2026-03-29-phase3-mcp-server-cli.md
2026-03-31 21:42:28 +07:00

1375 lines
41 KiB
Markdown

# 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<string, z.ZodType>;
jsonSchemas: Record<string, unknown>;
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<string, RegisteredComponent>();
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<string, unknown>; exportPath: string }> {
const entries: Array<{ name: string; mod: Record<string, unknown>; 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<string, unknown> | 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<string, unknown>; exportPath: string }): void {
const meta = this.findMeta(entry.mod);
if (!meta) return;
const schemas = this.findSchemas(entry.mod);
const jsonSchemas: Record<string, unknown> = {};
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<string, unknown>): 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<string, unknown>): Record<string, z.ZodType> {
const schemas: Record<string, z.ZodType> = {};
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<string, z.ZodType>;
}
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<DiscoverResult[]> {
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<string, unknown>;
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}>...</${name}.${part}>`);
}
lines.push(`</${name}>`);
} 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<InspectResult | null> {
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<string, unknown>;
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<ValidateResult> {
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<AddResult | null> {
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}>...</${name}.${part}>`);
}
lines.push(`${pad}</${name}>`);
} 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<ComposeResult> {
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<string, string>;
}
/** 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<string, string> = {};
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 <component> Copy styled component to your project
pettyui inspect <component> Show component schema and usage
pettyui list List all available components
pettyui discover <intent> Search components by intent
Options:
--dir <path> Target directory for add (default: src/components/ui)
`;
/** Runs the CLI with given arguments. */
export async function runCli(argv: string[]): Promise<void> {
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 <component>"); 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 <intent>"); 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 <component>"); 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"
```