@hellocrossman/mcp-sdk 0.3.5 → 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
@@ -147,8 +147,29 @@ function checkIsESM(filePath, root) {
147
147
  return false;
148
148
  }
149
149
  }
150
- function alreadySetup(content) {
151
- 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;
152
173
  }
153
174
  function checkPgInstalled(root) {
154
175
  return fs.existsSync(path.join(root, "node_modules", "pg"));
@@ -215,13 +236,81 @@ function stripExistingMcpConfig(content) {
215
236
  result = result.replace(/\n{3,}/g, "\n\n");
216
237
  return result;
217
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
+ }
218
291
  async function stepDetectApp(state, prompt, specifiedFile, force) {
219
- printStep(1, 5, "Detecting Express app");
220
- 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;
221
310
  if (specifiedFile) {
222
311
  const resolved = path.resolve(state.root, specifiedFile);
223
312
  if (fs.existsSync(resolved)) {
224
- entryFile = resolved;
313
+ appFile = resolved;
225
314
  }
226
315
  else {
227
316
  console.log(` ${c("yellow", "!")} File not found: ${specifiedFile}`);
@@ -229,11 +318,11 @@ async function stepDetectApp(state, prompt, specifiedFile, force) {
229
318
  }
230
319
  }
231
320
  else {
232
- entryFile = findExpressEntryFile(state.root);
321
+ appFile = findExpressEntryFile(state.root);
233
322
  }
234
- if (entryFile && !specifiedFile) {
235
- const relPath = path.relative(state.root, entryFile);
236
- const content = fs.readFileSync(entryFile, "utf-8");
323
+ if (appFile && !specifiedFile) {
324
+ const relPath = path.relative(state.root, appFile);
325
+ const content = fs.readFileSync(appFile, "utf-8");
237
326
  const hasCreation = APP_VAR_PATTERN.test(content);
238
327
  const hasListen = LISTEN_PATTERN.test(content);
239
328
  const signals = [];
@@ -249,7 +338,7 @@ async function stepDetectApp(state, prompt, specifiedFile, force) {
249
338
  if (custom) {
250
339
  const resolved = path.resolve(state.root, custom);
251
340
  if (fs.existsSync(resolved)) {
252
- entryFile = resolved;
341
+ appFile = resolved;
253
342
  }
254
343
  else {
255
344
  console.log(` ${c("yellow", "!")} File not found: ${custom}`);
@@ -261,13 +350,13 @@ async function stepDetectApp(state, prompt, specifiedFile, force) {
261
350
  }
262
351
  }
263
352
  }
264
- if (!entryFile) {
353
+ if (!appFile) {
265
354
  console.log(` ${c("yellow", "!")} Could not find an Express app file.`);
266
355
  const custom = await prompt.ask(` Enter the path to your Express app file: `);
267
356
  if (custom) {
268
357
  const resolved = path.resolve(state.root, custom);
269
358
  if (fs.existsSync(resolved)) {
270
- entryFile = resolved;
359
+ appFile = resolved;
271
360
  }
272
361
  else {
273
362
  console.log(` ${c("yellow", "!")} File not found: ${custom}`);
@@ -278,31 +367,23 @@ async function stepDetectApp(state, prompt, specifiedFile, force) {
278
367
  return false;
279
368
  }
280
369
  }
281
- let content = fs.readFileSync(entryFile, "utf-8");
282
- if (alreadySetup(content)) {
283
- if (force) {
284
- console.log(` ${c("yellow", "~")} Found existing MCP config in ${path.relative(state.root, entryFile)}`);
285
- console.log(` ${c("dim", " Removing old configuration for re-scan...")}`);
286
- content = stripExistingMcpConfig(content);
287
- fs.writeFileSync(entryFile, content);
288
- console.log(` ${c("green", "+")} Old config removed. Starting fresh.`);
289
- }
290
- else {
291
- console.log(` ${c("green", "+")} MCP SDK is already set up in ${path.relative(state.root, entryFile)}`);
292
- console.log(` ${c("dim", " Run with")} ${c("cyan", "--force")} ${c("dim", "to re-scan and reconfigure.")}`);
293
- return false;
294
- }
295
- }
370
+ const content = fs.readFileSync(appFile, "utf-8");
296
371
  const appMatch = content.match(APP_VAR_PATTERN);
297
- state.entryFile = entryFile;
372
+ state.appFile = appFile;
298
373
  state.appVarName = appMatch ? appMatch[1] : "app";
299
- state.isESM = checkIsESM(entryFile, state.root);
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;
300
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
+ }
301
382
  return true;
302
383
  }
303
384
  async function stepScanRoutes(state) {
304
- printStep(2, 5, "Scanning API routes");
305
- state.routes = scanAllRoutes(state.entryFile, state.routePrefix);
385
+ printStep(2, 6, "Scanning API routes");
386
+ state.routes = scanAllRoutes(state.appFile, state.routePrefix);
306
387
  if (state.routes.length === 0) {
307
388
  console.log(` ${c("dim", " No routes found under")} ${state.routePrefix}`);
308
389
  console.log(` ${c("dim", " Routes will be discovered at runtime when your app starts.")}`);
@@ -327,7 +408,7 @@ async function stepScanRoutes(state) {
327
408
  }
328
409
  }
329
410
  async function stepScanDatabase(state, prompt) {
330
- printStep(3, 5, "Database discovery");
411
+ printStep(3, 6, "Database discovery");
331
412
  const wantDb = await prompt.confirm(` Scan your database for tables to expose as tools?`);
332
413
  if (!wantDb) {
333
414
  state.databaseEnabled = false;
@@ -415,7 +496,7 @@ async function stepScanDatabase(state, prompt) {
415
496
  }
416
497
  }
417
498
  async function stepEnrichAndReview(state, prompt) {
418
- printStep(4, 5, "AI enrichment & tool review");
499
+ printStep(4, 6, "AI enrichment & tool review");
419
500
  if (state.tools.length === 0) {
420
501
  console.log(` ${c("dim", " No tools discovered. Routes will be discovered at runtime.")}`);
421
502
  state.enrichmentEnabled = true;
@@ -479,10 +560,239 @@ async function stepEnrichAndReview(state, prompt) {
479
560
  state.includeWrites = wantWrites;
480
561
  }
481
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
+ }
482
794
  async function stepGenerate(state) {
483
- printStep(5, 5, "Generating MCP server");
484
- const content = fs.readFileSync(state.entryFile, "utf-8");
485
- const importLine = state.isESM ? IMPORT_LINE_ESM : IMPORT_LINE_CJS;
795
+ printStep(6, 6, "Generating MCP server");
486
796
  const disabledRoutePaths = state.tools
487
797
  .filter((t) => t.source === "route" && !t.enabled)
488
798
  .map((t) => t.path);
@@ -500,7 +810,7 @@ async function stepGenerate(state) {
500
810
  disabledTables.add(tableName);
501
811
  }
502
812
  const configParts = [];
503
- configParts.push(`app: ${state.appVarName}`);
813
+ configParts.push(`app`);
504
814
  if (disabledRoutes.length > 0) {
505
815
  configParts.push(`excludeRoutes: ${JSON.stringify(disabledRoutes)}`);
506
816
  }
@@ -518,38 +828,127 @@ async function stepGenerate(state) {
518
828
  if (!state.enrichmentEnabled) {
519
829
  configParts.push(`enrichment: false`);
520
830
  }
521
- 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;
522
880
  if (configParts.length <= 2) {
523
- setupCode = `createMcpServer({ ${configParts.join(", ")} });`;
881
+ configStr = `createMcpServer({ ${configParts.join(", ")} });`;
524
882
  }
525
883
  else {
526
884
  const indent = " ";
527
- setupCode = `createMcpServer({\n${configParts.map((p) => `${indent}${p},`).join("\n")}\n});`;
885
+ configStr = `createMcpServer({\n${configParts.map((p) => `${indent}${p},`).join("\n")}\n});`;
528
886
  }
529
- const lines = content.split("\n");
530
- let lastImportIndex = -1;
531
- for (let i = 0; i < lines.length; i++) {
532
- if (/^import\s/.test(lines[i]) || /^const\s.*=\s*require/.test(lines[i])) {
533
- lastImportIndex = i;
534
- }
887
+ fileLines.push(configStr);
888
+ fileLines.push(``);
889
+ if (hasCustomTools) {
890
+ const customToolsContent = generateCustomToolsFile(state);
891
+ fs.writeFileSync(customToolsPath, customToolsContent);
535
892
  }
536
- lines.splice(lastImportIndex + 1, 0, importLine);
537
- let listenIndex = -1;
538
- for (let i = lines.length - 1; i >= 0; i--) {
539
- if (LISTEN_PATTERN.test(lines[i])) {
540
- listenIndex = i;
541
- 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;
542
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));
543
930
  }
544
- const commentLine = `// MCP server - exposes your API to AI assistants (Claude, ChatGPT, Cursor)`;
545
- if (listenIndex >= 0) {
546
- lines.splice(listenIndex, 0, "", commentLine, setupCode);
547
- }
548
- else {
549
- 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;
550
951
  }
551
- fs.writeFileSync(state.entryFile, lines.join("\n"));
552
- const relPath = path.relative(state.root, state.entryFile);
553
952
  const enabledCount = state.tools.filter((t) => t.enabled).length;
554
953
  console.log();
555
954
  console.log(c("green", ` +------------------------------+`));
@@ -558,10 +957,17 @@ async function stepGenerate(state) {
558
957
  console.log(c("green", ` | |`));
559
958
  console.log(c("green", ` +------------------------------+`));
560
959
  console.log();
561
- 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
+ }
562
967
  console.log();
563
968
  console.log(` ${c("dim", "Configuration")}`);
564
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")}`);
565
971
  console.log(` ${c("dim", "\u2502")} Database ${state.databaseEnabled ? c("green", "enabled") : c("dim", "disabled")}`);
566
972
  console.log(` ${c("dim", "\u2502")} AI enrichment ${state.enrichmentEnabled ? c("green", "enabled") : c("dim", "disabled")}`);
567
973
  console.log(` ${c("dim", "\u2502")} Write operations ${state.includeWrites ? c("green", "enabled") : c("dim", "disabled")}`);
@@ -633,13 +1039,16 @@ async function runWizard(specifiedFile, force) {
633
1039
  const prompt = createPrompt();
634
1040
  const state = {
635
1041
  root: findProjectRoot(),
636
- entryFile: "",
1042
+ appFile: "",
637
1043
  appVarName: "app",
1044
+ appExportName: "app",
1045
+ entryFiles: [],
638
1046
  isESM: false,
639
1047
  routePrefix: "/api",
640
1048
  routes: [],
641
1049
  dbTables: [],
642
1050
  tools: [],
1051
+ customTools: [],
643
1052
  databaseEnabled: true,
644
1053
  enrichmentEnabled: true,
645
1054
  includeWrites: false,
@@ -653,6 +1062,7 @@ async function runWizard(specifiedFile, force) {
653
1062
  await stepScanRoutes(state);
654
1063
  await stepScanDatabase(state, prompt);
655
1064
  await stepEnrichAndReview(state, prompt);
1065
+ await stepCustomTools(state, prompt);
656
1066
  await stepGenerate(state);
657
1067
  }
658
1068
  catch (err) {