MCP discover tool
This commit is contained in:
parent
5e66d6fae1
commit
7e88fbc347
@ -1,10 +1,19 @@
|
|||||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
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. */
|
/** Creates and configures the PettyUI MCP server. */
|
||||||
export function createPettyUIServer(): McpServer {
|
export function createPettyUIServer(): McpServer {
|
||||||
const server = new McpServer({
|
const server = new McpServer({ name: "pettyui", version: "0.1.0" });
|
||||||
name: "pettyui",
|
const registry = new ComponentRegistry();
|
||||||
version: "0.1.0",
|
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;
|
return server;
|
||||||
}
|
}
|
||||||
|
|||||||
30
packages/mcp/src/tools/add.ts
Normal file
30
packages/mcp/src/tools/add.ts
Normal 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],
|
||||||
|
};
|
||||||
|
}
|
||||||
13
packages/mcp/src/tools/discover.ts
Normal file
13
packages/mcp/src/tools/discover.ts
Normal 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,
|
||||||
|
}));
|
||||||
|
}
|
||||||
41
packages/mcp/src/tools/validate.ts
Normal file
41
packages/mcp/src/tools/validate.ts
Normal 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 };
|
||||||
|
}
|
||||||
30
packages/mcp/tests/tools/add.test.ts
Normal file
30
packages/mcp/tests/tools/add.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
29
packages/mcp/tests/tools/discover.test.ts
Normal file
29
packages/mcp/tests/tools/discover.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user