@flowfuse/nr-launcher 2.31.4-d304c3f-202606241245.0 → 2.32.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.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,20 @@
1
+ #### 2.32.0: Release
2
+
3
+ - chore: bump @flowfuse/nr-assistant to ^0.17.0 (#574)
4
+ - Add support for insights via launcher (#571)
5
+ - Bump actions/checkout from 6.0.3 to 7.0.0 (#570)
6
+ - [7606] Failed restarts get stuck on restarting (live status) due to nr-launcher swallowing the start error (#573)
7
+ - Only generate forge-l/d themes against v4.x css (#572)
8
+ - Update nr-theme version to 1.11.0 (#569)
9
+ - [7553] Load FlowFuse editor behavior regardless of theme (#567)
10
+ - Bump js-yaml from 4.1.1 to 4.2.0 (#564)
11
+ - Bump actions/checkout from 6.0.2 to 6.0.3 (#553)
12
+ - ci: set dependabot pull request limit to 30 (#565)
13
+ - Bump ws (#562)
14
+ - Bump tar from 7.5.13 to 7.5.16 (#563)
15
+ - Bump hono from 4.12.23 to 4.12.25 (#566)
16
+ - Move publish to after all build steps (#559)
17
+
1
18
  #### 2.31.3: Release
2
19
 
3
20
  - Disable visual notification of new NR version in the editor (#560)
package/lib/admin.js CHANGED
@@ -148,6 +148,7 @@ class AdminInterface {
148
148
  await launcher.start(request.body.safe ? States.SAFE : States.RUNNING)
149
149
  } catch (error) {
150
150
  await launcher.logAuditEvent('start-failed', { error })
151
+ return response.status(500).send({ error: error.message })
151
152
  }
152
153
  response.send({})
153
154
  }, 2000)
@@ -157,6 +158,7 @@ class AdminInterface {
157
158
  await launcher.start(request.body.safe ? States.SAFE : States.RUNNING)
158
159
  } catch (error) {
159
160
  await launcher.logAuditEvent('start-failed', { error })
161
+ return response.status(500).send({ error: error.message })
160
162
  }
161
163
  response.send({})
162
164
  } else {
@@ -185,6 +187,33 @@ class AdminInterface {
185
187
  await launcher.stop()
186
188
  response.send({})
187
189
  process.exit(0)
190
+ } else if (request.body.cmd === 'mcp:get-features') {
191
+ // MCP ROUTE: step 5 (hosted)
192
+ // Called By: driver (localfs/k8s/docker)
193
+ // Calls To : ./launcher.js helper functions
194
+ try {
195
+ const { endpoints } = request.body?.data || {}
196
+ const features = await launcher.getMCPFeatures(endpoints)
197
+ response.send(features)
198
+ } catch (error) {
199
+ response.status(500).send({ err: error.message })
200
+ }
201
+ } else if (request.body.cmd === 'mcp:call-tool') {
202
+ try {
203
+ const { endpoint, name, input } = request.body?.data || {}
204
+ const result = await launcher.callMCPTool(endpoint, name, input)
205
+ response.send(result)
206
+ } catch (error) {
207
+ response.status(500).send({ err: error.message })
208
+ }
209
+ } else if (request.body.cmd === 'mcp:read-resource') {
210
+ try {
211
+ const { endpoint, uri } = request.body?.data || {}
212
+ const result = await launcher.readMCPResource(endpoint, uri)
213
+ response.send(result)
214
+ } catch (error) {
215
+ response.status(500).send({ err: error.message })
216
+ }
188
217
  } else {
189
218
  response.status(404).send({})
190
219
  }
package/lib/launcher.js CHANGED
@@ -6,6 +6,7 @@ const LogBuffer = require('./logBuffer')
6
6
  const { getSettingsFile } = require('./runtimeSettings')
7
7
  const SampleBuffer = require('./resources/sampleBuffer')
8
8
  const resourceSample = require('./resources/sample')
9
+ const { MCP } = require('./mcp')
9
10
  const hasProperty = (object, property) => !!(object && Object.prototype.hasOwnProperty.call(object, property))
10
11
 
11
12
  /** The point at which we check to see if we are in a boot loop */
@@ -93,6 +94,9 @@ class Launcher {
93
94
  this.timing = {
94
95
  created: Date.now()
95
96
  }
97
+
98
+ /** @type {MCP} */
99
+ this.mcp = null
96
100
  }
97
101
 
98
102
  /** @type {Number} */
@@ -217,6 +221,15 @@ class Launcher {
217
221
  }
218
222
  }
219
223
  }
224
+ try {
225
+ this.mcp = new MCP({
226
+ nodeRedUserDir: path.join(this.settings.rootDir, this.settings.userDir),
227
+ port: this.settings.port
228
+ })
229
+ } catch (error) {
230
+ console.error('Failed to initialize MCP client', error)
231
+ }
232
+
220
233
  this.timing.loading = Date.now() - loadingStart
221
234
  try {
222
235
  await this.updatePackage()
@@ -343,6 +356,56 @@ class Launcher {
343
356
  return this.proc && this.proc.exitCode === null
344
357
  }
345
358
 
359
+ // #region MCP Support
360
+
361
+ // MCP ROUTE: step 6 (hosted)
362
+ // Called By: launcher route in ./admin.js:204
363
+ // Calls To : MCP helper class methods
364
+
365
+ /**
366
+ * Get a list of features supported by the MCP server based on the provided endpoints.
367
+ * @param {import('./mcp.js').McpEndpointSpec[]} endpoints - List of MCP endpoints to query for features
368
+ * @returns {Promise<{[x: string]: Object}>} - List of MCP features supported by the servers
369
+ */
370
+ async getMCPFeatures (endpoints) {
371
+ if (!this.mcp) {
372
+ throw new Error('MCP client not initialized')
373
+ }
374
+ this.logBuffer.add({ level: 'system', msg: 'Getting MCP features' })
375
+ return this.mcp.getFeatures(endpoints)
376
+ }
377
+
378
+ /**
379
+ * Call a tool on the MCP server.
380
+ * @param {import('./mcp.js').McpEndpointSpec} endpoint - The MCP endpoint to call the tool on
381
+ * @param {string} name - The name of the tool to call
382
+ * @param {object} input - The input to pass to the tool
383
+ * @returns {Promise<object>} - The result of the tool call
384
+ */
385
+ async callMCPTool (endpoint, name, input) {
386
+ if (!this.mcp) {
387
+ throw new Error('MCP client not initialized')
388
+ }
389
+ this.logBuffer.add({ level: 'system', msg: 'Calling MCP tool' })
390
+ return this.mcp.callTool(endpoint, name, input)
391
+ }
392
+
393
+ /**
394
+ * Read a resource from the MCP server.
395
+ * @param {import('./mcp.js').McpEndpointSpec} endpoint - The MCP endpoint to read the resource from
396
+ * @param {string} uri - The URI of the resource to read
397
+ * @returns {Promise<object>} - The result of the resource read
398
+ */
399
+ async readMCPResource (endpoint, uri) {
400
+ if (!this.mcp) {
401
+ throw new Error('MCP client not initialized')
402
+ }
403
+ this.logBuffer.add({ level: 'system', msg: 'Reading MCP resource' })
404
+ return this.mcp.readResource(endpoint, uri)
405
+ }
406
+
407
+ // #endregion MCP Support
408
+
346
409
  async start (targetState) {
347
410
  const NRstartTime = Date.now()
348
411
  if (this.deferredStop) {
@@ -363,6 +426,12 @@ class Launcher {
363
426
  return
364
427
  }
365
428
  this.logBuffer.add({ level: 'system', msg: 'Starting Node-RED' })
429
+
430
+ // The user's node_modules may have just been (re)installed, so any
431
+ // previously-resolved MCP SDK could now be stale. Drop the cached
432
+ // reference so it is re-resolved against the current install on next use.
433
+ this.mcp?.resetSdk?.()
434
+
366
435
  const filterEnv = (env) =>
367
436
  Object.entries(env).reduce((acc, [key, value]) =>
368
437
  key.startsWith('FORGE') ? acc : { ...acc, [key]: value }, {})
@@ -721,6 +790,9 @@ class Launcher {
721
790
 
722
791
  async stop () {
723
792
  this.logBuffer.add({ level: 'system', msg: 'Stopping Node-RED' })
793
+ // Drop any cached MCP SDK reference: the SDK files may be removed or
794
+ // replaced before the next start, so they must not be reused.
795
+ this.mcp?.resetSdk?.()
724
796
  // Stop the health poll - do not want to mistake a stopping/stopped Node-RED
725
797
  // for an unhealthly one
726
798
  clearInterval(this.healthPoll)
package/lib/mcp.js ADDED
@@ -0,0 +1,466 @@
1
+ const path = require('path')
2
+ const { warn, debug } = console
3
+
4
+ const CLIENT_NAME = 'flowfuse-launcher'
5
+ const CLIENT_VERSION = '1.0.0'
6
+
7
+ const MCP_SDK_PACKAGE = '@modelcontextprotocol/sdk'
8
+ const MCP_HOST_PACKAGE = '@flowfuse-nodes/nr-mcp-server-nodes'
9
+
10
+ /** Scope value the platform issues for tokens authorised to talk to MCP servers. */
11
+ const MCP_TOKEN_SCOPE = 'ff-expert:mcp'
12
+
13
+ /**
14
+ * @typedef {object} accessToken
15
+ * @property {string} [scheme] - auth scheme, e.g. 'Bearer'. Basic auth is currently rejected.
16
+ * @property {string} [token] - the token value
17
+ * @property {string|string[]} [scope] - token scope(s); only tokens including `ff-expert:mcp` are honoured
18
+ *
19
+ * @typedef {object} McpEndpointSpec - Specification for an MCP endpoint. Additional properties are allowed and will be passed back in the result (for correlation purposes).
20
+ * @property {string} endpoint - The path of the MCP server e.g. '/mcp'. Should not contain the host/port; those are determined to agent/launcher
21
+ * @property {object} [headers] - extra request headers to send with every MCP HTTP request
22
+ * @property {accessToken} [accessToken] - access token; merged into `Authorization` when scoped for MCP
23
+ *
24
+ * @typedef {object} McpEndpoint {{ key: string, url: string, headers: object, skip: boolean, ... }} (additional properties allowed)
25
+ * @property {string} key - unique key for the endpoint; used for result mapping and logging (often the same as `url`)
26
+ * @property {string} url - MCP endpoint URL or path on the local Node-RED instance
27
+ * @property {object} [headers] - extra request headers to send with every MCP HTTP request
28
+ * @property {boolean} skip - when true, the endpoint should be skipped (e.g. due to unsupported auth scheme)}
29
+ * @property {Error} [error] - if present, indicates an error that caused this endpoint to be skipped
30
+ *
31
+ * @typedef {import('@modelcontextprotocol/sdk/client').Client} McpClient
32
+ * @typedef {import('@modelcontextprotocol/sdk/client/streamableHttp.js').StreamableHTTPClientTransport} StreamableHTTPClientTransport
33
+ */
34
+
35
+ /**
36
+ * Error thrown when the MCP SDK cannot be located/loaded from the Node-RED
37
+ * instance. Carries a deliberately host-path-free message so it is safe to
38
+ * surface back to the platform/UI; the underlying detail (attempted specifiers,
39
+ * resolver error) is logged at debug level only. The original error, when any,
40
+ * is attached as `cause`.
41
+ */
42
+ class McpSdkUnavailableError extends Error {
43
+ constructor (message, { cause } = {}) {
44
+ super(message || 'MCP SDK is not available in this Node-RED instance')
45
+ this.name = 'McpSdkUnavailableError'
46
+ this.code = 'MCP_SDK_UNAVAILABLE'
47
+ if (cause) {
48
+ this.cause = cause
49
+ }
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Resolve an MCP SDK sub-module to an absolute, symlink-resolved file path,
55
+ * scoped to the Node-RED project's node_modules. The SDK is shipped by
56
+ * @flowfuse-nodes/nr-mcp-server-nodes (or any other Node-RED package that depends
57
+ * on it), so it lives in the running Node-RED instance rather than the
58
+ * device-agent's own dependencies.
59
+ *
60
+ * We use `require.resolve(..., { paths })` rather than hand-building a path into
61
+ * `dist/cjs`: this honours the SDK's `exports` map and Node's full resolution
62
+ * algorithm, so it keeps working across build-layout changes and non-flat
63
+ * installs (npm-hoisted, host-nested, pnpm, yarn). `paths` keeps resolution
64
+ * scoped to the project so an unrelated copy elsewhere can't be picked up.
65
+ *
66
+ * Resolution is attempted, in order, from:
67
+ * 1. The host package's own node_modules:
68
+ * <projectDir>/node_modules/<host>/node_modules — where the SDK ends up when
69
+ * the host package is symlinked to a source checkout (npm cannot hoist deps
70
+ * across the symlink boundary).
71
+ * 2. The project root node_modules:
72
+ * <projectDir>/node_modules — the normal hoisted `npm install` case.
73
+ *
74
+ * @param {string} subPath - sub-path within the SDK, e.g. 'client/index.js'
75
+ * @param {string} projectDir - the Node-RED project directory (must contain node_modules)
76
+ * @returns {string} absolute, symlink-resolved path to the SDK module file
77
+ */
78
+ function resolveFromProject (subPath, projectDir) {
79
+ if (!projectDir) {
80
+ throw new Error('Node-RED project directory is not available')
81
+ }
82
+ const specifier = `${MCP_SDK_PACKAGE}/${subPath}`
83
+ const paths = [
84
+ path.join(projectDir, 'node_modules', MCP_HOST_PACKAGE, 'node_modules'),
85
+ path.join(projectDir, 'node_modules')
86
+ ]
87
+ return require.resolve(specifier, { paths })
88
+ }
89
+
90
+ /**
91
+ * Resolve an endpoint string to a URL.
92
+ * - If it already parses as an absolute URL, it is used as-is.
93
+ * - Otherwise it is treated as a path on the local Node-RED instance and joined to
94
+ * `http://127.0.0.1:<port>`. 127.0.0.1 is preferred over `localhost` to avoid
95
+ * IPv4/IPv6 resolution differences and hosts-file surprises.
96
+ * @param {string} endpoint
97
+ * @param {string} host
98
+ * @param {number} port
99
+ * @returns {URL}
100
+ */
101
+ function endpointToUrl (endpoint, host, port) {
102
+ try {
103
+ const u = new URL(endpoint)
104
+ if (u.protocol === 'http:' || u.protocol === 'https:') {
105
+ return u
106
+ }
107
+ } catch (_) { /* fall through to local-path handling */ }
108
+ const pathPart = endpoint.startsWith('/') ? endpoint : `/${endpoint}`
109
+ return new URL(`http://${host || '127.0.0.1'}:${port}${pathPart}`)
110
+ }
111
+
112
+ /**
113
+ * Normalise an endpoint into a uniform shape the rest of the class can work with.
114
+ * Accepts either a bare string (URL/path) or an object spec carrying auth/headers.
115
+ *
116
+ * @param {McpEndpointSpec|string} spec
117
+ * @param {string} host
118
+ * @param {number} port
119
+ * @returns {McpEndpoint}
120
+ * `key` is what the result map is keyed by; `skip` is set when the endpoint
121
+ * should not be contacted (e.g. unsupported auth scheme).
122
+ */
123
+ function normalizeEndpoint (spec, host, port) {
124
+ if (typeof spec === 'string') {
125
+ return {
126
+ key: spec,
127
+ url: endpointToUrl(spec, host, port).toString(),
128
+ headers: { 'Content-Type': 'application/json' },
129
+ skip: false
130
+ }
131
+ }
132
+ if (!spec || typeof spec !== 'object' || typeof spec.mcpEndpoint !== 'string') {
133
+ return {
134
+ key: '<invalid>',
135
+ skip: true,
136
+ error: 'Invalid endpoint spec: must be a string or object with an "mcpEndpoint" property'
137
+ }
138
+ }
139
+ const headers = { ...(spec.headers || {}) }
140
+ if (!headers['Content-Type'] && !headers['content-type']) {
141
+ headers['Content-Type'] = 'application/json'
142
+ }
143
+ let skip = false
144
+ const accessToken = spec.accessToken || null
145
+ if (accessToken) {
146
+ const { scheme, token, scope } = accessToken
147
+ const expertScopedToken = scope === MCP_TOKEN_SCOPE || (Array.isArray(scope) && scope.includes(MCP_TOKEN_SCOPE))
148
+ if (expertScopedToken) {
149
+ if (scheme === 'Basic' || scheme === 'basic') {
150
+ // Basic auth is currently unsupported; mark for the caller to skip.
151
+ skip = true
152
+ } else if (token) {
153
+ headers.Authorization = scheme ? `${scheme} ${token}` : token
154
+ }
155
+ }
156
+ }
157
+ return {
158
+ ...spec, // preserve any additional properties for correlation/logging purposes
159
+ key: spec.mcpEndpoint,
160
+ url: endpointToUrl(spec.mcpEndpoint, host, port).toString(),
161
+ headers,
162
+ skip
163
+ }
164
+ }
165
+
166
+ /**
167
+ * MCP client wrapper around the upstream `@modelcontextprotocol/sdk` Client.
168
+ *
169
+ * Configuration (port / project directory) is captured at construction time so that
170
+ * call-site code only has to supply the per-request payload.
171
+ */
172
+ class MCP {
173
+ /**
174
+ * @param {object} options
175
+ * @param {string} options.nodeRedUserDir - Node-RED project directory containing node_modules; the MCP SDK is loaded from here
176
+ * @param {string} [options.host='127.0.0.1'] - host to use when connecting to local endpoints; typically '127.0.0.1'
177
+ * @param {number} options.port - local Node-RED HTTP port; used when an endpoint is a bare path rather than a full URL
178
+ */
179
+ constructor ({ nodeRedUserDir, host = '127.0.0.1', port } = {}) {
180
+ this.nodeRedUserDir = nodeRedUserDir
181
+ this.host = host
182
+ this.port = port
183
+ /** Cached SDK classes — populated on first successful load. */
184
+ this._sdk = null
185
+ /** Absolute paths the cached SDK was loaded from; used to purge the require cache on reset. */
186
+ this._sdkPaths = null
187
+ }
188
+
189
+ // #region internal helpers
190
+
191
+ /**
192
+ * Lazy load and cache the MCP SDK classes used by this module.
193
+ * Throws {@link McpSdkUnavailableError} if the SDK can't be located/loaded
194
+ * from the Node-RED project's node_modules.
195
+ * @returns {{ Client: McpClient, StreamableHTTPClientTransport: StreamableHTTPClientTransport }}
196
+ * @throws {McpSdkUnavailableError}
197
+ */
198
+ _loadSdk () {
199
+ if (this._sdk) {
200
+ return this._sdk // already loaded and cached
201
+ }
202
+ let clientPath, transportPath
203
+ try {
204
+ clientPath = resolveFromProject('client/index.js', this.nodeRedUserDir)
205
+ transportPath = resolveFromProject('client/streamableHttp.js', this.nodeRedUserDir)
206
+ } catch (err) {
207
+ // Keep host filesystem paths out of the surfaced error; log detail at debug level only.
208
+ debug(`MCP SDK resolution failed: ${err.message}`)
209
+ throw new McpSdkUnavailableError(undefined, { cause: err })
210
+ }
211
+ debug(`Loading MCP SDK from ${clientPath}`)
212
+ const { Client } = require(clientPath)
213
+ const { StreamableHTTPClientTransport } = require(transportPath)
214
+ if (!Client || !StreamableHTTPClientTransport) {
215
+ throw new McpSdkUnavailableError('MCP SDK loaded but expected exports are missing')
216
+ }
217
+ this._sdkPaths = [clientPath, transportPath]
218
+ this._sdk = { Client, StreamableHTTPClientTransport }
219
+ return this._sdk
220
+ }
221
+
222
+ /**
223
+ * Drop the cached SDK reference and purge its entries from Node's require
224
+ * cache, so the next {@link _loadSdk} re-resolves and reloads from disk.
225
+ *
226
+ * The launcher calls this whenever Node-RED is (re)started or stopped: the
227
+ * project's node_modules may have just been reinstalled, relinked (Docker
228
+ * module_cache), or removed, so any previously-loaded SDK could be stale or
229
+ * gone. Purging the require cache matters for the in-place `npm install`
230
+ * case, where the SDK is overwritten at the same path the process already
231
+ * cached.
232
+ */
233
+ resetSdk () {
234
+ this._sdk = null
235
+ if (this._sdkPaths) {
236
+ for (const p of this._sdkPaths) {
237
+ this._purgeFromRequireCache(p)
238
+ }
239
+ this._sdkPaths = null
240
+ }
241
+ }
242
+
243
+ /**
244
+ * Delete the given module file and every other cached module under the same
245
+ * SDK package root from `require.cache`, so a subsequent require reloads the
246
+ * package fresh rather than stitching new entry points onto stale submodules.
247
+ * The SDK is only ever required by this wrapper (Node-RED runs in a separate
248
+ * child process), so purging is safe.
249
+ * @param {string} resolvedPath - an absolute path inside the SDK package
250
+ */
251
+ _purgeFromRequireCache (resolvedPath) {
252
+ const marker = `${path.sep}${MCP_SDK_PACKAGE.split('/').join(path.sep)}${path.sep}`
253
+ const idx = resolvedPath.indexOf(marker)
254
+ const pkgRoot = idx >= 0 ? resolvedPath.slice(0, idx + marker.length) : resolvedPath
255
+ const matches = Object.keys(require.cache).filter(k => k === resolvedPath || k.startsWith(pkgRoot))
256
+ for (const key of matches) {
257
+ delete require.cache[key]
258
+ }
259
+ }
260
+
261
+ /**
262
+ * Connect a fresh MCP client to the given (already-normalised) endpoint spec.
263
+ * Caller is responsible for `client.close()` when finished.
264
+ * @param {McpEndpoint} endpoint
265
+ * @returns {Promise<McpClient>} a connected MCP Client instance
266
+ */
267
+ async _connect (endpoint) {
268
+ const { Client, StreamableHTTPClientTransport } = this._loadSdk()
269
+ const transport = new StreamableHTTPClientTransport(endpoint.url, {
270
+ requestInit: { headers: endpoint.headers }
271
+ })
272
+ const client = new Client({ name: CLIENT_NAME, version: CLIENT_VERSION })
273
+ await client.connect(transport)
274
+ return client
275
+ }
276
+
277
+ async _getEndpointFeatures (/** @type {McpEndpoint} */ endpoint) {
278
+ const features = {
279
+ tools: [],
280
+ resources: [],
281
+ resourceTemplates: [],
282
+ prompts: [],
283
+ capabilities: {}
284
+ }
285
+ /** @type {McpClient} */
286
+ let client
287
+ try {
288
+ client = await this._connect(endpoint)
289
+ const capabilities = client.getServerCapabilities() || {}
290
+ features.capabilities = capabilities
291
+
292
+ if (capabilities.tools) {
293
+ try {
294
+ const { tools } = await client.listTools()
295
+ features.tools = tools || []
296
+ } catch (err) {
297
+ debug(`MCP listTools failed for ${endpoint.key}: ${err.message}`)
298
+ }
299
+ }
300
+ if (capabilities.resources) {
301
+ try {
302
+ const { resources } = await client.listResources()
303
+ features.resources = resources || []
304
+ } catch (err) {
305
+ debug(`MCP listResources failed for ${endpoint.key}: ${err.message}`)
306
+ }
307
+ try {
308
+ const { resourceTemplates } = await client.listResourceTemplates()
309
+ features.resourceTemplates = resourceTemplates || []
310
+ } catch (err) {
311
+ debug(`MCP listResourceTemplates failed for ${endpoint.key}: ${err.message}`)
312
+ }
313
+ }
314
+ if (capabilities.prompts) {
315
+ try {
316
+ const { prompts } = await client.listPrompts()
317
+ features.prompts = prompts || []
318
+ } catch (err) {
319
+ debug(`MCP listPrompts failed for ${endpoint.key}: ${err.message}`)
320
+ }
321
+ }
322
+ } catch (err) {
323
+ warn(`Failed to query MCP endpoint '${endpoint.key}': ${err.message}`)
324
+ features.error = err.message
325
+ } finally {
326
+ if (client) {
327
+ try {
328
+ await client.close()
329
+ } catch (_) { /* ignore */ }
330
+ }
331
+ }
332
+ return features
333
+ }
334
+
335
+ // #endregion internal helpers
336
+
337
+ // #region Public API
338
+
339
+ /**
340
+ * Get a list of features from MCP server(s) running in the Node-RED instance.
341
+ * @param {Array<string|McpEndpointSpec>} endpoints - list of MCP endpoints to query.
342
+ * Each entry may be a bare URL/path string, or an object `{ url, headers?, accessToken? }`
343
+ * where `accessToken` is `{ scheme, token, scope }`.
344
+ * @returns {Promise<Record<string, object>>} a map of endpoint key/url to features or error; the key is typically the URL or `spec.url` value
345
+ */
346
+ async getFeatures (endpoints) {
347
+ const result = []
348
+ if (!Array.isArray(endpoints) || endpoints.length === 0) {
349
+ return result
350
+ }
351
+ const normalisedEndpoints = endpoints.map(e => normalizeEndpoint(e, this.host, this.port))
352
+
353
+ // Fail fast if the SDK isn't installed/available.
354
+ try {
355
+ this._loadSdk()
356
+ } catch (err) {
357
+ warn(`MCP SDK not available: ${err.message}`)
358
+ for (const endpoint of normalisedEndpoints) {
359
+ result.push({
360
+ spec: endpoint,
361
+ error: `MCP SDK not available: ${err.message}`
362
+ })
363
+ }
364
+ return result
365
+ }
366
+
367
+ await Promise.all(normalisedEndpoints.map(async (spec) => {
368
+ /** @type {McpEndpoint} */
369
+ if (spec.error) {
370
+ result.push({
371
+ spec,
372
+ error: spec.error
373
+ })
374
+ return
375
+ }
376
+ if (spec.skip) {
377
+ result.push({
378
+ spec,
379
+ error: 'Endpoint skipped: unsupported auth scheme'
380
+ })
381
+ return
382
+ }
383
+ const features = await this._getEndpointFeatures(spec)
384
+ const reply = {
385
+ spec,
386
+ features,
387
+ error: features.error || null
388
+ }
389
+ result.push(reply)
390
+ }))
391
+ return result
392
+ }
393
+
394
+ /**
395
+ * Call a tool on an MCP server running in the Node-RED instance.
396
+ * Throws on any failure (caller is expected to convert to an error response).
397
+ * @param {string|McpEndpointSpec} endpoint - MCP endpoint URL/path, or `{ url, headers?, accessToken? }`
398
+ * @param {string} name - name of the tool to invoke
399
+ * @param {object} [input] - arguments to pass to the tool
400
+ * @returns {Promise<object>} the tool call result as returned by the MCP server
401
+ */
402
+ async callTool (endpoint, name, input) {
403
+ if (!endpoint) {
404
+ throw new Error('endpoint is required')
405
+ }
406
+ if (!name) {
407
+ throw new Error('Tool name is required')
408
+ }
409
+ const spec = normalizeEndpoint(endpoint, this.host, this.port)
410
+ if (spec.skip) {
411
+ throw new Error('Endpoint skipped: unsupported auth scheme')
412
+ }
413
+ let client
414
+ try {
415
+ client = await this._connect(spec)
416
+ const response = await client.callTool({ name, arguments: input || {} })
417
+ return response
418
+ } finally {
419
+ if (client) {
420
+ try {
421
+ await client.close()
422
+ } catch (_) { /* ignore */ }
423
+ }
424
+ }
425
+ }
426
+
427
+ /**
428
+ * Read a resource from an MCP server running in the Node-RED instance.
429
+ * Throws on any failure (caller is expected to convert to an error response).
430
+ * @param {string|McpEndpointSpec} endpoint - MCP endpoint URL/path, or `{ url, headers?, accessToken? }`
431
+ * @param {string} uri - URI of the resource
432
+ * @returns {Promise<object>} the resource read result
433
+ */
434
+ async readResource (endpoint, uri) {
435
+ if (!endpoint) {
436
+ throw new Error('endpoint is required')
437
+ }
438
+ if (!uri) {
439
+ throw new Error('Resource URI is required')
440
+ }
441
+ const spec = normalizeEndpoint(endpoint, this.host, this.port)
442
+ if (spec.skip) {
443
+ throw new Error('Endpoint skipped: unsupported auth scheme')
444
+ }
445
+
446
+ let client
447
+ try {
448
+ client = await this._connect(spec)
449
+ const response = await client.readResource({ uri })
450
+ return response
451
+ } finally {
452
+ if (client) {
453
+ try {
454
+ await client.close()
455
+ } catch (_) { /* ignore */ }
456
+ }
457
+ }
458
+ }
459
+
460
+ // #endregion public API
461
+ }
462
+
463
+ module.exports = {
464
+ MCP,
465
+ McpSdkUnavailableError
466
+ }