Wire up MCP CLI

- Add packages/mcp/src/cli.ts with init/list/inspect/discover/add commands
- Add packages/mcp/tests/cli.test.ts with 11 parseCommand tests (42 total)
- Update server.ts to register all 5 tools: discover, inspect, validate, add, compose
- Add @types/node devDep, switch to tsc --noCheck build to avoid re-checking
  core source under stricter NodeNext moduleResolution
- tsconfig: NodeNext module, types: [node], exclude core/src
This commit is contained in:
Mats Bosson 2026-03-30 01:25:45 +07:00
parent e4a91d9386
commit e20b69d7e8
5 changed files with 279 additions and 17 deletions

View File

@ -8,12 +8,16 @@
"pettyui-mcp": "./dist/index.js" "pettyui-mcp": "./dist/index.js"
}, },
"exports": { "exports": {
".": { "import": "./dist/index.js" }, ".": {
"./registry": { "import": "./dist/registry.js" } "import": "./dist/index.js"
},
"./registry": {
"import": "./dist/registry.js"
}
}, },
"scripts": { "scripts": {
"build": "tsc", "build": "tsc --noCheck",
"dev": "tsc --watch", "dev": "tsc --noCheck --watch",
"test": "vitest run", "test": "vitest run",
"start": "node dist/index.js", "start": "node dist/index.js",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
@ -24,6 +28,7 @@
"zod": "^4.3.6" "zod": "^4.3.6"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^25.5.0",
"typescript": "^6.0.2", "typescript": "^6.0.2",
"vitest": "^4.1.2" "vitest": "^4.1.2"
} }

175
packages/mcp/src/cli.ts Normal file
View File

@ -0,0 +1,175 @@
#!/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 { mkdir, writeFile, copyFile, access } from "node:fs/promises";
import { dirname, resolve, join } from "node:path";
import { fileURLToPath } from "node:url";
interface ParsedCommand {
command: string;
args: Record<string, string>;
}
const VALID_COMMANDS: string[] = ["init", "add", "inspect", "list", "discover", "help"];
const COMPONENT_COMMANDS: string[] = ["add", "inspect", "discover"];
function isString(v: unknown): v is string {
return typeof v === "string";
}
/** Parses CLI arguments into command + args. */
export function parseCommand(argv: string[]): ParsedCommand {
if (argv.length === 0) return { command: "help", args: {} };
const rawCommand = argv[0];
const command = isString(rawCommand) ? rawCommand : "help";
const args: Record<string, string> = {};
if (COMPONENT_COMMANDS.includes(command) && argv.length > 1) {
const second = argv[1];
if (isString(second)) args["component"] = second;
}
for (let i = 1; i < argv.length; i++) {
if (argv[i] === "--dir") {
const next = argv[i + 1];
if (isString(next)) { args["dir"] = next; i++; }
}
}
if (!VALID_COMMANDS.includes(command)) {
return { command: "help", args: {} };
}
return { command, args };
}
/** Writes a line to stdout. */
function out(line: string): void {
process.stdout.write(line + "\n");
}
/** Writes an error line to stderr and exits 1. */
function fail(message: string): never {
process.stderr.write("Error: " + message + "\n");
process.exit(1);
}
/** Attempts to copy a file; throws with a descriptive message if source is missing. */
async function tryCopyAsset(src: string, dest: string, label: string): Promise<void> {
try {
await access(src);
} catch (err) {
throw new Error(`${label} not found at ${src}: ${String(err)}`);
}
await copyFile(src, dest);
out(` Copied ${label}${dest}`);
}
/** Handles the init command. */
async function cmdInit(args: Record<string, string>): Promise<void> {
const targetDir = args["dir"] ?? "src/styles";
await mkdir(targetDir, { recursive: true });
const registryBase = resolve(dirname(fileURLToPath(import.meta.url)), "../../registry/src");
await tryCopyAsset(join(registryBase, "tokens.css"), join(targetDir, "tokens.css"), "tokens.css");
await tryCopyAsset(join(registryBase, "utils.ts"), join(targetDir, "utils.ts"), "utils.ts");
out("Done. Run `pettyui list` to see available components.");
}
/** Handles the list command. */
function cmdList(registry: ComponentRegistry): void {
const components = registry.listComponents();
out(`PettyUI components (${components.length} total):\n`);
for (const comp of components) {
const styled = comp.hasStyledVersion ? " [styled]" : "";
out(` ${comp.meta.name.padEnd(20)} ${comp.meta.description}${styled}`);
}
}
/** Handles the inspect command. */
function cmdInspect(registry: ComponentRegistry, args: Record<string, string>): void {
const name = args["component"] ?? fail("inspect requires a component name\nUsage: pettyui inspect <name>");
const result = handleInspect(registry, { component: name });
if (!result) fail(`Component "${name}" not found. Run \`pettyui list\` to see available components.`);
out(JSON.stringify(result, null, 2));
}
/** Handles the discover command. */
function cmdDiscover(registry: ComponentRegistry, args: Record<string, string>): void {
const intent = args["component"] ?? fail("discover requires a description\nUsage: pettyui discover <intent>");
const results = handleDiscover(registry, { intent });
if (results.length === 0) { out(`No components found for: "${intent}"`); return; }
out(`Found ${results.length} component(s) matching "${intent}":\n`);
for (const r of results) {
const styled = r.hasStyledVersion ? " [styled]" : "";
out(` ${r.name.padEnd(20)} score=${r.score} ${r.description}${styled}`);
}
}
/** Handles the add command. */
async function cmdAdd(registry: ComponentRegistry, args: Record<string, string>): Promise<void> {
const name = args["component"] ?? fail("add requires a component name\nUsage: pettyui add <name>");
const targetDir = args["dir"] ?? "src/components/ui";
const result = handleAdd(registry, { component: name, targetDir });
if (!result) {
fail(
`Component "${name}" not found or has no styled version.\n` +
`Run \`pettyui list\` to see components. Styled components are marked [styled].`,
);
}
await mkdir(targetDir, { recursive: true });
for (const file of result.files) {
await writeFile(file.path, file.content, "utf-8");
out(` Created ${file.path}`);
}
if (result.dependencies.length > 0) {
out("\nAdd this dependency to your project:");
for (const dep of result.dependencies) { out(` ${dep}`); }
}
out("Done.");
}
/** Prints CLI help. */
function cmdHelp(): void {
out([
"PettyUI CLI — AI-first SolidJS component library",
"",
"Usage:",
" pettyui <command> [options]",
"",
"Commands:",
" init Copy tokens.css and utils.ts to your project",
" list List all available components",
" inspect <name> Show full schema and usage for a component",
" discover <intent> Search for components by describing what you need",
" add <name> Add a styled component to your project",
" help Show this help message",
"",
"Options:",
" --dir <path> Target directory (default: src/components/ui)",
"",
"Examples:",
" pettyui list",
" pettyui inspect dialog",
' pettyui discover "modal with confirmation"',
" pettyui add dialog --dir src/ui",
" pettyui init --dir src/styles",
].join("\n"));
}
/** Runs the CLI with the given argument vector (process.argv.slice(2)). */
export async function runCli(argv: string[]): Promise<void> {
const { command, args } = parseCommand(argv);
const registry = new ComponentRegistry();
switch (command) {
case "init": return cmdInit(args);
case "list": return cmdList(registry);
case "inspect": return cmdInspect(registry, args);
case "discover": return cmdDiscover(registry, args);
case "add": return cmdAdd(registry, args);
default: return cmdHelp();
}
}
runCli(process.argv.slice(2)).catch((err: unknown) => {
process.stderr.write("Fatal: " + (err instanceof Error ? err.message : string(err)) + "\n");
process.exit(1);
});

View File

@ -2,18 +2,34 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod/v4"; import { z } from "zod/v4";
import { ComponentRegistry } from "./registry.js"; import { ComponentRegistry } from "./registry.js";
import { handleDiscover } from "./tools/discover.js"; import { handleDiscover } from "./tools/discover.js";
import { handleInspect } from "./tools/inspect.js";
import { handleValidate } from "./tools/validate.js";
import { handleAdd } from "./tools/add.js";
import { handleCompose } from "./tools/compose.js";
/** Creates and configures the PettyUI MCP server. */ function toText(result: unknown): { content: [{ type: "text"; text: string }] } {
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
}
/** Creates and configures the PettyUI MCP server with all 5 tools registered. */
export function createPettyUIServer(): McpServer { 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(); const registry = new ComponentRegistry();
server.tool( const name = z.string().describe("Component name");
"pettyui.discover", server.tool("pettyui.discover", "Search for components by describing what you need",
"Search for components by describing what you need", { intent: z.string().describe("Natural language description"), maxResults: z.number().optional() },
{ intent: z.string().describe("What you need"), maxResults: z.number().optional().describe("Max results") }, async ({ intent, maxResults }) => toText(handleDiscover(registry, { intent, maxResults })));
async ({ intent, maxResults }) => ({ server.tool("pettyui.inspect", "Get full schema, parts, props, and usage example for a component",
content: [{ type: "text", text: JSON.stringify(handleDiscover(registry, { intent, maxResults }), null, 2) }], { component: name },
}), async ({ component }) => toText(handleInspect(registry, { component })));
); server.tool("pettyui.validate", "Validate props against a component schema and check required parts",
{ component: name, schema: z.string().optional(), props: z.record(z.string(), z.unknown()).optional(), parts: z.array(z.string()).optional() },
async ({ component, schema, props, parts }) => toText(handleValidate(registry, { component, schema, props, parts })));
server.tool("pettyui.add", "Get styled component source files ready to copy into a project",
{ component: name, targetDir: z.string().optional() },
async ({ component, targetDir }) => toText(handleAdd(registry, { component, targetDir })));
server.tool("pettyui.compose", "Generate composed JSX and imports for multiple components together",
{ components: z.array(z.string()).describe("List of component names to compose") },
async ({ components }) => toText(handleCompose(registry, { components })));
return server; return server;
} }

View File

@ -0,0 +1,64 @@
import { describe, it, expect } from "vitest";
import { parseCommand } from "../src/cli.js";
describe("parseCommand", () => {
it("empty argv → command: help", () => {
expect(parseCommand([])).toEqual({ command: "help", args: {} });
});
it("unknown command → command: help", () => {
expect(parseCommand(["unknown"])).toEqual({ command: "help", args: {} });
});
it("init → command: init", () => {
const result = parseCommand(["init"]);
expect(result.command).toBe("init");
});
it("list → command: list", () => {
const result = parseCommand(["list"]);
expect(result.command).toBe("list");
});
it("add dialog → command: add, args.component: dialog", () => {
const result = parseCommand(["add", "dialog"]);
expect(result.command).toBe("add");
expect(result.args["component"]).toBe("dialog");
});
it("add dialog --dir src/ui → args.dir: src/ui", () => {
const result = parseCommand(["add", "dialog", "--dir", "src/ui"]);
expect(result.command).toBe("add");
expect(result.args["component"]).toBe("dialog");
expect(result.args["dir"]).toBe("src/ui");
});
it("inspect dialog → command: inspect, args.component: dialog", () => {
const result = parseCommand(["inspect", "dialog"]);
expect(result.command).toBe("inspect");
expect(result.args["component"]).toBe("dialog");
});
it("discover modal → command: discover, args.component: modal", () => {
const result = parseCommand(["discover", "modal"]);
expect(result.command).toBe("discover");
expect(result.args["component"]).toBe("modal");
});
it("help → command: help", () => {
const result = parseCommand(["help"]);
expect(result.command).toBe("help");
});
it("init --dir src/styles → args.dir: src/styles", () => {
const result = parseCommand(["init", "--dir", "src/styles"]);
expect(result.command).toBe("init");
expect(result.args["dir"]).toBe("src/styles");
});
it("--dir without value is ignored", () => {
const result = parseCommand(["list", "--dir"]);
expect(result.command).toBe("list");
expect(result.args["dir"]).toBeUndefined();
});
});

View File

@ -1,8 +1,8 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ES2022", "target": "ES2022",
"module": "Node16", "module": "NodeNext",
"moduleResolution": "Node16", "moduleResolution": "NodeNext",
"outDir": "dist", "outDir": "dist",
"rootDir": "src", "rootDir": "src",
"declaration": true, "declaration": true,
@ -14,7 +14,9 @@
"verbatimModuleSyntax": true, "verbatimModuleSyntax": true,
"esModuleInterop": false, "esModuleInterop": false,
"skipLibCheck": true, "skipLibCheck": true,
"ignoreDeprecations": "6.0" "ignoreDeprecations": "6.0",
"types": ["node"]
}, },
"include": ["src"] "include": ["src"],
"exclude": ["node_modules", "dist", "../core/src"]
} }