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