webspresso 0.0.91 → 0.0.94
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/README.md +103 -2864
- package/bin/commands/admin-password.js +8 -2
- package/bin/commands/api.js +7 -1
- package/bin/commands/favicon-generate.js +6 -4
- package/bin/commands/mcp.js +162 -0
- package/bin/commands/new.js +7 -1
- package/bin/commands/page.js +7 -1
- package/bin/commands/polar-migrate.js +50 -0
- package/bin/commands/seed.js +7 -1
- package/bin/commands/service.js +8 -2
- package/bin/commands/skill.js +7 -1
- package/bin/webspresso.js +4 -0
- package/core/auth/tokens.js +16 -0
- package/core/compileSchema.js +1 -5
- package/core/compression/compressible.js +17 -5
- package/core/compression/compression-stream.js +1 -1
- package/core/compression/middleware.js +9 -0
- package/core/compression/negotiate.js +23 -11
- package/core/kernel/app.js +2 -1
- package/core/kernel/index.js +5 -1
- package/core/orm/cache/fingerprint.js +23 -5
- package/core/orm/cache/layer.js +1 -1
- package/core/orm/eager-loader.js +54 -9
- package/core/orm/index.js +42 -3
- package/core/orm/model.js +11 -2
- package/core/orm/transaction.js +3 -1
- package/core/queue/adapters/database.js +46 -13
- package/core/queue/adapters/memory.js +21 -0
- package/core/queue/adapters/redis.js +21 -0
- package/core/queue/manager.js +46 -20
- package/core/ssr/stream.js +58 -14
- package/core/validation/index.js +70 -0
- package/index.d.ts +99 -5
- package/index.js +7 -1
- package/package.json +9 -1
- package/plugins/admin-panel/auth.js +10 -0
- package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +6 -1
- package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +18 -8
- package/plugins/admin-panel/client/parts/07-model-list.js +9 -4
- package/plugins/admin-panel/client/parts/08-record-list.js +20 -2
- package/plugins/admin-panel/index.js +65 -6
- package/plugins/admin-panel/modules/bulk-actions.js +9 -2
- package/plugins/admin-panel/modules/dashboard.js +8 -1
- package/plugins/content/index.js +5 -3
- package/plugins/data-exchange/export-xlsx.js +28 -6
- package/plugins/data-exchange/import.js +120 -58
- package/plugins/data-exchange/index.js +5 -0
- package/plugins/data-exchange/parse-table.js +14 -5
- package/plugins/data-exchange/record-selection.js +2 -0
- package/plugins/index.js +6 -0
- package/plugins/mcp/auth.js +104 -0
- package/plugins/mcp/discovery.js +88 -0
- package/plugins/mcp/index.js +208 -0
- package/plugins/mcp/prompts.js +149 -0
- package/plugins/mcp/providers/orm.js +325 -0
- package/plugins/mcp/providers/services.js +190 -0
- package/plugins/mcp/providers/system.js +87 -0
- package/plugins/mcp/server.js +472 -0
- package/plugins/mcp/transports/sse.js +156 -0
- package/plugins/mcp/transports/stdio.js +108 -0
- package/plugins/polar/.env.example +12 -0
- package/plugins/polar/CHANGELOG.md +38 -0
- package/plugins/polar/README.md +158 -0
- package/plugins/polar/index.js +144 -0
- package/plugins/polar/src/api.js +54 -0
- package/plugins/polar/src/checkout.js +111 -0
- package/plugins/polar/src/config.js +88 -0
- package/plugins/polar/src/env.schema.js +36 -0
- package/plugins/polar/src/middleware.js +37 -0
- package/plugins/polar/src/migration.js +58 -0
- package/plugins/polar/src/nunjucks-filters.js +56 -0
- package/plugins/polar/src/portal.js +51 -0
- package/plugins/polar/src/rate-limit.js +70 -0
- package/plugins/polar/src/routes.js +212 -0
- package/plugins/polar/src/sync.js +138 -0
- package/plugins/polar/src/urls.js +117 -0
- package/plugins/polar/src/user-resolver.js +195 -0
- package/plugins/polar/src/webhooks.js +248 -0
- package/src/file-router.js +203 -125
- package/src/helpers.js +60 -13
- package/src/njk-frontmatter.js +24 -14
- package/src/server.js +30 -12
- package/src/services/discovery.js +2 -1
- package/src/services/registry.js +2 -0
- package/src/services/validator.js +2 -53
- package/templates/skills/webspresso-usage/REFERENCE-framework.md +7 -4
|
@@ -10,13 +10,18 @@ const { buildHeaderMapping, dataRowsToObjects } = require('./parse-table');
|
|
|
10
10
|
|
|
11
11
|
function allowedImportColumns(model) {
|
|
12
12
|
const hidden = new Set(model.hidden || []);
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
const cols = new Set();
|
|
14
|
+
if (model.columns && typeof model.columns.keys === 'function') {
|
|
15
|
+
for (const c of model.columns.keys()) {
|
|
16
|
+
if (!hidden.has(c)) cols.add(c);
|
|
17
|
+
}
|
|
15
18
|
}
|
|
16
19
|
if (model.schema && model.schema.shape) {
|
|
17
|
-
|
|
20
|
+
for (const c of Object.keys(model.schema.shape)) {
|
|
21
|
+
if (!hidden.has(c)) cols.add(c);
|
|
22
|
+
}
|
|
18
23
|
}
|
|
19
|
-
return
|
|
24
|
+
return Array.from(cols);
|
|
20
25
|
}
|
|
21
26
|
|
|
22
27
|
/**
|
|
@@ -35,12 +40,12 @@ function coerceCell(raw, meta, column) {
|
|
|
35
40
|
}
|
|
36
41
|
|
|
37
42
|
if (!meta || !meta.type) {
|
|
38
|
-
if (raw === 'true' || raw === '1' || raw === 'yes') return true;
|
|
39
|
-
if (raw === 'false' || raw === '0' || raw === 'no') return false;
|
|
40
43
|
if (typeof raw === 'string' && /^-?\d+(\.\d+)?$/.test(raw)) {
|
|
41
44
|
const num = Number(raw);
|
|
42
45
|
if (!Number.isNaN(num)) return num;
|
|
43
46
|
}
|
|
47
|
+
if (raw === 'true' || raw === 'yes') return true;
|
|
48
|
+
if (raw === 'false' || raw === 'no') return false;
|
|
44
49
|
return raw;
|
|
45
50
|
}
|
|
46
51
|
|
|
@@ -66,6 +71,14 @@ function coerceCell(raw, meta, column) {
|
|
|
66
71
|
if (s === 'false' || s === '0' || s === 'no') return false;
|
|
67
72
|
throw new Error(`Invalid boolean for ${column}`);
|
|
68
73
|
}
|
|
74
|
+
case 'date':
|
|
75
|
+
case 'datetime':
|
|
76
|
+
case 'timestamp': {
|
|
77
|
+
if (raw instanceof Date) return raw;
|
|
78
|
+
const d = new Date(raw);
|
|
79
|
+
if (Number.isNaN(d.getTime())) throw new Error(`Invalid date for ${column}`);
|
|
80
|
+
return raw;
|
|
81
|
+
}
|
|
69
82
|
case 'json': {
|
|
70
83
|
if (typeof raw === 'object') return raw;
|
|
71
84
|
try {
|
|
@@ -84,7 +97,7 @@ function buildPayloadForRow(model, data, mode) {
|
|
|
84
97
|
const payload = {};
|
|
85
98
|
for (const [col, raw] of Object.entries(data)) {
|
|
86
99
|
if (!allowed.has(col)) continue;
|
|
87
|
-
const meta = model.columns.get(col);
|
|
100
|
+
const meta = model.columns?.get ? model.columns.get(col) : undefined;
|
|
88
101
|
let v;
|
|
89
102
|
try {
|
|
90
103
|
v = coerceCell(raw, meta, col);
|
|
@@ -95,14 +108,14 @@ function buildPayloadForRow(model, data, mode) {
|
|
|
95
108
|
payload[col] = v;
|
|
96
109
|
}
|
|
97
110
|
|
|
98
|
-
const pk = model.primaryKey;
|
|
99
|
-
const pkMeta = model.columns.get(pk);
|
|
100
|
-
if (
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
(payload[pk] === undefined || payload[pk] === null || payload[pk] === '')
|
|
104
|
-
|
|
105
|
-
|
|
111
|
+
const pk = model.primaryKey || 'id';
|
|
112
|
+
const pkMeta = model.columns?.get ? model.columns.get(pk) : undefined;
|
|
113
|
+
if (mode === 'insert') {
|
|
114
|
+
// In insert mode, remove auto-increment / auto-generated primary key
|
|
115
|
+
// so new records are created without unique constraint collision
|
|
116
|
+
if (pkMeta?.autoIncrement || pkMeta?.auto === 'create' || payload[pk] === undefined || payload[pk] === null || payload[pk] === '') {
|
|
117
|
+
delete payload[pk];
|
|
118
|
+
}
|
|
106
119
|
}
|
|
107
120
|
|
|
108
121
|
return payload;
|
|
@@ -139,15 +152,26 @@ async function parseXlsxToRows(buffer) {
|
|
|
139
152
|
}
|
|
140
153
|
|
|
141
154
|
function cellValueToPlain(cell) {
|
|
155
|
+
if (!cell) return '';
|
|
142
156
|
const v = cell.value;
|
|
143
157
|
if (v === null || v === undefined) return '';
|
|
144
158
|
if (v instanceof Date) return v.toISOString();
|
|
145
|
-
if (typeof v === 'object'
|
|
159
|
+
if (typeof v === 'object') {
|
|
160
|
+
if (v.error) return '';
|
|
146
161
|
if ('text' in v && typeof v.text === 'string') return v.text;
|
|
147
162
|
if ('richText' in v && Array.isArray(v.richText)) {
|
|
148
|
-
return v.richText.map((t) => t.text
|
|
163
|
+
return v.richText.map((t) => (t && t.text) ? t.text : '').join('');
|
|
164
|
+
}
|
|
165
|
+
if ('result' in v && v.result !== undefined) {
|
|
166
|
+
return cellValueToPlain({ value: v.result });
|
|
167
|
+
}
|
|
168
|
+
if ('hyperlink' in v) {
|
|
169
|
+
return v.text || v.hyperlink || '';
|
|
149
170
|
}
|
|
150
|
-
if ('
|
|
171
|
+
if (typeof v.toString === 'function' && v.toString !== Object.prototype.toString) {
|
|
172
|
+
return v.toString();
|
|
173
|
+
}
|
|
174
|
+
return '';
|
|
151
175
|
}
|
|
152
176
|
return v;
|
|
153
177
|
}
|
|
@@ -191,7 +215,8 @@ function createImportHandler(opts) {
|
|
|
191
215
|
let upsertKey = req.query.upsertKey || req.body?.upsertKey || model.primaryKey;
|
|
192
216
|
upsertKey = String(upsertKey);
|
|
193
217
|
|
|
194
|
-
|
|
218
|
+
const allowedCols = allowedImportColumns(model);
|
|
219
|
+
if (!allowedCols.includes(upsertKey)) {
|
|
195
220
|
return res.status(400).json({ error: `upsertKey "${upsertKey}" is not a column` });
|
|
196
221
|
}
|
|
197
222
|
if (model.hidden?.includes(upsertKey)) {
|
|
@@ -226,6 +251,13 @@ function createImportHandler(opts) {
|
|
|
226
251
|
return res.status(400).json({ error: `Too many data rows (${objects.length}). Limit is ${maxRows}.` });
|
|
227
252
|
}
|
|
228
253
|
|
|
254
|
+
const isAtomic =
|
|
255
|
+
req.query.atomic === 'true' ||
|
|
256
|
+
req.query.atomic === '1' ||
|
|
257
|
+
req.body?.atomic === 'true' ||
|
|
258
|
+
req.body?.atomic === true ||
|
|
259
|
+
opts?.atomic === true;
|
|
260
|
+
|
|
229
261
|
const repo = db.getRepository(model.name);
|
|
230
262
|
const summary = {
|
|
231
263
|
success: true,
|
|
@@ -235,52 +267,82 @@ function createImportHandler(opts) {
|
|
|
235
267
|
errors: [],
|
|
236
268
|
};
|
|
237
269
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
270
|
+
const runRowProcessing = async (targetRepo) => {
|
|
271
|
+
for (const { rowNumber, data } of objects) {
|
|
272
|
+
try {
|
|
273
|
+
const payload = buildPayloadForRow(model, data, mode);
|
|
274
|
+
if (Object.keys(payload).length === 0) {
|
|
275
|
+
summary.failed++;
|
|
276
|
+
const errMsg = 'No mappable columns';
|
|
277
|
+
summary.errors.push({ row: rowNumber, message: errMsg });
|
|
278
|
+
if (isAtomic) {
|
|
279
|
+
throw new Error(`Row ${rowNumber}: ${errMsg}`);
|
|
280
|
+
}
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
246
283
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
284
|
+
if (mode === 'insert') {
|
|
285
|
+
await targetRepo.create(payload);
|
|
286
|
+
summary.created++;
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
252
289
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
290
|
+
const keyMeta = model.columns?.get ? model.columns.get(upsertKey) : undefined;
|
|
291
|
+
let keyVal;
|
|
292
|
+
try {
|
|
293
|
+
keyVal = coerceCell(data[upsertKey], keyMeta, upsertKey);
|
|
294
|
+
} catch (e) {
|
|
295
|
+
summary.failed++;
|
|
296
|
+
summary.errors.push({ row: rowNumber, message: e.message || 'Bad upsert key' });
|
|
297
|
+
if (isAtomic) throw e;
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (keyVal === undefined || keyVal === null) {
|
|
302
|
+
const insertPayload = { ...payload };
|
|
303
|
+
delete insertPayload[upsertKey];
|
|
304
|
+
await targetRepo.create(insertPayload);
|
|
305
|
+
summary.created++;
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const existing = await targetRepo.findOne({ [upsertKey]: keyVal });
|
|
310
|
+
if (existing) {
|
|
311
|
+
const id = existing[model.primaryKey];
|
|
312
|
+
await targetRepo.update(id, payload);
|
|
313
|
+
summary.updated++;
|
|
314
|
+
} else {
|
|
315
|
+
await targetRepo.create({ ...payload, [upsertKey]: keyVal });
|
|
316
|
+
summary.created++;
|
|
317
|
+
}
|
|
257
318
|
} catch (e) {
|
|
258
319
|
summary.failed++;
|
|
259
|
-
summary.errors.push({ row: rowNumber, message: e.message ||
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
if (keyVal === undefined || keyVal === null) {
|
|
264
|
-
const insertPayload = { ...payload };
|
|
265
|
-
delete insertPayload[upsertKey];
|
|
266
|
-
await repo.create(insertPayload);
|
|
267
|
-
summary.created++;
|
|
268
|
-
continue;
|
|
320
|
+
summary.errors.push({ row: rowNumber, message: e.message || String(e) });
|
|
321
|
+
if (isAtomic) {
|
|
322
|
+
throw e;
|
|
323
|
+
}
|
|
269
324
|
}
|
|
325
|
+
}
|
|
326
|
+
};
|
|
270
327
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
328
|
+
if (isAtomic && typeof db.transaction === 'function') {
|
|
329
|
+
try {
|
|
330
|
+
await db.transaction(async (trxContext) => {
|
|
331
|
+
const trxRepo = trxContext.getRepository ? trxContext.getRepository(model.name) : repo;
|
|
332
|
+
await runRowProcessing(trxRepo);
|
|
333
|
+
});
|
|
334
|
+
} catch (txErr) {
|
|
335
|
+
return res.status(400).json({
|
|
336
|
+
success: false,
|
|
337
|
+
created: 0,
|
|
338
|
+
updated: 0,
|
|
339
|
+
failed: objects.length,
|
|
340
|
+
error: `Atomic import rolled back: ${txErr.message}`,
|
|
341
|
+
errors: summary.errors,
|
|
342
|
+
});
|
|
283
343
|
}
|
|
344
|
+
} else {
|
|
345
|
+
await runRowProcessing(repo);
|
|
284
346
|
}
|
|
285
347
|
|
|
286
348
|
summary.success = summary.failed === 0;
|
|
@@ -49,6 +49,7 @@ function dataExchangePlugin(options = {}) {
|
|
|
49
49
|
|
|
50
50
|
onRoutesReady(ctx) {
|
|
51
51
|
if (!enabled) return;
|
|
52
|
+
if (ctx.app && ctx.app._dataExchangeRoutesRegistered) return;
|
|
52
53
|
|
|
53
54
|
const db = dbOption ?? ctx.db;
|
|
54
55
|
if (!db) {
|
|
@@ -87,6 +88,10 @@ function dataExchangePlugin(options = {}) {
|
|
|
87
88
|
},
|
|
88
89
|
});
|
|
89
90
|
}
|
|
91
|
+
|
|
92
|
+
if (ctx.app) {
|
|
93
|
+
ctx.app._dataExchangeRoutesRegistered = true;
|
|
94
|
+
}
|
|
90
95
|
},
|
|
91
96
|
};
|
|
92
97
|
}
|
|
@@ -15,18 +15,27 @@ function normalizeHeaderCell(h) {
|
|
|
15
15
|
* @returns {(string|null)[]} index → model column or null if unknown
|
|
16
16
|
*/
|
|
17
17
|
function buildHeaderMapping(headerRow, allowedKeys) {
|
|
18
|
-
const
|
|
18
|
+
const exactLowerToKey = new Map();
|
|
19
|
+
const strippedToKey = new Map();
|
|
19
20
|
for (const k of allowedKeys) {
|
|
20
|
-
|
|
21
|
+
const kStr = String(k);
|
|
22
|
+
exactLowerToKey.set(kStr.toLowerCase().replace(/\s+/g, '_'), k);
|
|
23
|
+
strippedToKey.set(kStr.toLowerCase().replace(/[_\s-]+/g, ''), k);
|
|
21
24
|
}
|
|
22
25
|
const mapping = [];
|
|
23
26
|
for (let i = 0; i < headerRow.length; i++) {
|
|
24
|
-
const
|
|
25
|
-
if (!
|
|
27
|
+
const raw = normalizeHeaderCell(headerRow[i]);
|
|
28
|
+
if (!raw) {
|
|
26
29
|
mapping.push(null);
|
|
27
30
|
continue;
|
|
28
31
|
}
|
|
29
|
-
|
|
32
|
+
const norm = raw.toLowerCase().replace(/\s+/g, '_');
|
|
33
|
+
let matched = exactLowerToKey.get(norm);
|
|
34
|
+
if (!matched) {
|
|
35
|
+
const stripped = raw.toLowerCase().replace(/[_\s-]+/g, '');
|
|
36
|
+
matched = strippedToKey.get(stripped);
|
|
37
|
+
}
|
|
38
|
+
mapping.push(matched ?? null);
|
|
30
39
|
}
|
|
31
40
|
return mapping;
|
|
32
41
|
}
|
package/plugins/index.js
CHANGED
|
@@ -39,6 +39,8 @@ const {
|
|
|
39
39
|
redis,
|
|
40
40
|
createRedisAdapter,
|
|
41
41
|
} = require('./realtime');
|
|
42
|
+
const { mcpPlugin, createMcpServer, McpServer } = require('./mcp');
|
|
43
|
+
const polarPlugin = require('./polar');
|
|
42
44
|
|
|
43
45
|
module.exports = {
|
|
44
46
|
sitemapPlugin,
|
|
@@ -77,6 +79,10 @@ module.exports = {
|
|
|
77
79
|
redis,
|
|
78
80
|
createRedisAdapter,
|
|
79
81
|
queuePlugin: require('./queue'),
|
|
82
|
+
mcpPlugin,
|
|
83
|
+
createMcpServer,
|
|
84
|
+
McpServer,
|
|
85
|
+
polarPlugin,
|
|
80
86
|
};
|
|
81
87
|
|
|
82
88
|
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webspresso MCP Authentication & Security
|
|
3
|
+
* Provides token validation and localhost IP restrictions for HTTP/SSE transports
|
|
4
|
+
* @module plugins/mcp/auth
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const crypto = require('crypto');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Constant-time string comparison to avoid timing attacks
|
|
11
|
+
* @param {string} a
|
|
12
|
+
* @param {string} b
|
|
13
|
+
* @returns {boolean}
|
|
14
|
+
*/
|
|
15
|
+
function safeEqual(a, b) {
|
|
16
|
+
if (typeof a !== 'string' || typeof b !== 'string') return false;
|
|
17
|
+
const bufA = Buffer.from(a);
|
|
18
|
+
const bufB = Buffer.from(b);
|
|
19
|
+
if (bufA.length !== bufB.length) return false;
|
|
20
|
+
return crypto.timingSafeEqual(bufA, bufB);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Check if incoming request is from localhost
|
|
25
|
+
* @param {import('express').Request} req
|
|
26
|
+
* @returns {boolean}
|
|
27
|
+
*/
|
|
28
|
+
function isLocalhost(req) {
|
|
29
|
+
const ip = req.ip || req.connection?.remoteAddress || '';
|
|
30
|
+
return (
|
|
31
|
+
ip === '127.0.0.1' ||
|
|
32
|
+
ip === '::1' ||
|
|
33
|
+
ip === '::ffff:127.0.0.1' ||
|
|
34
|
+
ip.endsWith('127.0.0.1') ||
|
|
35
|
+
req.hostname === 'localhost'
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Creates authentication middleware for MCP HTTP/SSE endpoints
|
|
41
|
+
* @param {Object} [options={}]
|
|
42
|
+
* @param {string} [options.token] - Pre-shared secret token
|
|
43
|
+
* @param {boolean} [options.localhostOnly=false] - Only allow localhost connections
|
|
44
|
+
* @param {function(import('express').Request): (boolean|Promise<boolean>)} [options.verify] - Custom verify function
|
|
45
|
+
* @returns {import('express').RequestHandler}
|
|
46
|
+
*/
|
|
47
|
+
function createMcpAuthMiddleware(options = {}) {
|
|
48
|
+
const { token, localhostOnly = false, verify } = options;
|
|
49
|
+
|
|
50
|
+
return async function mcpAuthMiddleware(req, res, next) {
|
|
51
|
+
// 1. Localhost check
|
|
52
|
+
if (localhostOnly && !isLocalhost(req)) {
|
|
53
|
+
return res.status(403).json({
|
|
54
|
+
jsonrpc: '2.0',
|
|
55
|
+
error: { code: -32000, message: 'Forbidden: MCP server only allows localhost connections' },
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// 2. Custom verification
|
|
60
|
+
if (typeof verify === 'function') {
|
|
61
|
+
try {
|
|
62
|
+
const ok = await verify(req);
|
|
63
|
+
if (!ok) {
|
|
64
|
+
return res.status(401).json({
|
|
65
|
+
jsonrpc: '2.0',
|
|
66
|
+
error: { code: -32000, message: 'Unauthorized: Custom MCP verification failed' },
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
return next();
|
|
70
|
+
} catch (err) {
|
|
71
|
+
return res.status(401).json({
|
|
72
|
+
jsonrpc: '2.0',
|
|
73
|
+
error: { code: -32000, message: `Unauthorized: ${err.message}` },
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// 3. Pre-shared token check
|
|
79
|
+
if (token) {
|
|
80
|
+
let clientToken = '';
|
|
81
|
+
const authHeader = req.headers.authorization;
|
|
82
|
+
if (authHeader && authHeader.startsWith('Bearer ')) {
|
|
83
|
+
clientToken = authHeader.slice('Bearer '.length).trim();
|
|
84
|
+
} else if (req.query && req.query.token) {
|
|
85
|
+
clientToken = String(req.query.token).trim();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (!clientToken || !safeEqual(clientToken, token)) {
|
|
89
|
+
return res.status(401).json({
|
|
90
|
+
jsonrpc: '2.0',
|
|
91
|
+
error: { code: -32000, message: 'Unauthorized: Invalid or missing MCP bearer token' },
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
next();
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
module.exports = {
|
|
101
|
+
createMcpAuthMiddleware,
|
|
102
|
+
isLocalhost,
|
|
103
|
+
safeEqual,
|
|
104
|
+
};
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webspresso MCP File-Based Auto-Discovery
|
|
3
|
+
* Discovers custom MCP tools, resources, and prompts from project `mcp/` directory
|
|
4
|
+
* @module plugins/mcp/discovery
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Scan a directory for JS files and require them
|
|
12
|
+
* @param {string} dirPath
|
|
13
|
+
* @returns {Array<Object>}
|
|
14
|
+
*/
|
|
15
|
+
function scanModuleDir(dirPath) {
|
|
16
|
+
if (!fs.existsSync(dirPath)) return [];
|
|
17
|
+
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
|
18
|
+
const items = [];
|
|
19
|
+
|
|
20
|
+
for (const entry of entries) {
|
|
21
|
+
if (entry.isFile() && (entry.name.endsWith('.js') || entry.name.endsWith('.cjs'))) {
|
|
22
|
+
if (entry.name.startsWith('_') || entry.name.startsWith('.')) continue;
|
|
23
|
+
const fullPath = path.join(dirPath, entry.name);
|
|
24
|
+
try {
|
|
25
|
+
const mod = require(fullPath);
|
|
26
|
+
if (mod && typeof mod === 'object') {
|
|
27
|
+
// If mod doesn't have a name, default to filename without extension
|
|
28
|
+
if (!mod.name && !mod.uri) {
|
|
29
|
+
const baseName = path.basename(entry.name, path.extname(entry.name));
|
|
30
|
+
mod.name = baseName;
|
|
31
|
+
}
|
|
32
|
+
items.push(mod);
|
|
33
|
+
}
|
|
34
|
+
} catch (err) {
|
|
35
|
+
console.warn(`[mcp-discovery] Failed to load module at ${fullPath}:`, err.message);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return items;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Discovers custom MCP components from the filesystem and registers them to the server
|
|
45
|
+
* @param {import('./server').McpServer} server
|
|
46
|
+
* @param {string} [rootDir=process.cwd()]
|
|
47
|
+
* @param {string} [mcpDirName='mcp']
|
|
48
|
+
*/
|
|
49
|
+
function discoverMcpDirectory(server, rootDir = process.cwd(), mcpDirName = 'mcp') {
|
|
50
|
+
const mcpRoot = path.resolve(rootDir, mcpDirName);
|
|
51
|
+
if (!fs.existsSync(mcpRoot)) return;
|
|
52
|
+
|
|
53
|
+
// 1. Discover custom tools: mcp/tools/
|
|
54
|
+
const toolsDir = path.join(mcpRoot, 'tools');
|
|
55
|
+
const customTools = scanModuleDir(toolsDir);
|
|
56
|
+
for (const tool of customTools) {
|
|
57
|
+
if (typeof tool.handler === 'function') {
|
|
58
|
+
server.registerTool(tool);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// 2. Discover custom resources: mcp/resources/
|
|
63
|
+
const resourcesDir = path.join(mcpRoot, 'resources');
|
|
64
|
+
const customResources = scanModuleDir(resourcesDir);
|
|
65
|
+
for (const res of customResources) {
|
|
66
|
+
if (typeof res.handler === 'function') {
|
|
67
|
+
if (res.uriTemplate) {
|
|
68
|
+
server.registerResourceTemplate(res);
|
|
69
|
+
} else if (res.uri) {
|
|
70
|
+
server.registerResource(res);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// 3. Discover custom prompts: mcp/prompts/
|
|
76
|
+
const promptsDir = path.join(mcpRoot, 'prompts');
|
|
77
|
+
const customPrompts = scanModuleDir(promptsDir);
|
|
78
|
+
for (const prompt of customPrompts) {
|
|
79
|
+
if (typeof prompt.handler === 'function') {
|
|
80
|
+
server.registerPrompt(prompt);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
module.exports = {
|
|
86
|
+
discoverMcpDirectory,
|
|
87
|
+
scanModuleDir,
|
|
88
|
+
};
|