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
|
@@ -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,156 @@
|
|
|
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
|
+
let isCleanedUp = false;
|
|
48
|
+
let keepAlive = null;
|
|
49
|
+
|
|
50
|
+
const cleanup = () => {
|
|
51
|
+
if (isCleanedUp) return;
|
|
52
|
+
isCleanedUp = true;
|
|
53
|
+
if (keepAlive) {
|
|
54
|
+
clearInterval(keepAlive);
|
|
55
|
+
keepAlive = null;
|
|
56
|
+
}
|
|
57
|
+
sessions.delete(sessionId);
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const sessionObj = {
|
|
61
|
+
id: sessionId,
|
|
62
|
+
req,
|
|
63
|
+
res,
|
|
64
|
+
send: (data) => {
|
|
65
|
+
try {
|
|
66
|
+
res.write(`event: message\ndata: ${JSON.stringify(data)}\n\n`);
|
|
67
|
+
if (typeof res.flush === 'function') res.flush();
|
|
68
|
+
} catch (err) {
|
|
69
|
+
console.warn(`[mcp-sse] Failed to write to session ${sessionId}:`, err.message);
|
|
70
|
+
cleanup();
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
sessions.set(sessionId, sessionObj);
|
|
76
|
+
|
|
77
|
+
// Send initial endpoint event per MCP spec
|
|
78
|
+
res.write(`event: endpoint\ndata: ${endpointUri}\n\n`);
|
|
79
|
+
if (typeof res.flush === 'function') {
|
|
80
|
+
res.flush();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Ping interval to keep connection alive through proxies
|
|
84
|
+
keepAlive = setInterval(() => {
|
|
85
|
+
try {
|
|
86
|
+
res.write(': ping\n\n');
|
|
87
|
+
} catch (e) {
|
|
88
|
+
cleanup();
|
|
89
|
+
}
|
|
90
|
+
}, 15000);
|
|
91
|
+
if (typeof keepAlive.unref === 'function') {
|
|
92
|
+
keepAlive.unref();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
req.on('close', cleanup);
|
|
96
|
+
res.on('close', cleanup);
|
|
97
|
+
res.on('finish', cleanup);
|
|
98
|
+
res.on('error', cleanup);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// 2. POST /_mcp/messages - MCP message ingestion for an active SSE session
|
|
102
|
+
app.post(`${normalizedBase}/messages`, ...middlewareChain, async (req, res) => {
|
|
103
|
+
const sessionId = req.query?.sessionId;
|
|
104
|
+
if (!sessionId) {
|
|
105
|
+
return res.status(400).json(createJsonRpcError(null, ERROR_CODES.INVALID_PARAMS, 'Missing sessionId query parameter'));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const session = sessions.get(sessionId);
|
|
109
|
+
if (!session) {
|
|
110
|
+
return res.status(404).json(createJsonRpcError(null, ERROR_CODES.INVALID_PARAMS, `Active SSE session "${sessionId}" not found`));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const message = req.body;
|
|
114
|
+
if (!message || typeof message !== 'object') {
|
|
115
|
+
return res.status(400).json(createJsonRpcError(null, ERROR_CODES.PARSE_ERROR, 'Invalid JSON body'));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
const callCtx = { req, res, db: req.db, service: req.service };
|
|
120
|
+
const response = await server.handleMessage(message, callCtx);
|
|
121
|
+
|
|
122
|
+
if (response) {
|
|
123
|
+
session.send(response);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return res.status(202).send('Accepted');
|
|
127
|
+
} catch (err) {
|
|
128
|
+
return res.status(500).json(createJsonRpcError(message?.id || null, ERROR_CODES.INTERNAL_ERROR, err.message));
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// 3. POST /_mcp - Direct Streamable HTTP endpoint for single-turn JSON-RPC execution
|
|
133
|
+
app.post(`${normalizedBase}`, ...middlewareChain, async (req, res) => {
|
|
134
|
+
const message = req.body;
|
|
135
|
+
if (!message || typeof message !== 'object') {
|
|
136
|
+
return res.status(400).json(createJsonRpcError(null, ERROR_CODES.PARSE_ERROR, 'Invalid JSON body'));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
const callCtx = { req, res, db: req.db, service: req.service };
|
|
141
|
+
const response = await server.handleMessage(message, callCtx);
|
|
142
|
+
if (!response) {
|
|
143
|
+
return res.status(204).end();
|
|
144
|
+
}
|
|
145
|
+
return res.json(response);
|
|
146
|
+
} catch (err) {
|
|
147
|
+
return res.status(500).json(createJsonRpcError(message?.id || null, ERROR_CODES.INTERNAL_ERROR, err.message));
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
return { sessions };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
module.exports = {
|
|
155
|
+
mountSseTransport,
|
|
156
|
+
};
|