/** * MCP tool — `mandu.refactor.migrate_route_conventions` * * Detects Next.js-style inline patterns in user code and migrates them to * Mandu's file-system route conventions: * * • Inline `` → extract to `loading.tsx` * • Inline `` → `error.tsx` * • Inline `if (!x) return ` / `notFound()` call → `not-found.tsx` * * Scope: * • We only act on files under the `app/` tree (Mandu's routes dir). * • We never overwrite an existing convention file — if `loading.tsx` * already exists, we report the route as already-migrated. * • We do not parse TSX with a real parser — detection uses conservative * regex markers. False positives are acceptable; false file writes are * not, so we bail out of extraction on any ambiguity. */ import type { Tool } from "@modelcontextprotocol/sdk/types.js"; import { readdir } from "fs/promises"; import path from "path"; // ───────────────────────────────────────────────────────────────────────── // Types // ───────────────────────────────────────────────────────────────────────── export type RouteConvention = "loading" | "error" | "not-found"; export interface MigrateRouteConventionsInput { dryRun?: boolean; routes?: string[]; } export interface MigrateExtractionEntry { route: string; convention: RouteConvention; extractedPath: string; sourceFile: string; note?: string; } export interface MigrateRouteConventionsResult { routes: string[]; extracted: MigrateExtractionEntry[]; skipped: Array<{ route: string; reason: string }>; dryRun: boolean; } // ───────────────────────────────────────────────────────────────────────── // Validation // ───────────────────────────────────────────────────────────────────────── function validateInput( raw: Record, ): | { ok: true; value: { dryRun: boolean; routes?: string[] } } | { ok: false; error: string; field: string; hint: string } { const dryRun = raw.dryRun; if (dryRun !== undefined && typeof dryRun !== "boolean") { return { ok: false, error: "'dryRun' must be a boolean", field: "dryRun", hint: "Omit to default to true, pass false to actually write files", }; } const routes = raw.routes; if (routes !== undefined) { if (!Array.isArray(routes) || !routes.every((r) => typeof r === "string")) { return { ok: false, error: "'routes' must be an array of strings", field: "routes", hint: "E.g. ['app/dashboard', 'app/users/[id]']", }; } } return { ok: true, value: { dryRun: dryRun === undefined ? true : dryRun, ...(Array.isArray(routes) ? { routes: routes as string[] } : {}), }, }; } // ───────────────────────────────────────────────────────────────────────── // Route discovery — a lightweight replacement for fs-scanner that keeps // this tool self-contained. We look for `page.ts(x)` files under `app/`. // ───────────────────────────────────────────────────────────────────────── async function collectRoutes( projectRoot: string, filter?: string[], ): Promise { const appDir = path.join(projectRoot, "app"); const found: string[] = []; await walkPages(appDir, found); const rels = found.map((p) => path.relative(projectRoot, path.dirname(p))); if (!filter || filter.length === 0) return rels.sort(); const set = new Set(filter.map((f) => f.replace(/\\/g, "/"))); return rels.filter((r) => set.has(r.replace(/\\/g, "/"))).sort(); } async function walkPages(dir: string, out: string[]): Promise { let entries: Array<{ name: string; isDirectory(): boolean; isFile(): boolean }>; try { entries = await readdir(dir, { withFileTypes: true }); } catch { return; } for (const e of entries) { const full = path.join(dir, e.name); if (e.isDirectory()) { if (e.name.startsWith("_") || e.name === "node_modules") continue; await walkPages(full, out); } else if ( e.isFile() && /^page\.(?:tsx|ts|jsx|js)$/.test(e.name) ) { out.push(full); } } } // ───────────────────────────────────────────────────────────────────────── // Detection // ───────────────────────────────────────────────────────────────────────── export interface DetectionHit { convention: RouteConvention; fallbackSnippet: string; } /** * Scan a single page source for inline Suspense / ErrorBoundary / NotFound * patterns. Exported for regression tests. */ export function detectConventions(source: string): DetectionHit[] { const hits: DetectionHit[] = []; // `}>` const suspense = //.exec(source); if (suspense) { hits.push({ convention: "loading", fallbackSnippet: suspense[1].trim(), }); } // `` const errBoundary = //.exec(source); if (errBoundary) { hits.push({ convention: "error", fallbackSnippet: errBoundary[1].trim(), }); } // `if (…) return ` — lenient single-line match const inlineNotFound = /return\s*|notFound\s*\(\s*\)/.exec(source); if (inlineNotFound) { hits.push({ convention: "not-found", fallbackSnippet: "
Not found
", }); } return hits; } function conventionFilename(c: RouteConvention): string { return `${c}.tsx`; } function renderConventionFile( convention: RouteConvention, fallback: string, ): string { const header = `/**\n` + ` * Auto-generated by mandu.refactor.migrate_route_conventions.\n` + ` * Review and hand-tune as needed.\n` + ` */\n`; switch (convention) { case "loading": return `${header}\nexport default function Loading() {\n return ${fallback};\n}\n`; case "error": return ( `${header}\n` + `export default function Error({ error, reset }: { error: Error; reset: () => void }) {\n` + ` return ${fallback};\n` + `}\n` ); case "not-found": return `${header}\nexport default function NotFound() {\n return ${fallback};\n}\n`; } } async function fileExists(absPath: string): Promise { try { const f = Bun.file(absPath); return await f.exists(); } catch { return false; } } // ───────────────────────────────────────────────────────────────────────── // Public handler // ───────────────────────────────────────────────────────────────────────── async function runMigrate( projectRoot: string, input: MigrateRouteConventionsInput, ): Promise< | MigrateRouteConventionsResult | { error: string; field?: string; hint?: string } > { const validated = validateInput(input as Record); if (!validated.ok) { return { error: validated.error, field: validated.field, hint: validated.hint }; } const { dryRun, routes: filter } = validated.value; const routes = await collectRoutes(projectRoot, filter); const extracted: MigrateExtractionEntry[] = []; const skipped: Array<{ route: string; reason: string }> = []; for (const route of routes) { const routeDir = path.join(projectRoot, route); // Find the page file (tsx > ts > jsx > js) let pageFile: string | null = null; for (const ext of ["tsx", "ts", "jsx", "js"]) { const cand = path.join(routeDir, `page.${ext}`); if (await fileExists(cand)) { pageFile = cand; break; } } if (!pageFile) { skipped.push({ route, reason: "no page file" }); continue; } let source: string; try { source = await Bun.file(pageFile).text(); } catch (err) { skipped.push({ route, reason: `read failed: ${err instanceof Error ? err.message : String(err)}`, }); continue; } const hits = detectConventions(source); if (hits.length === 0) continue; for (const hit of hits) { const target = path.join(routeDir, conventionFilename(hit.convention)); const relTarget = path.relative(projectRoot, target); if (await fileExists(target)) { extracted.push({ route, convention: hit.convention, extractedPath: relTarget, sourceFile: path.relative(projectRoot, pageFile), note: "already exists — skipped write", }); continue; } const content = renderConventionFile(hit.convention, hit.fallbackSnippet); if (!dryRun) { try { await Bun.write(target, content); } catch (err) { skipped.push({ route, reason: `write ${hit.convention}: ${err instanceof Error ? err.message : String(err)}`, }); continue; } } extracted.push({ route, convention: hit.convention, extractedPath: relTarget, sourceFile: path.relative(projectRoot, pageFile), }); } } return { routes, extracted, skipped, dryRun }; } // ───────────────────────────────────────────────────────────────────────── // MCP tool definition + handler map // ───────────────────────────────────────────────────────────────────────── export const migrateRouteConventionsToolDefinitions: Tool[] = [ { name: "mandu.refactor.migrate_route_conventions", description: "Detect Next.js-style inline patterns in `app/**/page.*` files (Suspense fallback, ErrorBoundary, inline NotFound) and extract them to Mandu's file-system route conventions (`loading.tsx`, `error.tsx`, `not-found.tsx`). Never overwrites an existing convention file. Dry-run by default.", annotations: { readOnlyHint: false, destructiveHint: true, }, inputSchema: { type: "object", properties: { dryRun: { type: "boolean", description: "When true (default), return the plan without writing files. When false, create the convention files.", }, routes: { type: "array", items: { type: "string" }, description: "Optional list of route directories to restrict the scan to. Paths are relative to the project root (e.g. 'app/dashboard').", }, }, required: [], }, }, ]; export function migrateRouteConventionsTools(projectRoot: string) { const handlers: Record) => Promise> = { "mandu.refactor.migrate_route_conventions": async (args) => runMigrate(projectRoot, args as MigrateRouteConventionsInput), }; return handlers; }