@flowfuse/nr-assistant 0.3.1-2c05106-202506300804.0 → 0.3.1-6237044-202507301022.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,667 @@
1
+ // a singleton instance of the Assistant class with an init method for accepting the RED instance
2
+ 'use strict'
3
+
4
+ const { z } = require('zod')
5
+ const { getLongestUpstreamPath } = require('./flowGraph')
6
+ const { hasProperty } = require('./utils')
7
+ const semver = require('semver')
8
+
9
+ const FF_ASSISTANT_USER_AGENT = 'FlowFuse Assistant Plugin/' + require('../package.json').version
10
+
11
+ /**
12
+ * @typedef {Object} AssistantSettings
13
+ * @property {boolean} enabled - Whether the Assistant is enabled
14
+ * @property {number} requestTimeout - The timeout for requests to the Assistant backend in milliseconds
15
+ * @property {string} url - The URL of the Assistant server
16
+ * @property {string} token - The authentication token for the Assistant server
17
+ * @property {Object} [got] - The got instance to use for HTTP requests
18
+ * @property {Object} completions - Settings for completions
19
+ * @property {string} completions.modelUrl - The URL to the ML model
20
+ * @property {string} completions.vocabularyUrl - The URL to the completions vocabulary lookup data
21
+ */
22
+
23
+ class Assistant {
24
+ constructor () {
25
+ // Main properties
26
+ /** @type {import('node-red').NodeRedInstance} */
27
+ this.RED = null
28
+ /** @type {import('got').Got} */
29
+ this.got = null
30
+ /** @type {AssistantSettings} */
31
+ this.options = null
32
+ this._loading = false // Flag to indicate if the Assistant is currently loading
33
+ this._enabled = false // Flag to indicate if the Assistant is enabled
34
+
35
+ // MCP Client and Server and associated properties
36
+ /** @type {import('@modelcontextprotocol/sdk/client/index.js').Client} */
37
+ this._mcpClient = null
38
+ /** @type {import('@modelcontextprotocol/sdk/server/index.js').Server} */
39
+ // eslint-disable-next-line no-unused-vars
40
+ this._mcpServer = null
41
+ this.mcpReady = false // Flag to indicate if MCP is ready
42
+
43
+ // ONNX.js and associated properties (primarily for completions)
44
+ /** @type {import('onnxruntime-web') } */
45
+ this._ort = null
46
+ /** @type {import('onnxruntime-web').InferenceSession} */
47
+ this._completionsSession = null
48
+ this.completionsReady = false // Flag to indicate if the completions model is ready
49
+ /** @type {import('./completions/Labeller.js').CompletionsLabeller} */
50
+ this.labeller = null // Instance of CompletionsLabeller for encoding/decoding completions
51
+
52
+ // NOTES: Since this plugin may be loaded via device agent and device agent might be the 2.x stream, we
53
+ // should try to avoid (or handle) instances where Node14 is used, as it does not support ESM imports or
54
+ // private class fields (so for now, we stick to the _old style private properties_ with an underscore prefix).
55
+ }
56
+
57
+ /**
58
+ * Initialize the Assistant instance with the provided RED instance and options.
59
+ * This method sets up the necessary components for the Assistant, including the Model Context Protocol (MCP) and ONNX.js.
60
+ * @param {*} RED - The Node-RED RED API
61
+ * @param {AssistantSettings} options - The options for initializing the Assistant
62
+ */
63
+ async init (RED, options = {}) {
64
+ if (this._loading) {
65
+ RED.log.debug('FlowFuse Assistant is busy loading')
66
+ return
67
+ }
68
+ try {
69
+ this._loading = true // Set loading to true when initializing
70
+ await this.dispose() // Dispose of any existing instance before initializing a new one
71
+ this.RED = RED
72
+ this.options = options || {}
73
+ this.got = this.options.got || require('got') // got can me passed in for testing purposes
74
+
75
+ if (!this.options.enabled) {
76
+ RED.log.info('FlowFuse Assistant Plugin is not enabled')
77
+ return
78
+ }
79
+ if (!this.options.url || !this.options.token) {
80
+ RED.log.warn('FlowFuse Assistant Plugin configuration is missing required options')
81
+ throw new Error('Plugin configuration is missing required options')
82
+ }
83
+
84
+ const nrVersion = this.RED.version()
85
+ const nrMajorVersion = semver.major(nrVersion)
86
+ const nrMinorVersion = semver.minor(nrVersion)
87
+ const nodeMajorVersion = semver.major(process.versions.node)
88
+
89
+ const clientSettings = {
90
+ enabled: this.options.enabled !== false && !!this.options.url,
91
+ requestTimeout: this.options.requestTimeout || 60000
92
+ }
93
+ RED.comms.publish('nr-assistant/initialise', clientSettings, true /* retain */)
94
+
95
+ // ### Initialise Model Context Protocol (MCP)
96
+ // TODO: If "feature" is disabled, skip loading MCP. See issue #57
97
+ this.options.mcp = this.options.mcp || { enabled: true }
98
+ const mcpFeatureEnabled = this.options.mcp.enabled && true // FUTURE: Feature Flag - See issue #57
99
+ const mcpEnabled = mcpFeatureEnabled && this.isInitialized && this.isEnabled
100
+ if (mcpEnabled) {
101
+ try {
102
+ const { client, server } = await this.loadMCP()
103
+ this._mcpClient = client
104
+ this._mcpServer = server
105
+ this.mcpReady = true
106
+ // tell frontend that the MCP client is ready so it can add the action(s) to the Action List
107
+ RED.comms.publish('nr-assistant/mcp/ready', clientSettings, true /* retain */)
108
+ RED.log.info('FlowFuse Assistant Model Context Protocol (MCP) loaded')
109
+ } catch (error) {
110
+ this.mcpReady = false
111
+ // ESM Support in Node 20 is much better than versions v18-, so lets include a node version
112
+ // Write a warning to log as a hint/prompt
113
+ // NOTE: Node 18 is EOL as of writing this
114
+ RED.log.warn('FlowFuse Assistant MCP could not be loaded. Assistant features that require MCP will not be available')
115
+ if (nodeMajorVersion < 20) {
116
+ RED.log.debug(`Node.js version ${nodeMajorVersion} may not be supported by MCP Client / Server.`)
117
+ }
118
+ }
119
+ } else if (!mcpFeatureEnabled) {
120
+ RED.log.info('FlowFuse Assistant MCP is disabled')
121
+ }
122
+
123
+ // ### Initialise completions (depends on MCP so checks the mcpReady flag)
124
+ // TODO: If "feature" is disabled, skip loading. See issue #57
125
+ this.options.completions = this.options.completions || { enabled: true } // default to enabled
126
+ const completionsFeatureEnabled = this.options.completions.enabled && true // FUTURE: Feature Flag - See issue #57
127
+ const completionsSupported = (nrMajorVersion > 4 || (nrMajorVersion === 4 && nrMinorVersion >= 1))
128
+ const completionsEnabled = completionsFeatureEnabled && this.isInitialized && this.isEnabled
129
+
130
+ if (!completionsSupported) {
131
+ RED.log.warn('FlowFuse Assistant Completions require Node-RED 4.1 or greater')
132
+ } else if (!completionsFeatureEnabled) {
133
+ RED.log.info('FlowFuse Assistant Completions are disabled')
134
+ } else if (this.mcpReady && completionsEnabled && completionsSupported) {
135
+ // if modelUrl is not set, use the default model URL from this.options.url + 'assets/completions/model.onnx'
136
+ this.options.completions.modelUrl = this.options.completions.modelUrl || new URL('assets/completions/model.onnx', this.options.url).href
137
+ // if vocabularyUrl is not set, use the default vocabulary URL from this.options.url + 'assets/completions/vocabulary.json'
138
+ this.options.completions.vocabularyUrl = this.options.completions.vocabularyUrl || new URL('assets/completions/vocabulary.json', this.options.url).href
139
+
140
+ // RED.events.once('comms:message:nr-assistant/completions/load', async (opts) => {
141
+ RED.events.once('comms:message:nr-assistant/completions/load', async (opts) => {
142
+ try {
143
+ RED.log.info('FlowFuse Assistant is Loading Advanced Completions...')
144
+ await this.loadCompletions()
145
+ RED.comms.publish('nr-assistant/completions/ready', { enabled: true }, true /* retain */)
146
+ RED.log.info('FlowFuse Assistant Completions Loaded')
147
+ this.completionsReady = true
148
+ } catch (error) {
149
+ this.completionsReady = false
150
+ RED.log.warn('FlowFuse Assistant Advanced Completions could not be loaded.') // degraded functionality
151
+ RED.log.debug(`Completions loading error: ${error.message}`)
152
+ }
153
+ })
154
+ }
155
+ this.initAdminEndpoints(RED) // Initialize the admin endpoints for the Assistant
156
+ const degraded = (mcpEnabled && !this.mcpReady)
157
+ RED.log.info('FlowFuse Assistant Plugin loaded' + (degraded ? ' (reduced functionality)' : ''))
158
+ } finally {
159
+ this._loading = false // Set loading to false when initialization is complete
160
+ }
161
+ }
162
+
163
+ async dispose () {
164
+ if (this._completionsSession) {
165
+ await this._completionsSession.release()
166
+ }
167
+ this.labeller = null
168
+ this._completionsSession = null
169
+ this._ort = null
170
+
171
+ try {
172
+ if (this._mcpClient) {
173
+ await this._mcpClient.close()
174
+ }
175
+ if (this._mcpServer) {
176
+ await this._mcpServer.close()
177
+ }
178
+ } finally {
179
+ this._mcpClient = null
180
+ this._mcpServer = null
181
+ }
182
+
183
+ this.RED = null
184
+ this.got = null
185
+ }
186
+
187
+ get isInitialized () {
188
+ return this.RED !== null && this.got !== null
189
+ }
190
+
191
+ get isLoading () {
192
+ return this._loading
193
+ }
194
+
195
+ get isEnabled () {
196
+ if (!this.options) {
197
+ return false
198
+ }
199
+ return !!(this.options.enabled && this.options.url && this.options.token)
200
+ }
201
+
202
+ async loadCompletions () {
203
+ if (!this.isInitialized) {
204
+ throw new Error('Assistant is not initialized')
205
+ }
206
+ if (!this.options || !this.options.completions) {
207
+ throw new Error('Assistant completions options are not set')
208
+ }
209
+ await this._loadCompletionsLabels()
210
+ await this._loadMlRuntime()
211
+ await this._loadCompletionsModel()
212
+ }
213
+
214
+ async _loadCompletionsLabels (url = this.options.completions.vocabularyUrl) {
215
+ const response = await this.got(url, {
216
+ responseType: 'json',
217
+ headers: {
218
+ Authorization: `Bearer ${this.options.token}`,
219
+ 'User-Agent': FF_ASSISTANT_USER_AGENT
220
+ }
221
+ })
222
+ if (!response.body || typeof response.body !== 'object') {
223
+ throw new Error('Invalid vocabulary format')
224
+ }
225
+ /** @type {{ input_features: string[], classifications: string[], core_nodes: string[] }} */
226
+ const labels = response.body
227
+ const isArrayOfStrings = (arr) => Array.isArray(arr) && arr.every(item => typeof item === 'string')
228
+ if (!isArrayOfStrings(labels.input_features)) {
229
+ throw new Error('Completion Input Labels are not valid')
230
+ }
231
+ if (!isArrayOfStrings(labels.classifications)) {
232
+ throw new Error('Completion Classifications Labels are not valid')
233
+ }
234
+ if (!isArrayOfStrings(labels.core_nodes)) {
235
+ throw new Error('Completion Core Nodes Labels are not valid')
236
+ }
237
+ const CompletionsLabeller = require('./completions/Labeller.js').CompletionsLabeller // Import the CompletionsLabeller class
238
+ this.labeller = new CompletionsLabeller({
239
+ inputFeatureLabels: labels.input_features,
240
+ classifierLabels: labels.classifications,
241
+ nodeLabels: labels.core_nodes
242
+ })
243
+ }
244
+
245
+ async _loadMlRuntime () {
246
+ this._ort = await import('onnxruntime-web')
247
+ if (!this._ort) {
248
+ throw new Error('Failed to load ML Runtime')
249
+ }
250
+ }
251
+
252
+ async _loadCompletionsModel (url = this.options.completions.modelUrl) {
253
+ try {
254
+ const response = await this.got(url, {
255
+ headers: {
256
+ Authorization: `Bearer ${this.options.token}`,
257
+ 'User-Agent': FF_ASSISTANT_USER_AGENT
258
+ },
259
+ responseType: 'buffer' // Ensure we get raw binary
260
+ })
261
+ if (!response.body || !Buffer.isBuffer(response.body)) {
262
+ throw new Error('Invalid model format')
263
+ }
264
+ this._completionsSession = await this._ort.InferenceSession.create(response.body)
265
+ } catch (error) {
266
+ console.error('Error loading ML model:', error)
267
+ throw new Error(`Failed to load ML model: ${error.message}`, { cause: error })
268
+ }
269
+ if (!this._completionsSession) {
270
+ throw new Error('Failed to load ML model')
271
+ }
272
+ }
273
+
274
+ async loadMCP () {
275
+ const { Client } = await import('@modelcontextprotocol/sdk/client/index.js')
276
+ const { McpServer } = await import('@modelcontextprotocol/sdk/server/mcp.js')
277
+ const { InMemoryTransport } = await import('@modelcontextprotocol/sdk/inMemory.js')
278
+ // Create in-process server
279
+ const server = new McpServer({
280
+ name: 'NR MCP Server',
281
+ version: '1.0.0'
282
+ })
283
+
284
+ server.prompt('explain_flow', 'Explain what the selected node-red flow of nodes do', {
285
+ nodes: z
286
+ .string()
287
+ .startsWith('[')
288
+ .endsWith(']')
289
+ .min(23) // Minimum length for a valid JSON array
290
+ .max(100000) // on average, an exported node is ~400-1000 characters long, 100000 characters _should_ realistically be enough for a flow of 100 nodes
291
+ .describe('JSON string that represents a flow of Node-RED nodes'),
292
+ flowName: z.string().optional().describe('Optional name of the flow to explain'),
293
+ userContext: z.string().optional().describe('Optional user context to aid explanation')
294
+ }, async ({ nodes, flowName, userContext }) => {
295
+ const promptBuilder = []
296
+ // promptBuilder.push('Generate a JSON response containing 2 string properties: "summary" and "details". Summary should be a brief overview of what the following Node-RED flow JSON does, Details should provide a little more detail of the flow but should be concise and to the point. Use bullet lists or number lists if it gets too wordy.') // FUTURE: ask for a summary and details in JSON format
297
+ promptBuilder.push('Generate a "### Summary" section, followed by a "### Details" section only. They should explain the following Node-RED flow json. "Summary" should be a brief TLDR, Details should provide a little more information but should be concise and to the point. Use bullet lists or number lists if it gets too wordy.')
298
+ if (flowName) {
299
+ promptBuilder.push(`The parent flow is named "${flowName}".`)
300
+ promptBuilder.push('')
301
+ }
302
+ if (userContext) {
303
+ promptBuilder.push(`User Context: "${userContext}".`)
304
+ promptBuilder.push('')
305
+ }
306
+ promptBuilder.push('Here are the nodes in the flow:')
307
+ promptBuilder.push('```json')
308
+ promptBuilder.push(nodes)
309
+ promptBuilder.push('```')
310
+ return {
311
+ messages: [{
312
+ role: 'user',
313
+ content: {
314
+ type: 'text',
315
+ text: promptBuilder.join('\n')
316
+ }
317
+ }]
318
+ }
319
+ })
320
+
321
+ server.tool('predict_next', 'Predict the next node or nodes to follow the provided nodes in a Node-RED flow', {
322
+ flow: z.array(
323
+ z.object({
324
+ id: z.string()
325
+ }).passthrough()
326
+ ).optional().describe('A Node-RED flow related to the prediction.'),
327
+ sourceNode: z.object({
328
+ id: z.string(),
329
+ // allow any other properties in the test object
330
+ [z.string()]: z.any()
331
+ }).passthrough().describe('The node in the flow from which to the prediction will be made'),
332
+ sourcePort: z.number().optional().describe('Optional source port to connect the predicted node to')
333
+ },
334
+ /** @type {import('@modelcontextprotocol/sdk/server/mcp.js').ToolCallback} */
335
+ async ({ flow, sourceNode, sourcePort }) => {
336
+ const attachToNode = sourceNode || {}
337
+ /** @type {Array<{type: string}>} */
338
+ let suggestedNodes = []
339
+ /** @type {Array<{type: string}>} */
340
+ const upstreamNodes = []
341
+ if (flow && flow.length && attachToNode.id) {
342
+ upstreamNodes.push(...getLongestUpstreamPath(flow, attachToNode.id))
343
+ }
344
+ if (this.completionsReady) {
345
+ const allNodes = [...upstreamNodes, attachToNode] // Include the last node in the input
346
+ const typeNames = allNodes.map(node => node.type) // Extract the type names from the nodes
347
+ const vectorizedText = this.labeller.encode_sequence(typeNames)
348
+
349
+ const feeds = {
350
+ X: new this._ort.Tensor('float32', vectorizedText, [1, vectorizedText.length])
351
+ }
352
+
353
+ const results = await this._completionsSession.run(feeds)
354
+ // Get top 3 + 5 additional predictions (only 3 will be used since there are 5 permanent suggestions in the typeSearch, we need enough to fill the suggestions array)
355
+ const predictions = this.labeller.decode_predictions(results.probabilities.cpuData, 3 + 5)
356
+
357
+ suggestedNodes = predictions.map(prediction => ({ type: prediction.className })) // Create new nodes with the predicted types
358
+ }
359
+
360
+ // typical patterns like "link in" > "something" > "link out" or "http in" > "something" > "http response"
361
+ // can be recognized and suggested if the model does not predict them...
362
+ if (flow && flow.length > 1) {
363
+ // if the flow has a "split" but not a "join", we can suggest a "join" node
364
+ const hasSplit = flow.some(node => node.type === 'split')
365
+ const hasJoin = flow.some(n => n.type === 'join')
366
+ const joinSuggested = suggestedNodes.some(n => n.type === 'join')
367
+ if (hasSplit && !hasJoin && !joinSuggested && sourceNode.type !== 'split') {
368
+ suggestedNodes.unshift({ type: 'join', x: 0, y: 0 })
369
+ }
370
+ // if the flow contains a "link in" but no "link out" nodes, we can suggest a "link out" node
371
+ const hasLinkIn = flow.some(n => n.type === 'link in')
372
+ const hasLinkOut = flow.some(n => n.type === 'link out')
373
+ const linkOutSuggested = suggestedNodes.some(n => n.type === 'link out')
374
+ if (hasLinkIn && !hasLinkOut && !linkOutSuggested && sourceNode.type !== 'link in') {
375
+ suggestedNodes.unshift({ type: 'link out', x: 0, y: 0 })
376
+ }
377
+ // if the flow has a "http in" but not a "http response", we can suggest an "http response" node
378
+ const hasHTTP = flow.some(n => n.type === 'http in')
379
+ const hasHTTPResponse = flow.some(n => n.type === 'http response')
380
+ const httpResponseSuggested = suggestedNodes.some(n => n.type === 'http response')
381
+ if (hasHTTP && !hasHTTPResponse && !httpResponseSuggested && sourceNode.type !== 'http in') {
382
+ suggestedNodes.unshift({ type: 'http response', x: 0, y: 0 })
383
+ }
384
+ }
385
+ // if the first suggestion is exactly the same as the source node, move it to the end of the list
386
+ if (suggestedNodes.length > 0 && suggestedNodes[0].type === sourceNode.type) {
387
+ suggestedNodes.push(suggestedNodes.shift())
388
+ }
389
+ const suggestions = suggestedNodes.map(node => [node])
390
+ return {
391
+ structuredContent: {
392
+ sourceId: sourceNode.id,
393
+ sourcePort: sourcePort || 0, // TODO: have the tool accept a sourcePort parameter
394
+ suggestions // use the suggestions array
395
+ }
396
+ }
397
+ })
398
+
399
+ // Create in-process client
400
+ const client = new Client({
401
+ name: 'NR MCP Client',
402
+ version: '1.0.0'
403
+ })
404
+
405
+ const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()
406
+ await Promise.all([
407
+ server.connect(serverTransport),
408
+ client.connect(clientTransport)
409
+ ])
410
+
411
+ return {
412
+ client,
413
+ server
414
+ }
415
+ }
416
+
417
+ // #region Admin Endpoints & HTTP Handlers
418
+
419
+ initAdminEndpoints (RED) {
420
+ RED.httpAdmin.post('/nr-assistant/:method', RED.auth.needsPermission('write'), function (req, res) {
421
+ return assistant.handlePostMethodRequest(req, res)
422
+ })
423
+
424
+ RED.httpAdmin.get('/nr-assistant/mcp/prompts', RED.auth.needsPermission('write'), async function (req, res) {
425
+ return assistant.handlePostPromptsRequest(req, res)
426
+ })
427
+
428
+ RED.httpAdmin.post('/nr-assistant/mcp/prompts/:promptId', RED.auth.needsPermission('write'), async function (req, res) {
429
+ return assistant.handlePostPromptRequest(req, res)
430
+ })
431
+
432
+ RED.httpAdmin.post('/nr-assistant/mcp/tools/:toolId', RED.auth.needsPermission('write'), async function (req, res) {
433
+ return assistant.handlePostToolRequest(req, res)
434
+ })
435
+ }
436
+
437
+ /**
438
+ * Handles POST requests to the /nr-assistant/:method endpoint.
439
+ * This is for handling custom methods that the Assistant can perform.
440
+ * @param {import('express').Request} req - The request object
441
+ * @param {import('express').Response} res - The response object
442
+ */
443
+ async handlePostMethodRequest (req, res) {
444
+ if (!this.isInitialized || this.isLoading) {
445
+ return res.status(503).send('Assistant is not ready')
446
+ }
447
+
448
+ const method = req.params.method
449
+ // limit method to prevent path traversal
450
+ if (!method || typeof method !== 'string' || /[^a-z0-9-_]/.test(method)) {
451
+ res.status(400)
452
+ res.json({ status: 'error', message: 'Invalid method' })
453
+ return
454
+ }
455
+ const input = req.body
456
+ if (!input || !input.prompt || typeof input.prompt !== 'string') {
457
+ res.status(400)
458
+ res.json({ status: 'error', message: 'prompt is required' })
459
+ return
460
+ }
461
+ const body = {
462
+ prompt: input.prompt, // this is the prompt to the AI
463
+ promptHint: input.promptHint, // this is used to let the AI know what we are generating (`function node? Node JavaScript? flow?)
464
+ context: input.context, // this is used to provide additional context to the AI (e.g. the selected text of the function node)
465
+ transactionId: input.transactionId // used to correlate the request with the response
466
+ }
467
+ // join url & method (taking care of trailing slashes)
468
+ const url = `${this.options.url.replace(/\/$/, '')}/${method.replace(/^\//, '')}`
469
+ this.got.post(url, {
470
+ headers: {
471
+ Accept: '*/*',
472
+ 'Accept-Language': 'en-GB,en-US;q=0.9,en;q=0.8,es;q=0.7',
473
+ Authorization: `Bearer ${this.options.token}`,
474
+ 'Content-Type': 'application/json',
475
+ 'User-Agent': FF_ASSISTANT_USER_AGENT
476
+ },
477
+ json: body
478
+ }).then(response => {
479
+ const data = JSON.parse(response.body)
480
+ res.json({
481
+ status: 'ok',
482
+ data
483
+ })
484
+ }).catch((error) => {
485
+ let body = error.response && error.response.body
486
+ if (typeof body === 'string') {
487
+ try {
488
+ body = JSON.parse(body)
489
+ } catch (e) {
490
+ // ignore
491
+ }
492
+ }
493
+ let message = 'FlowFuse Assistant request was unsuccessful'
494
+ const errorData = { status: 'error', message, body }
495
+ const errorCode = (error.response && error.response.statusCode) || 500
496
+ res.status(errorCode).json(errorData)
497
+ this.RED.log.trace('nr-assistant error:', error)
498
+ if (body && typeof body === 'object' && body.error) {
499
+ message = `${message}: ${body.error}`
500
+ }
501
+ this.RED.log.warn(message)
502
+ })
503
+ }
504
+
505
+ /**
506
+ * Handles POST requests to the /nr-assistant/mcp/prompts endpoint.
507
+ * Returns a list of available prompts from the Model Context Protocol (MCP).
508
+ * @param {import('express').Request} req - The request object
509
+ * @param {import('express').Response} res - The response object
510
+ */
511
+ async handlePostPromptsRequest (req, res) {
512
+ if (!this.isInitialized || this.isLoading) {
513
+ return res.status(503).send('Assistant is not ready')
514
+ }
515
+ if (!this.mcpReady) {
516
+ return res.status(503).send('Model Context Protocol (MCP) is not ready')
517
+ }
518
+
519
+ try {
520
+ const prompts = await this._mcpClient.getPrompts()
521
+ res.json({ status: 'ok', data: prompts })
522
+ } catch (error) {
523
+ this.RED.log.error('Failed to retrieve MCP prompts:', error)
524
+ res.status(500).json({ status: 'error', message: 'Failed to retrieve MCP prompts' })
525
+ }
526
+ }
527
+
528
+ /**
529
+ * Handles POST requests to the /nr-assistant/mcp/prompts/:promptId endpoint.
530
+ * Executes a prompt from the Model Context Protocol (MCP) with the provided prompt ID.
531
+ * @param {import('express').Request} req - The request object
532
+ * @param {import('express').Response} res - The response object
533
+ */
534
+ async handlePostPromptRequest (req, res) {
535
+ if (!this.isInitialized || this.isLoading) {
536
+ return res.status(503).send('Assistant is not ready')
537
+ }
538
+ if (!this.mcpReady) {
539
+ return res.status(503).send('Model Context Protocol (MCP) is not ready')
540
+ }
541
+
542
+ const promptId = req.params.promptId
543
+ if (!promptId || typeof promptId !== 'string') {
544
+ return res.status(400).json({ status: 'error', message: 'Invalid prompt ID' })
545
+ }
546
+
547
+ const input = req.body
548
+ if (!input || !input.nodes || typeof input.nodes !== 'string') {
549
+ res.status(400).json({ status: 'error', message: 'nodes selection is required' })
550
+ return
551
+ }
552
+ try {
553
+ // Only include flowName and userContext if they are defined
554
+ const promptArgs = { nodes: input.nodes }
555
+ if (input.flowName !== undefined) promptArgs.flowName = input.flowName
556
+ if (input.userContext !== undefined) promptArgs.userContext = input.userContext
557
+
558
+ const response = await this._mcpClient.getPrompt({
559
+ name: promptId,
560
+ arguments: promptArgs
561
+ })
562
+
563
+ const body = {
564
+ prompt: promptId, // this is the prompt to the AI
565
+ transactionId: input.transactionId, // used to correlate the request with the response
566
+ context: {
567
+ type: 'prompt',
568
+ promptId,
569
+ prompt: response
570
+ }
571
+ }
572
+
573
+ // join url & method (taking care of trailing slashes)
574
+ const url = `${this.options.url.replace(/\/$/, '')}/mcp`
575
+ const responseFromAI = await this.got.post(url, {
576
+ headers: {
577
+ Accept: '*/*',
578
+ 'Accept-Language': 'en-GB,en-US;q=0.9,en;q=0.8,es;q=0.7',
579
+ Authorization: `Bearer ${this.options.token}`,
580
+ 'Content-Type': 'application/json'
581
+ },
582
+ json: body
583
+ })
584
+ const responseBody = JSON.parse(responseFromAI.body)
585
+ // Assuming the response from the AI is in the expected format
586
+ if (!responseBody || responseFromAI.statusCode !== 200) {
587
+ res.status(responseFromAI.statusCode || 500).json({ status: 'error', message: 'AI response was not successful', data: responseBody })
588
+ return
589
+ }
590
+ // If the response is successful, return the data
591
+ res.json({
592
+ status: 'ok',
593
+ data: responseBody.data || responseBody // Use data if available, otherwise return the whole response
594
+ })
595
+ } catch (error) {
596
+ this.RED.log.error('Failed to execute MCP prompt:', error)
597
+ res.status(500).json({ status: 'error', message: 'Failed to execute MCP prompt' })
598
+ }
599
+ }
600
+
601
+ /**
602
+ * Handles POST requests to the /nr-assistant/mcp/tools/:toolId endpoint.
603
+ * Executes a tool from the Model Context Protocol (MCP) with the provided tool ID
604
+ * and input.
605
+ * @param {import('express').Request} req - The request object
606
+ * @param {import('express').Response} res - The response object
607
+ */
608
+ async handlePostToolRequest (req, res) {
609
+ if (!this.isInitialized || this.isLoading) {
610
+ return res.status(503).send('Assistant is not ready')
611
+ }
612
+ if (!this.mcpReady) {
613
+ return res.status(503).send('Model Context Protocol (MCP) is not ready')
614
+ }
615
+
616
+ let sourcePort = 0 // default source port
617
+ const input = req.body || {}
618
+ const sourceNode = input.sourceNode
619
+ const toolId = req.params.toolId
620
+
621
+ // Validate input
622
+ if (!sourceNode || typeof sourceNode !== 'object') {
623
+ res.status(400).json({ status: 'error', message: 'Invalid input' })
624
+ return
625
+ }
626
+ if (toolId !== 'predict_next') { // only predict_next is currently supported
627
+ res.status(400).json({ status: 'error', message: 'Invalid tool ID' })
628
+ return
629
+ }
630
+
631
+ if (hasProperty(input, 'sourcePort') && !isNaN(+input.sourcePort) && +sourcePort < 0) {
632
+ sourcePort = parseInt(input.sourcePort, 10)
633
+ }
634
+
635
+ // code for predict_next
636
+ try {
637
+ const response = await this._mcpClient.callTool({
638
+ name: toolId,
639
+ arguments: {
640
+ flow: input.flow || undefined, // optional flow nodes
641
+ sourceNode,
642
+ sourcePort
643
+ }
644
+ })
645
+ const body = {
646
+ tool: toolId,
647
+ transactionId: input.transactionId, // used to correlate the request with the response
648
+ result: response
649
+ }
650
+
651
+ // If the response is successful, return the data
652
+ res.json({
653
+ status: 'ok',
654
+ data: body
655
+ })
656
+ } catch (error) {
657
+ this.RED.log.error('Failed to execute MCP tool:', error)
658
+ res.status(500).json({ status: 'error', message: 'Failed to execute MCP tool' })
659
+ }
660
+ }
661
+
662
+ // #endregion
663
+ }
664
+
665
+ const assistant = new Assistant() // singleton instance of the Assistant class
666
+
667
+ module.exports = assistant