@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.
- package/LICENSE +201 -0
- package/README.md +162 -0
- package/index.js +133 -0
- package/package.json +58 -0
- package/resources/skills/nodered-flow-builder/SKILL.md +659 -0
- package/resources/skills/nodered-flow-layout/SKILL.md +395 -0
- package/resources/skills/nodered-flowfuse-dashboard/SKILL.md +941 -0
- package/resources/skills/nodered-fundamentals/SKILL.md +323 -0
- package/resources/skills/nodered-jsonata/SKILL.md +1039 -0
- package/resources/skills/nodered-mustache/SKILL.md +588 -0
- package/resources/skills/nodered-node-reference/SKILL.md +1020 -0
- package/resources/skills/nodered-node-reference/examples/common.json +113 -0
- package/resources/skills/nodered-node-reference/examples/network.json +107 -0
- package/resources/skills/nodered-node-reference/examples/parser.json +147 -0
- package/resources/skills/nodered-node-reference/examples/sequence.json +141 -0
- package/resources/skills/nodered-node-reference/examples/storage.json +104 -0
- package/resources/skills/nodered-patterns/SKILL.md +414 -0
- package/resources/skills/nodered-patterns/examples/error-handler.json +72 -0
- package/resources/skills/nodered-patterns/examples/http-endpoint.json +42 -0
- package/resources/skills/nodered-patterns/examples/mqtt-subscriber.json +47 -0
- package/resources/skills/nodered-patterns/examples/timer-flow.json +50 -0
- package/resources/skills/nodered-subflows/SKILL.md +261 -0
- package/resources/skills/nodered-uibuilder/SKILL.md +500 -0
- package/src/auth/api-key-verifier.js +36 -0
- package/src/auth/composite-verifier.js +59 -0
- package/src/auth/config.js +106 -0
- package/src/auth/oauth-clients-store.js +107 -0
- package/src/auth/oauth-provider.js +149 -0
- package/src/auth/oauth-token-store.js +312 -0
- package/src/nodered/auth.js +158 -0
- package/src/nodered/client.js +199 -0
- package/src/nodered/comms-client.js +500 -0
- package/src/renderer/colors.js +161 -0
- package/src/renderer/geometry.js +115 -0
- package/src/renderer/html-builder.js +571 -0
- package/src/renderer/index.js +51 -0
- package/src/renderer/ir-builder.js +161 -0
- package/src/renderer/layout.js +126 -0
- package/src/renderer/mermaid-builder.js +109 -0
- package/src/renderer/svg-builder.js +228 -0
- package/src/schemas/responses.js +283 -0
- package/src/server.js +844 -0
- package/src/skills/loader.js +84 -0
- package/src/staging-store.js +258 -0
- package/src/tools/add-nodes-to-group.js +216 -0
- package/src/tools/connect-nodes.js +115 -0
- package/src/tools/constants.js +45 -0
- package/src/tools/create-flow.js +87 -0
- package/src/tools/create-node.js +126 -0
- package/src/tools/create-subflow-instance.js +123 -0
- package/src/tools/create-subflow.js +101 -0
- package/src/tools/delete-context.js +60 -0
- package/src/tools/delete-flow.js +81 -0
- package/src/tools/delete-group.js +116 -0
- package/src/tools/delete-node.js +73 -0
- package/src/tools/delete-subflow.js +103 -0
- package/src/tools/deploy.js +94 -0
- package/src/tools/disconnect-nodes.js +158 -0
- package/src/tools/export-flow.js +161 -0
- package/src/tools/export-subflow.js +78 -0
- package/src/tools/flow-utils.js +376 -0
- package/src/tools/get-config-nodes.js +86 -0
- package/src/tools/get-context.js +76 -0
- package/src/tools/get-flow-diagram.js +99 -0
- package/src/tools/get-flow-nodes.js +116 -0
- package/src/tools/get-flows.js +74 -0
- package/src/tools/get-node-detail.js +77 -0
- package/src/tools/get-node-type-detail.js +92 -0
- package/src/tools/get-palette-nodes.js +63 -0
- package/src/tools/get-staging-status.js +34 -0
- package/src/tools/get-subflow-detail.js +110 -0
- package/src/tools/get-subflows.js +105 -0
- package/src/tools/import-flow.js +310 -0
- package/src/tools/inject-message.js +117 -0
- package/src/tools/install-node.js +31 -0
- package/src/tools/read-debug-messages.js +155 -0
- package/src/tools/refresh-staging.js +62 -0
- package/src/tools/remove-nodes-from-group.js +162 -0
- package/src/tools/render-staging.js +69 -0
- package/src/tools/response-utils.js +42 -0
- package/src/tools/search-nodes.js +134 -0
- package/src/tools/uninstall-node.js +31 -0
- package/src/tools/update-flow.js +95 -0
- package/src/tools/update-group.js +77 -0
- package/src/tools/update-node.js +132 -0
- package/src/tools/update-subflow.js +84 -0
- package/src/transport/http.js +252 -0
- package/src/transport/stdio.js +16 -0
- 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
|
+
};
|