@mandujs/mcp 0.30.0 → 0.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,406 +1,406 @@
1
- /**
2
- * MCP tool — `mandu.refactor.extract_contract`
3
- *
4
- * Scans `app/api/**\/route.ts` for inline ad-hoc Zod schemas and extracts
5
- * each into a sibling contract module following the `defineContract()`
6
- * convention from `@mandujs/core`.
7
- *
8
- * Detection is conservative: we look for `z.object({ … })` literals bound
9
- * to a local identifier (`const FooSchema = z.object({ … })`). We do NOT
10
- * attempt to re-parse the route handler — extraction emits a new file and
11
- * leaves a `TODO` comment next to each source site instructing the author
12
- * to swap to the contract import. The intent is "halfway refactor"
13
- * assistance, not a full AST-level transform.
14
- *
15
- * Output format:
16
- * For `app/api/users/route.ts` containing
17
- * `const CreateUser = z.object({ name: z.string() });`
18
- * we emit `contract/users.contract.ts`:
19
- * ```ts
20
- * import { z } from "zod";
21
- * import { defineContract } from "@mandujs/core";
22
- * export const usersContract = defineContract({
23
- * create: {
24
- * method: "POST",
25
- * path: "/api/users",
26
- * input: z.object({ name: z.string() }),
27
- * output: z.unknown(),
28
- * },
29
- * });
30
- * ```
31
- */
32
-
33
- import type { Tool } from "@modelcontextprotocol/sdk/types.js";
34
- import { readdir } from "fs/promises";
35
- import path from "path";
36
-
37
- // ─────────────────────────────────────────────────────────────────────────
38
- // Types
39
- // ─────────────────────────────────────────────────────────────────────────
40
-
41
- export interface ExtractContractInput {
42
- dryRun?: boolean;
43
- route?: string;
44
- }
45
-
46
- export interface ExtractedContractEntry {
47
- route: string;
48
- contractFile: string;
49
- schemaName: string;
50
- sourceFile: string;
51
- }
52
-
53
- export interface ExtractContractResult {
54
- extracted: ExtractedContractEntry[];
55
- skipped: Array<{ route: string; reason: string }>;
56
- dryRun: boolean;
57
- }
58
-
59
- // ─────────────────────────────────────────────────────────────────────────
60
- // Validation
61
- // ─────────────────────────────────────────────────────────────────────────
62
-
63
- function validateInput(
64
- raw: Record<string, unknown>,
65
- ):
66
- | { ok: true; value: { dryRun: boolean; route?: string } }
67
- | { ok: false; error: string; field: string; hint: string } {
68
- const dryRun = raw.dryRun;
69
- if (dryRun !== undefined && typeof dryRun !== "boolean") {
70
- return {
71
- ok: false,
72
- error: "'dryRun' must be a boolean",
73
- field: "dryRun",
74
- hint: "Omit to default to true",
75
- };
76
- }
77
- const route = raw.route;
78
- if (route !== undefined && typeof route !== "string") {
79
- return {
80
- ok: false,
81
- error: "'route' must be a string",
82
- field: "route",
83
- hint: "E.g. 'app/api/users'",
84
- };
85
- }
86
- return {
87
- ok: true,
88
- value: {
89
- dryRun: dryRun === undefined ? true : dryRun,
90
- ...(typeof route === "string" ? { route } : {}),
91
- },
92
- };
93
- }
94
-
95
- // ─────────────────────────────────────────────────────────────────────────
96
- // Route discovery — find `app/api/**\/route.ts(x)` files
97
- // ─────────────────────────────────────────────────────────────────────────
98
-
99
- async function findApiRoutes(projectRoot: string): Promise<string[]> {
100
- const apiDir = path.join(projectRoot, "app", "api");
101
- const out: string[] = [];
102
- await walkApi(apiDir, out);
103
- return out.sort();
104
- }
105
-
106
- async function walkApi(dir: string, out: string[]): Promise<void> {
107
- let entries: Array<{ name: string; isDirectory(): boolean; isFile(): boolean }>;
108
- try {
109
- entries = await readdir(dir, { withFileTypes: true });
110
- } catch {
111
- return;
112
- }
113
- for (const e of entries) {
114
- const full = path.join(dir, e.name);
115
- if (e.isDirectory()) {
116
- if (e.name === "node_modules" || e.name.startsWith("_")) continue;
117
- await walkApi(full, out);
118
- } else if (e.isFile() && /^route\.(?:tsx|ts|jsx|js)$/.test(e.name)) {
119
- out.push(full);
120
- }
121
- }
122
- }
123
-
124
- // ─────────────────────────────────────────────────────────────────────────
125
- // Inline-schema detection
126
- // ─────────────────────────────────────────────────────────────────────────
127
-
128
- export interface DetectedSchema {
129
- name: string;
130
- /** The balanced `z.object({ … })` literal. */
131
- body: string;
132
- /** The HTTP method associated (heuristic from exported fn names). */
133
- method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
134
- }
135
-
136
- /**
137
- * Walk forward from `z.object(` and return the index just past the
138
- * matching `)` using brace-depth tracking. Returns -1 if unbalanced.
139
- */
140
- export function findZodObjectEnd(source: string, start: number): number {
141
- // Expect `z.object(` at `start`.
142
- const open = source.indexOf("(", start);
143
- if (open < 0) return -1;
144
- let depth = 0;
145
- for (let i = open; i < source.length; i++) {
146
- const ch = source[i];
147
- if (ch === "(" || ch === "{" || ch === "[") depth++;
148
- else if (ch === ")" || ch === "}" || ch === "]") {
149
- depth--;
150
- if (depth === 0 && ch === ")") return i + 1;
151
- }
152
- }
153
- return -1;
154
- }
155
-
156
- /**
157
- * Detect inline Zod schemas bound to a const identifier.
158
- *
159
- * Exported for regression tests.
160
- */
161
- export function detectInlineSchemas(source: string): DetectedSchema[] {
162
- const out: DetectedSchema[] = [];
163
- const constRegex = /\bconst\s+(\w+)\s*=\s*z\.object\s*\(/g;
164
- let m: RegExpExecArray | null;
165
- while ((m = constRegex.exec(source)) !== null) {
166
- const name = m[1];
167
- const openZ = source.indexOf("z.object", m.index);
168
- if (openZ < 0) continue;
169
- const end = findZodObjectEnd(source, openZ);
170
- if (end < 0) continue;
171
- const body = source.slice(openZ, end);
172
- out.push({
173
- name,
174
- body,
175
- method: inferMethod(source, name),
176
- });
177
- }
178
- return out;
179
- }
180
-
181
- function inferMethod(
182
- source: string,
183
- schemaName: string,
184
- ): "GET" | "POST" | "PUT" | "PATCH" | "DELETE" {
185
- // Heuristic: look at which exported HTTP handler mentions the schema.
186
- const methods = ["POST", "PUT", "PATCH", "DELETE", "GET"] as const;
187
- for (const method of methods) {
188
- const re = new RegExp(
189
- `export\\s+(?:async\\s+)?function\\s+${method}\\b[\\s\\S]*?\\b${schemaName}\\b`,
190
- );
191
- if (re.test(source)) return method;
192
- }
193
- // Fallback: if the schema name hints at mutation, guess POST.
194
- if (/create|update|delete|patch/i.test(schemaName)) return "POST";
195
- return "GET";
196
- }
197
-
198
- // ─────────────────────────────────────────────────────────────────────────
199
- // Emit contract file
200
- // ─────────────────────────────────────────────────────────────────────────
201
-
202
- export interface ContractModuleOutput {
203
- fileName: string;
204
- contractName: string;
205
- source: string;
206
- }
207
-
208
- /**
209
- * Build a `contract/<name>.contract.ts` source for the given route +
210
- * detected schemas. Exported for tests.
211
- */
212
- export function renderContractModule(
213
- routeRel: string,
214
- schemas: DetectedSchema[],
215
- ): ContractModuleOutput {
216
- // routeRel like `app/api/users/route.ts` → group name `users`
217
- const parts = routeRel.split(/[\\/]/);
218
- const apiIdx = parts.indexOf("api");
219
- const segs = apiIdx >= 0 ? parts.slice(apiIdx + 1) : parts.slice(-2);
220
- const group =
221
- segs
222
- .filter((s) => s !== "route.ts" && s !== "route.tsx")
223
- .filter(Boolean)
224
- .join("-")
225
- .replace(/[\[\]]/g, "")
226
- .replace(/[^A-Za-z0-9_-]/g, "") || "api";
227
-
228
- const contractName = `${camelize(group)}Contract`;
229
- const urlPath = "/api/" + segs.filter((s) => !/^route\./.test(s)).join("/");
230
-
231
- const endpoints = schemas
232
- .map((s) => {
233
- const opKey = deriveOpKey(s.name, s.method);
234
- return (
235
- ` ${opKey}: {\n` +
236
- ` method: ${JSON.stringify(s.method)},\n` +
237
- ` path: ${JSON.stringify(urlPath)},\n` +
238
- ` input: ${s.body},\n` +
239
- ` output: z.unknown(),\n` +
240
- ` },`
241
- );
242
- })
243
- .join("\n");
244
-
245
- const src =
246
- `/**\n` +
247
- ` * Auto-generated by mandu.refactor.extract_contract.\n` +
248
- ` * Replace output: z.unknown() with your response schema.\n` +
249
- ` */\n` +
250
- `import { z } from "zod";\n` +
251
- `import { defineContract } from "@mandujs/core";\n\n` +
252
- `export const ${contractName} = defineContract({\n` +
253
- `${endpoints}\n` +
254
- `});\n`;
255
-
256
- return {
257
- fileName: `${group}.contract.ts`,
258
- contractName,
259
- source: src,
260
- };
261
- }
262
-
263
- function camelize(s: string): string {
264
- return s
265
- .split(/[-_]/)
266
- .map((part, i) =>
267
- i === 0 ? part.toLowerCase() : part.charAt(0).toUpperCase() + part.slice(1).toLowerCase(),
268
- )
269
- .join("");
270
- }
271
-
272
- function deriveOpKey(
273
- schemaName: string,
274
- method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE",
275
- ): string {
276
- const lower = schemaName.toLowerCase();
277
- if (lower.includes("create")) return "create";
278
- if (lower.includes("update") || lower.includes("patch")) return "update";
279
- if (lower.includes("delete")) return "remove";
280
- if (lower.includes("list") || lower.includes("query")) return "list";
281
- return method === "GET" ? "read" : "mutate";
282
- }
283
-
284
- // ─────────────────────────────────────────────────────────────────────────
285
- // Public handler
286
- // ─────────────────────────────────────────────────────────────────────────
287
-
288
- async function runExtract(
289
- projectRoot: string,
290
- input: ExtractContractInput,
291
- ): Promise<
292
- | ExtractContractResult
293
- | { error: string; field?: string; hint?: string }
294
- > {
295
- const validated = validateInput(input as Record<string, unknown>);
296
- if (!validated.ok) {
297
- return { error: validated.error, field: validated.field, hint: validated.hint };
298
- }
299
- const { dryRun, route: filter } = validated.value;
300
-
301
- const routes = await findApiRoutes(projectRoot);
302
- const extracted: ExtractedContractEntry[] = [];
303
- const skipped: Array<{ route: string; reason: string }> = [];
304
-
305
- const contractDir = path.join(projectRoot, "contract");
306
-
307
- for (const routeFile of routes) {
308
- const routeRel = path.relative(projectRoot, routeFile).replace(/\\/g, "/");
309
- const routeDirRel = routeRel.replace(/\/route\.(?:tsx?|jsx?)$/, "");
310
- if (filter && !routeDirRel.startsWith(filter.replace(/\\/g, "/"))) continue;
311
-
312
- let source: string;
313
- try {
314
- source = await Bun.file(routeFile).text();
315
- } catch (err) {
316
- skipped.push({
317
- route: routeDirRel,
318
- reason: `read failed: ${err instanceof Error ? err.message : String(err)}`,
319
- });
320
- continue;
321
- }
322
-
323
- let schemas: DetectedSchema[];
324
- try {
325
- schemas = detectInlineSchemas(source);
326
- } catch (err) {
327
- skipped.push({
328
- route: routeDirRel,
329
- reason: `parse error: ${err instanceof Error ? err.message : String(err)}`,
330
- });
331
- continue;
332
- }
333
- if (schemas.length === 0) {
334
- skipped.push({ route: routeDirRel, reason: "no inline z.object schemas" });
335
- continue;
336
- }
337
-
338
- const out = renderContractModule(routeRel, schemas);
339
- const target = path.join(contractDir, out.fileName);
340
- const targetRel = path.relative(projectRoot, target).replace(/\\/g, "/");
341
-
342
- if (!dryRun) {
343
- try {
344
- await Bun.write(target, out.source);
345
- } catch (err) {
346
- skipped.push({
347
- route: routeDirRel,
348
- reason: `write failed: ${err instanceof Error ? err.message : String(err)}`,
349
- });
350
- continue;
351
- }
352
- }
353
-
354
- for (const s of schemas) {
355
- extracted.push({
356
- route: routeDirRel,
357
- contractFile: targetRel,
358
- schemaName: s.name,
359
- sourceFile: routeRel,
360
- });
361
- }
362
- }
363
-
364
- return { extracted, skipped, dryRun };
365
- }
366
-
367
- // ─────────────────────────────────────────────────────────────────────────
368
- // MCP tool definition + handler map
369
- // ─────────────────────────────────────────────────────────────────────────
370
-
371
- export const extractContractToolDefinitions: Tool[] = [
372
- {
373
- name: "mandu.refactor.extract_contract",
374
- description:
375
- "Scan `app/api/**/route.ts` for inline Zod schemas and extract them to `contract/<group>.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.",
376
- annotations: {
377
- readOnlyHint: false,
378
- destructiveHint: true,
379
- },
380
- inputSchema: {
381
- type: "object",
382
- properties: {
383
- dryRun: {
384
- type: "boolean",
385
- description:
386
- "When true (default), return the plan without writing files. When false, write the contract files to `contract/`.",
387
- },
388
- route: {
389
- type: "string",
390
- description:
391
- "Optional prefix to restrict scan (e.g. 'app/api/users').",
392
- },
393
- },
394
- required: [],
395
- },
396
- },
397
- ];
398
-
399
- export function extractContractTools(projectRoot: string) {
400
- const handlers: Record<string, (args: Record<string, unknown>) => Promise<unknown>> =
401
- {
402
- "mandu.refactor.extract_contract": async (args) =>
403
- runExtract(projectRoot, args as ExtractContractInput),
404
- };
405
- return handlers;
406
- }
1
+ /**
2
+ * MCP tool — `mandu.refactor.extract_contract`
3
+ *
4
+ * Scans `app/api/**\/route.ts` for inline ad-hoc Zod schemas and extracts
5
+ * each into a sibling contract module following the `defineContract()`
6
+ * convention from `@mandujs/core`.
7
+ *
8
+ * Detection is conservative: we look for `z.object({ … })` literals bound
9
+ * to a local identifier (`const FooSchema = z.object({ … })`). We do NOT
10
+ * attempt to re-parse the route handler — extraction emits a new file and
11
+ * leaves a `TODO` comment next to each source site instructing the author
12
+ * to swap to the contract import. The intent is "halfway refactor"
13
+ * assistance, not a full AST-level transform.
14
+ *
15
+ * Output format:
16
+ * For `app/api/users/route.ts` containing
17
+ * `const CreateUser = z.object({ name: z.string() });`
18
+ * we emit `contract/users.contract.ts`:
19
+ * ```ts
20
+ * import { z } from "zod";
21
+ * import { defineContract } from "@mandujs/core";
22
+ * export const usersContract = defineContract({
23
+ * create: {
24
+ * method: "POST",
25
+ * path: "/api/users",
26
+ * input: z.object({ name: z.string() }),
27
+ * output: z.unknown(),
28
+ * },
29
+ * });
30
+ * ```
31
+ */
32
+
33
+ import type { Tool } from "@modelcontextprotocol/sdk/types.js";
34
+ import { readdir } from "fs/promises";
35
+ import path from "path";
36
+
37
+ // ─────────────────────────────────────────────────────────────────────────
38
+ // Types
39
+ // ─────────────────────────────────────────────────────────────────────────
40
+
41
+ export interface ExtractContractInput {
42
+ dryRun?: boolean;
43
+ route?: string;
44
+ }
45
+
46
+ export interface ExtractedContractEntry {
47
+ route: string;
48
+ contractFile: string;
49
+ schemaName: string;
50
+ sourceFile: string;
51
+ }
52
+
53
+ export interface ExtractContractResult {
54
+ extracted: ExtractedContractEntry[];
55
+ skipped: Array<{ route: string; reason: string }>;
56
+ dryRun: boolean;
57
+ }
58
+
59
+ // ─────────────────────────────────────────────────────────────────────────
60
+ // Validation
61
+ // ─────────────────────────────────────────────────────────────────────────
62
+
63
+ function validateInput(
64
+ raw: Record<string, unknown>,
65
+ ):
66
+ | { ok: true; value: { dryRun: boolean; route?: string } }
67
+ | { ok: false; error: string; field: string; hint: string } {
68
+ const dryRun = raw.dryRun;
69
+ if (dryRun !== undefined && typeof dryRun !== "boolean") {
70
+ return {
71
+ ok: false,
72
+ error: "'dryRun' must be a boolean",
73
+ field: "dryRun",
74
+ hint: "Omit to default to true",
75
+ };
76
+ }
77
+ const route = raw.route;
78
+ if (route !== undefined && typeof route !== "string") {
79
+ return {
80
+ ok: false,
81
+ error: "'route' must be a string",
82
+ field: "route",
83
+ hint: "E.g. 'app/api/users'",
84
+ };
85
+ }
86
+ return {
87
+ ok: true,
88
+ value: {
89
+ dryRun: dryRun === undefined ? true : dryRun,
90
+ ...(typeof route === "string" ? { route } : {}),
91
+ },
92
+ };
93
+ }
94
+
95
+ // ─────────────────────────────────────────────────────────────────────────
96
+ // Route discovery — find `app/api/**\/route.ts(x)` files
97
+ // ─────────────────────────────────────────────────────────────────────────
98
+
99
+ async function findApiRoutes(projectRoot: string): Promise<string[]> {
100
+ const apiDir = path.join(projectRoot, "app", "api");
101
+ const out: string[] = [];
102
+ await walkApi(apiDir, out);
103
+ return out.sort();
104
+ }
105
+
106
+ async function walkApi(dir: string, out: string[]): Promise<void> {
107
+ let entries: Array<{ name: string; isDirectory(): boolean; isFile(): boolean }>;
108
+ try {
109
+ entries = await readdir(dir, { withFileTypes: true });
110
+ } catch {
111
+ return;
112
+ }
113
+ for (const e of entries) {
114
+ const full = path.join(dir, e.name);
115
+ if (e.isDirectory()) {
116
+ if (e.name === "node_modules" || e.name.startsWith("_")) continue;
117
+ await walkApi(full, out);
118
+ } else if (e.isFile() && /^route\.(?:tsx|ts|jsx|js)$/.test(e.name)) {
119
+ out.push(full);
120
+ }
121
+ }
122
+ }
123
+
124
+ // ─────────────────────────────────────────────────────────────────────────
125
+ // Inline-schema detection
126
+ // ─────────────────────────────────────────────────────────────────────────
127
+
128
+ export interface DetectedSchema {
129
+ name: string;
130
+ /** The balanced `z.object({ … })` literal. */
131
+ body: string;
132
+ /** The HTTP method associated (heuristic from exported fn names). */
133
+ method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
134
+ }
135
+
136
+ /**
137
+ * Walk forward from `z.object(` and return the index just past the
138
+ * matching `)` using brace-depth tracking. Returns -1 if unbalanced.
139
+ */
140
+ export function findZodObjectEnd(source: string, start: number): number {
141
+ // Expect `z.object(` at `start`.
142
+ const open = source.indexOf("(", start);
143
+ if (open < 0) return -1;
144
+ let depth = 0;
145
+ for (let i = open; i < source.length; i++) {
146
+ const ch = source[i];
147
+ if (ch === "(" || ch === "{" || ch === "[") depth++;
148
+ else if (ch === ")" || ch === "}" || ch === "]") {
149
+ depth--;
150
+ if (depth === 0 && ch === ")") return i + 1;
151
+ }
152
+ }
153
+ return -1;
154
+ }
155
+
156
+ /**
157
+ * Detect inline Zod schemas bound to a const identifier.
158
+ *
159
+ * Exported for regression tests.
160
+ */
161
+ export function detectInlineSchemas(source: string): DetectedSchema[] {
162
+ const out: DetectedSchema[] = [];
163
+ const constRegex = /\bconst\s+(\w+)\s*=\s*z\.object\s*\(/g;
164
+ let m: RegExpExecArray | null;
165
+ while ((m = constRegex.exec(source)) !== null) {
166
+ const name = m[1];
167
+ const openZ = source.indexOf("z.object", m.index);
168
+ if (openZ < 0) continue;
169
+ const end = findZodObjectEnd(source, openZ);
170
+ if (end < 0) continue;
171
+ const body = source.slice(openZ, end);
172
+ out.push({
173
+ name,
174
+ body,
175
+ method: inferMethod(source, name),
176
+ });
177
+ }
178
+ return out;
179
+ }
180
+
181
+ function inferMethod(
182
+ source: string,
183
+ schemaName: string,
184
+ ): "GET" | "POST" | "PUT" | "PATCH" | "DELETE" {
185
+ // Heuristic: look at which exported HTTP handler mentions the schema.
186
+ const methods = ["POST", "PUT", "PATCH", "DELETE", "GET"] as const;
187
+ for (const method of methods) {
188
+ const re = new RegExp(
189
+ `export\\s+(?:async\\s+)?function\\s+${method}\\b[\\s\\S]*?\\b${schemaName}\\b`,
190
+ );
191
+ if (re.test(source)) return method;
192
+ }
193
+ // Fallback: if the schema name hints at mutation, guess POST.
194
+ if (/create|update|delete|patch/i.test(schemaName)) return "POST";
195
+ return "GET";
196
+ }
197
+
198
+ // ─────────────────────────────────────────────────────────────────────────
199
+ // Emit contract file
200
+ // ─────────────────────────────────────────────────────────────────────────
201
+
202
+ export interface ContractModuleOutput {
203
+ fileName: string;
204
+ contractName: string;
205
+ source: string;
206
+ }
207
+
208
+ /**
209
+ * Build a `contract/<name>.contract.ts` source for the given route +
210
+ * detected schemas. Exported for tests.
211
+ */
212
+ export function renderContractModule(
213
+ routeRel: string,
214
+ schemas: DetectedSchema[],
215
+ ): ContractModuleOutput {
216
+ // routeRel like `app/api/users/route.ts` → group name `users`
217
+ const parts = routeRel.split(/[\\/]/);
218
+ const apiIdx = parts.indexOf("api");
219
+ const segs = apiIdx >= 0 ? parts.slice(apiIdx + 1) : parts.slice(-2);
220
+ const group =
221
+ segs
222
+ .filter((s) => s !== "route.ts" && s !== "route.tsx")
223
+ .filter(Boolean)
224
+ .join("-")
225
+ .replace(/[\[\]]/g, "")
226
+ .replace(/[^A-Za-z0-9_-]/g, "") || "api";
227
+
228
+ const contractName = `${camelize(group)}Contract`;
229
+ const urlPath = "/api/" + segs.filter((s) => !s.startsWith("route.")).join("/");
230
+
231
+ const endpoints = schemas
232
+ .map((s) => {
233
+ const opKey = deriveOpKey(s.name, s.method);
234
+ return (
235
+ ` ${opKey}: {\n` +
236
+ ` method: ${JSON.stringify(s.method)},\n` +
237
+ ` path: ${JSON.stringify(urlPath)},\n` +
238
+ ` input: ${s.body},\n` +
239
+ ` output: z.unknown(),\n` +
240
+ ` },`
241
+ );
242
+ })
243
+ .join("\n");
244
+
245
+ const src =
246
+ `/**\n` +
247
+ ` * Auto-generated by mandu.refactor.extract_contract.\n` +
248
+ ` * Replace output: z.unknown() with your response schema.\n` +
249
+ ` */\n` +
250
+ `import { z } from "zod";\n` +
251
+ `import { defineContract } from "@mandujs/core";\n\n` +
252
+ `export const ${contractName} = defineContract({\n` +
253
+ `${endpoints}\n` +
254
+ `});\n`;
255
+
256
+ return {
257
+ fileName: `${group}.contract.ts`,
258
+ contractName,
259
+ source: src,
260
+ };
261
+ }
262
+
263
+ function camelize(s: string): string {
264
+ return s
265
+ .split(/[-_]/)
266
+ .map((part, i) =>
267
+ i === 0 ? part.toLowerCase() : part.charAt(0).toUpperCase() + part.slice(1).toLowerCase(),
268
+ )
269
+ .join("");
270
+ }
271
+
272
+ function deriveOpKey(
273
+ schemaName: string,
274
+ method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE",
275
+ ): string {
276
+ const lower = schemaName.toLowerCase();
277
+ if (lower.includes("create")) return "create";
278
+ if (lower.includes("update") || lower.includes("patch")) return "update";
279
+ if (lower.includes("delete")) return "remove";
280
+ if (lower.includes("list") || lower.includes("query")) return "list";
281
+ return method === "GET" ? "read" : "mutate";
282
+ }
283
+
284
+ // ─────────────────────────────────────────────────────────────────────────
285
+ // Public handler
286
+ // ─────────────────────────────────────────────────────────────────────────
287
+
288
+ async function runExtract(
289
+ projectRoot: string,
290
+ input: ExtractContractInput,
291
+ ): Promise<
292
+ | ExtractContractResult
293
+ | { error: string; field?: string; hint?: string }
294
+ > {
295
+ const validated = validateInput(input as Record<string, unknown>);
296
+ if (!validated.ok) {
297
+ return { error: validated.error, field: validated.field, hint: validated.hint };
298
+ }
299
+ const { dryRun, route: filter } = validated.value;
300
+
301
+ const routes = await findApiRoutes(projectRoot);
302
+ const extracted: ExtractedContractEntry[] = [];
303
+ const skipped: Array<{ route: string; reason: string }> = [];
304
+
305
+ const contractDir = path.join(projectRoot, "contract");
306
+
307
+ for (const routeFile of routes) {
308
+ const routeRel = path.relative(projectRoot, routeFile).replace(/\\/g, "/");
309
+ const routeDirRel = routeRel.replace(/\/route\.(?:tsx?|jsx?)$/, "");
310
+ if (filter && !routeDirRel.startsWith(filter.replace(/\\/g, "/"))) continue;
311
+
312
+ let source: string;
313
+ try {
314
+ source = await Bun.file(routeFile).text();
315
+ } catch (err) {
316
+ skipped.push({
317
+ route: routeDirRel,
318
+ reason: `read failed: ${err instanceof Error ? err.message : String(err)}`,
319
+ });
320
+ continue;
321
+ }
322
+
323
+ let schemas: DetectedSchema[];
324
+ try {
325
+ schemas = detectInlineSchemas(source);
326
+ } catch (err) {
327
+ skipped.push({
328
+ route: routeDirRel,
329
+ reason: `parse error: ${err instanceof Error ? err.message : String(err)}`,
330
+ });
331
+ continue;
332
+ }
333
+ if (schemas.length === 0) {
334
+ skipped.push({ route: routeDirRel, reason: "no inline z.object schemas" });
335
+ continue;
336
+ }
337
+
338
+ const out = renderContractModule(routeRel, schemas);
339
+ const target = path.join(contractDir, out.fileName);
340
+ const targetRel = path.relative(projectRoot, target).replace(/\\/g, "/");
341
+
342
+ if (!dryRun) {
343
+ try {
344
+ await Bun.write(target, out.source);
345
+ } catch (err) {
346
+ skipped.push({
347
+ route: routeDirRel,
348
+ reason: `write failed: ${err instanceof Error ? err.message : String(err)}`,
349
+ });
350
+ continue;
351
+ }
352
+ }
353
+
354
+ for (const s of schemas) {
355
+ extracted.push({
356
+ route: routeDirRel,
357
+ contractFile: targetRel,
358
+ schemaName: s.name,
359
+ sourceFile: routeRel,
360
+ });
361
+ }
362
+ }
363
+
364
+ return { extracted, skipped, dryRun };
365
+ }
366
+
367
+ // ─────────────────────────────────────────────────────────────────────────
368
+ // MCP tool definition + handler map
369
+ // ─────────────────────────────────────────────────────────────────────────
370
+
371
+ export const extractContractToolDefinitions: Tool[] = [
372
+ {
373
+ name: "mandu.refactor.extract_contract",
374
+ description:
375
+ "Scan `app/api/**/route.ts` for inline Zod schemas and extract them to `contract/<group>.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.",
376
+ annotations: {
377
+ readOnlyHint: false,
378
+ destructiveHint: true,
379
+ },
380
+ inputSchema: {
381
+ type: "object",
382
+ properties: {
383
+ dryRun: {
384
+ type: "boolean",
385
+ description:
386
+ "When true (default), return the plan without writing files. When false, write the contract files to `contract/`.",
387
+ },
388
+ route: {
389
+ type: "string",
390
+ description:
391
+ "Optional prefix to restrict scan (e.g. 'app/api/users').",
392
+ },
393
+ },
394
+ required: [],
395
+ },
396
+ },
397
+ ];
398
+
399
+ export function extractContractTools(projectRoot: string) {
400
+ const handlers: Record<string, (args: Record<string, unknown>) => Promise<unknown>> =
401
+ {
402
+ "mandu.refactor.extract_contract": async (args) =>
403
+ runExtract(projectRoot, args as ExtractContractInput),
404
+ };
405
+ return handlers;
406
+ }