@gmag11/nodered-mcp-server 1.0.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.
Files changed (89) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +162 -0
  3. package/index.js +133 -0
  4. package/package.json +58 -0
  5. package/resources/skills/nodered-flow-builder/SKILL.md +659 -0
  6. package/resources/skills/nodered-flow-layout/SKILL.md +395 -0
  7. package/resources/skills/nodered-flowfuse-dashboard/SKILL.md +941 -0
  8. package/resources/skills/nodered-fundamentals/SKILL.md +323 -0
  9. package/resources/skills/nodered-jsonata/SKILL.md +1039 -0
  10. package/resources/skills/nodered-mustache/SKILL.md +588 -0
  11. package/resources/skills/nodered-node-reference/SKILL.md +1020 -0
  12. package/resources/skills/nodered-node-reference/examples/common.json +113 -0
  13. package/resources/skills/nodered-node-reference/examples/network.json +107 -0
  14. package/resources/skills/nodered-node-reference/examples/parser.json +147 -0
  15. package/resources/skills/nodered-node-reference/examples/sequence.json +141 -0
  16. package/resources/skills/nodered-node-reference/examples/storage.json +104 -0
  17. package/resources/skills/nodered-patterns/SKILL.md +414 -0
  18. package/resources/skills/nodered-patterns/examples/error-handler.json +72 -0
  19. package/resources/skills/nodered-patterns/examples/http-endpoint.json +42 -0
  20. package/resources/skills/nodered-patterns/examples/mqtt-subscriber.json +47 -0
  21. package/resources/skills/nodered-patterns/examples/timer-flow.json +50 -0
  22. package/resources/skills/nodered-subflows/SKILL.md +261 -0
  23. package/resources/skills/nodered-uibuilder/SKILL.md +500 -0
  24. package/src/auth/api-key-verifier.js +36 -0
  25. package/src/auth/composite-verifier.js +59 -0
  26. package/src/auth/config.js +106 -0
  27. package/src/auth/oauth-clients-store.js +107 -0
  28. package/src/auth/oauth-provider.js +149 -0
  29. package/src/auth/oauth-token-store.js +312 -0
  30. package/src/nodered/auth.js +158 -0
  31. package/src/nodered/client.js +199 -0
  32. package/src/nodered/comms-client.js +500 -0
  33. package/src/renderer/colors.js +161 -0
  34. package/src/renderer/geometry.js +115 -0
  35. package/src/renderer/html-builder.js +571 -0
  36. package/src/renderer/index.js +51 -0
  37. package/src/renderer/ir-builder.js +161 -0
  38. package/src/renderer/layout.js +126 -0
  39. package/src/renderer/mermaid-builder.js +109 -0
  40. package/src/renderer/svg-builder.js +228 -0
  41. package/src/schemas/responses.js +283 -0
  42. package/src/server.js +844 -0
  43. package/src/skills/loader.js +84 -0
  44. package/src/staging-store.js +258 -0
  45. package/src/tools/add-nodes-to-group.js +216 -0
  46. package/src/tools/connect-nodes.js +115 -0
  47. package/src/tools/constants.js +45 -0
  48. package/src/tools/create-flow.js +87 -0
  49. package/src/tools/create-node.js +126 -0
  50. package/src/tools/create-subflow-instance.js +123 -0
  51. package/src/tools/create-subflow.js +101 -0
  52. package/src/tools/delete-context.js +60 -0
  53. package/src/tools/delete-flow.js +81 -0
  54. package/src/tools/delete-group.js +116 -0
  55. package/src/tools/delete-node.js +73 -0
  56. package/src/tools/delete-subflow.js +103 -0
  57. package/src/tools/deploy.js +94 -0
  58. package/src/tools/disconnect-nodes.js +158 -0
  59. package/src/tools/export-flow.js +161 -0
  60. package/src/tools/export-subflow.js +78 -0
  61. package/src/tools/flow-utils.js +376 -0
  62. package/src/tools/get-config-nodes.js +86 -0
  63. package/src/tools/get-context.js +76 -0
  64. package/src/tools/get-flow-diagram.js +99 -0
  65. package/src/tools/get-flow-nodes.js +116 -0
  66. package/src/tools/get-flows.js +74 -0
  67. package/src/tools/get-node-detail.js +77 -0
  68. package/src/tools/get-node-type-detail.js +92 -0
  69. package/src/tools/get-palette-nodes.js +63 -0
  70. package/src/tools/get-staging-status.js +34 -0
  71. package/src/tools/get-subflow-detail.js +110 -0
  72. package/src/tools/get-subflows.js +105 -0
  73. package/src/tools/import-flow.js +310 -0
  74. package/src/tools/inject-message.js +117 -0
  75. package/src/tools/install-node.js +31 -0
  76. package/src/tools/read-debug-messages.js +155 -0
  77. package/src/tools/refresh-staging.js +62 -0
  78. package/src/tools/remove-nodes-from-group.js +162 -0
  79. package/src/tools/render-staging.js +69 -0
  80. package/src/tools/response-utils.js +42 -0
  81. package/src/tools/search-nodes.js +134 -0
  82. package/src/tools/uninstall-node.js +31 -0
  83. package/src/tools/update-flow.js +95 -0
  84. package/src/tools/update-group.js +77 -0
  85. package/src/tools/update-node.js +132 -0
  86. package/src/tools/update-subflow.js +84 -0
  87. package/src/transport/http.js +252 -0
  88. package/src/transport/stdio.js +16 -0
  89. package/src/transport/ws-server.js +223 -0
@@ -0,0 +1,161 @@
1
+ /**
2
+ * MCP tool: export-flow
3
+ *
4
+ * Returns a Node-RED-compatible JSON export for a single flow (by flowId),
5
+ * all flows, or a selected set of nodes (by nodeIds). The returned JSON
6
+ * string can be passed directly to `import-flow` to duplicate or migrate flows.
7
+ */
8
+ import { formatSuccess } from './response-utils.js';
9
+
10
+
11
+ import { ANN_READONLY } from './constants.js';
12
+ import { GenericObjectSchema } from '../schemas/responses.js';
13
+ /**
14
+ * Collect the tab node and all child nodes belonging to a given flow.
15
+ *
16
+ * @param {object[]} allNodes - All nodes from GET /flows
17
+ * @param {string} flowId - ID of the tab node to collect
18
+ * @returns {object[]} Array containing the tab node and all nodes with z === flowId
19
+ */
20
+ export function collectFlowNodes(allNodes, flowId) {
21
+ const tab = allNodes.find((n) => n.id === flowId);
22
+ const children = allNodes.filter((n) => n.z === flowId);
23
+ return tab ? [tab, ...children] : children;
24
+ }
25
+
26
+ /**
27
+ * Collect config nodes (nodes with no `z` property) that are referenced by
28
+ * any string property of the provided flow nodes.
29
+ *
30
+ * @param {object[]} allNodes - All nodes from GET /flows
31
+ * @param {object[]} flowNodes - The already-collected flow nodes to scan
32
+ * @returns {object[]} Array of config nodes referenced by the flow nodes
33
+ */
34
+ export function collectReferencedConfigNodes(allNodes, flowNodes) {
35
+ // Build a map of config node IDs (nodes with no `z` and not a tab/subflow)
36
+ const configNodeMap = new Map();
37
+ for (const node of allNodes) {
38
+ if (node.z === undefined && node.type !== 'tab' && node.type !== 'subflow') {
39
+ configNodeMap.set(node.id, node);
40
+ }
41
+ }
42
+
43
+ if (configNodeMap.size === 0) return [];
44
+
45
+ // Collect all string property values from flow nodes (excluding top-level metadata)
46
+ const metadataKeys = new Set(['id', 'type', 'z', 'x', 'y', 'wires', 'd', 'g', 'l', 'info', 'disabled', 'locked', 'name', 'label']);
47
+ const referenced = new Set();
48
+
49
+ for (const node of flowNodes) {
50
+ for (const [key, value] of Object.entries(node)) {
51
+ if (metadataKeys.has(key)) continue;
52
+ if (typeof value === 'string' && configNodeMap.has(value)) {
53
+ referenced.add(value);
54
+ }
55
+ }
56
+ }
57
+
58
+ return Array.from(referenced).map((id) => configNodeMap.get(id));
59
+ }
60
+
61
+ /**
62
+ * Collect nodes from allNodes whose IDs are in the given nodeIds array.
63
+ *
64
+ * @param {object[]} allNodes - All nodes from GET /flows
65
+ * @param {string[]} nodeIds - IDs of nodes to select
66
+ * @returns {object[]} Array of matching nodes (order follows nodeIds)
67
+ */
68
+ export function collectSelectedNodes(allNodes, nodeIds) {
69
+ const nodeMap = new Map(allNodes.map((n) => [n.id, n]));
70
+ return nodeIds.flatMap((id) => (nodeMap.has(id) ? [nodeMap.get(id)] : []));
71
+ }
72
+
73
+ /**
74
+ * Trim wires in a node array so that only targets within allowedIds are kept.
75
+ * Ports with no remaining targets become `[]`.
76
+ *
77
+ * @param {object[]} nodes - Nodes to process (NOT mutated; returns new array)
78
+ * @param {Set<string>} allowedIds - Set of node IDs that are in the selection
79
+ * @returns {object[]} New array of nodes with trimmed wires
80
+ */
81
+ export function trimWires(nodes, allowedIds) {
82
+ return nodes.map((node) => {
83
+ if (!Array.isArray(node.wires)) return node;
84
+ const trimmedWires = node.wires.map((port) =>
85
+ Array.isArray(port) ? port.filter((targetId) => allowedIds.has(targetId)) : []
86
+ );
87
+ return { ...node, wires: trimmedWires };
88
+ });
89
+ }
90
+
91
+ /**
92
+ * Handler for the export-flow MCP tool.
93
+ *
94
+ * @param {ReturnType<import('../nodered/client.js').createNodeRedClient>} client
95
+ * @param {object} params - Validated input parameters
96
+ * @param {'flow'|'nodes'} [params.exportMode='flow'] - Export mode
97
+ * @param {string} [params.flowId] - Tab ID for flow mode
98
+ * @param {string[]} [params.nodeIds] - Node IDs for nodes mode
99
+ * @returns {Promise<{ content: Array<{ type: string, text: string }> }>}
100
+ */
101
+ export async function handleExportFlowJson(staging, params) {
102
+ const { exportMode = 'flow', flowId, nodeIds } = params;
103
+
104
+ const allNodes = await staging.getFlows();
105
+
106
+ let result;
107
+
108
+ if (exportMode === 'nodes') {
109
+ // nodes mode: requires a non-empty nodeIds array
110
+ if (!nodeIds || nodeIds.length === 0) {
111
+ throw new Error('exportMode "nodes" requires a non-empty nodeIds array. Provide a list of node IDs to export, or use exportMode: "flow" to export an entire flow tab.');
112
+ }
113
+
114
+ const selectedNodes = collectSelectedNodes(allNodes, nodeIds);
115
+ const allowedIds = new Set(nodeIds);
116
+ const trimmedNodes = trimWires(selectedNodes, allowedIds);
117
+
118
+ result = {
119
+ exportMode: 'nodes',
120
+ nodeCount: trimmedNodes.length,
121
+ json: JSON.stringify(trimmedNodes),
122
+ };
123
+ } else {
124
+ // flow mode
125
+ if (flowId) {
126
+ // Single flow export
127
+ const tabNode = allNodes.find((n) => n.id === flowId && n.type === 'tab');
128
+ if (!tabNode) {
129
+ throw new Error(`Flow '${flowId}' not found. Use get-flows to list available flow tabs.`);
130
+ }
131
+
132
+ const flowNodes = collectFlowNodes(allNodes, flowId);
133
+ const configNodes = collectReferencedConfigNodes(allNodes, flowNodes);
134
+ const exportNodes = [...flowNodes, ...configNodes];
135
+
136
+ result = {
137
+ exportMode: 'flow',
138
+ flowId,
139
+ label: tabNode.label || '',
140
+ nodeCount: exportNodes.length,
141
+ json: JSON.stringify(exportNodes),
142
+ };
143
+ } else {
144
+ // All flows export
145
+ result = {
146
+ exportMode: 'flow',
147
+ nodeCount: allNodes.length,
148
+ json: JSON.stringify(allNodes),
149
+ };
150
+ }
151
+ }
152
+
153
+ return formatSuccess(result);
154
+ }
155
+
156
+ export const exportFlowDefinition = {
157
+ name: 'export-flow',
158
+ annotations: ANN_READONLY,
159
+ outputSchema: GenericObjectSchema,
160
+ handler: handleExportFlowJson,
161
+ };
@@ -0,0 +1,78 @@
1
+ /**
2
+ * MCP tool: export-subflow
3
+ *
4
+ * Exports a subflow definition, all its internal nodes, and any referenced
5
+ * config nodes as a JSON array string compatible with import-flow.
6
+ */
7
+
8
+ import { collectReferencedConfigNodes } from './export-flow.js';
9
+
10
+ import { formatSuccess } from './response-utils.js';
11
+ import { ANN_READONLY } from './constants.js';
12
+ import { GenericObjectSchema } from '../schemas/responses.js';
13
+ /**
14
+ * Collect the subflow definition, its internal nodes, and referenced config nodes.
15
+ *
16
+ * @param {object[]} allNodes - All nodes from GET /flows
17
+ * @param {string} subflowId - ID of the subflow to export
18
+ * @returns {{ subflowNodes: object[], name: string, nodeCount: number }}
19
+ * @throws {Error} If subflowId does not match any type: "subflow" node
20
+ */
21
+ export function collectSubflowExport(allNodes, subflowId) {
22
+ // Find the subflow definition
23
+ const subflow = allNodes.find(
24
+ (n) => n.type === 'subflow' && n.id === subflowId,
25
+ );
26
+
27
+ if (!subflow) {
28
+ throw new Error(`Subflow '${subflowId}' not found. Use get-subflows to list available subflow definitions.`);
29
+ }
30
+
31
+ // Collect internal nodes (z === subflowId, with wires property)
32
+ const internalNodes = allNodes.filter(
33
+ (n) => n.z === subflowId && ('wires' in n),
34
+ );
35
+
36
+ // Start with definition + internals
37
+ const subflowNodes = [subflow, ...internalNodes];
38
+
39
+ // Collect referenced config nodes
40
+ const configNodes = collectReferencedConfigNodes(allNodes, subflowNodes);
41
+
42
+ const allExported = [...subflowNodes, ...configNodes];
43
+
44
+ return {
45
+ subflowNodes: allExported,
46
+ name: subflow.name || '',
47
+ nodeCount: allExported.length,
48
+ };
49
+ }
50
+
51
+ /**
52
+ * Handler for the export-subflow MCP tool.
53
+ *
54
+ * @param {ReturnType<import('../nodered/client.js').createNodeRedClient>} client
55
+ * @param {object} params
56
+ * @param {string} params.subflowId
57
+ * @returns {Promise<{ content: Array<{ type: string, text: string }> }>}
58
+ */
59
+ export async function handleExportSubflow(staging, params) {
60
+ const allNodes = await staging.getFlows();
61
+
62
+ const { subflowNodes, name, nodeCount } = collectSubflowExport(allNodes, params.subflowId);
63
+
64
+ const data = {
65
+ subflowId: params.subflowId,
66
+ name,
67
+ nodeCount,
68
+ json: JSON.stringify(subflowNodes),
69
+ };
70
+ return formatSuccess(data);
71
+ }
72
+
73
+ export const exportSubflowDefinition = {
74
+ name: 'export-subflow',
75
+ annotations: ANN_READONLY,
76
+ outputSchema: GenericObjectSchema,
77
+ handler: handleExportSubflow,
78
+ };
@@ -0,0 +1,376 @@
1
+ /**
2
+ * Shared utilities for Node-RED flow inspection tools.
3
+ *
4
+ * Used by: get-flow-nodes, get-flow-diagram, get-config-nodes
5
+ */
6
+
7
+ /**
8
+ * Node configuration fields that are excluded from responses to avoid
9
+ * wasting LLM context tokens on large text blobs.
10
+ *
11
+ * @type {Set<string>}
12
+ */
13
+ export const BLOCKLISTED_FIELDS = new Set([
14
+ 'func', // function node: JavaScript code
15
+ 'template', // template node: Mustache/HTML template
16
+ 'format', // various nodes: formatted text content
17
+ 'html', // html node: HTML content
18
+ 'css', // ui nodes: CSS styles
19
+ ]);
20
+
21
+ /**
22
+ * Top-level metadata fields extracted separately; excluded from the config object.
23
+ *
24
+ * @type {Set<string>}
25
+ */
26
+ const METADATA_FIELDS = new Set(['id', 'type', 'z', 'x', 'y', 'wires', 'd', 'name']);
27
+
28
+ /**
29
+ * Return a sanitized copy of a node's configuration fields,
30
+ * excluding blocklisted large-text fields and top-level metadata fields.
31
+ *
32
+ * @param {object} node - Raw Node-RED node object
33
+ * @returns {object} Sanitized config object (may be empty)
34
+ */
35
+ export function sanitizeNodeConfig(node) {
36
+ const config = {};
37
+ for (const [key, value] of Object.entries(node)) {
38
+ if (!METADATA_FIELDS.has(key) && !BLOCKLISTED_FIELDS.has(key)) {
39
+ config[key] = value;
40
+ }
41
+ }
42
+ return config;
43
+ }
44
+
45
+ /**
46
+ * Extract nodes belonging to a specific flow (tab or subflow).
47
+ * Throws if the flowId does not match any known tab or subflow.
48
+ *
49
+ * Only returns flow-level nodes — config nodes (nodes without a `wires`
50
+ * property) are excluded even if they share the same `z`. Group nodes
51
+ * (`type: "group"`) are included since they are visual elements on the canvas.
52
+ *
53
+ * @param {object[]} allNodes - All nodes from the /flows response
54
+ * @param {string} flowId - Target flow ID
55
+ * @returns {object[]} Flow nodes whose `z` property equals `flowId`
56
+ * @throws {Error} If no tab or subflow with flowId exists
57
+ */
58
+ export function getFlowNodes(allNodes, flowId) {
59
+ const flowExists = allNodes.some(
60
+ (n) => (n.type === 'tab' || n.type === 'subflow') && n.id === flowId
61
+ );
62
+
63
+ if (!flowExists) {
64
+ throw new Error(`Flow not found: no tab or subflow with id "${flowId}". Use get-flows to list available flow tabs and subflows.`);
65
+ }
66
+
67
+ return allNodes.filter(
68
+ (n) => n.z === flowId && (('wires' in n) || n.type === 'group')
69
+ );
70
+ }
71
+
72
+ /**
73
+ * Build a reverse wire index: for each target node ID, the set of source node IDs
74
+ * that have a wire pointing to it.
75
+ *
76
+ * @param {object[]} nodes - Array of Node-RED node objects
77
+ * @returns {Map<string, Set<string>>} targetId → Set of sourceIds
78
+ */
79
+ export function buildReverseWireIndex(nodes) {
80
+ const reverseIndex = new Map();
81
+
82
+ for (const node of nodes) {
83
+ if (!node.wires) continue;
84
+ for (const portTargets of node.wires) {
85
+ for (const targetId of portTargets) {
86
+ if (!reverseIndex.has(targetId)) {
87
+ reverseIndex.set(targetId, new Set());
88
+ }
89
+ reverseIndex.get(targetId).add(node.id);
90
+ }
91
+ }
92
+ }
93
+
94
+ return reverseIndex;
95
+ }
96
+
97
+ /**
98
+ * Build a forward wire index: for each source node ID, the set of target node IDs
99
+ * it connects to across all output ports.
100
+ *
101
+ * @param {object[]} nodes - Array of Node-RED node objects
102
+ * @returns {Map<string, Set<string>>} sourceId → Set of targetIds
103
+ */
104
+ export function buildForwardWireIndex(nodes) {
105
+ const forwardIndex = new Map();
106
+
107
+ for (const node of nodes) {
108
+ if (!node.wires) continue;
109
+ const targets = new Set();
110
+ for (const portTargets of node.wires) {
111
+ for (const targetId of portTargets) {
112
+ targets.add(targetId);
113
+ }
114
+ }
115
+ if (targets.size > 0) {
116
+ forwardIndex.set(node.id, targets);
117
+ }
118
+ }
119
+
120
+ return forwardIndex;
121
+ }
122
+
123
+ /**
124
+ * Get the set of node IDs reachable from `fromNodeId` via BFS in the given direction.
125
+ *
126
+ * @param {object[]} nodes - Nodes to search within (already filtered to a flow)
127
+ * @param {string} fromNodeId - Starting node ID
128
+ * @param {'downstream'|'upstream'|'both'} direction - Traversal direction
129
+ * @param {Map<string, Set<string>>} reverseIndex - Reverse wire index (target → sources)
130
+ * @returns {Set<string>} Set of reachable node IDs (including fromNodeId itself)
131
+ * @throws {Error} If fromNodeId is not found among the nodes
132
+ */
133
+ export function getConnectedSubgraph(nodes, fromNodeId, direction, reverseIndex) {
134
+ const nodeIds = new Set(nodes.map((n) => n.id));
135
+
136
+ if (!nodeIds.has(fromNodeId)) {
137
+ throw new Error(`Node not found in flow: no node with id "${fromNodeId}". Use get-flow-nodes to list nodes in the flow or search-nodes to find a node by name.`);
138
+ }
139
+
140
+ const forwardIndex = buildForwardWireIndex(nodes);
141
+ const visited = new Set();
142
+ const queue = [fromNodeId];
143
+
144
+ while (queue.length > 0) {
145
+ const current = queue.shift();
146
+ if (visited.has(current)) continue;
147
+ visited.add(current);
148
+
149
+ // Traverse downstream (forward wires)
150
+ if (direction === 'downstream' || direction === 'both') {
151
+ const targets = forwardIndex.get(current) || new Set();
152
+ for (const targetId of targets) {
153
+ if (!visited.has(targetId) && nodeIds.has(targetId)) {
154
+ queue.push(targetId);
155
+ }
156
+ }
157
+ }
158
+
159
+ // Traverse upstream (reverse wires)
160
+ if (direction === 'upstream' || direction === 'both') {
161
+ const sources = reverseIndex.get(current) || new Set();
162
+ for (const sourceId of sources) {
163
+ if (!visited.has(sourceId) && nodeIds.has(sourceId)) {
164
+ queue.push(sourceId);
165
+ }
166
+ }
167
+ }
168
+ }
169
+
170
+ return visited;
171
+ }
172
+
173
+ /**
174
+ * Apply the chain of filters to a node list:
175
+ * 1. Subgraph filter (fromNodeId + direction)
176
+ * 2. Disabled-only filter
177
+ * 3. Node type filter
178
+ *
179
+ * @param {object[]} nodes - Nodes to filter
180
+ * @param {object} options
181
+ * @param {string} [options.fromNodeId] - Filter to connected subgraph from this node ID
182
+ * @param {'downstream'|'upstream'|'both'} [options.direction='both'] - Traversal direction
183
+ * @param {boolean} [options.disabledOnly] - Return only disabled nodes
184
+ * @param {string} [options.nodeType] - Return only nodes of this type
185
+ * @returns {object[]} Filtered nodes
186
+ * @throws {Error} If fromNodeId is specified but not found
187
+ */
188
+ export function applyFilters(nodes, { fromNodeId, direction = 'both', disabledOnly, nodeType } = {}) {
189
+ let filtered = nodes;
190
+
191
+ // 1. Subgraph filter
192
+ if (fromNodeId) {
193
+ const reverseIndex = buildReverseWireIndex(nodes);
194
+ const reachable = getConnectedSubgraph(nodes, fromNodeId, direction, reverseIndex);
195
+ filtered = filtered.filter((n) => reachable.has(n.id));
196
+ }
197
+
198
+ // 2. Disabled-only filter
199
+ if (disabledOnly) {
200
+ filtered = filtered.filter((n) => n.d === true);
201
+ }
202
+
203
+ // 3. Node type filter
204
+ if (nodeType) {
205
+ filtered = filtered.filter((n) => n.type === nodeType);
206
+ }
207
+
208
+ return filtered;
209
+ }
210
+
211
+ /**
212
+ * Paginate an array of items with offset + limit.
213
+ *
214
+ * @template T
215
+ * @param {T[]} items - Full array of items (post-filter)
216
+ * @param {number} offset - Zero-based start index (default 0)
217
+ * @param {number} limit - Maximum number of items to return (default 50)
218
+ * @returns {{ items: T[], totalCount: number, offset: number, limit: number, hasMore: boolean }}
219
+ */
220
+ export function paginate(items, offset = 0, limit = 50) {
221
+ const totalCount = items.length;
222
+ const sliced = items.slice(offset, offset + limit);
223
+ return {
224
+ items: sliced,
225
+ totalCount,
226
+ offset,
227
+ limit,
228
+ hasMore: offset + limit < totalCount,
229
+ };
230
+ }
231
+
232
+ /**
233
+ * Compute a bounding rectangle that encloses a set of nodes.
234
+ * Returns `{ x, y, w, h }` with optional padding on all sides.
235
+ *
236
+ * If the nodes array is empty, returns a zero-sized box at origin.
237
+ *
238
+ * @param {object[]} nodes - Array of nodes with `x` and `y` properties
239
+ * @param {number} [padding=20] - Padding to add on all sides
240
+ * @returns {{ x: number, y: number, w: number, h: number }}
241
+ */
242
+ export function computeBoundingBox(nodes, padding = 20) {
243
+ if (nodes.length === 0) {
244
+ return { x: 0, y: 0, w: 0, h: 0 };
245
+ }
246
+
247
+ const xs = nodes.map((n) => n.x ?? 0);
248
+ const ys = nodes.map((n) => n.y ?? 0);
249
+
250
+ const minX = Math.min(...xs);
251
+ const minY = Math.min(...ys);
252
+ const maxX = Math.max(...xs);
253
+ const maxY = Math.max(...ys);
254
+
255
+ return {
256
+ x: minX - padding,
257
+ y: minY - padding,
258
+ w: maxX - minX + 2 * padding,
259
+ h: maxY - minY + 2 * padding,
260
+ };
261
+ }
262
+
263
+ // ---------------------------------------------------------------------------
264
+ // Credential normalization (shared by update-node and create-node)
265
+ // ---------------------------------------------------------------------------
266
+
267
+ /**
268
+ * Known credential field names commonly used across Node-RED node types.
269
+ * When these appear at the top level of properties, they should be nested
270
+ * under `credentials` to match Node-RED's credential storage model.
271
+ *
272
+ * Node-RED stores sensitive fields (passwords, API keys, certificates) in a
273
+ * separate `credentials` object. Putting them at the top level of the node
274
+ * would cause Node-RED to treat them as regular (non-credential) properties.
275
+ *
276
+ * @type {Set<string>}
277
+ */
278
+ export const CREDENTIAL_FIELD_NAMES = new Set([
279
+ 'username', 'password', 'passphrase', 'key', 'privateKey',
280
+ 'cert', 'ca', 'clientKey', 'clientCert', 'token', 'secret',
281
+ 'accessKey', 'secretKey', 'apiKey', 'bearerToken', 'psk',
282
+ 'pass', 'user', 'passkey', 'sharedKey', 'hmacKey',
283
+ ]);
284
+
285
+ /**
286
+ * Normalize properties by moving credential fields into a `credentials`
287
+ * sub-object, deep-merging with existing credentials to preserve
288
+ * unspecified fields.
289
+ *
290
+ * Detection strategy (in priority order):
291
+ * 1. If `credentialKeys` is provided (from the `/credentials/:type/:id` API),
292
+ * it is used as the authoritative list of credential field names.
293
+ * 2. If the caller already sent a `credentials` property, deep-merge it
294
+ * with the node's existing `credentials` (preserving unspecified fields).
295
+ * 3. If the node has an existing `credentials` object (even if masked),
296
+ * use its keys to identify which incoming properties are credentials.
297
+ * 4. Fallback: match incoming properties against the well-known set of
298
+ * credential field names (CREDENTIAL_FIELD_NAMES).
299
+ *
300
+ * When `node` is null/undefined (used by create-node where no existing
301
+ * node exists), only strategies 1, 2, and 4 apply.
302
+ *
303
+ * @param {object} properties - Incoming properties from the caller
304
+ * @param {object|null} [node=null] - The existing node object (from GET /flows), or null for new nodes
305
+ * @param {string[]|null} [credentialKeys=null] - Authoritative list of credential field names from the Node-RED API, or null to auto-detect
306
+ * @returns {object} Normalized properties with credentials nested correctly
307
+ */
308
+ export function normalizeCredentials(properties, node = null, credentialKeys = null) {
309
+ const props = { ...properties };
310
+
311
+ // Case 1: caller already provided a `credentials` object → deep-merge
312
+ if (props.credentials && typeof props.credentials === 'object' && !Array.isArray(props.credentials)) {
313
+ const existingCreds = (node && node.credentials && typeof node.credentials === 'object' && !Array.isArray(node.credentials))
314
+ ? { ...node.credentials }
315
+ : {};
316
+ props.credentials = { ...existingCreds, ...props.credentials };
317
+ return props;
318
+ }
319
+
320
+ // Case 2: credential keys provided by API → use as authoritative list.
321
+ // null means "API not consulted", [] means "API confirmed no credentials".
322
+ if (credentialKeys !== null) {
323
+ // Empty array = node type has no credentials defined → don't move anything
324
+ if (credentialKeys.length === 0) {
325
+ return { ...props };
326
+ }
327
+
328
+ const credProps = {};
329
+ const nonCredProps = {};
330
+
331
+ for (const [key, value] of Object.entries(props)) {
332
+ if (key === 'credentials') continue;
333
+ if (credentialKeys.includes(key)) {
334
+ credProps[key] = value;
335
+ } else {
336
+ nonCredProps[key] = value;
337
+ }
338
+ }
339
+
340
+ if (Object.keys(credProps).length > 0) {
341
+ const existingCreds = (node && node.credentials && typeof node.credentials === 'object' && !Array.isArray(node.credentials))
342
+ ? { ...node.credentials }
343
+ : {};
344
+ nonCredProps.credentials = { ...existingCreds, ...credProps };
345
+ }
346
+
347
+ return nonCredProps;
348
+ }
349
+
350
+ // Case 3+4: auto-detect from node.credentials keys or fallback heuristic
351
+ const existingCredKeys = (node && node.credentials && typeof node.credentials === 'object' && !Array.isArray(node.credentials))
352
+ ? Object.keys(node.credentials)
353
+ : [];
354
+
355
+ const credProps = {};
356
+ const nonCredProps = {};
357
+
358
+ for (const [key, value] of Object.entries(props)) {
359
+ if (key === 'credentials') continue;
360
+
361
+ if (existingCredKeys.includes(key) || CREDENTIAL_FIELD_NAMES.has(key)) {
362
+ credProps[key] = value;
363
+ } else {
364
+ nonCredProps[key] = value;
365
+ }
366
+ }
367
+
368
+ if (Object.keys(credProps).length > 0) {
369
+ const existingCreds = (node && node.credentials && typeof node.credentials === 'object' && !Array.isArray(node.credentials))
370
+ ? { ...node.credentials }
371
+ : {};
372
+ nonCredProps.credentials = { ...existingCreds, ...credProps };
373
+ }
374
+
375
+ return nonCredProps;
376
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * MCP tool: get-config-nodes
3
+ *
4
+ * Returns a paginated list of global configuration nodes from the connected
5
+ * Node-RED instance. Configuration nodes are shared resources (e.g. MQTT
6
+ * brokers, TLS configs) used by flow nodes but not wired on the canvas.
7
+ */
8
+
9
+ import {
10
+ sanitizeNodeConfig,
11
+ paginate,
12
+ } from './flow-utils.js';
13
+ import { formatSuccess } from './response-utils.js';
14
+
15
+ import { ANN_READONLY } from './constants.js';
16
+ import { ConfigNodesResponseSchema } from '../schemas/responses.js';
17
+ /**
18
+ * Transform a raw /flows response into a paginated list of global config nodes.
19
+ *
20
+ * Config nodes are those that:
21
+ * - Have no `wires` property (unlike flow nodes, config nodes are not wired)
22
+ * - Are not of type `tab` or `subflow`
23
+ *
24
+ * Note: config nodes *may* have a `z` property when defined within a flow tab.
25
+ *
26
+ * @param {object} rawResponse - Response from GET /flows (v2 format: { rev, flows })
27
+ * @param {object} [options]
28
+ * @param {string} [options.nodeType] - Return only config nodes of this type
29
+ * @param {number} [options.offset=0] - Pagination offset
30
+ * @param {number} [options.limit=50] - Pagination limit
31
+ * @returns {{ nodes: object[], totalCount: number, offset: number, limit: number, hasMore: boolean }}
32
+ */
33
+ export function transformConfigNodes(rawResponse, options = {}) {
34
+ const { nodeType, offset = 0, limit = 50 } = options;
35
+ const allNodes = rawResponse.flows || [];
36
+
37
+ // Config nodes: no wires property (not placed on canvas), and not tab or subflow
38
+ let configNodes = allNodes.filter(
39
+ (n) => !('wires' in n) && n.type !== 'tab' && n.type !== 'subflow'
40
+ );
41
+
42
+ // Optional type filter
43
+ if (nodeType) {
44
+ configNodes = configNodes.filter((n) => n.type === nodeType);
45
+ }
46
+
47
+ // Paginate
48
+ const page = paginate(configNodes, offset, limit);
49
+
50
+ // Shape each node
51
+ const nodes = page.items.map((node) => ({
52
+ id: node.id,
53
+ type: node.type,
54
+ name: node.name || '',
55
+ config: sanitizeNodeConfig(node),
56
+ }));
57
+
58
+ return {
59
+ nodes,
60
+ totalCount: page.totalCount,
61
+ offset: page.offset,
62
+ limit: page.limit,
63
+ hasMore: page.hasMore,
64
+ };
65
+ }
66
+
67
+ /**
68
+ * Handler for the get-config-nodes MCP tool.
69
+ *
70
+ * @param {ReturnType<import('../nodered/client.js').createNodeRedClient>} client
71
+ * @param {object} params - Validated input parameters
72
+ * @returns {Promise<{ content: Array<{ type: string, text: string }> }>}
73
+ */
74
+ export async function handleGetConfigNodes(staging, params) {
75
+ const flows = await staging.getFlows();
76
+ const result = transformConfigNodes({ flows }, params);
77
+
78
+ return formatSuccess(result);
79
+ }
80
+
81
+ export const getConfigNodesDefinition = {
82
+ name: 'get-config-nodes',
83
+ annotations: ANN_READONLY,
84
+ outputSchema: ConfigNodesResponseSchema,
85
+ handler: handleGetConfigNodes,
86
+ };