import type { Tool } from "@modelcontextprotocol/sdk/types.js"; import { loadManifest, generateManifest, } from "@mandujs/core"; import { getProjectPaths, isInsideProject, readJsonFile } from "../utils/project.js"; import path from "path"; import fs from "fs/promises"; export const specToolDefinitions: Tool[] = [ { name: "mandu.route.list", description: "List all routes from .mandu/routes.manifest.json with their kind, pattern, slotModule, and contractModule.", annotations: { readOnlyHint: true, }, inputSchema: { type: "object", properties: {}, required: [], }, }, { name: "mandu.route.get", description: "Get full details of a specific route by its ID. Use before modifying a route.", annotations: { readOnlyHint: true, }, inputSchema: { type: "object", properties: { routeId: { type: "string", description: "The route ID to retrieve (use mandu.route.list to see all IDs)", }, }, required: ["routeId"], }, }, { name: "mandu.route.boundaries", description: "Inspect compiler-discovered client boundary metadata for one route or all routes, including route manifest records and optional bundle manifest entries.", annotations: { readOnlyHint: true, }, inputSchema: { type: "object", properties: { routeId: { type: "string", description: "Optional route ID to inspect. Omit to list boundaries for all routes.", }, includeBundle: { type: "boolean", description: "When true, also correlate route boundaries with .mandu/manifest.json boundary bundle entries.", }, }, required: [], }, }, { name: "mandu.route.add", description: "Scaffold a new route in app/ with optional slot and contract files, then regenerate the manifest.", annotations: { destructiveHint: false, readOnlyHint: false, }, inputSchema: { type: "object", properties: { path: { type: "string", description: "Route path relative to app/ (e.g., 'api/users' or 'blog/[slug]')", }, kind: { type: "string", enum: ["api", "page"], description: "Route type: 'api' creates route.ts with HTTP handlers, 'page' creates page.tsx with a React component", }, withSlot: { type: "boolean", description: "Also scaffold a server-side data loader at spec/slots/{routeId}.slot.ts (default: true)", }, withContract: { type: "boolean", description: "Also scaffold a Zod contract at spec/contracts/{routeId}.contract.ts (default: false)", }, }, required: ["path", "kind"], }, }, { name: "mandu.route.delete", description: "Delete a route's app/ source file and regenerate the manifest. Slot and contract files are preserved.", annotations: { destructiveHint: true, readOnlyHint: false, }, inputSchema: { type: "object", properties: { routeId: { type: "string", description: "The route ID to delete (use mandu.route.list to find it)", }, }, required: ["routeId"], }, }, { name: "mandu.manifest.validate", description: "Validate the routes manifest for structural integrity. Run after manual edits or when routes behave unexpectedly.", annotations: { readOnlyHint: true, }, inputSchema: { type: "object", properties: {}, required: [], }, }, ]; export function specTools(projectRoot: string) { const paths = getProjectPaths(projectRoot); const handlers: Record) => Promise> = { "mandu.route.list": async () => { const result = await loadManifest(paths.manifestPath); if (!result.success || !result.data) { return { error: result.errors }; } return { version: result.data.version, routes: result.data.routes.map((r) => ({ id: r.id, pattern: r.pattern, kind: r.kind, slotModule: r.slotModule, contractModule: r.contractModule, componentModule: r.componentModule, })), count: result.data.routes.length, }; }, "mandu.route.get": async (args: Record) => { const { routeId } = args as { routeId: string }; const result = await loadManifest(paths.manifestPath); if (!result.success || !result.data) { return { error: result.errors }; } const route = result.data.routes.find((r) => r.id === routeId); if (!route) { return { error: `Route not found: ${routeId}` }; } return { route }; }, "mandu.route.boundaries": async (args: Record) => { const { routeId, includeBundle = false } = args as { routeId?: string; includeBundle?: boolean }; const result = await loadManifest(paths.manifestPath); if (!result.success || !result.data) { return { error: result.errors }; } const bundleManifestPath = path.join(projectRoot, ".mandu", "manifest.json"); const bundleManifest = includeBundle ? await readJsonFile<{ boundaries?: Record; }>(bundleManifestPath) : null; const routes = routeId ? result.data.routes.filter((route) => route.id === routeId) : result.data.routes; if (routeId && routes.length === 0) { return { error: `Route not found: ${routeId}` }; } const inspected = routes.map((route) => { const boundaries = route.boundaries ?? []; const diagnostics: Array<{ code: string; severity: "warning"; message: string; routeId: string; boundaryId?: string; }> = []; if (includeBundle && boundaries.length > 0 && !bundleManifest) { diagnostics.push({ code: "MANDU_BOUNDARY_BUNDLE_MANIFEST_MISSING", severity: "warning", message: "Route has compiler-discovered client boundaries, but .mandu/manifest.json was not found. Run mandu build to inspect generated boundary chunks.", routeId: route.id, }); } const boundaryRecords = boundaries.map((boundary) => { const bundle = bundleManifest?.boundaries?.[boundary.id]; if (includeBundle && bundleManifest && !bundle) { diagnostics.push({ code: "MANDU_BOUNDARY_BUNDLE_ENTRY_MISSING", severity: "warning", message: `Boundary '${boundary.id}' is present in routes manifest but missing from .mandu/manifest.json boundaries.`, routeId: route.id, boundaryId: boundary.id, }); } return { id: boundary.id, routeId: boundary.routeId, module: boundary.module, importSpecifier: boundary.importSpecifier, exportName: boundary.exportName, localName: boundary.localName, hydrate: boundary.hydrate, ordinal: boundary.ordinal, propsSource: boundary.propsSource, propsKeys: boundary.propsKeys ?? [], hasSpreadProps: !!boundary.hasSpreadProps, source: boundary.source, bundle: bundle ? { js: bundle.js ?? null, priority: bundle.priority ?? null, hydrate: bundle.hydrate ?? null, } : null, }; }); return { routeId: route.id, pattern: route.pattern, module: route.module, boundaryCount: boundaryRecords.length, boundaries: boundaryRecords, diagnostics, }; }); return { source: { routesManifest: ".mandu/routes.manifest.json", bundleManifest: includeBundle && bundleManifest ? ".mandu/manifest.json" : null, }, includeBundle, routeId: routeId ?? null, routeCount: inspected.length, boundaryCount: inspected.reduce((count, route) => count + route.boundaryCount, 0), routes: inspected, }; }, "mandu.route.add": async (args: Record) => { const { path: routePath, kind, withSlot = true, withContract = false } = args as { path: string; kind: "api" | "page"; withSlot?: boolean; withContract?: boolean; }; const createdFiles: string[] = []; const normalizedRoutePath = normalizeRoutePath(routePath); if (!normalizedRoutePath) { return { error: "Route path must be a non-empty relative path inside app/", }; } // Scaffold app/ file const fileName = kind === "api" ? "route.ts" : "page.tsx"; const appFilePath = path.resolve(paths.appDir, normalizedRoutePath, fileName); if (!isInsideProject(appFilePath, paths.appDir)) { return { error: "Route path resolves outside app/", }; } if (await Bun.file(appFilePath).exists()) { return { error: `Route source already exists: app/${normalizedRoutePath}/${fileName}`, }; } const appFileDir = path.dirname(appFilePath); await fs.mkdir(appFileDir, { recursive: true }); if (kind === "api") { await Bun.write(appFilePath, `export function GET(req: Request) {\n return Response.json({ message: "Hello" });\n}\n`); } else { await Bun.write(appFilePath, `export default function Page() {\n return
Page
;\n}\n`); } createdFiles.push(`app/${normalizedRoutePath}/${fileName}`); // Derive route ID from path const routeId = normalizedRoutePath.replace(/\//g, "-").replace(/[\[\]\.]/g, ""); // Scaffold slot if requested if (withSlot) { const slotPath = path.join(paths.slotsDir, `${routeId}.slot.ts`); await fs.mkdir(paths.slotsDir, { recursive: true }); if (!(await Bun.file(slotPath).exists())) { await Bun.write(slotPath, `export default function slot(req: Request) {\n return {};\n}\n`); createdFiles.push(`spec/slots/${routeId}.slot.ts`); } } // Scaffold contract if requested if (withContract) { const contractPath = path.join(paths.contractsDir, `${routeId}.contract.ts`); await fs.mkdir(paths.contractsDir, { recursive: true }); if (!(await Bun.file(contractPath).exists())) { await Bun.write(contractPath, `import { z } from "zod";\n\nexport const contract = {\n request: z.object({}),\n response: z.object({}),\n};\n`); createdFiles.push(`spec/contracts/${routeId}.contract.ts`); } } // Rescan to regenerate manifest with auto-linking const genResult = await generateManifest(projectRoot); return { success: true, routeId, createdFiles, totalRoutes: genResult.manifest.routes.length, message: `Route '${routeId}' scaffolded successfully`, relatedSkills: ["mandu-create-feature"], }; }, "mandu.route.delete": async (args: Record) => { const { routeId } = args as { routeId: string }; // Load current manifest to find the route const result = await loadManifest(paths.manifestPath); if (!result.success || !result.data) { return { error: result.errors }; } const route = result.data.routes.find((r) => r.id === routeId); if (!route) { return { error: `Route not found: ${routeId}` }; } // Delete app/ source file (module path points to generated; need to find source) const deletedFiles: string[] = []; if (route.module && route.module.startsWith("app/")) { const fullPath = path.join(projectRoot, route.module); try { await fs.unlink(fullPath); deletedFiles.push(route.module); } catch {} } // Rescan manifest (slot/contract files preserved) const genResult = await generateManifest(projectRoot); return { success: true, deletedRoute: route, deletedFiles, preservedFiles: [route.slotModule, route.contractModule].filter(Boolean), totalRoutes: genResult.manifest.routes.length, message: `Route '${routeId}' deleted from app/. Slot/contract files preserved.`, }; }, "mandu.manifest.validate": async () => { const result = await loadManifest(paths.manifestPath); if (!result.success) { return { valid: false, errors: result.errors, }; } return { valid: true, routeCount: result.data?.routes.length || 0, version: result.data?.version, }; }, }; // Backward-compatible aliases (deprecated) handlers["mandu_list_routes"] = handlers["mandu.route.list"]; handlers["mandu_get_route"] = handlers["mandu.route.get"]; handlers["mandu_add_route"] = handlers["mandu.route.add"]; handlers["mandu_delete_route"] = handlers["mandu.route.delete"]; handlers["mandu_validate_manifest"] = handlers["mandu.manifest.validate"]; return handlers; } function normalizeRoutePath(value: unknown): string | null { if (typeof value !== "string") return null; const trimmed = value.trim().replace(/\\/g, "/"); const withoutAppPrefix = trimmed.replace(/^app\//, ""); const normalized = withoutAppPrefix.replace(/^\/+|\/+$/g, ""); if (!normalized) return null; const segments = normalized.split("/").filter(Boolean); if (segments.length === 0) return null; if (segments.some((segment) => segment === "." || segment === ".." || segment.includes(":") || segment.includes("\0"))) { return null; } return segments.join("/"); }