/** * MCP tool — `mandu.refactor.extract_contract` * * Scans `app/api/**\/route.ts` for inline ad-hoc Zod schemas and extracts * each into a sibling contract module following the `defineContract()` * convention from `@mandujs/core`. * * Detection is conservative: we look for `z.object({ … })` literals bound * to a local identifier (`const FooSchema = z.object({ … })`). We do NOT * attempt to re-parse the route handler — extraction emits a new file and * leaves a `TODO` comment next to each source site instructing the author * to swap to the contract import. The intent is "halfway refactor" * assistance, not a full AST-level transform. * * Output format: * For `app/api/users/route.ts` containing * `const CreateUser = z.object({ name: z.string() });` * we emit `contract/users.contract.ts`: * ```ts * import { z } from "zod"; * import { defineContract } from "@mandujs/core"; * export const usersContract = defineContract({ * create: { * method: "POST", * path: "/api/users", * input: z.object({ name: z.string() }), * output: z.unknown(), * }, * }); * ``` */ import type { Tool } from "@modelcontextprotocol/sdk/types.js"; import { readdir } from "fs/promises"; import path from "path"; // ───────────────────────────────────────────────────────────────────────── // Types // ───────────────────────────────────────────────────────────────────────── export interface ExtractContractInput { dryRun?: boolean; route?: string; } export interface ExtractedContractEntry { route: string; contractFile: string; schemaName: string; sourceFile: string; } export interface ExtractContractResult { extracted: ExtractedContractEntry[]; skipped: Array<{ route: string; reason: string }>; dryRun: boolean; } // ───────────────────────────────────────────────────────────────────────── // Validation // ───────────────────────────────────────────────────────────────────────── function validateInput( raw: Record, ): | { ok: true; value: { dryRun: boolean; route?: 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", }; } const route = raw.route; if (route !== undefined && typeof route !== "string") { return { ok: false, error: "'route' must be a string", field: "route", hint: "E.g. 'app/api/users'", }; } return { ok: true, value: { dryRun: dryRun === undefined ? true : dryRun, ...(typeof route === "string" ? { route } : {}), }, }; } // ───────────────────────────────────────────────────────────────────────── // Route discovery — find `app/api/**\/route.ts(x)` files // ───────────────────────────────────────────────────────────────────────── async function findApiRoutes(projectRoot: string): Promise { const apiDir = path.join(projectRoot, "app", "api"); const out: string[] = []; await walkApi(apiDir, out); return out.sort(); } async function walkApi(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 === "node_modules" || e.name.startsWith("_")) continue; await walkApi(full, out); } else if (e.isFile() && /^route\.(?:tsx|ts|jsx|js)$/.test(e.name)) { out.push(full); } } } // ───────────────────────────────────────────────────────────────────────── // Inline-schema detection // ───────────────────────────────────────────────────────────────────────── export interface DetectedSchema { name: string; /** The balanced `z.object({ … })` literal. */ body: string; /** The HTTP method associated (heuristic from exported fn names). */ method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; } /** * Walk forward from `z.object(` and return the index just past the * matching `)` using brace-depth tracking. Returns -1 if unbalanced. */ export function findZodObjectEnd(source: string, start: number): number { // Expect `z.object(` at `start`. const open = source.indexOf("(", start); if (open < 0) return -1; let depth = 0; for (let i = open; i < source.length; i++) { const ch = source[i]; if (ch === "(" || ch === "{" || ch === "[") depth++; else if (ch === ")" || ch === "}" || ch === "]") { depth--; if (depth === 0 && ch === ")") return i + 1; } } return -1; } /** * Detect inline Zod schemas bound to a const identifier. * * Exported for regression tests. */ export function detectInlineSchemas(source: string): DetectedSchema[] { const out: DetectedSchema[] = []; const constRegex = /\bconst\s+(\w+)\s*=\s*z\.object\s*\(/g; let m: RegExpExecArray | null; while ((m = constRegex.exec(source)) !== null) { const name = m[1]; const openZ = source.indexOf("z.object", m.index); if (openZ < 0) continue; const end = findZodObjectEnd(source, openZ); if (end < 0) continue; const body = source.slice(openZ, end); out.push({ name, body, method: inferMethod(source, name), }); } return out; } function inferMethod( source: string, schemaName: string, ): "GET" | "POST" | "PUT" | "PATCH" | "DELETE" { // Heuristic: look at which exported HTTP handler mentions the schema. const methods = ["POST", "PUT", "PATCH", "DELETE", "GET"] as const; for (const method of methods) { const re = new RegExp( `export\\s+(?:async\\s+)?function\\s+${method}\\b[\\s\\S]*?\\b${schemaName}\\b`, ); if (re.test(source)) return method; } // Fallback: if the schema name hints at mutation, guess POST. if (/create|update|delete|patch/i.test(schemaName)) return "POST"; return "GET"; } // ───────────────────────────────────────────────────────────────────────── // Emit contract file // ───────────────────────────────────────────────────────────────────────── export interface ContractModuleOutput { fileName: string; contractName: string; source: string; } /** * Build a `contract/.contract.ts` source for the given route + * detected schemas. Exported for tests. */ export function renderContractModule( routeRel: string, schemas: DetectedSchema[], ): ContractModuleOutput { // routeRel like `app/api/users/route.ts` → group name `users` const parts = routeRel.split(/[\\/]/); const apiIdx = parts.indexOf("api"); const segs = apiIdx >= 0 ? parts.slice(apiIdx + 1) : parts.slice(-2); const group = segs .filter((s) => s !== "route.ts" && s !== "route.tsx") .filter(Boolean) .join("-") .replace(/[\[\]]/g, "") .replace(/[^A-Za-z0-9_-]/g, "") || "api"; const contractName = `${camelize(group)}Contract`; const urlPath = "/api/" + segs.filter((s) => !s.startsWith("route.")).join("/"); const endpoints = schemas .map((s) => { const opKey = deriveOpKey(s.name, s.method); return ( ` ${opKey}: {\n` + ` method: ${JSON.stringify(s.method)},\n` + ` path: ${JSON.stringify(urlPath)},\n` + ` input: ${s.body},\n` + ` output: z.unknown(),\n` + ` },` ); }) .join("\n"); const src = `/**\n` + ` * Auto-generated by mandu.refactor.extract_contract.\n` + ` * Replace output: z.unknown() with your response schema.\n` + ` */\n` + `import { z } from "zod";\n` + `import { defineContract } from "@mandujs/core";\n\n` + `export const ${contractName} = defineContract({\n` + `${endpoints}\n` + `});\n`; return { fileName: `${group}.contract.ts`, contractName, source: src, }; } function camelize(s: string): string { return s .split(/[-_]/) .map((part, i) => i === 0 ? part.toLowerCase() : part.charAt(0).toUpperCase() + part.slice(1).toLowerCase(), ) .join(""); } function deriveOpKey( schemaName: string, method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE", ): string { const lower = schemaName.toLowerCase(); if (lower.includes("create")) return "create"; if (lower.includes("update") || lower.includes("patch")) return "update"; if (lower.includes("delete")) return "remove"; if (lower.includes("list") || lower.includes("query")) return "list"; return method === "GET" ? "read" : "mutate"; } // ───────────────────────────────────────────────────────────────────────── // Public handler // ───────────────────────────────────────────────────────────────────────── async function runExtract( projectRoot: string, input: ExtractContractInput, ): Promise< | ExtractContractResult | { 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, route: filter } = validated.value; const routes = await findApiRoutes(projectRoot); const extracted: ExtractedContractEntry[] = []; const skipped: Array<{ route: string; reason: string }> = []; const contractDir = path.join(projectRoot, "contract"); for (const routeFile of routes) { const routeRel = path.relative(projectRoot, routeFile).replace(/\\/g, "/"); const routeDirRel = routeRel.replace(/\/route\.(?:tsx?|jsx?)$/, ""); if (filter && !routeDirRel.startsWith(filter.replace(/\\/g, "/"))) continue; let source: string; try { source = await Bun.file(routeFile).text(); } catch (err) { skipped.push({ route: routeDirRel, reason: `read failed: ${err instanceof Error ? err.message : String(err)}`, }); continue; } let schemas: DetectedSchema[]; try { schemas = detectInlineSchemas(source); } catch (err) { skipped.push({ route: routeDirRel, reason: `parse error: ${err instanceof Error ? err.message : String(err)}`, }); continue; } if (schemas.length === 0) { skipped.push({ route: routeDirRel, reason: "no inline z.object schemas" }); continue; } const out = renderContractModule(routeRel, schemas); const target = path.join(contractDir, out.fileName); const targetRel = path.relative(projectRoot, target).replace(/\\/g, "/"); if (!dryRun) { try { await Bun.write(target, out.source); } catch (err) { skipped.push({ route: routeDirRel, reason: `write failed: ${err instanceof Error ? err.message : String(err)}`, }); continue; } } for (const s of schemas) { extracted.push({ route: routeDirRel, contractFile: targetRel, schemaName: s.name, sourceFile: routeRel, }); } } return { extracted, skipped, dryRun }; } // ───────────────────────────────────────────────────────────────────────── // MCP tool definition + handler map // ───────────────────────────────────────────────────────────────────────── export const extractContractToolDefinitions: Tool[] = [ { name: "mandu.refactor.extract_contract", description: "Scan `app/api/**/route.ts` for inline Zod schemas and extract them to `contract/.contract.ts` using the `defineContract()` convention. The source handler is left untouched — follow-up manual step is to import the contract and swap ad-hoc validation. 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, write the contract files to `contract/`.", }, route: { type: "string", description: "Optional prefix to restrict scan (e.g. 'app/api/users').", }, }, required: [], }, }, ]; export function extractContractTools(projectRoot: string) { const handlers: Record) => Promise> = { "mandu.refactor.extract_contract": async (args) => runExtract(projectRoot, args as ExtractContractInput), }; return handlers; }