@hellocrossman/mcp-sdk 0.3.4 → 0.4.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/dist/cli.js CHANGED
@@ -76,16 +76,46 @@ function findProjectRoot() {
76
76
  }
77
77
  return process.cwd();
78
78
  }
79
+ function scoreExpressFile(filePath) {
80
+ const content = fs.readFileSync(filePath, "utf-8");
81
+ const hasExpressImport = EXPRESS_PATTERNS.some((p) => p.test(content));
82
+ if (!hasExpressImport)
83
+ return null;
84
+ let score = 0;
85
+ const hasAppCreation = APP_VAR_PATTERN.test(content);
86
+ const hasListen = LISTEN_PATTERN.test(content);
87
+ const hasRoutes = /\.\s*(?:get|post|put|patch|delete)\s*\(/.test(content);
88
+ const hasAppParam = /function\s+\w+\s*\(\s*app\b/.test(content) ||
89
+ /\(\s*app\s*:\s*\w*Express/.test(content) ||
90
+ /\(\s*app\s*:\s*\w*Application/.test(content) ||
91
+ /=>\s*\{[^}]*app\s*\./.test(content);
92
+ const isNamedEntryish = /(?:index|app|server|main)\.(ts|js|mjs)$/.test(filePath);
93
+ if (hasAppCreation)
94
+ score += 10;
95
+ if (hasListen)
96
+ score += 8;
97
+ if (isNamedEntryish)
98
+ score += 3;
99
+ if (hasRoutes)
100
+ score += 2;
101
+ if (!hasAppCreation && hasAppParam)
102
+ score -= 5;
103
+ if (score <= 0)
104
+ return null;
105
+ return { path: filePath, score, hasAppCreation, hasListen, hasRoutes };
106
+ }
79
107
  function findExpressEntryFile(root) {
108
+ const candidates = [];
80
109
  for (const file of COMMON_ENTRY_FILES) {
81
110
  const fullPath = path.join(root, file);
82
111
  if (fs.existsSync(fullPath)) {
83
- const content = fs.readFileSync(fullPath, "utf-8");
84
- if (EXPRESS_PATTERNS.some((p) => p.test(content)))
85
- return fullPath;
112
+ const candidate = scoreExpressFile(fullPath);
113
+ if (candidate)
114
+ candidates.push(candidate);
86
115
  }
87
116
  }
88
117
  const srcDirs = ["server", "src", "."];
118
+ const seen = new Set(candidates.map((c) => c.path));
89
119
  for (const dir of srcDirs) {
90
120
  const dirPath = path.join(root, dir);
91
121
  if (!fs.existsSync(dirPath) || !fs.statSync(dirPath).isDirectory())
@@ -93,12 +123,18 @@ function findExpressEntryFile(root) {
93
123
  const files = fs.readdirSync(dirPath).filter((f) => /\.(ts|js|mjs)$/.test(f));
94
124
  for (const file of files) {
95
125
  const fullPath = path.join(dirPath, file);
96
- const content = fs.readFileSync(fullPath, "utf-8");
97
- if (EXPRESS_PATTERNS.some((p) => p.test(content)))
98
- return fullPath;
126
+ if (seen.has(fullPath))
127
+ continue;
128
+ seen.add(fullPath);
129
+ const candidate = scoreExpressFile(fullPath);
130
+ if (candidate)
131
+ candidates.push(candidate);
99
132
  }
100
133
  }
101
- return null;
134
+ if (candidates.length === 0)
135
+ return null;
136
+ candidates.sort((a, b) => b.score - a.score);
137
+ return candidates[0].path;
102
138
  }
103
139
  function checkIsESM(filePath, root) {
104
140
  if (filePath.endsWith(".ts") || filePath.endsWith(".mjs"))
@@ -111,8 +147,29 @@ function checkIsESM(filePath, root) {
111
147
  return false;
112
148
  }
113
149
  }
114
- function alreadySetup(content) {
115
- return content.includes("createMcpServer") || content.includes("@hellocrossman/mcp-sdk");
150
+ function alreadySetup(root) {
151
+ const mcpServerFile = path.join(root, "mcp-server.ts");
152
+ const mcpServerFileJs = path.join(root, "mcp-server.js");
153
+ if (fs.existsSync(mcpServerFile) || fs.existsSync(mcpServerFileJs))
154
+ return true;
155
+ const serverMcpFile = path.join(root, "server", "mcp-server.ts");
156
+ const serverMcpFileJs = path.join(root, "server", "mcp-server.js");
157
+ if (fs.existsSync(serverMcpFile) || fs.existsSync(serverMcpFileJs))
158
+ return true;
159
+ return false;
160
+ }
161
+ function findExistingMcpServerFile(root) {
162
+ const candidates = [
163
+ path.join(root, "mcp-server.ts"),
164
+ path.join(root, "mcp-server.js"),
165
+ path.join(root, "server", "mcp-server.ts"),
166
+ path.join(root, "server", "mcp-server.js"),
167
+ ];
168
+ for (const f of candidates) {
169
+ if (fs.existsSync(f))
170
+ return f;
171
+ }
172
+ return null;
116
173
  }
117
174
  function checkPgInstalled(root) {
118
175
  return fs.existsSync(path.join(root, "node_modules", "pg"));
@@ -179,13 +236,81 @@ function stripExistingMcpConfig(content) {
179
236
  result = result.replace(/\n{3,}/g, "\n\n");
180
237
  return result;
181
238
  }
239
+ function findAppExport(content, varName) {
240
+ const namedExportRe = new RegExp(`export\\s+(?:const|let|var)\\s+${varName}\\s*=`);
241
+ if (namedExportRe.test(content))
242
+ return varName;
243
+ if (/export\s+default\s+app\b/.test(content))
244
+ return "default";
245
+ const moduleExportsRe = new RegExp(`module\\.exports\\s*=\\s*${varName}`);
246
+ if (moduleExportsRe.test(content))
247
+ return "default";
248
+ const exportsRe = new RegExp(`module\\.exports\\.${varName}\\s*=`);
249
+ if (exportsRe.test(content))
250
+ return varName;
251
+ return varName;
252
+ }
253
+ function findEntryFiles(root, appFile) {
254
+ const entries = [];
255
+ const appFileRel = path.relative(root, appFile);
256
+ const appBaseName = path.basename(appFile, path.extname(appFile));
257
+ const appDir = path.dirname(appFile);
258
+ const searchDirs = [appDir, root, path.join(root, "server"), path.join(root, "src")];
259
+ const seen = new Set();
260
+ for (const dir of searchDirs) {
261
+ if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory())
262
+ continue;
263
+ const files = fs.readdirSync(dir).filter((f) => /\.(ts|js|mjs)$/.test(f));
264
+ for (const file of files) {
265
+ const fullPath = path.join(dir, file);
266
+ if (seen.has(fullPath) || fullPath === appFile)
267
+ continue;
268
+ seen.add(fullPath);
269
+ try {
270
+ const content = fs.readFileSync(fullPath, "utf-8");
271
+ const hasListen = LISTEN_PATTERN.test(content);
272
+ const importsApp = content.includes(`./${appBaseName}`) ||
273
+ content.includes(`'./${appFileRel}'`) ||
274
+ content.includes(`"./${appFileRel}"`) ||
275
+ content.includes(`from './${appBaseName}'`) ||
276
+ content.includes(`from "./${appBaseName}"`) ||
277
+ content.includes(`require('./${appBaseName}')`) ||
278
+ content.includes(`require("./${appBaseName}")`);
279
+ if (hasListen || importsApp) {
280
+ entries.push(fullPath);
281
+ }
282
+ }
283
+ catch { }
284
+ }
285
+ }
286
+ if (entries.length === 0) {
287
+ entries.push(appFile);
288
+ }
289
+ return entries;
290
+ }
182
291
  async function stepDetectApp(state, prompt, specifiedFile, force) {
183
- printStep(1, 5, "Detecting Express app");
184
- let entryFile = null;
292
+ printStep(1, 6, "Detecting Express app");
293
+ if (alreadySetup(state.root)) {
294
+ if (force) {
295
+ const existingFile = findExistingMcpServerFile(state.root);
296
+ if (existingFile) {
297
+ console.log(` ${c("yellow", "~")} Found existing ${c("bold", path.relative(state.root, existingFile))}`);
298
+ console.log(` ${c("dim", " Will regenerate with fresh scan...")}`);
299
+ }
300
+ }
301
+ else {
302
+ const existingFile = findExistingMcpServerFile(state.root);
303
+ const relPath = existingFile ? path.relative(state.root, existingFile) : "mcp-server.ts";
304
+ console.log(` ${c("green", "+")} MCP SDK is already set up (${c("bold", relPath)})`);
305
+ console.log(` ${c("dim", " Run with")} ${c("cyan", "--force")} ${c("dim", "to re-scan and reconfigure.")}`);
306
+ return false;
307
+ }
308
+ }
309
+ let appFile = null;
185
310
  if (specifiedFile) {
186
311
  const resolved = path.resolve(state.root, specifiedFile);
187
312
  if (fs.existsSync(resolved)) {
188
- entryFile = resolved;
313
+ appFile = resolved;
189
314
  }
190
315
  else {
191
316
  console.log(` ${c("yellow", "!")} File not found: ${specifiedFile}`);
@@ -193,15 +318,45 @@ async function stepDetectApp(state, prompt, specifiedFile, force) {
193
318
  }
194
319
  }
195
320
  else {
196
- entryFile = findExpressEntryFile(state.root);
321
+ appFile = findExpressEntryFile(state.root);
322
+ }
323
+ if (appFile && !specifiedFile) {
324
+ const relPath = path.relative(state.root, appFile);
325
+ const content = fs.readFileSync(appFile, "utf-8");
326
+ const hasCreation = APP_VAR_PATTERN.test(content);
327
+ const hasListen = LISTEN_PATTERN.test(content);
328
+ const signals = [];
329
+ if (hasCreation)
330
+ signals.push("creates Express app");
331
+ if (hasListen)
332
+ signals.push("calls .listen()");
333
+ const signalStr = signals.length > 0 ? ` ${c("dim", `(${signals.join(", ")})`)}` : "";
334
+ console.log(` ${c("green", "+")} Found Express app: ${c("bold", relPath)}${signalStr}`);
335
+ const useIt = await prompt.confirm(` Is this the right file?`);
336
+ if (!useIt) {
337
+ const custom = await prompt.ask(` Enter the path to your Express app file: `);
338
+ if (custom) {
339
+ const resolved = path.resolve(state.root, custom);
340
+ if (fs.existsSync(resolved)) {
341
+ appFile = resolved;
342
+ }
343
+ else {
344
+ console.log(` ${c("yellow", "!")} File not found: ${custom}`);
345
+ return false;
346
+ }
347
+ }
348
+ else {
349
+ return false;
350
+ }
351
+ }
197
352
  }
198
- if (!entryFile) {
353
+ if (!appFile) {
199
354
  console.log(` ${c("yellow", "!")} Could not find an Express app file.`);
200
355
  const custom = await prompt.ask(` Enter the path to your Express app file: `);
201
356
  if (custom) {
202
357
  const resolved = path.resolve(state.root, custom);
203
358
  if (fs.existsSync(resolved)) {
204
- entryFile = resolved;
359
+ appFile = resolved;
205
360
  }
206
361
  else {
207
362
  console.log(` ${c("yellow", "!")} File not found: ${custom}`);
@@ -212,32 +367,23 @@ async function stepDetectApp(state, prompt, specifiedFile, force) {
212
367
  return false;
213
368
  }
214
369
  }
215
- let content = fs.readFileSync(entryFile, "utf-8");
216
- if (alreadySetup(content)) {
217
- if (force) {
218
- console.log(` ${c("yellow", "~")} Found existing MCP config in ${path.relative(state.root, entryFile)}`);
219
- console.log(` ${c("dim", " Removing old configuration for re-scan...")}`);
220
- content = stripExistingMcpConfig(content);
221
- fs.writeFileSync(entryFile, content);
222
- console.log(` ${c("green", "+")} Old config removed. Starting fresh.`);
223
- }
224
- else {
225
- console.log(` ${c("green", "+")} MCP SDK is already set up in ${path.relative(state.root, entryFile)}`);
226
- console.log(` ${c("dim", " Run with")} ${c("cyan", "--force")} ${c("dim", "to re-scan and reconfigure.")}`);
227
- return false;
228
- }
229
- }
370
+ const content = fs.readFileSync(appFile, "utf-8");
230
371
  const appMatch = content.match(APP_VAR_PATTERN);
231
- state.entryFile = entryFile;
372
+ state.appFile = appFile;
232
373
  state.appVarName = appMatch ? appMatch[1] : "app";
233
- state.isESM = checkIsESM(entryFile, state.root);
234
- console.log(` ${c("green", "+")} Found Express app: ${c("bold", path.relative(state.root, entryFile))}`);
374
+ state.appExportName = findAppExport(content, state.appVarName);
375
+ state.isESM = checkIsESM(appFile, state.root);
376
+ const entryFiles = findEntryFiles(state.root, appFile);
377
+ state.entryFiles = entryFiles;
235
378
  console.log(` ${c("dim", ` Variable: ${state.appVarName}, Module: ${state.isESM ? "ESM" : "CommonJS"}`)}`);
379
+ if (entryFiles.length > 0 && entryFiles[0] !== appFile) {
380
+ console.log(` ${c("dim", ` Entry files: ${entryFiles.map((f) => path.relative(state.root, f)).join(", ")}`)}`);
381
+ }
236
382
  return true;
237
383
  }
238
384
  async function stepScanRoutes(state) {
239
- printStep(2, 5, "Scanning API routes");
240
- state.routes = scanAllRoutes(state.entryFile, state.routePrefix);
385
+ printStep(2, 6, "Scanning API routes");
386
+ state.routes = scanAllRoutes(state.appFile, state.routePrefix);
241
387
  if (state.routes.length === 0) {
242
388
  console.log(` ${c("dim", " No routes found under")} ${state.routePrefix}`);
243
389
  console.log(` ${c("dim", " Routes will be discovered at runtime when your app starts.")}`);
@@ -262,7 +408,7 @@ async function stepScanRoutes(state) {
262
408
  }
263
409
  }
264
410
  async function stepScanDatabase(state, prompt) {
265
- printStep(3, 5, "Database discovery");
411
+ printStep(3, 6, "Database discovery");
266
412
  const wantDb = await prompt.confirm(` Scan your database for tables to expose as tools?`);
267
413
  if (!wantDb) {
268
414
  state.databaseEnabled = false;
@@ -350,7 +496,7 @@ async function stepScanDatabase(state, prompt) {
350
496
  }
351
497
  }
352
498
  async function stepEnrichAndReview(state, prompt) {
353
- printStep(4, 5, "AI enrichment & tool review");
499
+ printStep(4, 6, "AI enrichment & tool review");
354
500
  if (state.tools.length === 0) {
355
501
  console.log(` ${c("dim", " No tools discovered. Routes will be discovered at runtime.")}`);
356
502
  state.enrichmentEnabled = true;
@@ -414,10 +560,239 @@ async function stepEnrichAndReview(state, prompt) {
414
560
  state.includeWrites = wantWrites;
415
561
  }
416
562
  }
563
+ async function stepCustomTools(state, prompt) {
564
+ printStep(5, 6, "Custom tools (optional)");
565
+ console.log(` ${c("dim", "Auto-discovered tools give broad coverage.")}`);
566
+ console.log(` ${c("dim", "Custom tools let you define purpose-built queries")}`);
567
+ console.log(` ${c("dim", "for specific business questions.\n")}`);
568
+ const wantCustom = await prompt.confirm(` Define custom tools?`, false);
569
+ if (!wantCustom) {
570
+ console.log(` ${c("dim", " Skipping custom tools.")}`);
571
+ return;
572
+ }
573
+ const availableRoutes = state.routes.map((r) => `${r.method} ${r.path}`);
574
+ const availableTables = state.dbTables.map((t) => t.name);
575
+ while (true) {
576
+ console.log();
577
+ const name = await prompt.ask(` ${c("cyan", "Tool name")} ${c("dim", "(e.g. get_traffic_overview):")} `);
578
+ if (!name)
579
+ break;
580
+ const toolName = name.replace(/[^a-zA-Z0-9_]/g, "_").toLowerCase();
581
+ const description = await prompt.ask(` ${c("cyan", "Description")} ${c("dim", "(what does this tool do?):")} `);
582
+ if (!description)
583
+ break;
584
+ const params = [];
585
+ console.log(`\n ${c("dim", "Define input parameters (press Enter with no name to finish):")}`);
586
+ while (true) {
587
+ const paramName = await prompt.ask(` ${c("cyan", "Parameter name:")} `);
588
+ if (!paramName)
589
+ break;
590
+ const paramDesc = await prompt.ask(` ${c("cyan", "Description:")} `);
591
+ console.log(` ${c("dim", "Type: 1) string 2) number 3) boolean")}`);
592
+ const typeChoice = await prompt.ask(` ${c("cyan", "Type")} ${c("dim", "(1/2/3, default: 1):")} `);
593
+ const paramType = typeChoice === "2" ? "number" : typeChoice === "3" ? "boolean" : "string";
594
+ const isRequired = await prompt.confirm(` Required?`);
595
+ params.push({ name: paramName, type: paramType, description: paramDesc || paramName, required: isRequired });
596
+ console.log(` ${c("green", "+")} Added parameter: ${c("bold", paramName)} (${paramType})`);
597
+ }
598
+ console.log(`\n ${c("dim", "How should this tool get its data?")}`);
599
+ console.log(` ${c("cyan", "1.")} Call an existing API route`);
600
+ console.log(` ${c("cyan", "2.")} Run a database query`);
601
+ const sourceChoice = await prompt.ask(` ${c("dim", ">")} `);
602
+ const customTool = {
603
+ name: toolName,
604
+ description,
605
+ params,
606
+ dataSource: sourceChoice === "2" ? "database" : "route",
607
+ };
608
+ if (sourceChoice === "2" && availableTables.length > 0) {
609
+ console.log(`\n ${c("dim", "Available tables:")}`);
610
+ availableTables.forEach((t, i) => console.log(` ${c("cyan", `${i + 1}.`)} ${t}`));
611
+ const tableChoice = await prompt.ask(` ${c("dim", "Table number or name:")} `);
612
+ const tableIndex = parseInt(tableChoice) - 1;
613
+ customTool.tableName = availableTables[tableIndex] || tableChoice;
614
+ const tableInfo = state.dbTables.find((t) => t.name === customTool.tableName);
615
+ if (tableInfo) {
616
+ const scopeColumns = tableInfo.columns
617
+ .filter((col) => /user_id|project_id|account_id|org_id|team_id|owner_id/.test(col.name))
618
+ .map((col) => col.name);
619
+ if (scopeColumns.length > 0) {
620
+ console.log(`\n ${c("dim", "User scoping - filter results by:")}`);
621
+ scopeColumns.forEach((col, i) => console.log(` ${c("cyan", `${i + 1}.`)} ${col}`));
622
+ console.log(` ${c("cyan", `${scopeColumns.length + 1}.`)} No scoping (public data)`);
623
+ const scopeChoice = await prompt.ask(` ${c("dim", ">")} `);
624
+ const scopeIndex = parseInt(scopeChoice) - 1;
625
+ if (scopeIndex >= 0 && scopeIndex < scopeColumns.length) {
626
+ customTool.scopeColumn = scopeColumns[scopeIndex];
627
+ }
628
+ }
629
+ }
630
+ }
631
+ else if (sourceChoice !== "2" && availableRoutes.length > 0) {
632
+ console.log(`\n ${c("dim", "Available routes:")}`);
633
+ availableRoutes.forEach((r, i) => console.log(` ${c("cyan", `${i + 1}.`)} ${r}`));
634
+ const routeChoice = await prompt.ask(` ${c("dim", "Route number:")} `);
635
+ const routeIndex = parseInt(routeChoice) - 1;
636
+ if (routeIndex >= 0 && routeIndex < state.routes.length) {
637
+ customTool.routeMethod = state.routes[routeIndex].method;
638
+ customTool.routePath = state.routes[routeIndex].path;
639
+ }
640
+ }
641
+ state.customTools.push(customTool);
642
+ console.log(`\n ${c("green", "+")} Added custom tool: ${c("bold", toolName)}`);
643
+ const addMore = await prompt.confirm(`\n Add another custom tool?`, false);
644
+ if (!addMore)
645
+ break;
646
+ }
647
+ if (state.customTools.length > 0) {
648
+ console.log(`\n ${c("green", "+")} ${state.customTools.length} custom tool${state.customTools.length === 1 ? "" : "s"} defined.`);
649
+ }
650
+ }
651
+ function generateCustomToolsFile(state) {
652
+ const lines = [];
653
+ lines.push(`// Custom tools for your MCP server`);
654
+ lines.push(`// Hand-crafted tools that persist across re-scans`);
655
+ lines.push(`// Edit this file to customize tool behavior`);
656
+ lines.push(``);
657
+ if (state.isESM) {
658
+ lines.push(`import type { Express } from 'express';`);
659
+ }
660
+ lines.push(``);
661
+ lines.push(`export interface CustomToolDefinition {`);
662
+ lines.push(` name: string;`);
663
+ lines.push(` description: string;`);
664
+ lines.push(` inputSchema: Record<string, unknown>;`);
665
+ lines.push(` handler: (args: Record<string, unknown>) => Promise<unknown>;`);
666
+ lines.push(`}`);
667
+ lines.push(``);
668
+ lines.push(`export function getCustomTools(app: ${state.isESM ? "Express" : "any"}, dbUrl?: string): CustomToolDefinition[] {`);
669
+ lines.push(` const tools: CustomToolDefinition[] = [];`);
670
+ lines.push(``);
671
+ for (const tool of state.customTools) {
672
+ const properties = [];
673
+ const required = [];
674
+ for (const param of tool.params) {
675
+ properties.push(` ${param.name}: { type: "${param.type}", description: "${param.description.replace(/"/g, '\\"')}" }`);
676
+ if (param.required)
677
+ required.push(`"${param.name}"`);
678
+ }
679
+ lines.push(` tools.push({`);
680
+ lines.push(` name: "${tool.name}",`);
681
+ lines.push(` description: "${tool.description.replace(/"/g, '\\"')}",`);
682
+ lines.push(` inputSchema: {`);
683
+ lines.push(` type: "object",`);
684
+ lines.push(` properties: {`);
685
+ lines.push(properties.join(",\n"));
686
+ lines.push(` },`);
687
+ if (required.length > 0) {
688
+ lines.push(` required: [${required.join(", ")}],`);
689
+ }
690
+ lines.push(` },`);
691
+ if (tool.dataSource === "database" && tool.tableName) {
692
+ lines.push(` handler: async (args) => {`);
693
+ lines.push(` if (!dbUrl) return { error: "No database connection configured" };`);
694
+ lines.push(` const pg = await import("pg");`);
695
+ lines.push(` const client = new pg.default.Client({ connectionString: dbUrl });`);
696
+ lines.push(` try {`);
697
+ lines.push(` await client.connect();`);
698
+ if (tool.scopeColumn) {
699
+ lines.push(` const scopeValue = args.${tool.scopeColumn} as string;`);
700
+ lines.push(` if (!scopeValue) return { error: "${tool.scopeColumn} is required for scoped queries" };`);
701
+ lines.push(` const result = await client.query(\`SELECT * FROM ${tool.tableName} WHERE ${tool.scopeColumn} = $1 LIMIT 100\`, [scopeValue]);`);
702
+ }
703
+ else {
704
+ lines.push(` const result = await client.query(\`SELECT * FROM ${tool.tableName} LIMIT 100\`);`);
705
+ }
706
+ lines.push(` return result.rows;`);
707
+ lines.push(` } finally {`);
708
+ lines.push(` await client.end();`);
709
+ lines.push(` }`);
710
+ lines.push(` },`);
711
+ }
712
+ else if (tool.dataSource === "route" && tool.routePath) {
713
+ lines.push(` handler: async (args) => {`);
714
+ lines.push(` // TODO: Implement route-based handler`);
715
+ lines.push(` // This tool maps to ${tool.routeMethod} ${tool.routePath}`);
716
+ lines.push(` return { message: "Implement this handler to call your API" };`);
717
+ lines.push(` },`);
718
+ }
719
+ else {
720
+ lines.push(` handler: async (args) => {`);
721
+ lines.push(` // TODO: Implement custom handler`);
722
+ lines.push(` return { message: "Implement this handler" };`);
723
+ lines.push(` },`);
724
+ }
725
+ lines.push(` });`);
726
+ lines.push(``);
727
+ }
728
+ lines.push(` return tools;`);
729
+ lines.push(`}`);
730
+ lines.push(``);
731
+ return lines.join("\n");
732
+ }
733
+ function validateGeneratedFiles(mcpServerPath, entryFiles) {
734
+ const errors = [];
735
+ const allFiles = [mcpServerPath, ...entryFiles];
736
+ for (const filePath of allFiles) {
737
+ if (!fs.existsSync(filePath))
738
+ continue;
739
+ const content = fs.readFileSync(filePath, "utf-8");
740
+ const relPath = path.basename(filePath);
741
+ let openBraces = 0;
742
+ let openParens = 0;
743
+ let openBrackets = 0;
744
+ let inString = null;
745
+ let inTemplateString = false;
746
+ for (let i = 0; i < content.length; i++) {
747
+ const ch = content[i];
748
+ const prev = i > 0 ? content[i - 1] : "";
749
+ if (inString) {
750
+ if (ch === inString && prev !== "\\")
751
+ inString = null;
752
+ continue;
753
+ }
754
+ if (inTemplateString) {
755
+ if (ch === "`" && prev !== "\\")
756
+ inTemplateString = false;
757
+ continue;
758
+ }
759
+ if (ch === "'" || ch === '"') {
760
+ inString = ch;
761
+ continue;
762
+ }
763
+ if (ch === "`") {
764
+ inTemplateString = true;
765
+ continue;
766
+ }
767
+ if (ch === "/" && content[i + 1] === "/") {
768
+ while (i < content.length && content[i] !== "\n")
769
+ i++;
770
+ continue;
771
+ }
772
+ if (ch === "{")
773
+ openBraces++;
774
+ else if (ch === "}")
775
+ openBraces--;
776
+ else if (ch === "(")
777
+ openParens++;
778
+ else if (ch === ")")
779
+ openParens--;
780
+ else if (ch === "[")
781
+ openBrackets++;
782
+ else if (ch === "]")
783
+ openBrackets--;
784
+ }
785
+ if (openBraces !== 0)
786
+ errors.push(`${relPath}: Unbalanced braces (${openBraces > 0 ? "missing }" : "extra }"})`);
787
+ if (openParens !== 0)
788
+ errors.push(`${relPath}: Unbalanced parentheses (${openParens > 0 ? "missing )" : "extra )"})`);
789
+ if (openBrackets !== 0)
790
+ errors.push(`${relPath}: Unbalanced brackets (${openBrackets > 0 ? "missing ]" : "extra ]"})`);
791
+ }
792
+ return errors;
793
+ }
417
794
  async function stepGenerate(state) {
418
- printStep(5, 5, "Generating MCP server");
419
- const content = fs.readFileSync(state.entryFile, "utf-8");
420
- const importLine = state.isESM ? IMPORT_LINE_ESM : IMPORT_LINE_CJS;
795
+ printStep(6, 6, "Generating MCP server");
421
796
  const disabledRoutePaths = state.tools
422
797
  .filter((t) => t.source === "route" && !t.enabled)
423
798
  .map((t) => t.path);
@@ -435,7 +810,7 @@ async function stepGenerate(state) {
435
810
  disabledTables.add(tableName);
436
811
  }
437
812
  const configParts = [];
438
- configParts.push(`app: ${state.appVarName}`);
813
+ configParts.push(`app`);
439
814
  if (disabledRoutes.length > 0) {
440
815
  configParts.push(`excludeRoutes: ${JSON.stringify(disabledRoutes)}`);
441
816
  }
@@ -453,38 +828,127 @@ async function stepGenerate(state) {
453
828
  if (!state.enrichmentEnabled) {
454
829
  configParts.push(`enrichment: false`);
455
830
  }
456
- let setupCode;
831
+ const appFileDir = path.dirname(state.appFile);
832
+ const appFileBase = path.basename(state.appFile, path.extname(state.appFile));
833
+ const ext = state.isESM ? ".ts" : ".js";
834
+ const mcpServerPath = path.join(appFileDir, `mcp-server${ext}`);
835
+ const mcpServerRel = path.relative(state.root, mcpServerPath);
836
+ const hasCustomTools = state.customTools.length > 0;
837
+ const customToolsPath = path.join(appFileDir, `mcp-custom-tools${ext}`);
838
+ const customToolsExists = fs.existsSync(customToolsPath);
839
+ const importAppPath = `./${appFileBase}`;
840
+ let fileLines = [];
841
+ fileLines.push(`// Auto-generated by @hellocrossman/mcp-sdk`);
842
+ fileLines.push(`// This file connects your Express app to AI assistants (Claude, ChatGPT, Cursor)`);
843
+ fileLines.push(`// Safe to regenerate with: npx @hellocrossman/mcp-sdk init --force`);
844
+ fileLines.push(``);
845
+ if (state.isESM) {
846
+ if (state.appExportName === "default") {
847
+ fileLines.push(`import app from '${importAppPath}';`);
848
+ }
849
+ else if (state.appExportName === "app") {
850
+ fileLines.push(`import { app } from '${importAppPath}';`);
851
+ }
852
+ else {
853
+ fileLines.push(`import { ${state.appExportName} as app } from '${importAppPath}';`);
854
+ }
855
+ fileLines.push(`import { createMcpServer } from '@hellocrossman/mcp-sdk';`);
856
+ if (hasCustomTools || customToolsExists) {
857
+ fileLines.push(`import { getCustomTools } from './mcp-custom-tools';`);
858
+ }
859
+ }
860
+ else {
861
+ if (state.appExportName === "default") {
862
+ fileLines.push(`const app = require('${importAppPath}');`);
863
+ }
864
+ else if (state.appExportName === "app") {
865
+ fileLines.push(`const { app } = require('${importAppPath}');`);
866
+ }
867
+ else {
868
+ fileLines.push(`const { ${state.appExportName}: app } = require('${importAppPath}');`);
869
+ }
870
+ fileLines.push(`const { createMcpServer } = require('@hellocrossman/mcp-sdk');`);
871
+ if (hasCustomTools || customToolsExists) {
872
+ fileLines.push(`const { getCustomTools } = require('./mcp-custom-tools');`);
873
+ }
874
+ }
875
+ fileLines.push(``);
876
+ if (hasCustomTools || customToolsExists) {
877
+ configParts.push(`customTools: getCustomTools(app, process.env.DATABASE_URL)`);
878
+ }
879
+ let configStr;
457
880
  if (configParts.length <= 2) {
458
- setupCode = `createMcpServer({ ${configParts.join(", ")} });`;
881
+ configStr = `createMcpServer({ ${configParts.join(", ")} });`;
459
882
  }
460
883
  else {
461
884
  const indent = " ";
462
- setupCode = `createMcpServer({\n${configParts.map((p) => `${indent}${p},`).join("\n")}\n});`;
885
+ configStr = `createMcpServer({\n${configParts.map((p) => `${indent}${p},`).join("\n")}\n});`;
463
886
  }
464
- const lines = content.split("\n");
465
- let lastImportIndex = -1;
466
- for (let i = 0; i < lines.length; i++) {
467
- if (/^import\s/.test(lines[i]) || /^const\s.*=\s*require/.test(lines[i])) {
468
- lastImportIndex = i;
469
- }
887
+ fileLines.push(configStr);
888
+ fileLines.push(``);
889
+ if (hasCustomTools) {
890
+ const customToolsContent = generateCustomToolsFile(state);
891
+ fs.writeFileSync(customToolsPath, customToolsContent);
470
892
  }
471
- lines.splice(lastImportIndex + 1, 0, importLine);
472
- let listenIndex = -1;
473
- for (let i = lines.length - 1; i >= 0; i--) {
474
- if (LISTEN_PATTERN.test(lines[i])) {
475
- listenIndex = i;
476
- break;
893
+ const mcpServerContent = fileLines.join("\n");
894
+ const backups = [];
895
+ backups.push({ path: mcpServerPath, content: fs.existsSync(mcpServerPath) ? fs.readFileSync(mcpServerPath, "utf-8") : null });
896
+ fs.writeFileSync(mcpServerPath, mcpServerContent);
897
+ let addedImportTo = [];
898
+ const modifiedEntries = [];
899
+ for (const entryFile of state.entryFiles) {
900
+ const entryContent = fs.readFileSync(entryFile, "utf-8");
901
+ if (entryContent.includes("mcp-server")) {
902
+ addedImportTo.push(path.relative(state.root, entryFile));
903
+ continue;
477
904
  }
905
+ backups.push({ path: entryFile, content: entryContent });
906
+ const entryDir = path.dirname(entryFile);
907
+ const importPath = `./${path.relative(entryDir, mcpServerPath).replace(/\.(ts|js)$/, "")}`;
908
+ const isEntryESM = checkIsESM(entryFile, state.root);
909
+ const importStatement = isEntryESM
910
+ ? `import '${importPath}';`
911
+ : `require('${importPath}');`;
912
+ const entryLines = entryContent.split("\n");
913
+ let lastImportIndex = -1;
914
+ for (let i = 0; i < entryLines.length; i++) {
915
+ if (/^import\s/.test(entryLines[i]) || /^const\s.*=\s*require/.test(entryLines[i]) || /^require\s*\(/.test(entryLines[i])) {
916
+ lastImportIndex = i;
917
+ }
918
+ }
919
+ const comment = `// MCP server - exposes your API to AI assistants`;
920
+ if (lastImportIndex >= 0) {
921
+ entryLines.splice(lastImportIndex + 1, 0, comment, importStatement);
922
+ }
923
+ else {
924
+ entryLines.unshift(comment, importStatement, "");
925
+ }
926
+ const newEntryContent = entryLines.join("\n");
927
+ fs.writeFileSync(entryFile, newEntryContent);
928
+ modifiedEntries.push({ path: entryFile, newContent: newEntryContent });
929
+ addedImportTo.push(path.relative(state.root, entryFile));
478
930
  }
479
- const commentLine = `// MCP server - exposes your API to AI assistants (Claude, ChatGPT, Cursor)`;
480
- if (listenIndex >= 0) {
481
- lines.splice(listenIndex, 0, "", commentLine, setupCode);
482
- }
483
- else {
484
- lines.push("", commentLine, setupCode);
931
+ const validationErrors = validateGeneratedFiles(mcpServerPath, modifiedEntries.map((e) => e.path));
932
+ if (validationErrors.length > 0) {
933
+ console.log(`\n ${c("yellow", "!")} Validation failed. Reverting changes...`);
934
+ for (const err of validationErrors) {
935
+ console.log(` ${c("dim", "-")} ${err}`);
936
+ }
937
+ for (const backup of backups) {
938
+ if (backup.content === null) {
939
+ try {
940
+ fs.unlinkSync(backup.path);
941
+ }
942
+ catch { }
943
+ }
944
+ else {
945
+ fs.writeFileSync(backup.path, backup.content);
946
+ }
947
+ }
948
+ console.log(` ${c("green", "+")} Changes reverted. Your files are unchanged.`);
949
+ console.log(` ${c("dim", " Please report this issue at:")} ${c("cyan", "https://github.com/hellocrossman/mcp-sdk/issues")}`);
950
+ return;
485
951
  }
486
- fs.writeFileSync(state.entryFile, lines.join("\n"));
487
- const relPath = path.relative(state.root, state.entryFile);
488
952
  const enabledCount = state.tools.filter((t) => t.enabled).length;
489
953
  console.log();
490
954
  console.log(c("green", ` +------------------------------+`));
@@ -493,10 +957,17 @@ async function stepGenerate(state) {
493
957
  console.log(c("green", ` | |`));
494
958
  console.log(c("green", ` +------------------------------+`));
495
959
  console.log();
496
- console.log(` ${c("green", "\u2713")} Updated ${c("bold", relPath)}`);
960
+ console.log(` ${c("green", "\u2713")} Created ${c("bold", mcpServerRel)}`);
961
+ if (hasCustomTools) {
962
+ console.log(` ${c("green", "\u2713")} Created ${c("bold", path.relative(state.root, customToolsPath))}`);
963
+ }
964
+ for (const entry of addedImportTo) {
965
+ console.log(` ${c("green", "\u2713")} Updated ${c("bold", entry)}`);
966
+ }
497
967
  console.log();
498
968
  console.log(` ${c("dim", "Configuration")}`);
499
969
  console.log(` ${c("dim", "\u2502")} Tools enabled ${c("bold", String(enabledCount))}`);
970
+ console.log(` ${c("dim", "\u2502")} Custom tools ${state.customTools.length > 0 ? c("green", `${state.customTools.length} defined`) : c("dim", "none")}`);
500
971
  console.log(` ${c("dim", "\u2502")} Database ${state.databaseEnabled ? c("green", "enabled") : c("dim", "disabled")}`);
501
972
  console.log(` ${c("dim", "\u2502")} AI enrichment ${state.enrichmentEnabled ? c("green", "enabled") : c("dim", "disabled")}`);
502
973
  console.log(` ${c("dim", "\u2502")} Write operations ${state.includeWrites ? c("green", "enabled") : c("dim", "disabled")}`);
@@ -568,13 +1039,16 @@ async function runWizard(specifiedFile, force) {
568
1039
  const prompt = createPrompt();
569
1040
  const state = {
570
1041
  root: findProjectRoot(),
571
- entryFile: "",
1042
+ appFile: "",
572
1043
  appVarName: "app",
1044
+ appExportName: "app",
1045
+ entryFiles: [],
573
1046
  isESM: false,
574
1047
  routePrefix: "/api",
575
1048
  routes: [],
576
1049
  dbTables: [],
577
1050
  tools: [],
1051
+ customTools: [],
578
1052
  databaseEnabled: true,
579
1053
  enrichmentEnabled: true,
580
1054
  includeWrites: false,
@@ -588,6 +1062,7 @@ async function runWizard(specifiedFile, force) {
588
1062
  await stepScanRoutes(state);
589
1063
  await stepScanDatabase(state, prompt);
590
1064
  await stepEnrichAndReview(state, prompt);
1065
+ await stepCustomTools(state, prompt);
591
1066
  await stepGenerate(state);
592
1067
  }
593
1068
  catch (err) {