From 7e88fbc347debac57da3b8c45060776db139243b Mon Sep 17 00:00:00 2001 From: Mats Bosson Date: Sun, 29 Mar 2026 23:49:53 +0700 Subject: [PATCH] MCP discover tool --- packages/mcp/src/server.ts | 17 +++++++--- packages/mcp/src/tools/add.ts | 30 +++++++++++++++++ packages/mcp/src/tools/discover.ts | 13 +++++++ packages/mcp/src/tools/validate.ts | 41 +++++++++++++++++++++++ packages/mcp/tests/tools/add.test.ts | 30 +++++++++++++++++ packages/mcp/tests/tools/discover.test.ts | 29 ++++++++++++++++ 6 files changed, 156 insertions(+), 4 deletions(-) create mode 100644 packages/mcp/src/tools/add.ts create mode 100644 packages/mcp/src/tools/discover.ts create mode 100644 packages/mcp/src/tools/validate.ts create mode 100644 packages/mcp/tests/tools/add.test.ts create mode 100644 packages/mcp/tests/tools/discover.test.ts diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts index ccdccd2..dbcf78e 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -1,10 +1,19 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod/v4"; +import { ComponentRegistry } from "./registry.js"; +import { handleDiscover } from "./tools/discover.js"; /** Creates and configures the PettyUI MCP server. */ export function createPettyUIServer(): McpServer { - const server = new McpServer({ - name: "pettyui", - version: "0.1.0", - }); + 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", + { intent: z.string().describe("What you need"), maxResults: z.number().optional().describe("Max results") }, + async ({ intent, maxResults }) => ({ + content: [{ type: "text", text: JSON.stringify(handleDiscover(registry, { intent, maxResults }), null, 2) }], + }), + ); return server; } diff --git a/packages/mcp/src/tools/add.ts b/packages/mcp/src/tools/add.ts new file mode 100644 index 0000000..04da962 --- /dev/null +++ b/packages/mcp/src/tools/add.ts @@ -0,0 +1,30 @@ +import { readFileSync, accessSync } from "node:fs"; +import { resolve, join } 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[]; } + +function toKebab(str: string): string { + return str.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase(); +} + +/** Copies a styled component from the registry to the user's project. */ +export function handleAdd(registry: ComponentRegistry, input: AddInput): AddResult | null { + const comp = registry.getComponent(input.component); + if (!comp || !comp.hasStyledVersion) return null; + + const kebab = toKebab(comp.meta.name); + const registryDir = resolve(import.meta.dirname, "../../registry/src/components"); + const sourcePath = join(registryDir, `${kebab}.tsx`); + + try { accessSync(sourcePath); } catch { return null; } + + const content = readFileSync(sourcePath, "utf-8"); + const targetDir = input.targetDir ?? "src/components/ui"; + + return { + files: [{ path: join(targetDir, `${kebab}.tsx`), content }], + dependencies: [comp.exportPath], + }; +} diff --git a/packages/mcp/src/tools/discover.ts b/packages/mcp/src/tools/discover.ts new file mode 100644 index 0000000..97e05f6 --- /dev/null +++ b/packages/mcp/src/tools/discover.ts @@ -0,0 +1,13 @@ +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; } + +/** Searches components by natural language intent description. */ +export function handleDiscover(registry: ComponentRegistry, input: DiscoverInput): DiscoverResult[] { + const max = input.maxResults ?? 10; + return registry.search(input.intent).slice(0, max).map((r) => ({ + name: r.meta.name, description: r.meta.description, exportPath: r.exportPath, + parts: r.meta.parts, hasStyledVersion: r.hasStyledVersion, score: r.score, + })); +} diff --git a/packages/mcp/src/tools/validate.ts b/packages/mcp/src/tools/validate.ts new file mode 100644 index 0000000..87475d9 --- /dev/null +++ b/packages/mcp/src/tools/validate.ts @@ -0,0 +1,41 @@ +import type { ComponentRegistry } from "../registry.js"; + +interface ValidateInput { component: string; schema?: string; props?: Record; parts?: string[]; } +interface ValidateResult { valid: boolean; errors: string[]; } + +/** Validates props against schema and/or checks required parts are present. */ +export function handleValidate(registry: ComponentRegistry, input: ValidateInput): ValidateResult { + const comp = registry.getComponent(input.component); + if (!comp) return { valid: false, errors: [`Component "${input.component}" not found`] }; + + const errors: string[] = []; + + // Validate props against named schema + if (input.props !== undefined && input.schema) { + const schema = comp.schemas[input.schema]; + if (!schema) { + errors.push(`Schema "${input.schema}" not found on ${comp.meta.name}`); + } else { + // schema is a Zod schema — call safeParse + const zSchema = schema as { safeParse: (data: unknown) => { success: boolean; error?: { issues: Array<{ message: string; path: (string | number)[] }> } } }; + const result = zSchema.safeParse(input.props); + if (!result.success && result.error) { + for (const issue of result.error.issues) { + const pathStr = issue.path.length > 0 ? ` at "${issue.path.join(".")}"` : ""; + errors.push(`${issue.message}${pathStr}`); + } + } + } + } + + // Check required parts + if (input.parts !== undefined) { + for (const required of comp.meta.requiredParts) { + if (!input.parts.includes(required)) { + errors.push(`Missing required part: ${required}`); + } + } + } + + return { valid: errors.length === 0, errors }; +} diff --git a/packages/mcp/tests/tools/add.test.ts b/packages/mcp/tests/tools/add.test.ts new file mode 100644 index 0000000..342f96d --- /dev/null +++ b/packages/mcp/tests/tools/add.test.ts @@ -0,0 +1,30 @@ +import { describe, it, expect } 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 source for Dialog", () => { + const result = handleAdd(registry, { component: "Dialog" }); + expect(result).not.toBeNull(); + expect(result?.files[0].content).toContain("Dialog"); + expect(result?.dependencies).toContain("pettyui/dialog"); + }); + + it("returns null without styled version", () => { + const result = handleAdd(registry, { component: "Checkbox" }); + expect(result).toBeNull(); + }); + + it("returns null for unknown component", () => { + const result = handleAdd(registry, { component: "FakeComponent" }); + expect(result).toBeNull(); + }); + + it("uses custom target dir", () => { + const result = handleAdd(registry, { component: "Dialog", targetDir: "src/ui" }); + expect(result).not.toBeNull(); + expect(result?.files[0].path).toContain("src/ui"); + }); +}); diff --git a/packages/mcp/tests/tools/discover.test.ts b/packages/mcp/tests/tools/discover.test.ts new file mode 100644 index 0000000..bbf65b7 --- /dev/null +++ b/packages/mcp/tests/tools/discover.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect } 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 — modal overlay → Dialog first", () => { + const results = handleDiscover(registry, { intent: "modal overlay" }); + expect(results.length).toBeGreaterThan(0); + expect(results[0].name).toBe("Dialog"); + }); + + it("returns multiple ranked matches — select option includes Select", () => { + const results = handleDiscover(registry, { intent: "select option" }); + const names = results.map((r) => r.name); + expect(names).toContain("Select"); + }); + + it("returns empty for no matches — xyznonexistent → []", () => { + const results = handleDiscover(registry, { intent: "xyznonexistent" }); + expect(results).toEqual([]); + }); + + it("limits results with maxResults: 3", () => { + const results = handleDiscover(registry, { intent: "a", maxResults: 3 }); + expect(results.length).toBeLessThanOrEqual(3); + }); +});