PettyUI/docs/superpowers/plans/2026-03-29-phase3-mcp-server-cli.md
Mats Bosson c68c31f5b4 Plan for MCP server
9 tasks: package scaffold, component registry, 5 MCP tools
(discover, inspect, validate, add, compose), CLI wrapper,
final verification.
2026-03-29 21:47:56 +07:00

41 KiB

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:

{
  "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:

{
  "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:

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:

#!/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
cd packages/mcp && pnpm install
  • Step 6: Build and verify
cd packages/mcp && pnpm build

Expected: Compiles without errors.

  • Step 7: Commit
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:

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
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:

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:

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:

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
cd packages/mcp && pnpm vitest run tests/registry.test.ts

Expected: All 8 tests pass.

  • Step 7: Commit
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:

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:

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:

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
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:

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:

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.

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:

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:

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
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:

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:

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
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:

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:

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
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:

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:

#!/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
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
cd packages/mcp && pnpm vitest run

Expected: All tests pass across registry, tools, and CLI.

  • Step 3: Build the MCP package
cd packages/mcp && pnpm build

Expected: Clean build.

  • Step 4: Verify MCP server starts
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
cd packages/mcp && node dist/cli.js list

Expected: Lists all 39 components with descriptions.

  • Step 6: Run full monorepo test suite
cd /Users/matsbosson/Documents/StayThree/PettyUI && pnpm test

Expected: All packages pass (core: 445 tests, registry: 3 tests, mcp: all tests).

  • Step 7: Commit
git add packages/mcp/
git commit -m "feat(mcp): complete MCP server with 5 tools + CLI, Phase 3 done"