import type { Tool } from "@modelcontextprotocol/sdk/types.js"; import path from "path"; import fs from "fs/promises"; export const componentToolDefinitions: Tool[] = [ { name: "mandu.component.add", annotations: { destructiveHint: false, readOnlyHint: false, }, description: "Scaffold a new client-side component in the correct FSD (Feature-Sliced Design) layer. " + "Mandu projects organize client components under src/client/ following FSD layers: " + "shared (reusable primitives), entities (domain objects), features (user interactions), " + "widgets (composite blocks), pages (page-level controllers). " + "Creates the component file and updates the layer's public API index.ts. " + "Use this instead of manually creating files in app/ to maintain FSD architecture.", inputSchema: { type: "object", properties: { name: { type: "string", description: "Component name in PascalCase (e.g., 'ReactionBar', 'UserAvatar')", }, layer: { type: "string", enum: ["shared", "entities", "features", "widgets", "pages"], description: "FSD layer: " + "'shared' (reusable UI primitives, utils — no business logic), " + "'entities' (domain models and their UI — User, Message, Post), " + "'features' (user interactions that change state — like, comment, follow), " + "'widgets' (composite sections combining entities+features), " + "'pages' (page-level client components — use sparingly, prefer features/entities)", }, slice: { type: "string", description: "Feature slice name in kebab-case (required for features/entities/widgets). " + "Examples: 'chat-reaction', 'user-profile', 'post-feed'. " + "For 'shared' layer, use segment name like 'ui', 'lib', 'api'.", }, segment: { type: "string", enum: ["ui", "model", "api", "lib", "config"], description: "Segment within the slice (default: 'ui'). 'ui' for React components, 'model' for hooks/store, 'api' for data fetching.", }, description: { type: "string", description: "Brief description of what this component does (added as a comment)", }, }, required: ["name", "layer"], }, }, ]; function toKebabCase(name: string): string { return name .replace(/([A-Z])/g, "-$1") .toLowerCase() .replace(/^-/, ""); } export function componentTools(projectRoot: string) { const handlers: Record) => Promise> = { "mandu.component.add": async (args: Record) => { const { name, layer, slice, segment = "ui", description = "", } = args as { name: string; layer: "shared" | "entities" | "features" | "widgets" | "pages"; slice?: string; segment?: string; description?: string; }; // Validate: features/entities/widgets require a slice if (["features", "entities", "widgets"].includes(layer) && !slice) { return { success: false, error: `The '${layer}' layer requires a 'slice' name (e.g., 'chat-reaction', 'user-profile').`, }; } // Build the file path const clientBase = path.join(projectRoot, "src", "client"); let componentDir: string; let indexPath: string; if (layer === "shared") { const sliceName = slice || "ui"; componentDir = path.join(clientBase, "shared", sliceName); indexPath = path.join(clientBase, "shared", sliceName, "index.ts"); } else if (layer === "pages") { componentDir = path.join(clientBase, "pages"); indexPath = path.join(clientBase, "pages", "index.ts"); } else { const sliceName = slice!; componentDir = path.join(clientBase, layer, sliceName, segment); indexPath = path.join(clientBase, layer, sliceName, "index.ts"); } const kebabName = toKebabCase(name); const componentFile = path.join(componentDir, `${kebabName}.tsx`); const relativePath = path.relative(projectRoot, componentFile).replace(/\\/g, "/"); // Check if file already exists try { await fs.access(componentFile); return { success: false, error: `Component file already exists: ${relativePath}`, }; } catch { // Good - file doesn't exist } // Create directory await fs.mkdir(componentDir, { recursive: true }); // Generate component template const descComment = description ? `\n * ${description}` : ""; const template = `/** * ${name} Component${descComment} * Layer: ${layer}${slice ? ` / ${slice}` : ""} */ import { useState } from "react"; interface ${name}Props { // TODO: Define props className?: string; } export function ${name}({ className }: ${name}Props) { return (
{/* TODO: Implement ${name} */}
); } `; await fs.writeFile(componentFile, template, "utf-8"); // Update index.ts (create or append export) const exportLine = `export { ${name} } from "./${segment}/${kebabName}.js";\n`; const simpleExportLine = `export { ${name} } from "./${kebabName}.js";\n`; try { let indexContent = ""; try { indexContent = await fs.readFile(indexPath, "utf-8"); } catch { // index.ts doesn't exist yet } const exportToAdd = layer === "shared" || layer === "pages" ? simpleExportLine : exportLine; if (!indexContent.includes(`{ ${name} }`)) { await fs.writeFile(indexPath, indexContent + exportToAdd, "utf-8"); } } catch { // index update failed - not critical } return { success: true, component: name, layer, slice: slice || null, segment, createdFiles: [relativePath], updatedFiles: [path.relative(projectRoot, indexPath).replace(/\\/g, "/")], message: `Created ${name} in ${layer}${slice ? `/${slice}` : ""}/${segment}`, nextSteps: [ `Edit ${relativePath} to implement the component`, `Import with: import { ${name} } from "@/client/${layer}${slice ? `/${slice}` : ""}"`, ], }; }, }; // Backward-compatible aliases (deprecated) handlers["mandu_add_component"] = handlers["mandu.component.add"]; return handlers; }