webspresso 0.0.91 → 0.0.93

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.
Files changed (60) hide show
  1. package/README.md +103 -2864
  2. package/bin/commands/admin-password.js +8 -2
  3. package/bin/commands/api.js +7 -1
  4. package/bin/commands/favicon-generate.js +6 -4
  5. package/bin/commands/mcp.js +162 -0
  6. package/bin/commands/new.js +7 -1
  7. package/bin/commands/page.js +7 -1
  8. package/bin/commands/seed.js +7 -1
  9. package/bin/commands/service.js +8 -2
  10. package/bin/commands/skill.js +7 -1
  11. package/bin/webspresso.js +2 -0
  12. package/core/compileSchema.js +1 -5
  13. package/core/compression/compressible.js +17 -5
  14. package/core/compression/compression-stream.js +1 -1
  15. package/core/compression/middleware.js +9 -0
  16. package/core/compression/negotiate.js +23 -11
  17. package/core/kernel/app.js +2 -1
  18. package/core/kernel/index.js +5 -1
  19. package/core/orm/cache/fingerprint.js +23 -5
  20. package/core/orm/eager-loader.js +54 -9
  21. package/core/orm/index.js +38 -2
  22. package/core/orm/model.js +11 -2
  23. package/core/queue/adapters/database.js +46 -13
  24. package/core/queue/adapters/memory.js +21 -0
  25. package/core/queue/adapters/redis.js +21 -0
  26. package/core/queue/manager.js +46 -20
  27. package/core/validation/index.js +70 -0
  28. package/index.d.ts +99 -5
  29. package/index.js +7 -1
  30. package/package.json +9 -1
  31. package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +6 -1
  32. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +18 -8
  33. package/plugins/admin-panel/client/parts/07-model-list.js +9 -4
  34. package/plugins/admin-panel/client/parts/08-record-list.js +20 -2
  35. package/plugins/admin-panel/index.js +65 -6
  36. package/plugins/admin-panel/modules/bulk-actions.js +9 -2
  37. package/plugins/admin-panel/modules/dashboard.js +8 -1
  38. package/plugins/data-exchange/export-xlsx.js +28 -6
  39. package/plugins/data-exchange/import.js +45 -20
  40. package/plugins/data-exchange/index.js +5 -0
  41. package/plugins/data-exchange/parse-table.js +14 -5
  42. package/plugins/data-exchange/record-selection.js +2 -0
  43. package/plugins/index.js +4 -0
  44. package/plugins/mcp/auth.js +104 -0
  45. package/plugins/mcp/discovery.js +88 -0
  46. package/plugins/mcp/index.js +208 -0
  47. package/plugins/mcp/prompts.js +149 -0
  48. package/plugins/mcp/providers/orm.js +325 -0
  49. package/plugins/mcp/providers/services.js +190 -0
  50. package/plugins/mcp/providers/system.js +87 -0
  51. package/plugins/mcp/server.js +472 -0
  52. package/plugins/mcp/transports/sse.js +139 -0
  53. package/plugins/mcp/transports/stdio.js +108 -0
  54. package/src/file-router.js +184 -123
  55. package/src/njk-frontmatter.js +24 -14
  56. package/src/server.js +30 -12
  57. package/src/services/discovery.js +2 -1
  58. package/src/services/registry.js +2 -0
  59. package/src/services/validator.js +2 -53
  60. package/templates/skills/webspresso-usage/REFERENCE-framework.md +6 -4
package/core/orm/index.js CHANGED
@@ -74,10 +74,46 @@ function createDatabase(config) {
74
74
  }
75
75
  }
76
76
 
77
- // Create Knex instance
77
+ // Create Knex instance (default useNullAsDefault: true for SQLite to prevent unneeded warnings)
78
+ const isSqlite = client === 'better-sqlite3' || client === 'sqlite3';
79
+ const knexConfig = {
80
+ ...(isSqlite && config.useNullAsDefault === undefined ? { useNullAsDefault: true } : {}),
81
+ ...config,
82
+ };
83
+
84
+ if (isSqlite) {
85
+ const userAfterCreate = knexConfig.pool?.afterCreate;
86
+ knexConfig.pool = {
87
+ ...(knexConfig.pool || {}),
88
+ afterCreate: (conn, cb) => {
89
+ try {
90
+ if (typeof conn.pragma === 'function') {
91
+ const isMemory =
92
+ knexConfig.connection === ':memory:' ||
93
+ knexConfig.connection?.filename === ':memory:' ||
94
+ knexConfig.connection?.database === ':memory:';
95
+
96
+ if (!isMemory) {
97
+ try { conn.pragma('journal_mode = WAL'); } catch (e) {}
98
+ }
99
+ try { conn.pragma('synchronous = NORMAL'); } catch (e) {}
100
+ try { conn.pragma('busy_timeout = 5000'); } catch (e) {}
101
+ try { conn.pragma('foreign_keys = ON'); } catch (e) {}
102
+ }
103
+ } catch (e) {}
104
+
105
+ if (typeof userAfterCreate === 'function') {
106
+ userAfterCreate(conn, cb);
107
+ } else {
108
+ cb(null, conn);
109
+ }
110
+ },
111
+ };
112
+ }
113
+
78
114
  let knexInstance;
79
115
  try {
80
- knexInstance = knex(config);
116
+ knexInstance = knex(knexConfig);
81
117
  } catch (e) {
82
118
  // Provide helpful error message
83
119
  if (e.message && (e.message.includes('Cannot find module') || e.message.includes('npm install'))) {
package/core/orm/model.js CHANGED
@@ -46,13 +46,22 @@ function defineModel(options) {
46
46
  throw new Error('Model schema is required and must be a Zod schema');
47
47
  }
48
48
 
49
+ let normalizedSchema = schema;
50
+ if (schema && schema.shape) {
51
+ for (const [key, val] of Object.entries(schema.shape)) {
52
+ if (val && typeof val._finalize === 'function') {
53
+ schema.shape[key] = val._finalize();
54
+ }
55
+ }
56
+ }
57
+
49
58
  // Check for duplicate registration
50
59
  if (modelRegistry.has(name)) {
51
60
  throw new Error(`Model "${name}" is already defined`);
52
61
  }
53
62
 
54
63
  // Extract column metadata from schema
55
- const columns = extractColumnsFromSchema(schema);
64
+ const columns = extractColumnsFromSchema(normalizedSchema);
56
65
 
57
66
  // Validate relations
58
67
  const normalizedRelations = {};
@@ -80,7 +89,7 @@ function defineModel(options) {
80
89
  const model = {
81
90
  name,
82
91
  table,
83
- schema,
92
+ schema: normalizedSchema,
84
93
  primaryKey,
85
94
  relations: normalizedRelations,
86
95
  scopes: {
@@ -46,6 +46,7 @@ class DatabaseQueueAdapter {
46
46
  table.dateTime('run_at').notNullable().index();
47
47
  table.dateTime('created_at').notNullable();
48
48
  table.dateTime('updated_at').notNullable();
49
+ table.index(['status', 'run_at', 'priority']);
49
50
  });
50
51
  }
51
52
 
@@ -88,8 +89,20 @@ class DatabaseQueueAdapter {
88
89
  * @returns {Promise<Job|null>}
89
90
  */
90
91
  async dequeue(jobNames = []) {
92
+ const jobs = await this.dequeueMany(jobNames, 1);
93
+ return jobs.length > 0 ? jobs[0] : null;
94
+ }
95
+
96
+ /**
97
+ * Dequeue multiple pending jobs up to limit in a single transaction
98
+ * @param {string[]} [jobNames]
99
+ * @param {number} [limit=1]
100
+ * @returns {Promise<Job[]>}
101
+ */
102
+ async dequeueMany(jobNames = [], limit = 1) {
91
103
  await this.ensureTable();
92
104
  const now = new Date();
105
+ const take = Math.max(1, limit || 1);
93
106
 
94
107
  return this.knex.transaction(async (trx) => {
95
108
  let query = trx(this.tableName)
@@ -97,14 +110,14 @@ class DatabaseQueueAdapter {
97
110
  .where('run_at', '<=', now)
98
111
  .orderBy('priority', 'desc')
99
112
  .orderBy('run_at', 'asc')
100
- .limit(1);
113
+ .limit(take);
101
114
 
102
115
  if (jobNames && jobNames.length > 0) {
103
116
  query = query.whereIn('name', jobNames);
104
117
  }
105
118
 
106
119
  // If Postgres or MySQL support forUpdate / skipLocked
107
- const client = this.knex.client.config.client;
120
+ const client = this.knex.client?.config?.client;
108
121
  if (client === 'pg' || client === 'postgres' || client === 'mysql' || client === 'mysql2') {
109
122
  try {
110
123
  query = query.forUpdate().skipLocked();
@@ -112,26 +125,46 @@ class DatabaseQueueAdapter {
112
125
  }
113
126
 
114
127
  const rows = await query;
115
- if (!rows || rows.length === 0) return null;
128
+ if (!rows || rows.length === 0) return [];
116
129
 
117
- const row = rows[0];
118
- const nextAttempts = Number(row.attempts) + 1;
130
+ const ids = rows.map((r) => r.id);
119
131
  const updatedAt = new Date();
120
132
 
133
+ if (take === 1) {
134
+ const row = rows[0];
135
+ const nextAttempts = Number(row.attempts) + 1;
136
+ await trx(this.tableName)
137
+ .where('id', row.id)
138
+ .update({
139
+ status: 'running',
140
+ attempts: nextAttempts,
141
+ updated_at: updatedAt,
142
+ });
143
+
144
+ return [this._rowToJob({
145
+ ...row,
146
+ status: 'running',
147
+ attempts: nextAttempts,
148
+ updated_at: updatedAt,
149
+ })];
150
+ }
151
+
121
152
  await trx(this.tableName)
122
- .where('id', row.id)
153
+ .whereIn('id', ids)
123
154
  .update({
124
155
  status: 'running',
125
- attempts: nextAttempts,
156
+ attempts: trx.raw('attempts + 1'),
126
157
  updated_at: updatedAt,
127
158
  });
128
159
 
129
- return this._rowToJob({
130
- ...row,
131
- status: 'running',
132
- attempts: nextAttempts,
133
- updated_at: updatedAt,
134
- });
160
+ return rows.map((row) =>
161
+ this._rowToJob({
162
+ ...row,
163
+ status: 'running',
164
+ attempts: Number(row.attempts) + 1,
165
+ updated_at: updatedAt,
166
+ })
167
+ );
135
168
  });
136
169
  }
137
170
 
@@ -60,6 +60,27 @@ class MemoryQueueAdapter {
60
60
  return null;
61
61
  }
62
62
 
63
+ /**
64
+ * Dequeue multiple pending jobs up to limit
65
+ * @param {string[]} [jobNames]
66
+ * @param {number} [limit=1]
67
+ * @returns {Promise<Job[]>}
68
+ */
69
+ async dequeueMany(jobNames = [], limit = 1) {
70
+ if (limit <= 1) {
71
+ const single = await this.dequeue(jobNames);
72
+ return single ? [single] : [];
73
+ }
74
+
75
+ const jobs = [];
76
+ for (let i = 0; i < limit; i++) {
77
+ const job = await this.dequeue(jobNames);
78
+ if (!job) break;
79
+ jobs.push(job);
80
+ }
81
+ return jobs;
82
+ }
83
+
63
84
  /**
64
85
  * Mark job as completed
65
86
  * @param {string|number} jobId
@@ -157,6 +157,27 @@ class RedisQueueAdapter {
157
157
  return null;
158
158
  }
159
159
 
160
+ /**
161
+ * Dequeue multiple pending jobs up to limit
162
+ * @param {string[]} [jobNames]
163
+ * @param {number} [limit=1]
164
+ * @returns {Promise<Job[]>}
165
+ */
166
+ async dequeueMany(jobNames = [], limit = 1) {
167
+ if (limit <= 1) {
168
+ const single = await this.dequeue(jobNames);
169
+ return single ? [single] : [];
170
+ }
171
+
172
+ const jobs = [];
173
+ for (let i = 0; i < limit; i++) {
174
+ const job = await this.dequeue(jobNames);
175
+ if (!job) break;
176
+ jobs.push(job);
177
+ }
178
+ return jobs;
179
+ }
180
+
160
181
  /**
161
182
  * Mark job as completed
162
183
  * @param {string|number} jobId
@@ -26,6 +26,8 @@ class QueueManager extends EventEmitter {
26
26
  this.adapter = options.adapter || new MemoryQueueAdapter();
27
27
  this.concurrency = Math.max(1, options.concurrency || 2);
28
28
  this.pollInterval = Math.max(50, options.pollInterval || 500);
29
+ this.maxPollInterval = Math.max(this.pollInterval, options.maxPollInterval || (this.pollInterval * 5));
30
+ this._currentPollInterval = this.pollInterval;
29
31
  this.jobsDir = options.jobsDir || null;
30
32
 
31
33
  /** @type {Map<string, { handler: Function, options: Object }>} */
@@ -125,7 +127,8 @@ class QueueManager extends EventEmitter {
125
127
  const enqueued = await this.adapter.enqueue(job);
126
128
  this.emit('job:queued', enqueued);
127
129
 
128
- // Trigger immediate poll if capacity is available
130
+ // Reset polling backoff and trigger immediate poll if capacity is available
131
+ this._currentPollInterval = this.pollInterval;
129
132
  if (this.isRunning && !this.isPaused && this.runningCount < this.concurrency) {
130
133
  setImmediate(() => this._tick());
131
134
  }
@@ -140,6 +143,7 @@ class QueueManager extends EventEmitter {
140
143
  if (this.isRunning) return this;
141
144
  this.isRunning = true;
142
145
  this.isPaused = false;
146
+ this._currentPollInterval = this.pollInterval;
143
147
  this._scheduleNextTick(0);
144
148
  this.emit('start');
145
149
  return this;
@@ -164,6 +168,7 @@ class QueueManager extends EventEmitter {
164
168
  resume() {
165
169
  if (!this.isRunning) return this.start();
166
170
  this.isPaused = false;
171
+ this._currentPollInterval = this.pollInterval;
167
172
  this._scheduleNextTick(0);
168
173
  this.emit('resume');
169
174
  return this;
@@ -268,7 +273,7 @@ class QueueManager extends EventEmitter {
268
273
  * Schedule next tick
269
274
  * @private
270
275
  */
271
- _scheduleNextTick(delay = this.pollInterval) {
276
+ _scheduleNextTick(delay = this._currentPollInterval) {
272
277
  if (!this.isRunning || this.isPaused || this.isDraining) return;
273
278
  if (this._timer) clearTimeout(this._timer);
274
279
  this._timer = setTimeout(() => this._tick(), delay);
@@ -281,28 +286,45 @@ class QueueManager extends EventEmitter {
281
286
  async _tick() {
282
287
  if (!this.isRunning || this.isPaused || this.isDraining) return;
283
288
 
284
- while (this.runningCount < this.concurrency) {
285
- if (this.isPaused || this.isDraining) break;
289
+ const availableSlots = this.concurrency - this.runningCount;
290
+ if (availableSlots <= 0) return;
286
291
 
287
- const registeredNames = Array.from(this.handlers.keys());
288
- if (registeredNames.length === 0) {
289
- break;
290
- }
292
+ const registeredNames = Array.from(this.handlers.keys());
293
+ if (registeredNames.length === 0) {
294
+ this._scheduleNextTick(this._currentPollInterval);
295
+ return;
296
+ }
291
297
 
292
- let job = null;
293
- try {
294
- job = await this.adapter.dequeue(registeredNames);
295
- } catch (err) {
296
- this.emit('error', err);
297
- break;
298
+ let jobs = [];
299
+ try {
300
+ if (typeof this.adapter.dequeueMany === 'function' && availableSlots > 1) {
301
+ jobs = await this.adapter.dequeueMany(registeredNames, availableSlots);
302
+ } else {
303
+ const single = await this.adapter.dequeue(registeredNames);
304
+ if (single) jobs = [single];
298
305
  }
306
+ } catch (err) {
307
+ this.emit('error', err);
308
+ this._scheduleNextTick(this._currentPollInterval);
309
+ return;
310
+ }
299
311
 
300
- if (!job) {
301
- // No jobs ready
302
- break;
303
- }
312
+ if (!jobs || jobs.length === 0) {
313
+ // No jobs ready -> back off polling interval
314
+ this._currentPollInterval = Math.min(
315
+ this.maxPollInterval,
316
+ Math.round(this._currentPollInterval * 1.5)
317
+ );
318
+ this._scheduleNextTick(this._currentPollInterval);
319
+ return;
320
+ }
321
+
322
+ // Found job(s) -> reset polling interval
323
+ this._currentPollInterval = this.pollInterval;
324
+
325
+ for (const job of jobs) {
326
+ if (this.isPaused || this.isDraining) break;
304
327
 
305
- // Found a job -> process it
306
328
  this.runningCount++;
307
329
  this.activeJobs.set(job.id, job);
308
330
 
@@ -320,7 +342,11 @@ class QueueManager extends EventEmitter {
320
342
  });
321
343
  }
322
344
 
323
- this._scheduleNextTick(this.pollInterval);
345
+ if (this.runningCount < this.concurrency) {
346
+ this._scheduleNextTick(0);
347
+ } else {
348
+ this._scheduleNextTick(this.pollInterval);
349
+ }
324
350
  }
325
351
 
326
352
  /**
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Webspresso Unified Validation Core
3
+ * Centralized Zod extensions, prototype pollution defense, and schema utilities
4
+ * @module core/validation
5
+ */
6
+
7
+ const z = require('zod');
8
+ const { extendZ, generateNanoid, zodNanoid } = require('../orm/utils/nanoid');
9
+
10
+ /**
11
+ * Standard Zod instance extended with z.nanoid() for framework-wide validation.
12
+ */
13
+ const zExtended = extendZ(z);
14
+
15
+ /**
16
+ * Check if an object contains dangerous prototype pollution keys
17
+ * @param {*} obj
18
+ * @returns {boolean}
19
+ */
20
+ function hasDangerousKeys(obj) {
21
+ if (obj === null || typeof obj !== 'object') {
22
+ return false;
23
+ }
24
+ if (Array.isArray(obj)) {
25
+ return obj.some(item => hasDangerousKeys(item));
26
+ }
27
+ for (const key of Object.keys(obj)) {
28
+ if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
29
+ return true;
30
+ }
31
+ if (typeof obj[key] === 'object' && obj[key] !== null && hasDangerousKeys(obj[key])) {
32
+ return true;
33
+ }
34
+ }
35
+ return false;
36
+ }
37
+
38
+ /**
39
+ * Sanitize input payload against prototype pollution
40
+ * @param {*} input
41
+ * @returns {*} Sanitized input
42
+ */
43
+ function sanitizeInput(input) {
44
+ if (input === null || typeof input !== 'object') {
45
+ return input;
46
+ }
47
+ if (!hasDangerousKeys(input)) {
48
+ return input;
49
+ }
50
+ if (Array.isArray(input)) {
51
+ return input.map(item => sanitizeInput(item));
52
+ }
53
+ const clean = {};
54
+ for (const key of Object.keys(input)) {
55
+ if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
56
+ continue;
57
+ }
58
+ clean[key] = sanitizeInput(input[key]);
59
+ }
60
+ return clean;
61
+ }
62
+
63
+ module.exports = {
64
+ z: zExtended,
65
+ extendZ,
66
+ generateNanoid,
67
+ zodNanoid,
68
+ hasDangerousKeys,
69
+ sanitizeInput,
70
+ };
package/index.d.ts CHANGED
@@ -34,7 +34,21 @@ export interface CreateAppOptions {
34
34
  version?: string;
35
35
  manifestPath?: string;
36
36
  prefix?: string;
37
- }; /**
37
+ publicDir?: string;
38
+ };
39
+ /**
40
+ * If truthy, route load() return values for stylesheets and scripts are promoted to pageHead
41
+ */
42
+ pageAssets?:
43
+ | boolean
44
+ | {
45
+ enabled?: boolean;
46
+ stylesheets?: boolean;
47
+ scripts?: boolean;
48
+ };
49
+ /** Trust proxy configuration (default: 1) */
50
+ trustProxy?: boolean | number | string;
51
+ /**
38
52
  * Custom 404 / 500 / 503 handlers. File-based route errors are forwarded with `next(err)` and hit `serverError` / `timeout`.
39
53
  * When `serverError` / `timeout` is a template path, it is not used for paths under `/api` (default JSON instead).
40
54
  */
@@ -56,6 +70,7 @@ export interface CreateAppOptions {
56
70
  port?: number;
57
71
  shutdown?: ShutdownOptions;
58
72
  compression?: boolean | CompressionOptions;
73
+ trustProxy?: boolean | number | string;
59
74
  [key: string]: unknown;
60
75
  };
61
76
  /** Opt-in Alpine / swup assets under `/__webspresso/client-runtime/*`. Env: WEBSPRESSO_ALPINE, WEBSPRESSO_SWUP. */
@@ -76,7 +91,14 @@ export interface ServiceContext {
76
91
  req?: Request;
77
92
  res?: Response;
78
93
  db?: DatabaseInstance | null;
79
- service?: <T = unknown>(name: string, input?: unknown, options?: unknown) => Promise<T>;
94
+ fsy?: Record<string, unknown>;
95
+ locale?: string;
96
+ t?: unknown;
97
+ service?: {
98
+ <T = unknown>(name: string, input?: unknown, options?: unknown): Promise<T>;
99
+ invalidate?: (name: string, input?: unknown, ctx?: unknown) => boolean;
100
+ clearCache?: (name?: string) => boolean;
101
+ };
80
102
  [key: string]: unknown;
81
103
  }
82
104
 
@@ -89,9 +111,16 @@ export interface ServiceDefinition<TInput = unknown, TOutput = unknown> {
89
111
  schema?: import('zod').ZodType<any> | Record<string, unknown> | ((input: unknown) => unknown) | null;
90
112
  handler: ServiceHandler<TInput, TOutput>;
91
113
  metadata?: Record<string, unknown>;
92
- auth?: boolean | string | string[];
93
- cache?: boolean | string | number;
94
- timeout?: number;
114
+ auth?: boolean | string | string[] | ((user: unknown, ctx: ServiceContext) => boolean | Promise<boolean>);
115
+ cache?:
116
+ | boolean
117
+ | string
118
+ | number
119
+ | {
120
+ ttl?: string | number;
121
+ key?: string | ((input: unknown, ctx: ServiceContext) => string);
122
+ };
123
+ timeout?: number | string;
95
124
  transaction?: boolean;
96
125
  [key: string]: unknown;
97
126
  }
@@ -274,6 +303,7 @@ export interface WebspressoApplication extends Application {
274
303
  server?: unknown;
275
304
  readonly isShuttingDown: boolean;
276
305
  shutdownManager: ShutdownManager;
306
+ serviceRegistry: ServiceRegistry;
277
307
  onShutdown(fn: () => Promise<void> | void): this;
278
308
  close(reason?: string): Promise<void>;
279
309
  enableShutdownHooks(): this;
@@ -1583,4 +1613,68 @@ export const ssr: {
1583
1613
  renderStream: typeof renderStream;
1584
1614
  };
1585
1615
 
1616
+ // --- Model Context Protocol (MCP) Plugin ---
1617
+
1618
+ export interface McpPluginOptions {
1619
+ path?: string;
1620
+ enabled?: boolean;
1621
+ auth?: {
1622
+ token?: string;
1623
+ localhostOnly?: boolean;
1624
+ verify?: (req: import('express').Request) => boolean | Promise<boolean>;
1625
+ };
1626
+ services?: boolean | {
1627
+ include?: string[];
1628
+ exclude?: string[];
1629
+ readOnly?: boolean;
1630
+ role?: string | string[];
1631
+ };
1632
+ orm?: boolean | {
1633
+ readOnly?: boolean;
1634
+ models?: string[];
1635
+ };
1636
+ system?: boolean;
1637
+ tools?: Array<{
1638
+ name: string;
1639
+ description?: string;
1640
+ inputSchema?: Record<string, unknown>;
1641
+ handler: (args: any, ctx: any) => Promise<any> | any;
1642
+ }>;
1643
+ resources?: Array<{
1644
+ uri?: string;
1645
+ uriTemplate?: string;
1646
+ name?: string;
1647
+ description?: string;
1648
+ mimeType?: string;
1649
+ handler: (uri: string, paramsOrCtx: any, ctx?: any) => Promise<any> | any;
1650
+ }>;
1651
+ prompts?: Array<{
1652
+ name: string;
1653
+ description?: string;
1654
+ arguments?: Array<{ name: string; description?: string; required?: boolean }>;
1655
+ handler: (args: any, ctx: any) => Promise<any> | any;
1656
+ }>;
1657
+ discovery?: boolean;
1658
+ name?: string;
1659
+ version?: string;
1660
+ instructions?: string;
1661
+ }
1662
+
1663
+ export class McpServer {
1664
+ name: string;
1665
+ version: string;
1666
+ instructions: string;
1667
+ constructor(options?: { name?: string; version?: string; instructions?: string; context?: Record<string, unknown> });
1668
+ registerTool(toolDef: any): this;
1669
+ registerTools(toolDefs: any[]): this;
1670
+ unregisterTool(name: string): boolean;
1671
+ registerResource(resourceDef: any): this;
1672
+ registerResourceTemplate(templateDef: any): this;
1673
+ registerPrompt(promptDef: any): this;
1674
+ handleMessage(message: any, callContext?: any): Promise<any>;
1675
+ }
1676
+
1677
+ export function mcpPlugin(options?: McpPluginOptions): WebspressoPlugin;
1678
+ export function createMcpServer(options?: McpPluginOptions): McpServer;
1679
+
1586
1680
 
package/index.js CHANGED
@@ -55,7 +55,7 @@ const {
55
55
  // ORM exports (lazy loaded)
56
56
  const orm = require('./core/orm');
57
57
 
58
- /** Application kernel (event bus, plugin shell, view resolver, flows). Use `kernel.createApp`; not the framework SSR `createApp`. */
58
+ /** Standalone experimental application kernel (event bus, plugin shell, view resolver, flows). Distinct from framework SSR `createApp`. */
59
59
  const kernel = require('./core/kernel');
60
60
 
61
61
  // Built-in plugins
@@ -87,6 +87,9 @@ const {
87
87
  socketIo,
88
88
  redis,
89
89
  createRedisAdapter,
90
+ mcpPlugin,
91
+ createMcpServer,
92
+ McpServer,
90
93
  } = require('./plugins');
91
94
 
92
95
  const content = require('./core/content');
@@ -237,6 +240,9 @@ module.exports = {
237
240
  queue: require('./core/queue'),
238
241
  createQueueManager: require('./core/queue').createQueueManager,
239
242
  QueueManager: require('./core/queue').QueueManager,
243
+ mcpPlugin,
244
+ createMcpServer,
245
+ McpServer,
240
246
 
241
247
  // Schema-driven CMS core (framework-agnostic)
242
248
  content,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webspresso",
3
- "version": "0.0.91",
3
+ "version": "0.0.93",
4
4
  "description": "Minimal, production-ready SSR framework for Node.js with file-based routing, Nunjucks templating, built-in i18n, and CLI tooling",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -42,6 +42,10 @@
42
42
  "bench:ci:local": "npm run bench:baseline && npm run bench:ci",
43
43
  "check:types": "tsc --project tests/ts-smoke/tsconfig.json",
44
44
  "rebuild:native": "npm rebuild better-sqlite3 bcrypt sharp",
45
+ "docs:dev": "docboot dev ./docs",
46
+ "docs:build": "docboot build ./docs",
47
+ "docs:serve": "docboot serve dist",
48
+ "docs:doctor": "docboot doctor ./docs",
45
49
  "release": "release-it"
46
50
  },
47
51
  "keywords": [
@@ -134,6 +138,7 @@
134
138
  "@vitest/coverage-v8": "^3.0.0",
135
139
  "better-sqlite3": "^11.10.0",
136
140
  "chokidar": "^3.5.3",
141
+ "docboot": "^0.3.0",
137
142
  "dotenv": "^16.3.1",
138
143
  "express-rate-limit": "^8.4.1",
139
144
  "release-it": "^20.2.1",
@@ -149,5 +154,8 @@
149
154
  "tar": "^7.5.11",
150
155
  "undici": "^6.24.0",
151
156
  "uuid": "^11.1.1"
157
+ },
158
+ "allowScripts": {
159
+ "better-sqlite3@11.10.0": true
152
160
  }
153
161
  }
@@ -59,10 +59,15 @@ async function downloadDataExchangeXlsx(modelName, payload) {
59
59
  const blob = await res.blob();
60
60
  var url = URL.createObjectURL(blob);
61
61
  var a = document.createElement('a');
62
+ a.style.display = 'none';
62
63
  a.href = url;
63
64
  a.download = modelName + '-export.xlsx';
65
+ document.body.appendChild(a);
64
66
  a.click();
65
- URL.revokeObjectURL(url);
67
+ setTimeout(function () {
68
+ if (a.parentNode) a.parentNode.removeChild(a);
69
+ URL.revokeObjectURL(url);
70
+ }, 1000);
66
71
  }
67
72
 
68
73
  // Helper: Capitalize first letter of each word