@flowfuse/nr-launcher 2.31.4-d304c3f-202606241506.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 +17 -0
- package/lib/admin.js +29 -0
- package/lib/launcher.js +72 -0
- package/lib/mcp.js +466 -0
- package/lib/theme/forge-dark/forge-dark-theme.css +2 -2
- package/lib/theme/forge-light/forge-light-theme.css +2 -2
- package/package.json +6 -6
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
|
+
}
|