@flowfuse/nr-launcher 2.32.1-b23502c-202607061802.0 → 2.32.1

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/CHANGELOG.md CHANGED
@@ -1,3 +1,10 @@
1
+ #### 2.32.1: Release
2
+
3
+ - fix: Ensure MCP server-side sessions are terminated (#580)
4
+ - ci: Add slack notification to the release pipeline (#578)
5
+ - Replace @node-red/util dependency with local red_util module (#577)
6
+ - Bump multer from 2.1.1 to 2.2.0 (#568)
7
+
1
8
  #### 2.32.0: Release
2
9
 
3
10
  - chore: bump @flowfuse/nr-assistant to ^0.17.0 (#574)
@@ -1,4 +1,4 @@
1
- const util = require('@node-red/util').util
1
+ const util = require('./red_util.js')
2
2
 
3
3
  function Memory (config) {
4
4
  this.data = config.data || {}
@@ -0,0 +1,322 @@
1
+ /* eslint-disable no-useless-escape */
2
+
3
+ // Extracted from @node-red/util/util.js to avoid dependency on @node-red/util package
4
+
5
+ function createError (code, message) {
6
+ const e = new Error(message)
7
+ e.code = code
8
+ return e
9
+ }
10
+
11
+ /**
12
+ * Parses a property expression, such as `msg.foo.bar[3]` to validate it
13
+ * and convert it to a canonical version expressed as an Array of property
14
+ * names.
15
+ *
16
+ * For example, `a["b"].c` returns `['a','b','c']`
17
+ *
18
+ * If `msg` is provided, any internal cross-references will be evaluated against that
19
+ * object. Otherwise, it will return a nested set of properties
20
+ *
21
+ * For example, without msg set, 'a[msg.foo]' returns `['a', [ 'msg', 'foo'] ]`
22
+ * But if msg is set to '{"foo": "bar"}', 'a[msg.foo]' returns `['a', 'bar' ]`
23
+ *
24
+ * @param {String} str - the property expression
25
+ * @return {Array} the normalised expression
26
+ * @memberof @node-red/util_util
27
+ */
28
+ function normalisePropertyExpression (str, msg, toString) {
29
+ // This must be kept in sync with validatePropertyExpression
30
+ // in editor/js/ui/utils.js
31
+
32
+ const length = str.length
33
+ if (length === 0) {
34
+ throw createError('INVALID_EXPR', 'Invalid property expression: zero-length')
35
+ }
36
+ const parts = []
37
+ let start = 0
38
+ let inString = false
39
+ let inBox = false
40
+ let quoteChar
41
+ let v
42
+ for (let i = 0; i < length; i++) {
43
+ const c = str[i]
44
+ if (!inString) {
45
+ if (c === "'" || c === '"') {
46
+ if (i !== start) {
47
+ throw createError('INVALID_EXPR', 'Invalid property expression: unexpected ' + c + ' at position ' + i)
48
+ }
49
+ inString = true
50
+ quoteChar = c
51
+ start = i + 1
52
+ } else if (c === '.') {
53
+ if (i === 0) {
54
+ throw createError('INVALID_EXPR', 'Invalid property expression: unexpected . at position 0')
55
+ }
56
+ if (start !== i) {
57
+ v = str.substring(start, i)
58
+ if (/^\d+$/.test(v)) {
59
+ parts.push(parseInt(v))
60
+ } else {
61
+ parts.push(v)
62
+ }
63
+ }
64
+ if (i === length - 1) {
65
+ throw createError('INVALID_EXPR', 'Invalid property expression: unterminated expression')
66
+ }
67
+ // Next char is first char of an identifier: a-z 0-9 $ _
68
+ if (!/[a-z0-9\$\_]/i.test(str[i + 1])) {
69
+ throw createError('INVALID_EXPR', 'Invalid property expression: unexpected ' + str[i + 1] + ' at position ' + (i + 1))
70
+ }
71
+ start = i + 1
72
+ } else if (c === '[') {
73
+ if (i === 0) {
74
+ throw createError('INVALID_EXPR', 'Invalid property expression: unexpected ' + c + ' at position ' + i)
75
+ }
76
+ if (start !== i) {
77
+ parts.push(str.substring(start, i))
78
+ }
79
+ if (i === length - 1) {
80
+ throw createError('INVALID_EXPR', 'Invalid property expression: unterminated expression')
81
+ }
82
+ // Start of a new expression. If it starts with msg it is a nested expression
83
+ // Need to scan ahead to find the closing bracket
84
+ if (/^msg[.\[]/.test(str.substring(i + 1))) {
85
+ let depth = 1
86
+ let inLocalString = false
87
+ let localStringQuote
88
+ for (let j = i + 1; j < length; j++) {
89
+ if (/["']/.test(str[j])) {
90
+ if (inLocalString) {
91
+ if (str[j] === localStringQuote) {
92
+ inLocalString = false
93
+ }
94
+ } else {
95
+ inLocalString = true
96
+ localStringQuote = str[j]
97
+ }
98
+ }
99
+ if (str[j] === '[') {
100
+ depth++
101
+ } else if (str[j] === ']') {
102
+ depth--
103
+ }
104
+ if (depth === 0) {
105
+ try {
106
+ if (msg) {
107
+ const crossRefProp = getMessageProperty(msg, str.substring(i + 1, j))
108
+ if (crossRefProp === undefined) {
109
+ throw createError('INVALID_EXPR', 'Invalid expression: undefined reference at position ' + (i + 1) + ' : ' + str.substring(i + 1, j))
110
+ }
111
+ parts.push(crossRefProp)
112
+ } else {
113
+ parts.push(normalisePropertyExpression(str.substring(i + 1, j), msg))
114
+ }
115
+ inBox = false
116
+ i = j
117
+ start = j + 1
118
+ break
119
+ } catch (err) {
120
+ throw createError('INVALID_EXPR', 'Invalid expression started at position ' + (i + 1))
121
+ }
122
+ }
123
+ }
124
+ if (depth > 0) {
125
+ throw createError('INVALID_EXPR', "Invalid property expression: unmatched '[' at position " + i)
126
+ }
127
+ continue
128
+ } else if (!/["'\d]/.test(str[i + 1])) {
129
+ // Next char is either a quote or a number
130
+ throw createError('INVALID_EXPR', 'Invalid property expression: unexpected ' + str[i + 1] + ' at position ' + (i + 1))
131
+ }
132
+ start = i + 1
133
+ inBox = true
134
+ } else if (c === ']') {
135
+ if (!inBox) {
136
+ throw createError('INVALID_EXPR', 'Invalid property expression: unexpected ' + c + ' at position ' + i)
137
+ }
138
+ if (start !== i) {
139
+ v = str.substring(start, i)
140
+ if (/^\d+$/.test(v)) {
141
+ parts.push(parseInt(v))
142
+ } else {
143
+ throw createError('INVALID_EXPR', 'Invalid property expression: unexpected array expression at position ' + start)
144
+ }
145
+ }
146
+ start = i + 1
147
+ inBox = false
148
+ } else if (c === ' ') {
149
+ throw createError('INVALID_EXPR', "Invalid property expression: unexpected ' ' at position " + i)
150
+ }
151
+ } else {
152
+ if (c === quoteChar) {
153
+ if (i - start === 0) {
154
+ throw createError('INVALID_EXPR', 'Invalid property expression: zero-length string at position ' + start)
155
+ }
156
+ parts.push(str.substring(start, i))
157
+ // If inBox, next char must be a ]. Otherwise it may be [ or .
158
+ if (inBox && !/\]/.test(str[i + 1])) {
159
+ throw createError('INVALID_EXPR', 'Invalid property expression: unexpected array expression at position ' + start)
160
+ } else if (!inBox && i + 1 !== length && !/[\[\.]/.test(str[i + 1])) {
161
+ throw createError('INVALID_EXPR', 'Invalid property expression: unexpected ' + str[i + 1] + ' expression at position ' + (i + 1))
162
+ }
163
+ start = i + 1
164
+ inString = false
165
+ }
166
+ }
167
+ }
168
+ if (inBox || inString) {
169
+ // eslint-disable-next-line new-cap
170
+ throw new createError('INVALID_EXPR', 'Invalid property expression: unterminated expression')
171
+ }
172
+ if (start < length) {
173
+ parts.push(str.substring(start))
174
+ }
175
+
176
+ if (toString) {
177
+ let result = parts.shift()
178
+ while (parts.length > 0) {
179
+ let p = parts.shift()
180
+ if (typeof p === 'string') {
181
+ if (/"/.test(p)) {
182
+ p = "'" + p + "'"
183
+ } else {
184
+ p = '"' + p + '"'
185
+ }
186
+ }
187
+ result = result + '[' + p + ']'
188
+ }
189
+ return result
190
+ }
191
+
192
+ return parts
193
+ }
194
+
195
+ /**
196
+ * Gets a property of a message object.
197
+ *
198
+ * Unlike {@link @node-red/util-util.getObjectProperty}, this function will strip `msg.` from the
199
+ * front of the property expression if present.
200
+ *
201
+ * @param {Object} msg - the message object
202
+ * @param {String} expr - the property expression
203
+ * @return {any} the message property, or undefined if it does not exist
204
+ * @throws Will throw an error if the *parent* of the property does not exist
205
+ * @memberof @node-red/util_util
206
+ */
207
+ function getMessageProperty (msg, expr) {
208
+ if (expr.indexOf('msg.') === 0) {
209
+ expr = expr.substring(4)
210
+ }
211
+ return getObjectProperty(msg, expr)
212
+ }
213
+
214
+ /**
215
+ * Gets a property of an object.
216
+ *
217
+ * Given the object:
218
+ *
219
+ * {
220
+ * "pet": {
221
+ * "type": "cat"
222
+ * }
223
+ * }
224
+ *
225
+ * - `pet.type` will return `"cat"`.
226
+ * - `pet.name` will return `undefined`
227
+ * - `car` will return `undefined`
228
+ * - `car.type` will throw an Error (as `car` does not exist)
229
+ *
230
+ * @param {Object} msg - the object
231
+ * @param {String} expr - the property expression
232
+ * @return {any} the object property, or undefined if it does not exist
233
+ * @throws Will throw an error if the *parent* of the property does not exist
234
+ * @memberof @node-red/util_util
235
+ */
236
+ function getObjectProperty (msg, expr) {
237
+ let result = null
238
+ const msgPropParts = normalisePropertyExpression(expr, msg)
239
+ msgPropParts.reduce(function (obj, key) {
240
+ result = (typeof obj[key] !== 'undefined' ? obj[key] : undefined)
241
+ return result
242
+ }, msg)
243
+ return result
244
+ }
245
+
246
+ /**
247
+ * Sets a property of an object.
248
+ *
249
+ * @param {Object} msg - the object
250
+ * @param {String} prop - the property expression
251
+ * @param {any} value - the value to set
252
+ * @param {boolean} createMissing - whether to create missing parent properties
253
+ * @memberof @node-red/util_util
254
+ */
255
+ function setObjectProperty (msg, prop, value, createMissing) {
256
+ if (typeof createMissing === 'undefined') {
257
+ createMissing = (typeof value !== 'undefined')
258
+ }
259
+ const msgPropParts = normalisePropertyExpression(prop, msg)
260
+ const length = msgPropParts.length
261
+ let obj = msg
262
+ let key
263
+ for (let i = 0; i < length - 1; i++) {
264
+ key = msgPropParts[i]
265
+ if (typeof key === 'string' || (typeof key === 'number' && !Array.isArray(obj))) {
266
+ if (hasOwnProperty.call(obj, key)) {
267
+ if (length > 1 && ((typeof obj[key] !== 'object' && typeof obj[key] !== 'function') || obj[key] === null)) {
268
+ // Break out early as we cannot create a property beneath
269
+ // this type of value
270
+ return false
271
+ }
272
+ obj = obj[key]
273
+ } else if (createMissing) {
274
+ if (typeof msgPropParts[i + 1] === 'string') {
275
+ obj[key] = {}
276
+ } else {
277
+ obj[key] = []
278
+ }
279
+ obj = obj[key]
280
+ } else {
281
+ return false
282
+ }
283
+ } else if (typeof key === 'number') {
284
+ // obj is an array
285
+ if (obj[key] === undefined) {
286
+ if (createMissing) {
287
+ if (typeof msgPropParts[i + 1] === 'string') {
288
+ obj[key] = {}
289
+ } else {
290
+ obj[key] = []
291
+ }
292
+ obj = obj[key]
293
+ } else {
294
+ return false
295
+ }
296
+ } else {
297
+ obj = obj[key]
298
+ }
299
+ }
300
+ }
301
+ key = msgPropParts[length - 1]
302
+ if (typeof value === 'undefined') {
303
+ if (typeof key === 'number' && Array.isArray(obj)) {
304
+ obj.splice(key, 1)
305
+ } else {
306
+ delete obj[key]
307
+ }
308
+ } else {
309
+ if (typeof obj === 'object' && obj !== null) {
310
+ obj[key] = value
311
+ } else {
312
+ // Cannot set a property of a non-object/array
313
+ return false
314
+ }
315
+ }
316
+ return true
317
+ }
318
+
319
+ module.exports = {
320
+ getObjectProperty,
321
+ setObjectProperty
322
+ }
package/lib/mcp.js CHANGED
@@ -260,7 +260,7 @@ class MCP {
260
260
 
261
261
  /**
262
262
  * Connect a fresh MCP client to the given (already-normalised) endpoint spec.
263
- * Caller is responsible for `client.close()` when finished.
263
+ * Caller is responsible for calling {@link _disconnect} when finished.
264
264
  * @param {McpEndpoint} endpoint
265
265
  * @returns {Promise<McpClient>} a connected MCP Client instance
266
266
  */
@@ -274,6 +274,31 @@ class MCP {
274
274
  return client
275
275
  }
276
276
 
277
+ /**
278
+ * Tear down a connected client, terminating the server-side session first.
279
+ *
280
+ * The launcher opens a fresh session for every operation, so it must also
281
+ * close them: the SDK client's `close()` only aborts the local connection —
282
+ * it does NOT send the HTTP DELETE that terminates the session on the server.
283
+ * Without an explicit `terminateSession()`, server-side sessions accumulate
284
+ * (the MCP host has no idle reaper), and every subsequent connection re-runs
285
+ * its registration pass across all of them. So we terminate, then close.
286
+ * @param {McpClient} [client]
287
+ */
288
+ async _disconnect (client) {
289
+ if (!client) {
290
+ return
291
+ }
292
+ try {
293
+ /** @type {StreamableHTTPClientTransport} */
294
+ const transport = client.transport
295
+ await transport?.terminateSession?.()
296
+ } catch (_) { /* server may not support DELETE; fall through to close */ }
297
+ try {
298
+ await client.close()
299
+ } catch (_) { /* ignore */ }
300
+ }
301
+
277
302
  async _getEndpointFeatures (/** @type {McpEndpoint} */ endpoint) {
278
303
  const features = {
279
304
  tools: [],
@@ -323,11 +348,7 @@ class MCP {
323
348
  warn(`Failed to query MCP endpoint '${endpoint.key}': ${err.message}`)
324
349
  features.error = err.message
325
350
  } finally {
326
- if (client) {
327
- try {
328
- await client.close()
329
- } catch (_) { /* ignore */ }
330
- }
351
+ await this._disconnect(client)
331
352
  }
332
353
  return features
333
354
  }
@@ -416,11 +437,7 @@ class MCP {
416
437
  const response = await client.callTool({ name, arguments: input || {} })
417
438
  return response
418
439
  } finally {
419
- if (client) {
420
- try {
421
- await client.close()
422
- } catch (_) { /* ignore */ }
423
- }
440
+ await this._disconnect(client)
424
441
  }
425
442
  }
426
443
 
@@ -449,11 +466,7 @@ class MCP {
449
466
  const response = await client.readResource({ uri })
450
467
  return response
451
468
  } finally {
452
- if (client) {
453
- try {
454
- await client.close()
455
- } catch (_) { /* ignore */ }
456
- }
469
+ await this._disconnect(client)
457
470
  }
458
471
  }
459
472