@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/cjs/cli.js CHANGED
@@ -104,16 +104,46 @@ function findProjectRoot() {
104
104
  }
105
105
  return process.cwd();
106
106
  }
107
+ function scoreExpressFile(filePath) {
108
+ const content = fs_1.default.readFileSync(filePath, "utf-8");
109
+ const hasExpressImport = EXPRESS_PATTERNS.some((p) => p.test(content));
110
+ if (!hasExpressImport)
111
+ return null;
112
+ let score = 0;
113
+ const hasAppCreation = APP_VAR_PATTERN.test(content);
114
+ const hasListen = LISTEN_PATTERN.test(content);
115
+ const hasRoutes = /\.\s*(?:get|post|put|patch|delete)\s*\(/.test(content);
116
+ const hasAppParam = /function\s+\w+\s*\(\s*app\b/.test(content) ||
117
+ /\(\s*app\s*:\s*\w*Express/.test(content) ||
118
+ /\(\s*app\s*:\s*\w*Application/.test(content) ||
119
+ /=>\s*\{[^}]*app\s*\./.test(content);
120
+ const isNamedEntryish = /(?:index|app|server|main)\.(ts|js|mjs)$/.test(filePath);
121
+ if (hasAppCreation)
122
+ score += 10;
123
+ if (hasListen)
124
+ score += 8;
125
+ if (isNamedEntryish)
126
+ score += 3;
127
+ if (hasRoutes)
128
+ score += 2;
129
+ if (!hasAppCreation && hasAppParam)
130
+ score -= 5;
131
+ if (score <= 0)
132
+ return null;
133
+ return { path: filePath, score, hasAppCreation, hasListen, hasRoutes };
134
+ }
107
135
  function findExpressEntryFile(root) {
136
+ const candidates = [];
108
137
  for (const file of COMMON_ENTRY_FILES) {
109
138
  const fullPath = path_1.default.join(root, file);
110
139
  if (fs_1.default.existsSync(fullPath)) {
111
- const content = fs_1.default.readFileSync(fullPath, "utf-8");
112
- if (EXPRESS_PATTERNS.some((p) => p.test(content)))
113
- return fullPath;
140
+ const candidate = scoreExpressFile(fullPath);
141
+ if (candidate)
142
+ candidates.push(candidate);
114
143
  }
115
144
  }
116
145
  const srcDirs = ["server", "src", "."];
146
+ const seen = new Set(candidates.map((c) => c.path));
117
147
  for (const dir of srcDirs) {
118
148
  const dirPath = path_1.default.join(root, dir);
119
149
  if (!fs_1.default.existsSync(dirPath) || !fs_1.default.statSync(dirPath).isDirectory())
@@ -121,12 +151,18 @@ function findExpressEntryFile(root) {
121
151
  const files = fs_1.default.readdirSync(dirPath).filter((f) => /\.(ts|js|mjs)$/.test(f));
122
152
  for (const file of files) {
123
153
  const fullPath = path_1.default.join(dirPath, file);
124
- const content = fs_1.default.readFileSync(fullPath, "utf-8");
125
- if (EXPRESS_PATTERNS.some((p) => p.test(content)))
126
- return fullPath;
154
+ if (seen.has(fullPath))
155
+ continue;
156
+ seen.add(fullPath);
157
+ const candidate = scoreExpressFile(fullPath);
158
+ if (candidate)
159
+ candidates.push(candidate);
127
160
  }
128
161
  }
129
- return null;
162
+ if (candidates.length === 0)
163
+ return null;
164
+ candidates.sort((a, b) => b.score - a.score);
165
+ return candidates[0].path;
130
166
  }
131
167
  function checkIsESM(filePath, root) {
132
168
  if (filePath.endsWith(".ts") || filePath.endsWith(".mjs"))
@@ -139,8 +175,29 @@ function checkIsESM(filePath, root) {
139
175
  return false;
140
176
  }
141
177
  }
142
- function alreadySetup(content) {
143
- 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;
144
201
  }
145
202
  function checkPgInstalled(root) {
146
203
  return fs_1.default.existsSync(path_1.default.join(root, "node_modules", "pg"));
@@ -207,13 +264,81 @@ function stripExistingMcpConfig(content) {
207
264
  result = result.replace(/\n{3,}/g, "\n\n");
208
265
  return result;
209
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
+ }
210
319
  async function stepDetectApp(state, prompt, specifiedFile, force) {
211
- printStep(1, 5, "Detecting Express app");
212
- 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;
213
338
  if (specifiedFile) {
214
339
  const resolved = path_1.default.resolve(state.root, specifiedFile);
215
340
  if (fs_1.default.existsSync(resolved)) {
216
- entryFile = resolved;
341
+ appFile = resolved;
217
342
  }
218
343
  else {
219
344
  console.log(` ${c("yellow", "!")} File not found: ${specifiedFile}`);
@@ -221,15 +346,45 @@ async function stepDetectApp(state, prompt, specifiedFile, force) {
221
346
  }
222
347
  }
223
348
  else {
224
- entryFile = findExpressEntryFile(state.root);
349
+ appFile = findExpressEntryFile(state.root);
350
+ }
351
+ if (appFile && !specifiedFile) {
352
+ const relPath = path_1.default.relative(state.root, appFile);
353
+ const content = fs_1.default.readFileSync(appFile, "utf-8");
354
+ const hasCreation = APP_VAR_PATTERN.test(content);
355
+ const hasListen = LISTEN_PATTERN.test(content);
356
+ const signals = [];
357
+ if (hasCreation)
358
+ signals.push("creates Express app");
359
+ if (hasListen)
360
+ signals.push("calls .listen()");
361
+ const signalStr = signals.length > 0 ? ` ${c("dim", `(${signals.join(", ")})`)}` : "";
362
+ console.log(` ${c("green", "+")} Found Express app: ${c("bold", relPath)}${signalStr}`);
363
+ const useIt = await prompt.confirm(` Is this the right file?`);
364
+ if (!useIt) {
365
+ const custom = await prompt.ask(` Enter the path to your Express app file: `);
366
+ if (custom) {
367
+ const resolved = path_1.default.resolve(state.root, custom);
368
+ if (fs_1.default.existsSync(resolved)) {
369
+ appFile = resolved;
370
+ }
371
+ else {
372
+ console.log(` ${c("yellow", "!")} File not found: ${custom}`);
373
+ return false;
374
+ }
375
+ }
376
+ else {
377
+ return false;
378
+ }
379
+ }
225
380
  }
226
- if (!entryFile) {
381
+ if (!appFile) {
227
382
  console.log(` ${c("yellow", "!")} Could not find an Express app file.`);
228
383
  const custom = await prompt.ask(` Enter the path to your Express app file: `);
229
384
  if (custom) {
230
385
  const resolved = path_1.default.resolve(state.root, custom);
231
386
  if (fs_1.default.existsSync(resolved)) {
232
- entryFile = resolved;
387
+ appFile = resolved;
233
388
  }
234
389
  else {
235
390
  console.log(` ${c("yellow", "!")} File not found: ${custom}`);
@@ -240,32 +395,23 @@ async function stepDetectApp(state, prompt, specifiedFile, force) {
240
395
  return false;
241
396
  }
242
397
  }
243
- let content = fs_1.default.readFileSync(entryFile, "utf-8");
244
- if (alreadySetup(content)) {
245
- if (force) {
246
- console.log(` ${c("yellow", "~")} Found existing MCP config in ${path_1.default.relative(state.root, entryFile)}`);
247
- console.log(` ${c("dim", " Removing old configuration for re-scan...")}`);
248
- content = stripExistingMcpConfig(content);
249
- fs_1.default.writeFileSync(entryFile, content);
250
- console.log(` ${c("green", "+")} Old config removed. Starting fresh.`);
251
- }
252
- else {
253
- console.log(` ${c("green", "+")} MCP SDK is already set up in ${path_1.default.relative(state.root, entryFile)}`);
254
- console.log(` ${c("dim", " Run with")} ${c("cyan", "--force")} ${c("dim", "to re-scan and reconfigure.")}`);
255
- return false;
256
- }
257
- }
398
+ const content = fs_1.default.readFileSync(appFile, "utf-8");
258
399
  const appMatch = content.match(APP_VAR_PATTERN);
259
- state.entryFile = entryFile;
400
+ state.appFile = appFile;
260
401
  state.appVarName = appMatch ? appMatch[1] : "app";
261
- state.isESM = checkIsESM(entryFile, state.root);
262
- console.log(` ${c("green", "+")} Found Express app: ${c("bold", path_1.default.relative(state.root, entryFile))}`);
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;
263
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
+ }
264
410
  return true;
265
411
  }
266
412
  async function stepScanRoutes(state) {
267
- printStep(2, 5, "Scanning API routes");
268
- 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);
269
415
  if (state.routes.length === 0) {
270
416
  console.log(` ${c("dim", " No routes found under")} ${state.routePrefix}`);
271
417
  console.log(` ${c("dim", " Routes will be discovered at runtime when your app starts.")}`);
@@ -290,7 +436,7 @@ async function stepScanRoutes(state) {
290
436
  }
291
437
  }
292
438
  async function stepScanDatabase(state, prompt) {
293
- printStep(3, 5, "Database discovery");
439
+ printStep(3, 6, "Database discovery");
294
440
  const wantDb = await prompt.confirm(` Scan your database for tables to expose as tools?`);
295
441
  if (!wantDb) {
296
442
  state.databaseEnabled = false;
@@ -378,7 +524,7 @@ async function stepScanDatabase(state, prompt) {
378
524
  }
379
525
  }
380
526
  async function stepEnrichAndReview(state, prompt) {
381
- printStep(4, 5, "AI enrichment & tool review");
527
+ printStep(4, 6, "AI enrichment & tool review");
382
528
  if (state.tools.length === 0) {
383
529
  console.log(` ${c("dim", " No tools discovered. Routes will be discovered at runtime.")}`);
384
530
  state.enrichmentEnabled = true;
@@ -442,10 +588,239 @@ async function stepEnrichAndReview(state, prompt) {
442
588
  state.includeWrites = wantWrites;
443
589
  }
444
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
+ }
445
822
  async function stepGenerate(state) {
446
- printStep(5, 5, "Generating MCP server");
447
- const content = fs_1.default.readFileSync(state.entryFile, "utf-8");
448
- const importLine = state.isESM ? IMPORT_LINE_ESM : IMPORT_LINE_CJS;
823
+ printStep(6, 6, "Generating MCP server");
449
824
  const disabledRoutePaths = state.tools
450
825
  .filter((t) => t.source === "route" && !t.enabled)
451
826
  .map((t) => t.path);
@@ -463,7 +838,7 @@ async function stepGenerate(state) {
463
838
  disabledTables.add(tableName);
464
839
  }
465
840
  const configParts = [];
466
- configParts.push(`app: ${state.appVarName}`);
841
+ configParts.push(`app`);
467
842
  if (disabledRoutes.length > 0) {
468
843
  configParts.push(`excludeRoutes: ${JSON.stringify(disabledRoutes)}`);
469
844
  }
@@ -481,38 +856,127 @@ async function stepGenerate(state) {
481
856
  if (!state.enrichmentEnabled) {
482
857
  configParts.push(`enrichment: false`);
483
858
  }
484
- 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;
485
908
  if (configParts.length <= 2) {
486
- setupCode = `createMcpServer({ ${configParts.join(", ")} });`;
909
+ configStr = `createMcpServer({ ${configParts.join(", ")} });`;
487
910
  }
488
911
  else {
489
912
  const indent = " ";
490
- setupCode = `createMcpServer({\n${configParts.map((p) => `${indent}${p},`).join("\n")}\n});`;
913
+ configStr = `createMcpServer({\n${configParts.map((p) => `${indent}${p},`).join("\n")}\n});`;
491
914
  }
492
- const lines = content.split("\n");
493
- let lastImportIndex = -1;
494
- for (let i = 0; i < lines.length; i++) {
495
- if (/^import\s/.test(lines[i]) || /^const\s.*=\s*require/.test(lines[i])) {
496
- lastImportIndex = i;
497
- }
915
+ fileLines.push(configStr);
916
+ fileLines.push(``);
917
+ if (hasCustomTools) {
918
+ const customToolsContent = generateCustomToolsFile(state);
919
+ fs_1.default.writeFileSync(customToolsPath, customToolsContent);
498
920
  }
499
- lines.splice(lastImportIndex + 1, 0, importLine);
500
- let listenIndex = -1;
501
- for (let i = lines.length - 1; i >= 0; i--) {
502
- if (LISTEN_PATTERN.test(lines[i])) {
503
- listenIndex = i;
504
- 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;
505
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));
506
958
  }
507
- const commentLine = `// MCP server - exposes your API to AI assistants (Claude, ChatGPT, Cursor)`;
508
- if (listenIndex >= 0) {
509
- lines.splice(listenIndex, 0, "", commentLine, setupCode);
510
- }
511
- else {
512
- 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;
513
979
  }
514
- fs_1.default.writeFileSync(state.entryFile, lines.join("\n"));
515
- const relPath = path_1.default.relative(state.root, state.entryFile);
516
980
  const enabledCount = state.tools.filter((t) => t.enabled).length;
517
981
  console.log();
518
982
  console.log(c("green", ` +------------------------------+`));
@@ -521,10 +985,17 @@ async function stepGenerate(state) {
521
985
  console.log(c("green", ` | |`));
522
986
  console.log(c("green", ` +------------------------------+`));
523
987
  console.log();
524
- 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
+ }
525
995
  console.log();
526
996
  console.log(` ${c("dim", "Configuration")}`);
527
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")}`);
528
999
  console.log(` ${c("dim", "\u2502")} Database ${state.databaseEnabled ? c("green", "enabled") : c("dim", "disabled")}`);
529
1000
  console.log(` ${c("dim", "\u2502")} AI enrichment ${state.enrichmentEnabled ? c("green", "enabled") : c("dim", "disabled")}`);
530
1001
  console.log(` ${c("dim", "\u2502")} Write operations ${state.includeWrites ? c("green", "enabled") : c("dim", "disabled")}`);
@@ -596,13 +1067,16 @@ async function runWizard(specifiedFile, force) {
596
1067
  const prompt = createPrompt();
597
1068
  const state = {
598
1069
  root: findProjectRoot(),
599
- entryFile: "",
1070
+ appFile: "",
600
1071
  appVarName: "app",
1072
+ appExportName: "app",
1073
+ entryFiles: [],
601
1074
  isESM: false,
602
1075
  routePrefix: "/api",
603
1076
  routes: [],
604
1077
  dbTables: [],
605
1078
  tools: [],
1079
+ customTools: [],
606
1080
  databaseEnabled: true,
607
1081
  enrichmentEnabled: true,
608
1082
  includeWrites: false,
@@ -616,6 +1090,7 @@ async function runWizard(specifiedFile, force) {
616
1090
  await stepScanRoutes(state);
617
1091
  await stepScanDatabase(state, prompt);
618
1092
  await stepEnrichAndReview(state, prompt);
1093
+ await stepCustomTools(state, prompt);
619
1094
  await stepGenerate(state);
620
1095
  }
621
1096
  catch (err) {