@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,110 @@
1
+ /**
2
+ * MCP tool: get-subflow-detail
3
+ *
4
+ * Returns the full definition of a single subflow including:
5
+ * - The subflow definition node
6
+ * - All internal nodes (with sanitized configs)
7
+ * - All instances placed in flow tabs
8
+ * - A Mermaid diagram of the internal flow
9
+ */
10
+
11
+ import { getFlowNodes, sanitizeNodeConfig } from './flow-utils.js';
12
+ import { buildIR } from '../renderer/ir-builder.js';
13
+ import { buildMermaid } from '../renderer/mermaid-builder.js';
14
+ import { formatSuccess } from './response-utils.js';
15
+
16
+ import { ANN_READONLY } from './constants.js';
17
+ import { SubflowDetailResponseSchema } from '../schemas/responses.js';
18
+ /**
19
+ * Transform the raw /flows response into a detailed subflow view.
20
+ *
21
+ * @param {object} rawResponse - Response from GET /flows (v2 format: { rev, flows })
22
+ * @param {string} subflowId - ID of the subflow to inspect
23
+ * @returns {{
24
+ * definition: object,
25
+ * internalNodes: object[],
26
+ * instances: object[],
27
+ * diagram: string
28
+ * }}
29
+ * @throws {Error} If subflowId does not match any type: "subflow" node
30
+ */
31
+ export function transformSubflowDetail(rawResponse, subflowId) {
32
+ const allNodes = rawResponse.flows || [];
33
+
34
+ // Find the subflow definition
35
+ const subflow = allNodes.find(
36
+ (n) => n.type === 'subflow' && n.id === subflowId,
37
+ );
38
+
39
+ if (!subflow) {
40
+ throw new Error(`Subflow '${subflowId}' not found. Use get-subflows to list available subflow definitions.`);
41
+ }
42
+
43
+ // Get internal nodes using the shared flow-utils (validates consistency)
44
+ let internalNodes;
45
+ try {
46
+ internalNodes = getFlowNodes(allNodes, subflowId);
47
+ } catch {
48
+ internalNodes = [];
49
+ }
50
+
51
+ // Get all instances (nodes of type "subflow:<subflowId>")
52
+ const instanceType = `subflow:${subflowId}`;
53
+ const instances = allNodes.filter((n) => n.type === instanceType);
54
+
55
+ // Generate Mermaid diagram from internal nodes (via shared renderer)
56
+ const ir = buildIR(internalNodes);
57
+ const diagram = buildMermaid(ir);
58
+
59
+ // Sanitize internal nodes: metadata + config (like get-flow-nodes does)
60
+ const sanitizedInternals = internalNodes.map((node) => ({
61
+ id: node.id,
62
+ type: node.type,
63
+ name: node.name || '',
64
+ disabled: node.d === true,
65
+ x: node.x,
66
+ y: node.y,
67
+ wires: node.wires || [],
68
+ config: sanitizeNodeConfig(node),
69
+ }));
70
+
71
+ // Simplify instances to key fields
72
+ const simplifiedInstances = instances.map((node) => ({
73
+ id: node.id,
74
+ name: node.name || '',
75
+ flowId: node.z || '',
76
+ x: node.x,
77
+ y: node.y,
78
+ wires: node.wires || [],
79
+ env: node.env || [],
80
+ }));
81
+
82
+ return {
83
+ definition: subflow,
84
+ internalNodes: sanitizedInternals,
85
+ instances: simplifiedInstances,
86
+ diagram,
87
+ };
88
+ }
89
+
90
+ /**
91
+ * Handler for the get-subflow-detail MCP tool.
92
+ *
93
+ * @param {ReturnType<import('../nodered/client.js').createNodeRedClient>} client
94
+ * @param {object} params
95
+ * @param {string} params.subflowId
96
+ * @returns {Promise<{ content: Array<{ type: string, text: string }> }>}
97
+ */
98
+ export async function handleGetSubflowDetail(staging, params) {
99
+ const flows = await staging.getFlows();
100
+ const result = transformSubflowDetail({ flows }, params.subflowId);
101
+
102
+ return formatSuccess(result);
103
+ }
104
+
105
+ export const getSubflowDetailDefinition = {
106
+ name: 'get-subflow-detail',
107
+ annotations: ANN_READONLY,
108
+ outputSchema: SubflowDetailResponseSchema,
109
+ handler: handleGetSubflowDetail,
110
+ };
@@ -0,0 +1,105 @@
1
+ /**
2
+ * MCP tool: get-subflows
3
+ *
4
+ * Returns a summarized list of subflow definitions from the connected
5
+ * Node-RED instance, with enriched metadata for LLM consumption.
6
+ */
7
+ import { formatSuccess } from './response-utils.js';
8
+
9
+
10
+ import { ANN_READONLY } from './constants.js';
11
+ import { SubflowSummarySchema } from '../schemas/responses.js';
12
+ import { z } from 'zod';
13
+ /**
14
+ * Transform the raw Node-RED /flows response into an LLM-friendly
15
+ * summary of subflow definitions only.
16
+ *
17
+ * @param {object} rawResponse - Response from GET /flows (v2 format: { rev, flows })
18
+ * @returns {Array<{
19
+ * id: string,
20
+ * name: string,
21
+ * info: string,
22
+ * inputCount: number,
23
+ * outputCount: number,
24
+ * internalNodeCount: number,
25
+ * internalNodeTypes: string[],
26
+ * instanceCount: number,
27
+ * instances: Array<{ id: string, name: string, flowId: string }>
28
+ * }>}
29
+ */
30
+ export function transformSubflows(rawResponse) {
31
+ const allNodes = rawResponse.flows || [];
32
+
33
+ // Identify subflow definitions only
34
+ const subflows = allNodes.filter(
35
+ (node) => node.type === 'subflow',
36
+ );
37
+
38
+ if (subflows.length === 0) return [];
39
+
40
+ // Build lookup for instance nodes (type: "subflow:<id>")
41
+ const subflowTypeSet = new Set(subflows.map((sf) => sf.id));
42
+ const instancesBySubflow = new Map();
43
+ const childrenBySubflow = new Map();
44
+
45
+ for (const sf of subflows) {
46
+ instancesBySubflow.set(sf.id, []);
47
+ childrenBySubflow.set(sf.id, []);
48
+ }
49
+
50
+ for (const node of allNodes) {
51
+ // Collect internal nodes (z === subflowId)
52
+ if (node.z && childrenBySubflow.has(node.z)) {
53
+ childrenBySubflow.get(node.z).push(node);
54
+ }
55
+ // Collect instances (type === "subflow:<id>")
56
+ if (node.type && node.type.startsWith('subflow:')) {
57
+ const subflowId = node.type.slice('subflow:'.length);
58
+ if (instancesBySubflow.has(subflowId)) {
59
+ instancesBySubflow.get(subflowId).push(node);
60
+ }
61
+ }
62
+ }
63
+
64
+ return subflows.map((sf) => {
65
+ const children = childrenBySubflow.get(sf.id) || [];
66
+ const instances = instancesBySubflow.get(sf.id) || [];
67
+ const uniqueTypes = [...new Set(children.map((n) => n.type))];
68
+
69
+ return {
70
+ id: sf.id,
71
+ name: sf.name || '',
72
+ info: sf.info || '',
73
+ inputCount: Array.isArray(sf.in) ? sf.in.length : 0,
74
+ outputCount: Array.isArray(sf.out) ? sf.out.length : 0,
75
+ internalNodeCount: children.length,
76
+ internalNodeTypes: uniqueTypes,
77
+ instanceCount: instances.length,
78
+ instances: instances.map((i) => ({
79
+ id: i.id,
80
+ name: i.name || '',
81
+ flowId: i.z || '',
82
+ })),
83
+ };
84
+ });
85
+ }
86
+
87
+ /**
88
+ * Handler for the get-subflows MCP tool.
89
+ *
90
+ * @param {ReturnType<import('../nodered/client.js').createNodeRedClient>} client
91
+ * @returns {Promise<{ content: Array<{ type: string, text: string }> }>}
92
+ */
93
+ export async function handleGetSubflows(staging) {
94
+ const flows = await staging.getFlows();
95
+ const subflows = transformSubflows({ flows });
96
+
97
+ return formatSuccess({ subflows });
98
+ }
99
+
100
+ export const getSubflowsDefinition = {
101
+ name: 'get-subflows',
102
+ annotations: ANN_READONLY,
103
+ outputSchema: z.object({ subflows: z.array(SubflowSummarySchema) }),
104
+ handler: handleGetSubflows,
105
+ };
@@ -0,0 +1,310 @@
1
+ /**
2
+ * MCP tool: import-flow
3
+ *
4
+ * Imports a Node-RED flow JSON (array or { nodes: [...] } object) into the
5
+ * running Node-RED instance. Supports two conflict strategies (regenerate /
6
+ * overwrite) and an optional targetFlowId to inject nodes into an existing tab.
7
+ *
8
+ * All flows are redeployed on import (PUT /flows full deploy).
9
+ */
10
+
11
+ import { randomUUID } from 'crypto';
12
+
13
+ import { formatSuccess } from './response-utils.js';
14
+ import { ANN_MUTATION } from './constants.js';
15
+ import { ImportFlowResponseSchema } from '../schemas/responses.js';
16
+ /**
17
+ * Parse and normalize a flowJson string to a flat node array.
18
+ *
19
+ * Accepts:
20
+ * - A JSON array string: `[ {...}, ... ]`
21
+ * - A JSON object string with a `nodes` property: `{ "nodes": [ {...}, ... ] }`
22
+ *
23
+ * @param {string} input - Raw JSON string from the caller
24
+ * @returns {object[]} Flat array of Node-RED node objects
25
+ * @throws {Error} On invalid JSON, wrong shape, or empty array
26
+ */
27
+ export function normalizeFlowJson(input) {
28
+ let parsed;
29
+ try {
30
+ parsed = JSON.parse(input);
31
+ } catch {
32
+ throw new Error('Invalid flowJson: not valid JSON. Provide a JSON string representing a Node-RED flow array or an object with a "nodes" array.');
33
+ }
34
+
35
+ let nodes;
36
+ if (Array.isArray(parsed)) {
37
+ nodes = parsed;
38
+ } else if (parsed && typeof parsed === 'object' && Array.isArray(parsed.nodes)) {
39
+ nodes = parsed.nodes;
40
+ } else {
41
+ throw new Error('Invalid flowJson: expected a JSON array or an object with a "nodes" array. Example: [{"id":"...","type":"debug"}] or {"nodes":[...]}');
42
+ }
43
+
44
+ if (nodes.length === 0) {
45
+ throw new Error('flowJson is empty — nothing to import. Provide at least one node in the JSON array.');
46
+ }
47
+
48
+ return nodes;
49
+ }
50
+
51
+ /**
52
+ * Remap all `id` and `z` fields in a node array to fresh UUIDs, preserving
53
+ * all internal references (wire targets, `z` cross-references).
54
+ *
55
+ * @param {object[]} nodes - Node array to remap (not mutated)
56
+ * @returns {object[]} New array with remapped IDs
57
+ */
58
+ export function regenerateIds(nodes) {
59
+ // Build old-id → new-id map
60
+ const idMap = new Map();
61
+ for (const node of nodes) {
62
+ if (node.id) {
63
+ idMap.set(node.id, randomUUID());
64
+ }
65
+ }
66
+
67
+ return nodes.map((node) => {
68
+ const remapped = { ...node };
69
+
70
+ // Remap own id
71
+ if (node.id && idMap.has(node.id)) {
72
+ remapped.id = idMap.get(node.id);
73
+ }
74
+
75
+ // Remap z (parent flow reference)
76
+ if (node.z && idMap.has(node.z)) {
77
+ remapped.z = idMap.get(node.z);
78
+ }
79
+
80
+ // Remap wire targets
81
+ if (Array.isArray(node.wires)) {
82
+ remapped.wires = node.wires.map((port) =>
83
+ Array.isArray(port)
84
+ ? port.map((targetId) => idMap.get(targetId) ?? targetId)
85
+ : port
86
+ );
87
+ }
88
+
89
+ return remapped;
90
+ });
91
+ }
92
+
93
+ /**
94
+ * Merge imported nodes into the existing flows array using the given strategy.
95
+ *
96
+ * Strategies:
97
+ * - `regenerate`: imported nodes have already had IDs regenerated, so there
98
+ * are no collisions. Just append.
99
+ * - `overwrite`: replace any existing node whose ID matches an imported node,
100
+ * then append remaining new nodes.
101
+ *
102
+ * @param {object[]} existing - Current flows array from GET /flows
103
+ * @param {object[]} imported - Imported nodes (already strategy-processed)
104
+ * @param {'regenerate'|'overwrite'} strategy
105
+ * @returns {{ updatedFlows: object[], conflicts: number }}
106
+ */
107
+ export function mergeFlows(existing, imported, strategy) {
108
+ if (strategy === 'regenerate') {
109
+ // IDs are all fresh — no possible conflicts, just append
110
+ return { updatedFlows: [...existing, ...imported], conflicts: 0 };
111
+ }
112
+
113
+ if (strategy === 'overwrite') {
114
+ const importedById = new Map(imported.map((n) => [n.id, n]));
115
+ let conflicts = 0;
116
+
117
+ // Replace existing nodes that are being overwritten
118
+ const kept = existing.map((node) => {
119
+ if (importedById.has(node.id)) {
120
+ conflicts++;
121
+ return importedById.get(node.id);
122
+ }
123
+ return node;
124
+ });
125
+
126
+ // Append imported nodes that were not replacements (new IDs)
127
+ const existingIds = new Set(existing.map((n) => n.id));
128
+ const brandNew = imported.filter((n) => !existingIds.has(n.id));
129
+
130
+ return { updatedFlows: [...kept, ...brandNew], conflicts };
131
+ }
132
+
133
+ throw new Error(`Unknown conflictStrategy '${strategy}'. Use "regenerate" (new IDs, no collisions) or "overwrite" (replace existing nodes with matching IDs).`);
134
+ }
135
+
136
+ /**
137
+ * Discard tab nodes from the imported array and remap all remaining nodes'
138
+ * `z` field to the given targetFlowId.
139
+ *
140
+ * @param {object[]} nodes - Imported node array
141
+ * @param {string} targetFlowId - ID of the existing flow tab to inject into
142
+ * @returns {object[]} Non-tab nodes with `z` set to targetFlowId
143
+ */
144
+ export function applyTargetFlow(nodes, targetFlowId) {
145
+ return nodes
146
+ .filter((n) => n.type !== 'tab' && n.type !== 'subflow')
147
+ .map((n) => ({ ...n, z: targetFlowId }));
148
+ }
149
+
150
+ /**
151
+ * Reposition imported nodes into a free area of the target flow, avoiding
152
+ * overlap with existing nodes.
153
+ *
154
+ * Strategy: place the imported nodes to the right of the existing nodes,
155
+ * preserving the internal relative layout of the imported group.
156
+ *
157
+ * @param {object[]} existingNodes - Nodes already in the target flow
158
+ * @param {object[]} importedNodes - Nodes being imported (already remapped)
159
+ * @returns {object[]} Imported nodes with adjusted x, y positions
160
+ */
161
+ export function repositionNodes(existingNodes, importedNodes) {
162
+ // Filter to only regular nodes (not tabs/subflows/config nodes)
163
+ const regularExisting = existingNodes.filter(
164
+ (n) => n.x !== undefined && n.y !== undefined,
165
+ );
166
+
167
+ // Find rightmost position of existing nodes
168
+ let existingMaxX = 0;
169
+ let existingMinY = Infinity;
170
+ for (const node of regularExisting) {
171
+ if (node.x > existingMaxX) existingMaxX = node.x;
172
+ if (node.y < existingMinY) existingMinY = node.y;
173
+ }
174
+ // Default if no existing nodes with positions
175
+ if (!isFinite(existingMinY)) existingMinY = 80;
176
+
177
+ // Find leftmost position of imported nodes (for relative offset)
178
+ let importedMinX = Infinity;
179
+ let importedMinY = Infinity;
180
+ for (const node of importedNodes) {
181
+ if (node.x !== undefined && node.x < importedMinX) importedMinX = node.x;
182
+ if (node.y !== undefined && node.y < importedMinY) importedMinY = node.y;
183
+ }
184
+ // Default if imported nodes have no positions
185
+ if (!isFinite(importedMinX)) importedMinX = 120;
186
+ if (!isFinite(importedMinY)) importedMinY = 80;
187
+
188
+ // Gap between existing rightmost edge and imported leftmost edge
189
+ const HORIZONTAL_GAP = 200;
190
+
191
+ const offsetX = existingMaxX - importedMinX + HORIZONTAL_GAP;
192
+ const offsetY = existingMinY - importedMinY;
193
+
194
+ return importedNodes.map((node) => ({
195
+ ...node,
196
+ x: (node.x ?? importedMinX) + offsetX,
197
+ y: (node.y ?? importedMinY) + offsetY,
198
+ }));
199
+ }
200
+
201
+ /**
202
+ * Build the import summary object.
203
+ *
204
+ * @param {object[]} importedNodes - The nodes that were merged into the instance
205
+ * @param {number} conflicts - Number of ID collisions resolved
206
+ * @param {string} strategy - The conflict strategy applied
207
+ * @param {string|null} targetFlowId - The targetFlowId if used, else null
208
+ * @returns {{ imported: { flows: number, nodes: number, configNodes: number }, conflicts: number, strategy: string, targetFlowId: string|null }}
209
+ */
210
+ export function summarizeImport(importedNodes, conflicts, strategy, targetFlowId) {
211
+ let flows = 0;
212
+ let nodes = 0;
213
+ let configNodes = 0;
214
+
215
+ for (const node of importedNodes) {
216
+ if (node.type === 'tab' || node.type === 'subflow') {
217
+ flows++;
218
+ } else if (node.z === undefined || node.z === null || node.z === '') {
219
+ configNodes++;
220
+ } else {
221
+ nodes++;
222
+ }
223
+ }
224
+
225
+ return {
226
+ imported: { flows, nodes, configNodes },
227
+ conflicts,
228
+ strategy,
229
+ targetFlowId: targetFlowId ?? null,
230
+ };
231
+ }
232
+
233
+ /**
234
+ * Handler for the import-flow MCP tool.
235
+ *
236
+ * Orchestration:
237
+ * 1. Validate targetFlowId (if provided) — must exist and not be locked
238
+ * 2. Fetch existing flows
239
+ * 3. Normalize imported JSON
240
+ * 4. Apply targetFlow remapping or standard conflict strategy
241
+ * 5. PUT /flows with merged result
242
+ * 6. Return summary
243
+ *
244
+ * @param {ReturnType<import('../nodered/client.js').createNodeRedClient>} client
245
+ * @param {object} params
246
+ * @param {string} params.flowJson - Raw Node-RED flow JSON string
247
+ * @param {'regenerate'|'overwrite'} [params.conflictStrategy='regenerate']
248
+ * @param {string} [params.targetFlowId]
249
+ * @returns {Promise<{ content: Array<{ type: string, text: string }> }>}
250
+ */
251
+ export async function handleImportFlow(staging, client, params) {
252
+ const { flowJson, conflictStrategy = 'regenerate', targetFlowId } = params;
253
+
254
+ // Step 1: validate conflictStrategy early (before any network calls)
255
+ if (conflictStrategy !== 'regenerate' && conflictStrategy !== 'overwrite') {
256
+ throw new Error(`Unknown conflictStrategy '${conflictStrategy}'. Use "regenerate" or "overwrite"`);
257
+ }
258
+
259
+ // Step 2: Normalize the incoming JSON (pure, no network)
260
+ const importedNodes = normalizeFlowJson(flowJson);
261
+
262
+ // Step 3: Compute the nodes to merge (deterministic, no network dependency)
263
+ let nodesToMerge;
264
+ if (targetFlowId) {
265
+ const remapped = applyTargetFlow(importedNodes, targetFlowId);
266
+ nodesToMerge = conflictStrategy === 'regenerate' ? regenerateIds(remapped) : remapped;
267
+ } else if (conflictStrategy === 'regenerate') {
268
+ nodesToMerge = regenerateIds(importedNodes);
269
+ } else {
270
+ nodesToMerge = importedNodes;
271
+ }
272
+
273
+ // Step 4: Merge and stage locally
274
+ const mergeStrategy = (targetFlowId && conflictStrategy !== 'overwrite') ? 'regenerate' : conflictStrategy;
275
+
276
+ const result = await staging.applyMutation((rawResponse) => {
277
+ const existing = rawResponse.flows || [];
278
+
279
+ // Validate targetFlowId if provided
280
+ if (targetFlowId) {
281
+ const targetTab = existing.find(
282
+ (n) => n.id === targetFlowId && (n.type === 'tab' || n.type === 'subflow'),
283
+ );
284
+ if (!targetTab) {
285
+ throw new Error(`Target flow '${targetFlowId}' not found. Use get-flows to list available flow tabs.`);
286
+ }
287
+ if (targetTab.locked) {
288
+ throw new Error(`Target flow '${targetFlowId}' is locked. This flow is locked (read-only). Use get-flow-nodes to inspect its nodes without modifying them.`);
289
+ }
290
+
291
+ // Reposition imported nodes to avoid overlap with existing nodes in the target tab
292
+ const existingInTarget = existing.filter((n) => n.z === targetFlowId);
293
+ nodesToMerge = repositionNodes(existingInTarget, nodesToMerge);
294
+ }
295
+
296
+ return mergeFlows(existing, nodesToMerge, mergeStrategy);
297
+ });
298
+
299
+ const summary = summarizeImport(nodesToMerge, result.conflicts, conflictStrategy, targetFlowId ?? null);
300
+
301
+ const data = { ...summary, staging: staging.getStagingSummary() };
302
+ return formatSuccess(data);
303
+ }
304
+
305
+ export const importFlowDefinition = {
306
+ name: 'import-flow',
307
+ annotations: ANN_MUTATION,
308
+ outputSchema: ImportFlowResponseSchema,
309
+ handler: handleImportFlow,
310
+ };
@@ -0,0 +1,117 @@
1
+ import { ANN_INJECT } from './constants.js';
2
+ import { InjectMessageResponseSchema } from '../schemas/responses.js';
3
+ /**
4
+ * MCP tool: inject-message
5
+ *
6
+ * Fires an inject node by node ID or by name (optionally scoped to a flow).
7
+ * Uses the Node-RED Admin API POST /inject/:nodeId endpoint.
8
+ */
9
+
10
+ /**
11
+ * Resolve an inject node from all flows by nodeId or by name (+ optional flowId).
12
+ *
13
+ * @param {object[]} allNodes - All nodes from the GET /flows response
14
+ * @param {object} options
15
+ * @param {string} [options.nodeId] - The node UUID to inject
16
+ * @param {string} [options.name] - The node name to search for
17
+ * @param {string} [options.flowId] - Flow ID to scope the name search
18
+ * @returns {{ nodeId: string, name?: string }} The resolved node
19
+ * @throws {Error} If not found, ambiguous, or no identifier provided
20
+ */
21
+ export function resolveInjectNode(allNodes, { nodeId, name, flowId } = {}) {
22
+ // Must have at least one identifier
23
+ if (!nodeId && !name) {
24
+ throw new Error('Provide either nodeId or name. Use nodeId to target a specific inject node by UUID, or name with optional flowId to search by label.');
25
+ }
26
+
27
+ // If nodeId is given, find directly
28
+ if (nodeId) {
29
+ const node = allNodes.find((n) => n.id === nodeId);
30
+ if (!node) {
31
+ throw new Error(`Inject node not found: no node with id "${nodeId}". Use search-nodes with type: "inject" to find available inject nodes.`);
32
+ }
33
+ return { nodeId: node.id, name: node.name };
34
+ }
35
+
36
+ // Resolve by name (+ optional flowId)
37
+ let candidates = allNodes.filter(
38
+ (n) => n.name === name && n.type === 'inject',
39
+ );
40
+
41
+ if (flowId) {
42
+ candidates = candidates.filter((n) => n.z === flowId);
43
+ }
44
+
45
+ if (candidates.length === 0) {
46
+ const scope = flowId ? ` in flow "${flowId}"` : '';
47
+ throw new Error(
48
+ `Inject node not found: no inject node named "${name}"${scope}. Use search-nodes with type: "inject" to find available inject nodes by name.`,
49
+ );
50
+ }
51
+
52
+ if (candidates.length > 1) {
53
+ const ids = candidates.map((n) => `"${n.id}"`).join(', ');
54
+ throw new Error(
55
+ `Multiple inject nodes named "${name}" found (${ids}). Use nodeId to disambiguate.`,
56
+ );
57
+ }
58
+
59
+ return { nodeId: candidates[0].id, name: candidates[0].name };
60
+ }
61
+
62
+ /**
63
+ * Handler for the inject-message MCP tool.
64
+ *
65
+ * @param {import('../staging-store.js').StagingStore} staging
66
+ * @param {ReturnType<import('../nodered/client.js').createNodeRedClient>} client
67
+ * @returns {(params: { nodeId?: string, name?: string, flowId?: string }) => Promise<{ content: Array<{ type: string, text: string }> }>}
68
+ */
69
+ export function handleInjectMessage(staging, client) {
70
+ return async (params) => {
71
+ const { nodeId, name, flowId } = params;
72
+
73
+ // Pre-deploy guard: refuse to inject if there are undeployed changes
74
+ if (staging.hasPendingChanges()) {
75
+ const summary = staging.getStagingSummary();
76
+ throw new Error(
77
+ `Cannot inject: there are ${summary.pendingChanges} undeployed change(s). ` +
78
+ `Call \`deploy\` first to push your pending changes to Node-RED. ` +
79
+ `Dirty nodes: ${summary.dirtyNodeIds.join(', ') || 'none'}. ` +
80
+ `Dirty flows: ${summary.dirtyFlowIds.join(', ') || 'none'}.`,
81
+ );
82
+ }
83
+
84
+ // Fetch all flows from staging to resolve the inject node
85
+ const allNodes = await staging.getFlows();
86
+
87
+ // Resolve the target inject node
88
+ const resolved = resolveInjectNode(allNodes, { nodeId, name, flowId });
89
+
90
+ // Call POST /inject/:nodeId
91
+ const result = await client.post(`/inject/${resolved.nodeId}`);
92
+
93
+ const responseData = {
94
+ success: true,
95
+ nodeId: resolved.nodeId,
96
+ name: resolved.name,
97
+ message: typeof result === 'string' ? result : 'Injected',
98
+ };
99
+
100
+ return {
101
+ content: [
102
+ {
103
+ type: 'text',
104
+ text: JSON.stringify(responseData, null, 2),
105
+ },
106
+ ],
107
+ structuredContent: responseData,
108
+ };
109
+ };
110
+ }
111
+
112
+ export const injectMessageDefinition = {
113
+ name: 'inject-message',
114
+ annotations: ANN_INJECT,
115
+ outputSchema: InjectMessageResponseSchema,
116
+ handler: handleInjectMessage,
117
+ };
@@ -0,0 +1,31 @@
1
+ /**
2
+ * MCP tool: install-node
3
+ *
4
+ * Installs a new Node-RED node module from the npm registry via the Admin API's
5
+ * POST /nodes endpoint. Accepts a plain npm package name (no @version qualifiers
6
+ * — the API does not support them via JSON body). Returns the Node Module object
7
+ * with name, version, and the list of installed node types.
8
+ */
9
+ import { formatSuccess } from './response-utils.js';
10
+
11
+
12
+ import { ANN_INSTALL } from './constants.js';
13
+ import { GenericObjectSchema } from '../schemas/responses.js';
14
+ /**
15
+ * Handle the install-node MCP tool invocation.
16
+ *
17
+ * @param {ReturnType<import('../nodered/client.js').createNodeRedClient>} client
18
+ * @param {{ module: string }} params
19
+ * @returns {Promise<{ content: Array<{ type: string, text: string }> }>}
20
+ */
21
+ export async function handleInstallNode(client, { module: moduleName }) {
22
+ const result = await client.request('POST', '/nodes', { module: moduleName });
23
+ return formatSuccess(result);
24
+ }
25
+
26
+ export const installNodeDefinition = {
27
+ name: 'install-node',
28
+ annotations: ANN_INSTALL,
29
+ outputSchema: GenericObjectSchema,
30
+ handler: handleInstallNode,
31
+ };