MCP discover tool

This commit is contained in:
Mats Bosson 2026-03-29 23:49:53 +07:00
parent 5e66d6fae1
commit 7e88fbc347
6 changed files with 156 additions and 4 deletions

View File

@ -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;
}

View File

@ -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],
};
}

View File

@ -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,
}));
}

View File

@ -0,0 +1,41 @@
import type { ComponentRegistry } from "../registry.js";
interface ValidateInput { component: string; schema?: string; props?: Record<string, unknown>; 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 };
}

View File

@ -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");
});
});

View File

@ -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);
});
});