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
@@ -0,0 +1,472 @@
1
+ /**
2
+ * Webspresso MCP Server Engine
3
+ * Implements Model Context Protocol (MCP) specification version 2024-11-05
4
+ * Pure Node.js JSON-RPC 2.0 router with tools, resources, and prompts registry.
5
+ * @module plugins/mcp/server
6
+ */
7
+
8
+ const EventEmitter = require('events');
9
+
10
+ const PROTOCOL_VERSION = '2024-11-05';
11
+
12
+ // Standard JSON-RPC 2.0 error codes
13
+ const ERROR_CODES = {
14
+ PARSE_ERROR: -32700,
15
+ INVALID_REQUEST: -32600,
16
+ METHOD_NOT_FOUND: -32601,
17
+ INVALID_PARAMS: -32602,
18
+ INTERNAL_ERROR: -32603,
19
+ };
20
+
21
+ /**
22
+ * Creates a standard JSON-RPC 2.0 success response
23
+ * @param {string|number|null} id
24
+ * @param {*} result
25
+ * @returns {Object}
26
+ */
27
+ function createJsonRpcResponse(id, result) {
28
+ return {
29
+ jsonrpc: '2.0',
30
+ id: id !== undefined ? id : null,
31
+ result,
32
+ };
33
+ }
34
+
35
+ /**
36
+ * Creates a standard JSON-RPC 2.0 error response
37
+ * @param {string|number|null} id
38
+ * @param {number} code
39
+ * @param {string} message
40
+ * @param {*} [data]
41
+ * @returns {Object}
42
+ */
43
+ function createJsonRpcError(id, code, message, data) {
44
+ const errorObj = { code, message };
45
+ if (data !== undefined) {
46
+ errorObj.data = data;
47
+ }
48
+ return {
49
+ jsonrpc: '2.0',
50
+ id: id !== undefined ? id : null,
51
+ error: errorObj,
52
+ };
53
+ }
54
+
55
+ class McpServer extends EventEmitter {
56
+ /**
57
+ * @param {Object} [options]
58
+ * @param {string} [options.name='webspresso-mcp']
59
+ * @param {string} [options.version='1.0.0']
60
+ * @param {string} [options.instructions]
61
+ * @param {Object} [options.context]
62
+ */
63
+ constructor(options = {}) {
64
+ super();
65
+ this.name = options.name || 'webspresso-mcp';
66
+ this.version = options.version || '1.0.0';
67
+ this.instructions =
68
+ options.instructions ||
69
+ 'Webspresso Model Context Protocol server exposing services, ORM models, and system capabilities.';
70
+ this.context = options.context || {};
71
+
72
+ // Registries
73
+ this.tools = new Map();
74
+ this.resources = new Map();
75
+ this.resourceTemplates = [];
76
+ this.prompts = new Map();
77
+
78
+ // Client session info after initialize
79
+ this.clientInfo = null;
80
+ this.isInitialized = false;
81
+ }
82
+
83
+ /**
84
+ * Register an MCP tool
85
+ * @param {Object} toolDef
86
+ * @param {string} toolDef.name
87
+ * @param {string} [toolDef.description]
88
+ * @param {Object} [toolDef.inputSchema] - JSON Schema object
89
+ * @param {function(Object, Object): Promise<*>|*} toolDef.handler
90
+ * @returns {this}
91
+ */
92
+ registerTool(toolDef) {
93
+ if (!toolDef || !toolDef.name) {
94
+ throw new Error('Tool must have a name');
95
+ }
96
+ if (typeof toolDef.handler !== 'function') {
97
+ throw new Error(`Tool "${toolDef.name}" must have a handler function`);
98
+ }
99
+
100
+ this.tools.set(toolDef.name, {
101
+ name: toolDef.name,
102
+ description: toolDef.description || '',
103
+ inputSchema: toolDef.inputSchema || {
104
+ type: 'object',
105
+ properties: {},
106
+ additionalProperties: true,
107
+ },
108
+ handler: toolDef.handler,
109
+ });
110
+ return this;
111
+ }
112
+
113
+ /**
114
+ * Register multiple tools
115
+ * @param {Array<Object>} toolDefs
116
+ * @returns {this}
117
+ */
118
+ registerTools(toolDefs = []) {
119
+ for (const tool of toolDefs) {
120
+ this.registerTool(tool);
121
+ }
122
+ return this;
123
+ }
124
+
125
+ /**
126
+ * Remove a registered tool
127
+ * @param {string} name
128
+ * @returns {boolean}
129
+ */
130
+ unregisterTool(name) {
131
+ return this.tools.delete(name);
132
+ }
133
+
134
+ /**
135
+ * Register an MCP resource
136
+ * @param {Object} resourceDef
137
+ * @param {string} resourceDef.uri
138
+ * @param {string} [resourceDef.name]
139
+ * @param {string} [resourceDef.description]
140
+ * @param {string} [resourceDef.mimeType='application/json']
141
+ * @param {function(string, Object): Promise<*>|*} resourceDef.handler
142
+ * @returns {this}
143
+ */
144
+ registerResource(resourceDef) {
145
+ if (!resourceDef || !resourceDef.uri) {
146
+ throw new Error('Resource must have a uri');
147
+ }
148
+ if (typeof resourceDef.handler !== 'function') {
149
+ throw new Error(`Resource "${resourceDef.uri}" must have a handler function`);
150
+ }
151
+
152
+ this.resources.set(resourceDef.uri, {
153
+ uri: resourceDef.uri,
154
+ name: resourceDef.name || resourceDef.uri,
155
+ description: resourceDef.description || '',
156
+ mimeType: resourceDef.mimeType || 'application/json',
157
+ handler: resourceDef.handler,
158
+ });
159
+ return this;
160
+ }
161
+
162
+ /**
163
+ * Register a parameterized resource template
164
+ * @param {Object} templateDef
165
+ * @param {string} templateDef.uriTemplate - e.g. "webspresso://models/{model}"
166
+ * @param {string} [templateDef.name]
167
+ * @param {string} [templateDef.description]
168
+ * @param {string} [templateDef.mimeType='application/json']
169
+ * @param {function(string, Object, Object): Promise<*>|*} templateDef.handler - (uri, params, ctx)
170
+ * @returns {this}
171
+ */
172
+ registerResourceTemplate(templateDef) {
173
+ if (!templateDef || !templateDef.uriTemplate) {
174
+ throw new Error('Resource template must have a uriTemplate');
175
+ }
176
+ if (typeof templateDef.handler !== 'function') {
177
+ throw new Error(`Resource template "${templateDef.uriTemplate}" must have a handler function`);
178
+ }
179
+
180
+ // Convert uriTemplate to regex for matching
181
+ const paramNames = [];
182
+ const patternStr = templateDef.uriTemplate.replace(/\{([a-zA-Z0-9_]+)\}/g, (_, name) => {
183
+ paramNames.push(name);
184
+ return '([^/]+)';
185
+ });
186
+ const regex = new RegExp(`^${patternStr}$`);
187
+
188
+ this.resourceTemplates.push({
189
+ uriTemplate: templateDef.uriTemplate,
190
+ name: templateDef.name || templateDef.uriTemplate,
191
+ description: templateDef.description || '',
192
+ mimeType: templateDef.mimeType || 'application/json',
193
+ handler: templateDef.handler,
194
+ regex,
195
+ paramNames,
196
+ });
197
+ return this;
198
+ }
199
+
200
+ /**
201
+ * Register an MCP prompt template
202
+ * @param {Object} promptDef
203
+ * @param {string} promptDef.name
204
+ * @param {string} [promptDef.description]
205
+ * @param {Array<Object>} [promptDef.arguments]
206
+ * @param {function(Object, Object): Promise<*>|*} promptDef.handler
207
+ * @returns {this}
208
+ */
209
+ registerPrompt(promptDef) {
210
+ if (!promptDef || !promptDef.name) {
211
+ throw new Error('Prompt must have a name');
212
+ }
213
+ if (typeof promptDef.handler !== 'function') {
214
+ throw new Error(`Prompt "${promptDef.name}" must have a handler function`);
215
+ }
216
+
217
+ this.prompts.set(promptDef.name, {
218
+ name: promptDef.name,
219
+ description: promptDef.description || '',
220
+ arguments: promptDef.arguments || [],
221
+ handler: promptDef.handler,
222
+ });
223
+ return this;
224
+ }
225
+
226
+ /**
227
+ * Handle incoming JSON-RPC 2.0 message
228
+ * @param {Object} message - Parsed JSON-RPC message object
229
+ * @param {Object} [callContext={}] - Extra context per call (e.g. req, db)
230
+ * @returns {Promise<Object|null>} Response object or null if notification
231
+ */
232
+ async handleMessage(message, callContext = {}) {
233
+ if (!message || typeof message !== 'object') {
234
+ return createJsonRpcError(null, ERROR_CODES.INVALID_REQUEST, 'Invalid Request: Message must be an object');
235
+ }
236
+
237
+ const { id, method, params } = message;
238
+ const isNotification = id === undefined || id === null;
239
+
240
+ // Notifications without response
241
+ if (method === 'notifications/initialized') {
242
+ this.isInitialized = true;
243
+ this.emit('initialized', params);
244
+ return null;
245
+ }
246
+
247
+ if (method === 'notifications/cancelled') {
248
+ this.emit('cancelled', params);
249
+ return null;
250
+ }
251
+
252
+ // Request handlers
253
+ try {
254
+ switch (method) {
255
+ case 'initialize': {
256
+ this.clientInfo = params?.clientInfo || null;
257
+ return createJsonRpcResponse(id, {
258
+ protocolVersion: PROTOCOL_VERSION,
259
+ capabilities: {
260
+ tools: { listChanged: true },
261
+ resources: { subscribe: false, listChanged: true },
262
+ prompts: { listChanged: true },
263
+ logging: {},
264
+ },
265
+ serverInfo: {
266
+ name: this.name,
267
+ version: this.version,
268
+ },
269
+ instructions: this.instructions,
270
+ });
271
+ }
272
+
273
+ case 'ping': {
274
+ return createJsonRpcResponse(id, {});
275
+ }
276
+
277
+ case 'tools/list': {
278
+ const toolsList = [];
279
+ for (const tool of this.tools.values()) {
280
+ toolsList.push({
281
+ name: tool.name,
282
+ description: tool.description,
283
+ inputSchema: tool.inputSchema,
284
+ });
285
+ }
286
+ return createJsonRpcResponse(id, { tools: toolsList });
287
+ }
288
+
289
+ case 'tools/call': {
290
+ if (!params || !params.name) {
291
+ return createJsonRpcError(id, ERROR_CODES.INVALID_PARAMS, 'Missing tool name in params');
292
+ }
293
+
294
+ const tool = this.tools.get(params.name);
295
+ if (!tool) {
296
+ return createJsonRpcResponse(id, {
297
+ content: [
298
+ {
299
+ type: 'text',
300
+ text: `Error: Tool "${params.name}" not found.`,
301
+ },
302
+ ],
303
+ isError: true,
304
+ });
305
+ }
306
+
307
+ try {
308
+ const mergedCtx = { ...this.context, ...callContext };
309
+ const args = params.arguments || {};
310
+ const result = await tool.handler(args, mergedCtx);
311
+
312
+ // Format result according to MCP specs
313
+ let content;
314
+ if (result && Array.isArray(result.content)) {
315
+ content = result.content;
316
+ } else if (typeof result === 'string') {
317
+ content = [{ type: 'text', text: result }];
318
+ } else {
319
+ content = [{ type: 'text', text: JSON.stringify(result, null, 2) }];
320
+ }
321
+
322
+ return createJsonRpcResponse(id, {
323
+ content,
324
+ isError: Boolean(result?.isError),
325
+ });
326
+ } catch (err) {
327
+ return createJsonRpcResponse(id, {
328
+ content: [
329
+ {
330
+ type: 'text',
331
+ text: `Tool execution failed: ${err.message}`,
332
+ },
333
+ ],
334
+ isError: true,
335
+ });
336
+ }
337
+ }
338
+
339
+ case 'resources/list': {
340
+ const resourceList = [];
341
+ for (const res of this.resources.values()) {
342
+ resourceList.push({
343
+ uri: res.uri,
344
+ name: res.name,
345
+ description: res.description,
346
+ mimeType: res.mimeType,
347
+ });
348
+ }
349
+ return createJsonRpcResponse(id, { resources: resourceList });
350
+ }
351
+
352
+ case 'resources/templates/list': {
353
+ const templateList = this.resourceTemplates.map((t) => ({
354
+ uriTemplate: t.uriTemplate,
355
+ name: t.name,
356
+ description: t.description,
357
+ mimeType: t.mimeType,
358
+ }));
359
+ return createJsonRpcResponse(id, { resourceTemplates: templateList });
360
+ }
361
+
362
+ case 'resources/read': {
363
+ if (!params || !params.uri) {
364
+ return createJsonRpcError(id, ERROR_CODES.INVALID_PARAMS, 'Missing uri in params');
365
+ }
366
+
367
+ const uri = params.uri;
368
+ const mergedCtx = { ...this.context, ...callContext };
369
+
370
+ // Direct resource match
371
+ if (this.resources.has(uri)) {
372
+ const res = this.resources.get(uri);
373
+ const data = await res.handler(uri, mergedCtx);
374
+ const text = typeof data === 'string' ? data : JSON.stringify(data, null, 2);
375
+ return createJsonRpcResponse(id, {
376
+ contents: [
377
+ {
378
+ uri,
379
+ mimeType: res.mimeType,
380
+ text,
381
+ },
382
+ ],
383
+ });
384
+ }
385
+
386
+ // Template matching
387
+ for (const tmpl of this.resourceTemplates) {
388
+ const match = uri.match(tmpl.regex);
389
+ if (match) {
390
+ const matchedParams = {};
391
+ tmpl.paramNames.forEach((name, idx) => {
392
+ matchedParams[name] = match[idx + 1];
393
+ });
394
+
395
+ const data = await tmpl.handler(uri, matchedParams, mergedCtx);
396
+ const text = typeof data === 'string' ? data : JSON.stringify(data, null, 2);
397
+ return createJsonRpcResponse(id, {
398
+ contents: [
399
+ {
400
+ uri,
401
+ mimeType: tmpl.mimeType,
402
+ text,
403
+ },
404
+ ],
405
+ });
406
+ }
407
+ }
408
+
409
+ return createJsonRpcError(id, ERROR_CODES.INVALID_PARAMS, `Resource not found: ${uri}`);
410
+ }
411
+
412
+ case 'prompts/list': {
413
+ const promptsList = [];
414
+ for (const prompt of this.prompts.values()) {
415
+ promptsList.push({
416
+ name: prompt.name,
417
+ description: prompt.description,
418
+ arguments: prompt.arguments,
419
+ });
420
+ }
421
+ return createJsonRpcResponse(id, { prompts: promptsList });
422
+ }
423
+
424
+ case 'prompts/get': {
425
+ if (!params || !params.name) {
426
+ return createJsonRpcError(id, ERROR_CODES.INVALID_PARAMS, 'Missing prompt name in params');
427
+ }
428
+
429
+ const prompt = this.prompts.get(params.name);
430
+ if (!prompt) {
431
+ return createJsonRpcError(id, ERROR_CODES.INVALID_PARAMS, `Prompt "${params.name}" not found`);
432
+ }
433
+
434
+ const mergedCtx = { ...this.context, ...callContext };
435
+ const args = params.arguments || {};
436
+ const result = await prompt.handler(args, mergedCtx);
437
+
438
+ return createJsonRpcResponse(id, {
439
+ description: prompt.description,
440
+ messages: Array.isArray(result?.messages)
441
+ ? result.messages
442
+ : [
443
+ {
444
+ role: 'user',
445
+ content: {
446
+ type: 'text',
447
+ text: typeof result === 'string' ? result : JSON.stringify(result, null, 2),
448
+ },
449
+ },
450
+ ],
451
+ });
452
+ }
453
+
454
+ default: {
455
+ if (isNotification) return null;
456
+ return createJsonRpcError(id, ERROR_CODES.METHOD_NOT_FOUND, `Method "${method}" not found`);
457
+ }
458
+ }
459
+ } catch (err) {
460
+ if (isNotification) return null;
461
+ return createJsonRpcError(id, ERROR_CODES.INTERNAL_ERROR, err.message);
462
+ }
463
+ }
464
+ }
465
+
466
+ module.exports = {
467
+ McpServer,
468
+ PROTOCOL_VERSION,
469
+ ERROR_CODES,
470
+ createJsonRpcResponse,
471
+ createJsonRpcError,
472
+ };
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Webspresso MCP HTTP & SSE Transport
3
+ * Implements MCP HTTP endpoints (Server-Sent Events + HTTP POST message endpoint)
4
+ * @module plugins/mcp/transports/sse
5
+ */
6
+
7
+ const crypto = require('crypto');
8
+ const { createJsonRpcError, ERROR_CODES } = require('../server');
9
+
10
+ /**
11
+ * Mounts MCP SSE and HTTP POST routes onto Express app
12
+ * @param {Object} options
13
+ * @param {import('express').Express} options.app
14
+ * @param {import('../server').McpServer} options.server
15
+ * @param {string} [options.path='/_mcp']
16
+ * @param {import('express').RequestHandler} [options.authMiddleware]
17
+ * @returns {{ sessions: Map<string, Object> }}
18
+ */
19
+ function mountSseTransport(options) {
20
+ const { app, server, path: basePath = '/_mcp', authMiddleware } = options;
21
+
22
+ const normalizedBase = basePath.replace(/\/+$/, '');
23
+ const sessions = new Map();
24
+
25
+ const middlewareChain = authMiddleware ? [authMiddleware] : [];
26
+
27
+ // 1. GET /_mcp/sse - Server-Sent Events initiation
28
+ app.get(`${normalizedBase}/sse`, ...middlewareChain, (req, res) => {
29
+ // Check if compression is enabled on res, opt-out for SSE
30
+ if (typeof res.compress === 'function') {
31
+ res.compress(false);
32
+ }
33
+
34
+ res.writeHead(200, {
35
+ 'Content-Type': 'text/event-stream',
36
+ 'Cache-Control': 'no-cache, no-transform',
37
+ 'Connection': 'keep-alive',
38
+ 'X-Accel-Buffering': 'no',
39
+ });
40
+ if (typeof res.flushHeaders === 'function') {
41
+ res.flushHeaders();
42
+ }
43
+
44
+ const sessionId = crypto.randomUUID ? crypto.randomUUID() : crypto.randomBytes(16).toString('hex');
45
+ const endpointUri = `${normalizedBase}/messages?sessionId=${sessionId}`;
46
+
47
+ const sessionObj = {
48
+ id: sessionId,
49
+ req,
50
+ res,
51
+ send: (data) => {
52
+ try {
53
+ res.write(`event: message\ndata: ${JSON.stringify(data)}\n\n`);
54
+ if (typeof res.flush === 'function') res.flush();
55
+ } catch (err) {
56
+ console.warn(`[mcp-sse] Failed to write to session ${sessionId}:`, err.message);
57
+ }
58
+ },
59
+ };
60
+
61
+ sessions.set(sessionId, sessionObj);
62
+
63
+ // Send initial endpoint event per MCP spec
64
+ res.write(`event: endpoint\ndata: ${endpointUri}\n\n`);
65
+ if (typeof res.flush === 'function') {
66
+ res.flush();
67
+ }
68
+
69
+ // Ping interval to keep connection alive through proxies
70
+ const keepAlive = setInterval(() => {
71
+ try {
72
+ res.write(': ping\n\n');
73
+ } catch (e) {
74
+ clearInterval(keepAlive);
75
+ }
76
+ }, 15000);
77
+
78
+ req.on('close', () => {
79
+ clearInterval(keepAlive);
80
+ sessions.delete(sessionId);
81
+ });
82
+ });
83
+
84
+ // 2. POST /_mcp/messages - MCP message ingestion for an active SSE session
85
+ app.post(`${normalizedBase}/messages`, ...middlewareChain, async (req, res) => {
86
+ const sessionId = req.query?.sessionId;
87
+ if (!sessionId) {
88
+ return res.status(400).json(createJsonRpcError(null, ERROR_CODES.INVALID_PARAMS, 'Missing sessionId query parameter'));
89
+ }
90
+
91
+ const session = sessions.get(sessionId);
92
+ if (!session) {
93
+ return res.status(404).json(createJsonRpcError(null, ERROR_CODES.INVALID_PARAMS, `Active SSE session "${sessionId}" not found`));
94
+ }
95
+
96
+ const message = req.body;
97
+ if (!message || typeof message !== 'object') {
98
+ return res.status(400).json(createJsonRpcError(null, ERROR_CODES.PARSE_ERROR, 'Invalid JSON body'));
99
+ }
100
+
101
+ try {
102
+ const callCtx = { req, res, db: req.db, service: req.service };
103
+ const response = await server.handleMessage(message, callCtx);
104
+
105
+ if (response) {
106
+ session.send(response);
107
+ }
108
+
109
+ return res.status(202).send('Accepted');
110
+ } catch (err) {
111
+ return res.status(500).json(createJsonRpcError(message?.id || null, ERROR_CODES.INTERNAL_ERROR, err.message));
112
+ }
113
+ });
114
+
115
+ // 3. POST /_mcp - Direct Streamable HTTP endpoint for single-turn JSON-RPC execution
116
+ app.post(`${normalizedBase}`, ...middlewareChain, async (req, res) => {
117
+ const message = req.body;
118
+ if (!message || typeof message !== 'object') {
119
+ return res.status(400).json(createJsonRpcError(null, ERROR_CODES.PARSE_ERROR, 'Invalid JSON body'));
120
+ }
121
+
122
+ try {
123
+ const callCtx = { req, res, db: req.db, service: req.service };
124
+ const response = await server.handleMessage(message, callCtx);
125
+ if (!response) {
126
+ return res.status(204).end();
127
+ }
128
+ return res.json(response);
129
+ } catch (err) {
130
+ return res.status(500).json(createJsonRpcError(message?.id || null, ERROR_CODES.INTERNAL_ERROR, err.message));
131
+ }
132
+ });
133
+
134
+ return { sessions };
135
+ }
136
+
137
+ module.exports = {
138
+ mountSseTransport,
139
+ };
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Webspresso MCP Stdio Transport
3
+ * Runs MCP server over standard input and standard output (JSON-RPC 2.0 lines).
4
+ * Redirects console.log to process.stderr to prevent JSON-RPC stream corruption.
5
+ * @module plugins/mcp/transports/stdio
6
+ */
7
+
8
+ const readline = require('readline');
9
+ const { ERROR_CODES, createJsonRpcError } = require('../server');
10
+
11
+ let originalConsoleLog = null;
12
+ let originalConsoleInfo = null;
13
+
14
+ /**
15
+ * Redirects standard console logs to stderr
16
+ */
17
+ function redirectConsoleToStderr() {
18
+ if (originalConsoleLog) return;
19
+ originalConsoleLog = console.log;
20
+ originalConsoleInfo = console.info;
21
+
22
+ console.log = (...args) => {
23
+ process.stderr.write(`${args.map((a) => (typeof a === 'object' ? JSON.stringify(a) : a)).join(' ')}\n`);
24
+ };
25
+ console.info = (...args) => {
26
+ process.stderr.write(`${args.map((a) => (typeof a === 'object' ? JSON.stringify(a) : a)).join(' ')}\n`);
27
+ };
28
+ }
29
+
30
+ /**
31
+ * Restores original console log functions
32
+ */
33
+ function restoreConsole() {
34
+ if (originalConsoleLog) {
35
+ console.log = originalConsoleLog;
36
+ originalConsoleLog = null;
37
+ }
38
+ if (originalConsoleInfo) {
39
+ console.info = originalConsoleInfo;
40
+ originalConsoleInfo = null;
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Starts Stdio transport for an MCP Server
46
+ * @param {import('../server').McpServer} server
47
+ * @param {Object} [options]
48
+ * @param {ReadableStream} [options.input=process.stdin]
49
+ * @param {WritableStream} [options.output=process.stdout]
50
+ * @param {boolean} [options.redirectStderr=true]
51
+ * @returns {{ close: Function, rl: readline.Interface }}
52
+ */
53
+ function startStdioTransport(server, options = {}) {
54
+ const input = options.input || process.stdin;
55
+ const output = options.output || process.stdout;
56
+ const shouldRedirect = options.redirectStderr !== false;
57
+
58
+ if (shouldRedirect) {
59
+ redirectConsoleToStderr();
60
+ }
61
+
62
+ const rl = readline.createInterface({
63
+ input,
64
+ output: null,
65
+ terminal: false,
66
+ });
67
+
68
+ const sendResponse = (msg) => {
69
+ if (msg) {
70
+ output.write(`${JSON.stringify(msg)}\n`);
71
+ }
72
+ };
73
+
74
+ rl.on('line', async (line) => {
75
+ const trimmed = line.trim();
76
+ if (!trimmed) return;
77
+
78
+ let parsed;
79
+ try {
80
+ parsed = JSON.parse(trimmed);
81
+ } catch (err) {
82
+ sendResponse(createJsonRpcError(null, ERROR_CODES.PARSE_ERROR, `Parse error: ${err.message}`));
83
+ return;
84
+ }
85
+
86
+ try {
87
+ const response = await server.handleMessage(parsed);
88
+ sendResponse(response);
89
+ } catch (err) {
90
+ sendResponse(createJsonRpcError(parsed?.id || null, ERROR_CODES.INTERNAL_ERROR, err.message));
91
+ }
92
+ });
93
+
94
+ const close = () => {
95
+ rl.close();
96
+ if (shouldRedirect) {
97
+ restoreConsole();
98
+ }
99
+ };
100
+
101
+ return { close, rl };
102
+ }
103
+
104
+ module.exports = {
105
+ startStdioTransport,
106
+ redirectConsoleToStderr,
107
+ restoreConsole,
108
+ };