@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/cjs/cli.js CHANGED
@@ -175,8 +175,29 @@ function checkIsESM(filePath, root) {
175
175
  return false;
176
176
  }
177
177
  }
178
- function alreadySetup(content) {
179
- return content.includes("createMcpServer") || content.includes("@hellocrossman/mcp-sdk");
178
+ function alreadySetup(root) {
179
+ const mcpServerFile = path_1.default.join(root, "mcp-server.ts");
180
+ const mcpServerFileJs = path_1.default.join(root, "mcp-server.js");
181
+ if (fs_1.default.existsSync(mcpServerFile) || fs_1.default.existsSync(mcpServerFileJs))
182
+ return true;
183
+ const serverMcpFile = path_1.default.join(root, "server", "mcp-server.ts");
184
+ const serverMcpFileJs = path_1.default.join(root, "server", "mcp-server.js");
185
+ if (fs_1.default.existsSync(serverMcpFile) || fs_1.default.existsSync(serverMcpFileJs))
186
+ return true;
187
+ return false;
188
+ }
189
+ function findExistingMcpServerFile(root) {
190
+ const candidates = [
191
+ path_1.default.join(root, "mcp-server.ts"),
192
+ path_1.default.join(root, "mcp-server.js"),
193
+ path_1.default.join(root, "server", "mcp-server.ts"),
194
+ path_1.default.join(root, "server", "mcp-server.js"),
195
+ ];
196
+ for (const f of candidates) {
197
+ if (fs_1.default.existsSync(f))
198
+ return f;
199
+ }
200
+ return null;
180
201
  }
181
202
  function checkPgInstalled(root) {
182
203
  return fs_1.default.existsSync(path_1.default.join(root, "node_modules", "pg"));
@@ -243,13 +264,81 @@ function stripExistingMcpConfig(content) {
243
264
  result = result.replace(/\n{3,}/g, "\n\n");
244
265
  return result;
245
266
  }
267
+ function findAppExport(content, varName) {
268
+ const namedExportRe = new RegExp(`export\\s+(?:const|let|var)\\s+${varName}\\s*=`);
269
+ if (namedExportRe.test(content))
270
+ return varName;
271
+ if (/export\s+default\s+app\b/.test(content))
272
+ return "default";
273
+ const moduleExportsRe = new RegExp(`module\\.exports\\s*=\\s*${varName}`);
274
+ if (moduleExportsRe.test(content))
275
+ return "default";
276
+ const exportsRe = new RegExp(`module\\.exports\\.${varName}\\s*=`);
277
+ if (exportsRe.test(content))
278
+ return varName;
279
+ return varName;
280
+ }
281
+ function findEntryFiles(root, appFile) {
282
+ const entries = [];
283
+ const appFileRel = path_1.default.relative(root, appFile);
284
+ const appBaseName = path_1.default.basename(appFile, path_1.default.extname(appFile));
285
+ const appDir = path_1.default.dirname(appFile);
286
+ const searchDirs = [appDir, root, path_1.default.join(root, "server"), path_1.default.join(root, "src")];
287
+ const seen = new Set();
288
+ for (const dir of searchDirs) {
289
+ if (!fs_1.default.existsSync(dir) || !fs_1.default.statSync(dir).isDirectory())
290
+ continue;
291
+ const files = fs_1.default.readdirSync(dir).filter((f) => /\.(ts|js|mjs)$/.test(f));
292
+ for (const file of files) {
293
+ const fullPath = path_1.default.join(dir, file);
294
+ if (seen.has(fullPath) || fullPath === appFile)
295
+ continue;
296
+ seen.add(fullPath);
297
+ try {
298
+ const content = fs_1.default.readFileSync(fullPath, "utf-8");
299
+ const hasListen = LISTEN_PATTERN.test(content);
300
+ const importsApp = content.includes(`./${appBaseName}`) ||
301
+ content.includes(`'./${appFileRel}'`) ||
302
+ content.includes(`"./${appFileRel}"`) ||
303
+ content.includes(`from './${appBaseName}'`) ||
304
+ content.includes(`from "./${appBaseName}"`) ||
305
+ content.includes(`require('./${appBaseName}')`) ||
306
+ content.includes(`require("./${appBaseName}")`);
307
+ if (hasListen || importsApp) {
308
+ entries.push(fullPath);
309
+ }
310
+ }
311
+ catch { }
312
+ }
313
+ }
314
+ if (entries.length === 0) {
315
+ entries.push(appFile);
316
+ }
317
+ return entries;
318
+ }
246
319
  async function stepDetectApp(state, prompt, specifiedFile, force) {
247
- printStep(1, 5, "Detecting Express app");
248
- let entryFile = null;
320
+ printStep(1, 6, "Detecting Express app");
321
+ if (alreadySetup(state.root)) {
322
+ if (force) {
323
+ const existingFile = findExistingMcpServerFile(state.root);
324
+ if (existingFile) {
325
+ console.log(` ${c("yellow", "~")} Found existing ${c("bold", path_1.default.relative(state.root, existingFile))}`);
326
+ console.log(` ${c("dim", " Will regenerate with fresh scan...")}`);
327
+ }
328
+ }
329
+ else {
330
+ const existingFile = findExistingMcpServerFile(state.root);
331
+ const relPath = existingFile ? path_1.default.relative(state.root, existingFile) : "mcp-server.ts";
332
+ console.log(` ${c("green", "+")} MCP SDK is already set up (${c("bold", relPath)})`);
333
+ console.log(` ${c("dim", " Run with")} ${c("cyan", "--force")} ${c("dim", "to re-scan and reconfigure.")}`);
334
+ return false;
335
+ }
336
+ }
337
+ let appFile = null;
249
338
  if (specifiedFile) {
250
339
  const resolved = path_1.default.resolve(state.root, specifiedFile);
251
340
  if (fs_1.default.existsSync(resolved)) {
252
- entryFile = resolved;
341
+ appFile = resolved;
253
342
  }
254
343
  else {
255
344
  console.log(` ${c("yellow", "!")} File not found: ${specifiedFile}`);
@@ -257,11 +346,11 @@ async function stepDetectApp(state, prompt, specifiedFile, force) {
257
346
  }
258
347
  }
259
348
  else {
260
- entryFile = findExpressEntryFile(state.root);
349
+ appFile = findExpressEntryFile(state.root);
261
350
  }
262
- if (entryFile && !specifiedFile) {
263
- const relPath = path_1.default.relative(state.root, entryFile);
264
- const content = fs_1.default.readFileSync(entryFile, "utf-8");
351
+ if (appFile && !specifiedFile) {
352
+ const relPath = path_1.default.relative(state.root, appFile);
353
+ const content = fs_1.default.readFileSync(appFile, "utf-8");
265
354
  const hasCreation = APP_VAR_PATTERN.test(content);
266
355
  const hasListen = LISTEN_PATTERN.test(content);
267
356
  const signals = [];
@@ -277,7 +366,7 @@ async function stepDetectApp(state, prompt, specifiedFile, force) {
277
366
  if (custom) {
278
367
  const resolved = path_1.default.resolve(state.root, custom);
279
368
  if (fs_1.default.existsSync(resolved)) {
280
- entryFile = resolved;
369
+ appFile = resolved;
281
370
  }
282
371
  else {
283
372
  console.log(` ${c("yellow", "!")} File not found: ${custom}`);
@@ -289,13 +378,13 @@ async function stepDetectApp(state, prompt, specifiedFile, force) {
289
378
  }
290
379
  }
291
380
  }
292
- if (!entryFile) {
381
+ if (!appFile) {
293
382
  console.log(` ${c("yellow", "!")} Could not find an Express app file.`);
294
383
  const custom = await prompt.ask(` Enter the path to your Express app file: `);
295
384
  if (custom) {
296
385
  const resolved = path_1.default.resolve(state.root, custom);
297
386
  if (fs_1.default.existsSync(resolved)) {
298
- entryFile = resolved;
387
+ appFile = resolved;
299
388
  }
300
389
  else {
301
390
  console.log(` ${c("yellow", "!")} File not found: ${custom}`);
@@ -306,31 +395,23 @@ async function stepDetectApp(state, prompt, specifiedFile, force) {
306
395
  return false;
307
396
  }
308
397
  }
309
- let content = fs_1.default.readFileSync(entryFile, "utf-8");
310
- if (alreadySetup(content)) {
311
- if (force) {
312
- console.log(` ${c("yellow", "~")} Found existing MCP config in ${path_1.default.relative(state.root, entryFile)}`);
313
- console.log(` ${c("dim", " Removing old configuration for re-scan...")}`);
314
- content = stripExistingMcpConfig(content);
315
- fs_1.default.writeFileSync(entryFile, content);
316
- console.log(` ${c("green", "+")} Old config removed. Starting fresh.`);
317
- }
318
- else {
319
- console.log(` ${c("green", "+")} MCP SDK is already set up in ${path_1.default.relative(state.root, entryFile)}`);
320
- console.log(` ${c("dim", " Run with")} ${c("cyan", "--force")} ${c("dim", "to re-scan and reconfigure.")}`);
321
- return false;
322
- }
323
- }
398
+ const content = fs_1.default.readFileSync(appFile, "utf-8");
324
399
  const appMatch = content.match(APP_VAR_PATTERN);
325
- state.entryFile = entryFile;
400
+ state.appFile = appFile;
326
401
  state.appVarName = appMatch ? appMatch[1] : "app";
327
- state.isESM = checkIsESM(entryFile, state.root);
402
+ state.appExportName = findAppExport(content, state.appVarName);
403
+ state.isESM = checkIsESM(appFile, state.root);
404
+ const entryFiles = findEntryFiles(state.root, appFile);
405
+ state.entryFiles = entryFiles;
328
406
  console.log(` ${c("dim", ` Variable: ${state.appVarName}, Module: ${state.isESM ? "ESM" : "CommonJS"}`)}`);
407
+ if (entryFiles.length > 0 && entryFiles[0] !== appFile) {
408
+ console.log(` ${c("dim", ` Entry files: ${entryFiles.map((f) => path_1.default.relative(state.root, f)).join(", ")}`)}`);
409
+ }
329
410
  return true;
330
411
  }
331
412
  async function stepScanRoutes(state) {
332
- printStep(2, 5, "Scanning API routes");
333
- state.routes = (0, route_scan_js_1.scanAllRoutes)(state.entryFile, state.routePrefix);
413
+ printStep(2, 6, "Scanning API routes");
414
+ state.routes = (0, route_scan_js_1.scanAllRoutes)(state.appFile, state.routePrefix);
334
415
  if (state.routes.length === 0) {
335
416
  console.log(` ${c("dim", " No routes found under")} ${state.routePrefix}`);
336
417
  console.log(` ${c("dim", " Routes will be discovered at runtime when your app starts.")}`);
@@ -355,7 +436,7 @@ async function stepScanRoutes(state) {
355
436
  }
356
437
  }
357
438
  async function stepScanDatabase(state, prompt) {
358
- printStep(3, 5, "Database discovery");
439
+ printStep(3, 6, "Database discovery");
359
440
  const wantDb = await prompt.confirm(` Scan your database for tables to expose as tools?`);
360
441
  if (!wantDb) {
361
442
  state.databaseEnabled = false;
@@ -443,7 +524,7 @@ async function stepScanDatabase(state, prompt) {
443
524
  }
444
525
  }
445
526
  async function stepEnrichAndReview(state, prompt) {
446
- printStep(4, 5, "AI enrichment & tool review");
527
+ printStep(4, 6, "AI enrichment & tool review");
447
528
  if (state.tools.length === 0) {
448
529
  console.log(` ${c("dim", " No tools discovered. Routes will be discovered at runtime.")}`);
449
530
  state.enrichmentEnabled = true;
@@ -507,10 +588,239 @@ async function stepEnrichAndReview(state, prompt) {
507
588
  state.includeWrites = wantWrites;
508
589
  }
509
590
  }
591
+ async function stepCustomTools(state, prompt) {
592
+ printStep(5, 6, "Custom tools (optional)");
593
+ console.log(` ${c("dim", "Auto-discovered tools give broad coverage.")}`);
594
+ console.log(` ${c("dim", "Custom tools let you define purpose-built queries")}`);
595
+ console.log(` ${c("dim", "for specific business questions.\n")}`);
596
+ const wantCustom = await prompt.confirm(` Define custom tools?`, false);
597
+ if (!wantCustom) {
598
+ console.log(` ${c("dim", " Skipping custom tools.")}`);
599
+ return;
600
+ }
601
+ const availableRoutes = state.routes.map((r) => `${r.method} ${r.path}`);
602
+ const availableTables = state.dbTables.map((t) => t.name);
603
+ while (true) {
604
+ console.log();
605
+ const name = await prompt.ask(` ${c("cyan", "Tool name")} ${c("dim", "(e.g. get_traffic_overview):")} `);
606
+ if (!name)
607
+ break;
608
+ const toolName = name.replace(/[^a-zA-Z0-9_]/g, "_").toLowerCase();
609
+ const description = await prompt.ask(` ${c("cyan", "Description")} ${c("dim", "(what does this tool do?):")} `);
610
+ if (!description)
611
+ break;
612
+ const params = [];
613
+ console.log(`\n ${c("dim", "Define input parameters (press Enter with no name to finish):")}`);
614
+ while (true) {
615
+ const paramName = await prompt.ask(` ${c("cyan", "Parameter name:")} `);
616
+ if (!paramName)
617
+ break;
618
+ const paramDesc = await prompt.ask(` ${c("cyan", "Description:")} `);
619
+ console.log(` ${c("dim", "Type: 1) string 2) number 3) boolean")}`);
620
+ const typeChoice = await prompt.ask(` ${c("cyan", "Type")} ${c("dim", "(1/2/3, default: 1):")} `);
621
+ const paramType = typeChoice === "2" ? "number" : typeChoice === "3" ? "boolean" : "string";
622
+ const isRequired = await prompt.confirm(` Required?`);
623
+ params.push({ name: paramName, type: paramType, description: paramDesc || paramName, required: isRequired });
624
+ console.log(` ${c("green", "+")} Added parameter: ${c("bold", paramName)} (${paramType})`);
625
+ }
626
+ console.log(`\n ${c("dim", "How should this tool get its data?")}`);
627
+ console.log(` ${c("cyan", "1.")} Call an existing API route`);
628
+ console.log(` ${c("cyan", "2.")} Run a database query`);
629
+ const sourceChoice = await prompt.ask(` ${c("dim", ">")} `);
630
+ const customTool = {
631
+ name: toolName,
632
+ description,
633
+ params,
634
+ dataSource: sourceChoice === "2" ? "database" : "route",
635
+ };
636
+ if (sourceChoice === "2" && availableTables.length > 0) {
637
+ console.log(`\n ${c("dim", "Available tables:")}`);
638
+ availableTables.forEach((t, i) => console.log(` ${c("cyan", `${i + 1}.`)} ${t}`));
639
+ const tableChoice = await prompt.ask(` ${c("dim", "Table number or name:")} `);
640
+ const tableIndex = parseInt(tableChoice) - 1;
641
+ customTool.tableName = availableTables[tableIndex] || tableChoice;
642
+ const tableInfo = state.dbTables.find((t) => t.name === customTool.tableName);
643
+ if (tableInfo) {
644
+ const scopeColumns = tableInfo.columns
645
+ .filter((col) => /user_id|project_id|account_id|org_id|team_id|owner_id/.test(col.name))
646
+ .map((col) => col.name);
647
+ if (scopeColumns.length > 0) {
648
+ console.log(`\n ${c("dim", "User scoping - filter results by:")}`);
649
+ scopeColumns.forEach((col, i) => console.log(` ${c("cyan", `${i + 1}.`)} ${col}`));
650
+ console.log(` ${c("cyan", `${scopeColumns.length + 1}.`)} No scoping (public data)`);
651
+ const scopeChoice = await prompt.ask(` ${c("dim", ">")} `);
652
+ const scopeIndex = parseInt(scopeChoice) - 1;
653
+ if (scopeIndex >= 0 && scopeIndex < scopeColumns.length) {
654
+ customTool.scopeColumn = scopeColumns[scopeIndex];
655
+ }
656
+ }
657
+ }
658
+ }
659
+ else if (sourceChoice !== "2" && availableRoutes.length > 0) {
660
+ console.log(`\n ${c("dim", "Available routes:")}`);
661
+ availableRoutes.forEach((r, i) => console.log(` ${c("cyan", `${i + 1}.`)} ${r}`));
662
+ const routeChoice = await prompt.ask(` ${c("dim", "Route number:")} `);
663
+ const routeIndex = parseInt(routeChoice) - 1;
664
+ if (routeIndex >= 0 && routeIndex < state.routes.length) {
665
+ customTool.routeMethod = state.routes[routeIndex].method;
666
+ customTool.routePath = state.routes[routeIndex].path;
667
+ }
668
+ }
669
+ state.customTools.push(customTool);
670
+ console.log(`\n ${c("green", "+")} Added custom tool: ${c("bold", toolName)}`);
671
+ const addMore = await prompt.confirm(`\n Add another custom tool?`, false);
672
+ if (!addMore)
673
+ break;
674
+ }
675
+ if (state.customTools.length > 0) {
676
+ console.log(`\n ${c("green", "+")} ${state.customTools.length} custom tool${state.customTools.length === 1 ? "" : "s"} defined.`);
677
+ }
678
+ }
679
+ function generateCustomToolsFile(state) {
680
+ const lines = [];
681
+ lines.push(`// Custom tools for your MCP server`);
682
+ lines.push(`// Hand-crafted tools that persist across re-scans`);
683
+ lines.push(`// Edit this file to customize tool behavior`);
684
+ lines.push(``);
685
+ if (state.isESM) {
686
+ lines.push(`import type { Express } from 'express';`);
687
+ }
688
+ lines.push(``);
689
+ lines.push(`export interface CustomToolDefinition {`);
690
+ lines.push(` name: string;`);
691
+ lines.push(` description: string;`);
692
+ lines.push(` inputSchema: Record<string, unknown>;`);
693
+ lines.push(` handler: (args: Record<string, unknown>) => Promise<unknown>;`);
694
+ lines.push(`}`);
695
+ lines.push(``);
696
+ lines.push(`export function getCustomTools(app: ${state.isESM ? "Express" : "any"}, dbUrl?: string): CustomToolDefinition[] {`);
697
+ lines.push(` const tools: CustomToolDefinition[] = [];`);
698
+ lines.push(``);
699
+ for (const tool of state.customTools) {
700
+ const properties = [];
701
+ const required = [];
702
+ for (const param of tool.params) {
703
+ properties.push(` ${param.name}: { type: "${param.type}", description: "${param.description.replace(/"/g, '\\"')}" }`);
704
+ if (param.required)
705
+ required.push(`"${param.name}"`);
706
+ }
707
+ lines.push(` tools.push({`);
708
+ lines.push(` name: "${tool.name}",`);
709
+ lines.push(` description: "${tool.description.replace(/"/g, '\\"')}",`);
710
+ lines.push(` inputSchema: {`);
711
+ lines.push(` type: "object",`);
712
+ lines.push(` properties: {`);
713
+ lines.push(properties.join(",\n"));
714
+ lines.push(` },`);
715
+ if (required.length > 0) {
716
+ lines.push(` required: [${required.join(", ")}],`);
717
+ }
718
+ lines.push(` },`);
719
+ if (tool.dataSource === "database" && tool.tableName) {
720
+ lines.push(` handler: async (args) => {`);
721
+ lines.push(` if (!dbUrl) return { error: "No database connection configured" };`);
722
+ lines.push(` const pg = await import("pg");`);
723
+ lines.push(` const client = new pg.default.Client({ connectionString: dbUrl });`);
724
+ lines.push(` try {`);
725
+ lines.push(` await client.connect();`);
726
+ if (tool.scopeColumn) {
727
+ lines.push(` const scopeValue = args.${tool.scopeColumn} as string;`);
728
+ lines.push(` if (!scopeValue) return { error: "${tool.scopeColumn} is required for scoped queries" };`);
729
+ lines.push(` const result = await client.query(\`SELECT * FROM ${tool.tableName} WHERE ${tool.scopeColumn} = $1 LIMIT 100\`, [scopeValue]);`);
730
+ }
731
+ else {
732
+ lines.push(` const result = await client.query(\`SELECT * FROM ${tool.tableName} LIMIT 100\`);`);
733
+ }
734
+ lines.push(` return result.rows;`);
735
+ lines.push(` } finally {`);
736
+ lines.push(` await client.end();`);
737
+ lines.push(` }`);
738
+ lines.push(` },`);
739
+ }
740
+ else if (tool.dataSource === "route" && tool.routePath) {
741
+ lines.push(` handler: async (args) => {`);
742
+ lines.push(` // TODO: Implement route-based handler`);
743
+ lines.push(` // This tool maps to ${tool.routeMethod} ${tool.routePath}`);
744
+ lines.push(` return { message: "Implement this handler to call your API" };`);
745
+ lines.push(` },`);
746
+ }
747
+ else {
748
+ lines.push(` handler: async (args) => {`);
749
+ lines.push(` // TODO: Implement custom handler`);
750
+ lines.push(` return { message: "Implement this handler" };`);
751
+ lines.push(` },`);
752
+ }
753
+ lines.push(` });`);
754
+ lines.push(``);
755
+ }
756
+ lines.push(` return tools;`);
757
+ lines.push(`}`);
758
+ lines.push(``);
759
+ return lines.join("\n");
760
+ }
761
+ function validateGeneratedFiles(mcpServerPath, entryFiles) {
762
+ const errors = [];
763
+ const allFiles = [mcpServerPath, ...entryFiles];
764
+ for (const filePath of allFiles) {
765
+ if (!fs_1.default.existsSync(filePath))
766
+ continue;
767
+ const content = fs_1.default.readFileSync(filePath, "utf-8");
768
+ const relPath = path_1.default.basename(filePath);
769
+ let openBraces = 0;
770
+ let openParens = 0;
771
+ let openBrackets = 0;
772
+ let inString = null;
773
+ let inTemplateString = false;
774
+ for (let i = 0; i < content.length; i++) {
775
+ const ch = content[i];
776
+ const prev = i > 0 ? content[i - 1] : "";
777
+ if (inString) {
778
+ if (ch === inString && prev !== "\\")
779
+ inString = null;
780
+ continue;
781
+ }
782
+ if (inTemplateString) {
783
+ if (ch === "`" && prev !== "\\")
784
+ inTemplateString = false;
785
+ continue;
786
+ }
787
+ if (ch === "'" || ch === '"') {
788
+ inString = ch;
789
+ continue;
790
+ }
791
+ if (ch === "`") {
792
+ inTemplateString = true;
793
+ continue;
794
+ }
795
+ if (ch === "/" && content[i + 1] === "/") {
796
+ while (i < content.length && content[i] !== "\n")
797
+ i++;
798
+ continue;
799
+ }
800
+ if (ch === "{")
801
+ openBraces++;
802
+ else if (ch === "}")
803
+ openBraces--;
804
+ else if (ch === "(")
805
+ openParens++;
806
+ else if (ch === ")")
807
+ openParens--;
808
+ else if (ch === "[")
809
+ openBrackets++;
810
+ else if (ch === "]")
811
+ openBrackets--;
812
+ }
813
+ if (openBraces !== 0)
814
+ errors.push(`${relPath}: Unbalanced braces (${openBraces > 0 ? "missing }" : "extra }"})`);
815
+ if (openParens !== 0)
816
+ errors.push(`${relPath}: Unbalanced parentheses (${openParens > 0 ? "missing )" : "extra )"})`);
817
+ if (openBrackets !== 0)
818
+ errors.push(`${relPath}: Unbalanced brackets (${openBrackets > 0 ? "missing ]" : "extra ]"})`);
819
+ }
820
+ return errors;
821
+ }
510
822
  async function stepGenerate(state) {
511
- printStep(5, 5, "Generating MCP server");
512
- const content = fs_1.default.readFileSync(state.entryFile, "utf-8");
513
- const importLine = state.isESM ? IMPORT_LINE_ESM : IMPORT_LINE_CJS;
823
+ printStep(6, 6, "Generating MCP server");
514
824
  const disabledRoutePaths = state.tools
515
825
  .filter((t) => t.source === "route" && !t.enabled)
516
826
  .map((t) => t.path);
@@ -528,7 +838,7 @@ async function stepGenerate(state) {
528
838
  disabledTables.add(tableName);
529
839
  }
530
840
  const configParts = [];
531
- configParts.push(`app: ${state.appVarName}`);
841
+ configParts.push(`app`);
532
842
  if (disabledRoutes.length > 0) {
533
843
  configParts.push(`excludeRoutes: ${JSON.stringify(disabledRoutes)}`);
534
844
  }
@@ -546,38 +856,127 @@ async function stepGenerate(state) {
546
856
  if (!state.enrichmentEnabled) {
547
857
  configParts.push(`enrichment: false`);
548
858
  }
549
- let setupCode;
859
+ const appFileDir = path_1.default.dirname(state.appFile);
860
+ const appFileBase = path_1.default.basename(state.appFile, path_1.default.extname(state.appFile));
861
+ const ext = state.isESM ? ".ts" : ".js";
862
+ const mcpServerPath = path_1.default.join(appFileDir, `mcp-server${ext}`);
863
+ const mcpServerRel = path_1.default.relative(state.root, mcpServerPath);
864
+ const hasCustomTools = state.customTools.length > 0;
865
+ const customToolsPath = path_1.default.join(appFileDir, `mcp-custom-tools${ext}`);
866
+ const customToolsExists = fs_1.default.existsSync(customToolsPath);
867
+ const importAppPath = `./${appFileBase}`;
868
+ let fileLines = [];
869
+ fileLines.push(`// Auto-generated by @hellocrossman/mcp-sdk`);
870
+ fileLines.push(`// This file connects your Express app to AI assistants (Claude, ChatGPT, Cursor)`);
871
+ fileLines.push(`// Safe to regenerate with: npx @hellocrossman/mcp-sdk init --force`);
872
+ fileLines.push(``);
873
+ if (state.isESM) {
874
+ if (state.appExportName === "default") {
875
+ fileLines.push(`import app from '${importAppPath}';`);
876
+ }
877
+ else if (state.appExportName === "app") {
878
+ fileLines.push(`import { app } from '${importAppPath}';`);
879
+ }
880
+ else {
881
+ fileLines.push(`import { ${state.appExportName} as app } from '${importAppPath}';`);
882
+ }
883
+ fileLines.push(`import { createMcpServer } from '@hellocrossman/mcp-sdk';`);
884
+ if (hasCustomTools || customToolsExists) {
885
+ fileLines.push(`import { getCustomTools } from './mcp-custom-tools';`);
886
+ }
887
+ }
888
+ else {
889
+ if (state.appExportName === "default") {
890
+ fileLines.push(`const app = require('${importAppPath}');`);
891
+ }
892
+ else if (state.appExportName === "app") {
893
+ fileLines.push(`const { app } = require('${importAppPath}');`);
894
+ }
895
+ else {
896
+ fileLines.push(`const { ${state.appExportName}: app } = require('${importAppPath}');`);
897
+ }
898
+ fileLines.push(`const { createMcpServer } = require('@hellocrossman/mcp-sdk');`);
899
+ if (hasCustomTools || customToolsExists) {
900
+ fileLines.push(`const { getCustomTools } = require('./mcp-custom-tools');`);
901
+ }
902
+ }
903
+ fileLines.push(``);
904
+ if (hasCustomTools || customToolsExists) {
905
+ configParts.push(`customTools: getCustomTools(app, process.env.DATABASE_URL)`);
906
+ }
907
+ let configStr;
550
908
  if (configParts.length <= 2) {
551
- setupCode = `createMcpServer({ ${configParts.join(", ")} });`;
909
+ configStr = `createMcpServer({ ${configParts.join(", ")} });`;
552
910
  }
553
911
  else {
554
912
  const indent = " ";
555
- setupCode = `createMcpServer({\n${configParts.map((p) => `${indent}${p},`).join("\n")}\n});`;
913
+ configStr = `createMcpServer({\n${configParts.map((p) => `${indent}${p},`).join("\n")}\n});`;
556
914
  }
557
- const lines = content.split("\n");
558
- let lastImportIndex = -1;
559
- for (let i = 0; i < lines.length; i++) {
560
- if (/^import\s/.test(lines[i]) || /^const\s.*=\s*require/.test(lines[i])) {
561
- lastImportIndex = i;
562
- }
915
+ fileLines.push(configStr);
916
+ fileLines.push(``);
917
+ if (hasCustomTools) {
918
+ const customToolsContent = generateCustomToolsFile(state);
919
+ fs_1.default.writeFileSync(customToolsPath, customToolsContent);
563
920
  }
564
- lines.splice(lastImportIndex + 1, 0, importLine);
565
- let listenIndex = -1;
566
- for (let i = lines.length - 1; i >= 0; i--) {
567
- if (LISTEN_PATTERN.test(lines[i])) {
568
- listenIndex = i;
569
- break;
921
+ const mcpServerContent = fileLines.join("\n");
922
+ const backups = [];
923
+ backups.push({ path: mcpServerPath, content: fs_1.default.existsSync(mcpServerPath) ? fs_1.default.readFileSync(mcpServerPath, "utf-8") : null });
924
+ fs_1.default.writeFileSync(mcpServerPath, mcpServerContent);
925
+ let addedImportTo = [];
926
+ const modifiedEntries = [];
927
+ for (const entryFile of state.entryFiles) {
928
+ const entryContent = fs_1.default.readFileSync(entryFile, "utf-8");
929
+ if (entryContent.includes("mcp-server")) {
930
+ addedImportTo.push(path_1.default.relative(state.root, entryFile));
931
+ continue;
570
932
  }
933
+ backups.push({ path: entryFile, content: entryContent });
934
+ const entryDir = path_1.default.dirname(entryFile);
935
+ const importPath = `./${path_1.default.relative(entryDir, mcpServerPath).replace(/\.(ts|js)$/, "")}`;
936
+ const isEntryESM = checkIsESM(entryFile, state.root);
937
+ const importStatement = isEntryESM
938
+ ? `import '${importPath}';`
939
+ : `require('${importPath}');`;
940
+ const entryLines = entryContent.split("\n");
941
+ let lastImportIndex = -1;
942
+ for (let i = 0; i < entryLines.length; i++) {
943
+ if (/^import\s/.test(entryLines[i]) || /^const\s.*=\s*require/.test(entryLines[i]) || /^require\s*\(/.test(entryLines[i])) {
944
+ lastImportIndex = i;
945
+ }
946
+ }
947
+ const comment = `// MCP server - exposes your API to AI assistants`;
948
+ if (lastImportIndex >= 0) {
949
+ entryLines.splice(lastImportIndex + 1, 0, comment, importStatement);
950
+ }
951
+ else {
952
+ entryLines.unshift(comment, importStatement, "");
953
+ }
954
+ const newEntryContent = entryLines.join("\n");
955
+ fs_1.default.writeFileSync(entryFile, newEntryContent);
956
+ modifiedEntries.push({ path: entryFile, newContent: newEntryContent });
957
+ addedImportTo.push(path_1.default.relative(state.root, entryFile));
571
958
  }
572
- const commentLine = `// MCP server - exposes your API to AI assistants (Claude, ChatGPT, Cursor)`;
573
- if (listenIndex >= 0) {
574
- lines.splice(listenIndex, 0, "", commentLine, setupCode);
575
- }
576
- else {
577
- lines.push("", commentLine, setupCode);
959
+ const validationErrors = validateGeneratedFiles(mcpServerPath, modifiedEntries.map((e) => e.path));
960
+ if (validationErrors.length > 0) {
961
+ console.log(`\n ${c("yellow", "!")} Validation failed. Reverting changes...`);
962
+ for (const err of validationErrors) {
963
+ console.log(` ${c("dim", "-")} ${err}`);
964
+ }
965
+ for (const backup of backups) {
966
+ if (backup.content === null) {
967
+ try {
968
+ fs_1.default.unlinkSync(backup.path);
969
+ }
970
+ catch { }
971
+ }
972
+ else {
973
+ fs_1.default.writeFileSync(backup.path, backup.content);
974
+ }
975
+ }
976
+ console.log(` ${c("green", "+")} Changes reverted. Your files are unchanged.`);
977
+ console.log(` ${c("dim", " Please report this issue at:")} ${c("cyan", "https://github.com/hellocrossman/mcp-sdk/issues")}`);
978
+ return;
578
979
  }
579
- fs_1.default.writeFileSync(state.entryFile, lines.join("\n"));
580
- const relPath = path_1.default.relative(state.root, state.entryFile);
581
980
  const enabledCount = state.tools.filter((t) => t.enabled).length;
582
981
  console.log();
583
982
  console.log(c("green", ` +------------------------------+`));
@@ -586,10 +985,17 @@ async function stepGenerate(state) {
586
985
  console.log(c("green", ` | |`));
587
986
  console.log(c("green", ` +------------------------------+`));
588
987
  console.log();
589
- console.log(` ${c("green", "\u2713")} Updated ${c("bold", relPath)}`);
988
+ console.log(` ${c("green", "\u2713")} Created ${c("bold", mcpServerRel)}`);
989
+ if (hasCustomTools) {
990
+ console.log(` ${c("green", "\u2713")} Created ${c("bold", path_1.default.relative(state.root, customToolsPath))}`);
991
+ }
992
+ for (const entry of addedImportTo) {
993
+ console.log(` ${c("green", "\u2713")} Updated ${c("bold", entry)}`);
994
+ }
590
995
  console.log();
591
996
  console.log(` ${c("dim", "Configuration")}`);
592
997
  console.log(` ${c("dim", "\u2502")} Tools enabled ${c("bold", String(enabledCount))}`);
998
+ console.log(` ${c("dim", "\u2502")} Custom tools ${state.customTools.length > 0 ? c("green", `${state.customTools.length} defined`) : c("dim", "none")}`);
593
999
  console.log(` ${c("dim", "\u2502")} Database ${state.databaseEnabled ? c("green", "enabled") : c("dim", "disabled")}`);
594
1000
  console.log(` ${c("dim", "\u2502")} AI enrichment ${state.enrichmentEnabled ? c("green", "enabled") : c("dim", "disabled")}`);
595
1001
  console.log(` ${c("dim", "\u2502")} Write operations ${state.includeWrites ? c("green", "enabled") : c("dim", "disabled")}`);
@@ -661,13 +1067,16 @@ async function runWizard(specifiedFile, force) {
661
1067
  const prompt = createPrompt();
662
1068
  const state = {
663
1069
  root: findProjectRoot(),
664
- entryFile: "",
1070
+ appFile: "",
665
1071
  appVarName: "app",
1072
+ appExportName: "app",
1073
+ entryFiles: [],
666
1074
  isESM: false,
667
1075
  routePrefix: "/api",
668
1076
  routes: [],
669
1077
  dbTables: [],
670
1078
  tools: [],
1079
+ customTools: [],
671
1080
  databaseEnabled: true,
672
1081
  enrichmentEnabled: true,
673
1082
  includeWrites: false,
@@ -681,6 +1090,7 @@ async function runWizard(specifiedFile, force) {
681
1090
  await stepScanRoutes(state);
682
1091
  await stepScanDatabase(state, prompt);
683
1092
  await stepEnrichAndReview(state, prompt);
1093
+ await stepCustomTools(state, prompt);
684
1094
  await stepGenerate(state);
685
1095
  }
686
1096
  catch (err) {