@mandujs/mcp 0.12.2 → 0.16.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.
package/src/tools/spec.ts CHANGED
@@ -3,16 +3,19 @@ import {
3
3
  loadManifest,
4
4
  validateManifest,
5
5
  writeLock,
6
+ generateManifest,
7
+ GENERATED_RELATIVE_PATHS,
6
8
  type RouteSpec,
7
9
  type RoutesManifest,
8
10
  } from "@mandujs/core";
9
11
  import { getProjectPaths, readJsonFile, writeJsonFile } from "../utils/project.js";
10
12
  import path from "path";
13
+ import fs from "fs/promises";
11
14
 
12
15
  export const specToolDefinitions: Tool[] = [
13
16
  {
14
17
  name: "mandu_list_routes",
15
- description: "List all routes in the current Mandu project",
18
+ description: "List all routes in the current Mandu project (reads from .mandu/routes.manifest.json)",
16
19
  inputSchema: {
17
20
  type: "object",
18
21
  properties: {},
@@ -35,62 +38,34 @@ export const specToolDefinitions: Tool[] = [
35
38
  },
36
39
  {
37
40
  name: "mandu_add_route",
38
- description: "Add a new route to the manifest",
41
+ description: "Add a new route by scaffolding files in app/ and optionally in spec/slots/ and spec/contracts/",
39
42
  inputSchema: {
40
43
  type: "object",
41
44
  properties: {
42
- id: {
45
+ path: {
43
46
  type: "string",
44
- description: "Unique route identifier",
45
- },
46
- pattern: {
47
- type: "string",
48
- description: "URL pattern (e.g., /api/users/:id)",
47
+ description: "Route path relative to app/ (e.g., 'api/users' or 'blog/[slug]')",
49
48
  },
50
49
  kind: {
51
50
  type: "string",
52
51
  enum: ["api", "page"],
53
- description: "Route type: api or page",
54
- },
55
- slotModule: {
56
- type: "string",
57
- description: "Path to slot file (optional)",
52
+ description: "Route type: api (route.ts) or page (page.tsx)",
58
53
  },
59
- componentModule: {
60
- type: "string",
61
- description: "Path to component module (required for page kind)",
62
- },
63
- },
64
- required: ["id", "pattern", "kind"],
65
- },
66
- },
67
- {
68
- name: "mandu_update_route",
69
- description: "Update an existing route",
70
- inputSchema: {
71
- type: "object",
72
- properties: {
73
- routeId: {
74
- type: "string",
75
- description: "The route ID to update",
54
+ withSlot: {
55
+ type: "boolean",
56
+ description: "Scaffold a slot file in spec/slots/ (default: true)",
76
57
  },
77
- updates: {
78
- type: "object",
79
- description: "Partial route updates",
80
- properties: {
81
- pattern: { type: "string" },
82
- kind: { type: "string", enum: ["api", "page"] },
83
- slotModule: { type: "string" },
84
- componentModule: { type: "string" },
85
- },
58
+ withContract: {
59
+ type: "boolean",
60
+ description: "Scaffold a contract file in spec/contracts/",
86
61
  },
87
62
  },
88
- required: ["routeId", "updates"],
63
+ required: ["path", "kind"],
89
64
  },
90
65
  },
91
66
  {
92
67
  name: "mandu_delete_route",
93
- description: "Delete a route from the manifest",
68
+ description: "Delete a route's app/ files and rescan (preserves slot/contract files)",
94
69
  inputSchema: {
95
70
  type: "object",
96
71
  properties: {
@@ -103,8 +78,8 @@ export const specToolDefinitions: Tool[] = [
103
78
  },
104
79
  },
105
80
  {
106
- name: "mandu_validate_spec",
107
- description: "Validate the current spec manifest",
81
+ name: "mandu_validate_manifest",
82
+ description: "Validate the current routes manifest (.mandu/routes.manifest.json)",
108
83
  inputSchema: {
109
84
  type: "object",
110
85
  properties: {},
@@ -130,6 +105,7 @@ export function specTools(projectRoot: string) {
130
105
  pattern: r.pattern,
131
106
  kind: r.kind,
132
107
  slotModule: r.slotModule,
108
+ contractModule: r.contractModule,
133
109
  componentModule: r.componentModule,
134
110
  })),
135
111
  count: result.data.routes.length,
@@ -153,157 +129,102 @@ export function specTools(projectRoot: string) {
153
129
  },
154
130
 
155
131
  mandu_add_route: async (args: Record<string, unknown>) => {
156
- const { id, pattern, kind, slotModule, componentModule } = args as {
157
- id: string;
158
- pattern: string;
132
+ const { path: routePath, kind, withSlot = true, withContract = false } = args as {
133
+ path: string;
159
134
  kind: "api" | "page";
160
- slotModule?: string;
161
- componentModule?: string;
135
+ withSlot?: boolean;
136
+ withContract?: boolean;
162
137
  };
163
138
 
164
- // Load current manifest
165
- const result = await loadManifest(paths.manifestPath);
166
- if (!result.success || !result.data) {
167
- return { error: result.errors };
168
- }
139
+ const createdFiles: string[] = [];
169
140
 
170
- // Check for duplicate
171
- if (result.data.routes.some((r) => r.id === id)) {
172
- return { error: `Route with id '${id}' already exists` };
173
- }
174
-
175
- if (result.data.routes.some((r) => r.pattern === pattern)) {
176
- return { error: `Route with pattern '${pattern}' already exists` };
177
- }
141
+ // Scaffold app/ file
142
+ const fileName = kind === "api" ? "route.ts" : "page.tsx";
143
+ const appFilePath = path.join(paths.appDir, routePath, fileName);
144
+ const appFileDir = path.dirname(appFilePath);
178
145
 
179
- // Build new route
180
- const newRoute: RouteSpec = {
181
- id,
182
- pattern,
183
- kind,
184
- module: `apps/server/generated/routes/${id}.route.ts`,
185
- slotModule: slotModule || `spec/slots/${id}.slot.ts`,
186
- };
146
+ await fs.mkdir(appFileDir, { recursive: true });
187
147
 
188
- if (kind === "page") {
189
- newRoute.componentModule = componentModule || `apps/web/generated/routes/${id}.route.tsx`;
148
+ if (kind === "api") {
149
+ await Bun.write(appFilePath, `export function GET(req: Request) {\n return Response.json({ message: "Hello" });\n}\n`);
150
+ } else {
151
+ await Bun.write(appFilePath, `export default function Page() {\n return <div>Page</div>;\n}\n`);
190
152
  }
191
-
192
- // Validate new route
193
- const newManifest: RoutesManifest = {
194
- version: result.data.version,
195
- routes: [...result.data.routes, newRoute],
196
- };
197
-
198
- const validation = validateManifest(newManifest);
199
- if (!validation.success) {
200
- return { error: validation.errors };
153
+ createdFiles.push(`app/${routePath}/${fileName}`);
154
+
155
+ // Derive route ID from path
156
+ const routeId = routePath.replace(/\//g, "-").replace(/[\[\]\.]/g, "");
157
+
158
+ // Scaffold slot if requested
159
+ if (withSlot) {
160
+ const slotPath = path.join(paths.slotsDir, `${routeId}.slot.ts`);
161
+ await fs.mkdir(paths.slotsDir, { recursive: true });
162
+ if (!(await Bun.file(slotPath).exists())) {
163
+ await Bun.write(slotPath, `export default function slot(req: Request) {\n return {};\n}\n`);
164
+ createdFiles.push(`spec/slots/${routeId}.slot.ts`);
165
+ }
201
166
  }
202
167
 
203
- // Write updated manifest
204
- await writeJsonFile(paths.manifestPath, newManifest);
205
-
206
- // Update lock file
207
- await writeLock(paths.lockPath, newManifest);
208
-
209
- return {
210
- success: true,
211
- route: newRoute,
212
- message: `Route '${id}' added successfully`,
213
- };
214
- },
215
-
216
- mandu_update_route: async (args: Record<string, unknown>) => {
217
- const { routeId, updates } = args as {
218
- routeId: string;
219
- updates: Partial<RouteSpec>;
220
- };
221
-
222
- // Load current manifest
223
- const result = await loadManifest(paths.manifestPath);
224
- if (!result.success || !result.data) {
225
- return { error: result.errors };
168
+ // Scaffold contract if requested
169
+ if (withContract) {
170
+ const contractPath = path.join(paths.contractsDir, `${routeId}.contract.ts`);
171
+ await fs.mkdir(paths.contractsDir, { recursive: true });
172
+ if (!(await Bun.file(contractPath).exists())) {
173
+ await Bun.write(contractPath, `import { z } from "zod";\n\nexport const contract = {\n request: z.object({}),\n response: z.object({}),\n};\n`);
174
+ createdFiles.push(`spec/contracts/${routeId}.contract.ts`);
175
+ }
226
176
  }
227
177
 
228
- // Find route
229
- const routeIndex = result.data.routes.findIndex((r) => r.id === routeId);
230
- if (routeIndex === -1) {
231
- return { error: `Route not found: ${routeId}` };
232
- }
233
-
234
- // Apply updates
235
- const updatedRoute = {
236
- ...result.data.routes[routeIndex],
237
- ...updates,
238
- id: routeId, // ID cannot be changed
239
- };
240
-
241
- const newRoutes = [...result.data.routes];
242
- newRoutes[routeIndex] = updatedRoute as RouteSpec;
243
-
244
- // Validate
245
- const newManifest: RoutesManifest = {
246
- version: result.data.version,
247
- routes: newRoutes,
248
- };
249
-
250
- const validation = validateManifest(newManifest);
251
- if (!validation.success) {
252
- return { error: validation.errors };
253
- }
254
-
255
- // Write updated manifest
256
- await writeJsonFile(paths.manifestPath, newManifest);
257
-
258
- // Update lock file
259
- await writeLock(paths.lockPath, newManifest);
178
+ // Rescan to regenerate manifest with auto-linking
179
+ const genResult = await generateManifest(projectRoot);
260
180
 
261
181
  return {
262
182
  success: true,
263
- route: updatedRoute,
264
- message: `Route '${routeId}' updated successfully`,
183
+ routeId,
184
+ createdFiles,
185
+ totalRoutes: genResult.manifest.routes.length,
186
+ message: `Route '${routeId}' scaffolded successfully`,
265
187
  };
266
188
  },
267
189
 
268
190
  mandu_delete_route: async (args: Record<string, unknown>) => {
269
191
  const { routeId } = args as { routeId: string };
270
192
 
271
- // Load current manifest
193
+ // Load current manifest to find the route
272
194
  const result = await loadManifest(paths.manifestPath);
273
195
  if (!result.success || !result.data) {
274
196
  return { error: result.errors };
275
197
  }
276
198
 
277
- // Find route
278
- const routeIndex = result.data.routes.findIndex((r) => r.id === routeId);
279
- if (routeIndex === -1) {
199
+ const route = result.data.routes.find((r) => r.id === routeId);
200
+ if (!route) {
280
201
  return { error: `Route not found: ${routeId}` };
281
202
  }
282
203
 
283
- const deletedRoute = result.data.routes[routeIndex];
284
-
285
- // Remove route
286
- const newRoutes = result.data.routes.filter((r) => r.id !== routeId);
287
-
288
- const newManifest: RoutesManifest = {
289
- version: result.data.version,
290
- routes: newRoutes,
291
- };
292
-
293
- // Write updated manifest
294
- await writeJsonFile(paths.manifestPath, newManifest);
204
+ // Delete app/ source file (module path points to generated; need to find source)
205
+ const deletedFiles: string[] = [];
206
+ if (route.module && route.module.startsWith("app/")) {
207
+ const fullPath = path.join(projectRoot, route.module);
208
+ try {
209
+ await fs.unlink(fullPath);
210
+ deletedFiles.push(route.module);
211
+ } catch {}
212
+ }
295
213
 
296
- // Update lock file
297
- await writeLock(paths.lockPath, newManifest);
214
+ // Rescan manifest (slot/contract files preserved)
215
+ const genResult = await generateManifest(projectRoot);
298
216
 
299
217
  return {
300
218
  success: true,
301
- deletedRoute,
302
- message: `Route '${routeId}' deleted successfully`,
219
+ deletedRoute: route,
220
+ deletedFiles,
221
+ preservedFiles: [route.slotModule, route.contractModule].filter(Boolean),
222
+ totalRoutes: genResult.manifest.routes.length,
223
+ message: `Route '${routeId}' deleted from app/. Slot/contract files preserved.`,
303
224
  };
304
225
  },
305
226
 
306
- mandu_validate_spec: async () => {
227
+ mandu_validate_manifest: async () => {
307
228
  const result = await loadManifest(paths.manifestPath);
308
229
  if (!result.success) {
309
230
  return {
@@ -3,19 +3,27 @@ import fs from "fs/promises";
3
3
  import { pathToFileURL } from "url";
4
4
 
5
5
  /**
6
- * Find the Mandu project root by looking for routes.manifest.json
6
+ * Find the Mandu project root by looking for app/ directory or mandu.config.*
7
7
  */
8
8
  export async function findProjectRoot(startDir: string = process.cwd()): Promise<string | null> {
9
9
  let currentDir = path.resolve(startDir);
10
10
 
11
11
  while (currentDir !== path.dirname(currentDir)) {
12
- const manifestPath = path.join(currentDir, "spec", "routes.manifest.json");
12
+ // Check for app/ directory (FS Routes source)
13
13
  try {
14
- await fs.access(manifestPath);
15
- return currentDir;
16
- } catch {
17
- currentDir = path.dirname(currentDir);
14
+ const appStat = await fs.stat(path.join(currentDir, "app"));
15
+ if (appStat.isDirectory()) return currentDir;
16
+ } catch {}
17
+
18
+ // Check for mandu.config.* files
19
+ for (const configFile of ["mandu.config.ts", "mandu.config.js", "mandu.config.json"]) {
20
+ try {
21
+ await fs.access(path.join(currentDir, configFile));
22
+ return currentDir;
23
+ } catch {}
18
24
  }
25
+
26
+ currentDir = path.dirname(currentDir);
19
27
  }
20
28
 
21
29
  return null;
@@ -27,14 +35,16 @@ export async function findProjectRoot(startDir: string = process.cwd()): Promise
27
35
  export function getProjectPaths(rootDir: string) {
28
36
  return {
29
37
  root: rootDir,
38
+ appDir: path.join(rootDir, "app"),
30
39
  specDir: path.join(rootDir, "spec"),
31
- manifestPath: path.join(rootDir, "spec", "routes.manifest.json"),
32
- lockPath: path.join(rootDir, "spec", "spec.lock.json"),
40
+ manifestPath: path.join(rootDir, ".mandu", "routes.manifest.json"),
41
+ lockPath: path.join(rootDir, ".mandu", "spec.lock.json"),
33
42
  slotsDir: path.join(rootDir, "spec", "slots"),
34
- historyDir: path.join(rootDir, "spec", "history"),
35
- generatedMapPath: path.join(rootDir, "packages", "core", "map", "generated.map.json"),
36
- serverRoutesDir: path.join(rootDir, "apps", "server", "generated", "routes"),
37
- webRoutesDir: path.join(rootDir, "apps", "web", "generated", "routes"),
43
+ contractsDir: path.join(rootDir, "spec", "contracts"),
44
+ historyDir: path.join(rootDir, ".mandu", "history"),
45
+ generatedMapPath: path.join(rootDir, ".mandu", "generated", "generated.map.json"),
46
+ serverRoutesDir: path.join(rootDir, ".mandu", "generated", "server", "routes"),
47
+ webRoutesDir: path.join(rootDir, ".mandu", "generated", "web", "routes"),
38
48
  };
39
49
  }
40
50