@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,84 @@
1
+ /**
2
+ * Skill Loader — reads and caches skill content from `resources/skills/* /SKILL.md` files.
3
+ *
4
+ * Each skill directory contains a SKILL.md file with YAML frontmatter (name, description, etc.)
5
+ * followed by Markdown body content.
6
+ */
7
+
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import matter from 'gray-matter';
11
+
12
+ /**
13
+ * Extract the first sentence from a description string for use as a use-case hint.
14
+ *
15
+ * Splits on ". " (period + space) boundaries and returns the first sentence.
16
+ * Falls back to the full description if it's a single sentence.
17
+ *
18
+ * @param {string} description
19
+ * @returns {string}
20
+ */
21
+ function firstSentence(description) {
22
+ if (!description) return '';
23
+ const idx = description.indexOf('. ');
24
+ if (idx === -1) return description;
25
+ return description.slice(0, idx + 1); // include the period
26
+ }
27
+
28
+ /**
29
+ * Load all skills from the resources skills directory.
30
+ *
31
+ * Scans `resources/skills/* /SKILL.md`, parses YAML frontmatter, and returns a Map
32
+ * keyed by skill name.
33
+ *
34
+ * @param {string} basePath — project root directory (resolved by caller)
35
+ * @returns {Map<string, { name: string, description: string, content: string, path: string, category: string, useCase: string }>}
36
+ */
37
+ export function loadSkills(basePath) {
38
+ const skillsDir = path.join(basePath, 'resources', 'skills');
39
+ const skills = new Map();
40
+
41
+ // Handle missing skills directory gracefully — return empty map
42
+ if (!fs.existsSync(skillsDir)) {
43
+ return skills;
44
+ }
45
+
46
+ if (!fs.statSync(skillsDir).isDirectory()) {
47
+ return skills;
48
+ }
49
+
50
+ const entries = fs.readdirSync(skillsDir, { withFileTypes: true });
51
+
52
+ for (const entry of entries) {
53
+ if (!entry.isDirectory()) continue;
54
+
55
+ const skillFile = path.join(skillsDir, entry.name, 'SKILL.md');
56
+
57
+ // Skip directories without a SKILL.md
58
+ if (!fs.existsSync(skillFile)) continue;
59
+
60
+ try {
61
+ const raw = fs.readFileSync(skillFile, 'utf-8');
62
+ const parsed = matter(raw);
63
+
64
+ const skillName = parsed.data.name || entry.name;
65
+ const description = parsed.data.description || '';
66
+ const content = parsed.content.trim();
67
+ const category = parsed.data.category || entry.name;
68
+ const useCase = firstSentence(description);
69
+
70
+ skills.set(skillName, {
71
+ name: skillName,
72
+ description,
73
+ content,
74
+ path: skillFile,
75
+ category,
76
+ useCase,
77
+ });
78
+ } catch {
79
+ // Skip files that cannot be read or parsed
80
+ }
81
+ }
82
+
83
+ return skills;
84
+ }
@@ -0,0 +1,258 @@
1
+ /**
2
+ * In-Memory Staging Store for Node-RED MCP Server.
3
+ *
4
+ * Holds a mutable copy of the Node-RED flows, enabling write tools to stage
5
+ * changes locally and deploy explicitly. Mirrors the Node-RED editor's
6
+ * "workspace + explicit deploy" model.
7
+ *
8
+ * Key features:
9
+ * - Lazy-loads flows from Node-RED on first access
10
+ * - Tracks dirty nodes and flows for granular deploys
11
+ * - Provides deploy with three modes: full, flows, nodes
12
+ * - Supports invalidation and summary for LLM context
13
+ */
14
+
15
+ export class StagingStore {
16
+ /** @type {ReturnType<import('./nodered/client.js').createNodeRedClient>} */
17
+ #client;
18
+
19
+ /** @type {object[]} */
20
+ #flows = [];
21
+
22
+ /** @type {string|null} */
23
+ #rev = null;
24
+
25
+ /** @type {Set<string>} */
26
+ #dirtyNodeIds = new Set();
27
+
28
+ /** @type {Set<string>} */
29
+ #dirtyFlowIds = new Set();
30
+
31
+ /** @type {boolean} */
32
+ #isLoaded = false;
33
+
34
+ /** @type {Array<(...args: any[]) => void>} */
35
+ #listeners = [];
36
+
37
+ /**
38
+ * @param {ReturnType<import('./nodered/client.js').createNodeRedClient>} client
39
+ */
40
+ constructor(client) {
41
+ this.#client = client;
42
+ }
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // Public API
46
+ // ---------------------------------------------------------------------------
47
+
48
+ /**
49
+ * Ensure flows are loaded from Node-RED (lazy-load on first access).
50
+ * Idempotent — no-op if already loaded.
51
+ *
52
+ * @returns {Promise<void>}
53
+ */
54
+ async ensureLoaded() {
55
+ if (this.#isLoaded) return;
56
+
57
+ const rawResponse = await this.#client.request('GET', '/flows');
58
+ this.#rev = rawResponse?.rev ?? null;
59
+ this.#flows = rawResponse?.flows ?? [];
60
+ this.#isLoaded = true;
61
+ }
62
+
63
+ /**
64
+ * Return the current staged flows array.
65
+ * Triggers lazy-load if not yet loaded.
66
+ *
67
+ * @returns {Promise<object[]>} The staged flows array
68
+ */
69
+ async getFlows() {
70
+ await this.ensureLoaded();
71
+ return this.#flows;
72
+ }
73
+
74
+ /**
75
+ * Apply a pure mutation function to the staged flows and track dirty state.
76
+ *
77
+ * The mutation function receives a `{ flows }` wrapper (matching the shape
78
+ * of `rawResponse` from GET /flows) and must return an object containing at
79
+ * least `updatedFlows`. Extra properties are passed through to the caller.
80
+ *
81
+ * After applying the mutation, dirty tracking auto-detects added, removed,
82
+ * and modified nodes by comparing before/after snapshots.
83
+ *
84
+ * @template T
85
+ * @param {(rawResponse: { flows: object[] }) => { updatedFlows: object[], [key: string]: any }} fn - Pure mutation function
86
+ * @returns {Promise<T>} The result from fn, excluding `updatedFlows`
87
+ */
88
+ async applyMutation(fn) {
89
+ await this.ensureLoaded();
90
+
91
+ // Snapshot before mutation: id -> node
92
+ const beforeMap = new Map();
93
+ for (const node of this.#flows) {
94
+ beforeMap.set(node.id, node);
95
+ }
96
+
97
+ // Apply mutation — fn receives { flows } like the old rawResponse
98
+ const result = fn({ flows: this.#flows });
99
+ const { updatedFlows, ...output } = result;
100
+
101
+ // Snapshot after mutation
102
+ const afterMap = new Map();
103
+ for (const node of updatedFlows) {
104
+ afterMap.set(node.id, node);
105
+ }
106
+
107
+ // Track dirty: added or modified nodes
108
+ for (const [id, node] of afterMap) {
109
+ const before = beforeMap.get(id);
110
+ if (!before || JSON.stringify(before) !== JSON.stringify(node)) {
111
+ this.#dirtyNodeIds.add(id);
112
+ if (node.z) this.#dirtyFlowIds.add(node.z);
113
+ // Flow tab creation/modification also dirties the flow itself
114
+ if (node.type === 'tab' || node.type === 'subflow') {
115
+ this.#dirtyFlowIds.add(node.id);
116
+ }
117
+ }
118
+ }
119
+
120
+ // Track dirty: removed nodes
121
+ for (const [id, node] of beforeMap) {
122
+ if (!afterMap.has(id)) {
123
+ this.#dirtyNodeIds.add(id);
124
+ if (node.z) this.#dirtyFlowIds.add(node.z);
125
+ if (node.type === 'tab' || node.type === 'subflow') {
126
+ this.#dirtyFlowIds.add(node.id);
127
+ }
128
+ }
129
+ }
130
+
131
+ // Update internal state
132
+ this.#flows = updatedFlows;
133
+
134
+ // Emit change event for live visualization
135
+ this.#emit('staging:changed', {
136
+ dirtyNodeIds: this.#dirtyNodeIds,
137
+ dirtyFlowIds: this.#dirtyFlowIds,
138
+ });
139
+
140
+ return output;
141
+ }
142
+
143
+ /**
144
+ * Deploy the staged flows to Node-RED.
145
+ *
146
+ * Sends the full flows array via POST /flows with the specified deploy type.
147
+ * On success, re-fetches flows from Node-RED to sync rev and state, then
148
+ * clears dirty tracking. On 409 version_mismatch, throws without retrying.
149
+ *
150
+ * @param {string} [deployType='nodes'] - Node-RED-Deployment-Type header value: 'full', 'flows', or 'nodes'
151
+ * @returns {Promise<void>}
152
+ * @throws {Error} On deploy failure including version_mismatch (409)
153
+ */
154
+ async deploy(deployType = 'nodes') {
155
+ await this.ensureLoaded();
156
+
157
+ const flowsPayload = { rev: this.#rev, flows: this.#flows };
158
+
159
+ await this.#client.putFlows(flowsPayload, deployType);
160
+
161
+ // Re-fetch to sync rev and state
162
+ await this.invalidate();
163
+ await this.ensureLoaded();
164
+
165
+ // Clear dirty tracking — post-deploy everything is clean
166
+ this.#dirtyNodeIds.clear();
167
+ this.#dirtyFlowIds.clear();
168
+
169
+ // Emit change event after deploy (dirty sets are now empty)
170
+ this.#emit('staging:changed', {
171
+ dirtyNodeIds: this.#dirtyNodeIds,
172
+ dirtyFlowIds: this.#dirtyFlowIds,
173
+ });
174
+ }
175
+
176
+ /**
177
+ * Check whether there are pending (undeployed) changes.
178
+ *
179
+ * @returns {boolean} True if either dirty set is non-empty
180
+ */
181
+ hasPendingChanges() {
182
+ return this.#dirtyNodeIds.size > 0 || this.#dirtyFlowIds.size > 0;
183
+ }
184
+
185
+ /**
186
+ * Return the set of dirty node IDs.
187
+ *
188
+ * @returns {Set<string>}
189
+ */
190
+ getDirtyNodeIds() {
191
+ return this.#dirtyNodeIds;
192
+ }
193
+
194
+ /**
195
+ * Return the set of dirty flow IDs.
196
+ *
197
+ * @returns {Set<string>}
198
+ */
199
+ getDirtyFlowIds() {
200
+ return this.#dirtyFlowIds;
201
+ }
202
+
203
+ /**
204
+ * Subscribe to staging events.
205
+ *
206
+ * @param {string} event - Event name (currently only 'staging:changed')
207
+ * @param {(data: { dirtyNodeIds: Set<string>, dirtyFlowIds: Set<string> }) => void} callback
208
+ */
209
+ on(event, callback) {
210
+ this.#listeners.push({ event, callback });
211
+ }
212
+
213
+ /**
214
+ * Emit an event to all matching listeners.
215
+ *
216
+ * @param {string} event
217
+ * @param {object} data
218
+ */
219
+ #emit(event, data) {
220
+ for (const l of this.#listeners) {
221
+ if (l.event === event) {
222
+ try { l.callback(data); } catch { /* ignore listener errors */ }
223
+ }
224
+ }
225
+ }
226
+
227
+ /**
228
+ * Return a staging summary object for inclusion in tool responses.
229
+ *
230
+ * @returns {{ pendingChanges: number, dirtyNodeIds: string[], dirtyFlowIds: string[], deployed: boolean }}
231
+ */
232
+ getStagingSummary() {
233
+ return {
234
+ pendingChanges: this.#dirtyNodeIds.size,
235
+ dirtyNodeIds: [...this.#dirtyNodeIds],
236
+ dirtyFlowIds: [...this.#dirtyFlowIds],
237
+ deployed: !this.hasPendingChanges(),
238
+ };
239
+ }
240
+
241
+ // ---------------------------------------------------------------------------
242
+ // Internal
243
+ // ---------------------------------------------------------------------------
244
+
245
+ /**
246
+ * Invalidate the staging cache. Next getFlows() or applyMutation() call
247
+ * will re-fetch from Node-RED. Also clears dirty tracking.
248
+ *
249
+ * @returns {Promise<void>}
250
+ */
251
+ async invalidate() {
252
+ this.#flows = [];
253
+ this.#rev = null;
254
+ this.#isLoaded = false;
255
+ this.#dirtyNodeIds.clear();
256
+ this.#dirtyFlowIds.clear();
257
+ }
258
+ }
@@ -0,0 +1,216 @@
1
+ /**
2
+ * MCP tool: add-nodes-to-group
3
+ *
4
+ * Assigns a list of nodes to a Node-RED group. If the group does not
5
+ * exist, it is created with a computed bounding rectangle enclosing all
6
+ * member nodes. Nodes already in another group are automatically
7
+ * reassigned (their previous group membership is removed).
8
+ */
9
+
10
+ import { randomUUID } from 'crypto';
11
+ import { computeBoundingBox } from './flow-utils.js';
12
+
13
+ import { ANN_MUTATION } from './constants.js';
14
+ import { AddNodesToGroupResponseSchema } from '../schemas/responses.js';
15
+ /** Default group style, matching Node-RED's defaults. */
16
+ const DEFAULT_GROUP_STYLE = {
17
+ label: true,
18
+ fill: '#ffff7f',
19
+ 'fill-opacity': '0.5',
20
+ stroke: '#000000',
21
+ 'label-position': 'nw',
22
+ color: '#000000',
23
+ };
24
+
25
+ /**
26
+ * Apply the add-nodes-to-group operation to the flows array.
27
+ *
28
+ * @param {object} rawResponse - Raw GET /flows response (must contain `flows` array)
29
+ * @param {string} flowId - ID of the flow tab where nodes reside
30
+ * @param {string[]} nodeIds - IDs of nodes to add to the group
31
+ * @param {object} [options]
32
+ * @param {string} [options.groupId] - Existing group ID; if omitted, a new group is created
33
+ * @param {string} [options.groupName] - Name for new groups (ignored if groupId is provided)
34
+ * @param {object} [options.style] - Style overrides for new groups (merged onto defaults)
35
+ * @returns {{ updatedFlows: object[], groupId: string, groupName: string, nodeIds: string[], boundingBox: { x: number, y: number, w: number, h: number }, created: boolean }}
36
+ */
37
+ export function applyAddNodesToGroup(rawResponse, flowId, nodeIds, options = {}) {
38
+ const { groupId, groupName, style } = options;
39
+ const flows = rawResponse.flows ?? rawResponse;
40
+
41
+ // Validate flow exists and is not locked
42
+ const targetFlow = flows.find(
43
+ (n) => (n.type === 'tab' || n.type === 'subflow') && n.id === flowId,
44
+ );
45
+ if (!targetFlow) {
46
+ throw new Error(`Flow '${flowId}' not found. Use get-flows to list available flow tabs and subflows.`);
47
+ }
48
+ if (targetFlow.locked) {
49
+ throw new Error(`Flow '${flowId}' is locked. This flow is locked (read-only). Use get-flow-nodes to inspect its nodes without modifying them.`);
50
+ }
51
+
52
+ // Resolve all target nodes, validating existence and flow membership
53
+ const nodes = [];
54
+ for (const nid of nodeIds) {
55
+ const node = flows.find((n) => n.id === nid);
56
+ if (!node) {
57
+ throw new Error(`Node '${nid}' not found. Use search-nodes with the node name or get-flow-nodes to list nodes in the flow.`);
58
+ }
59
+ if (node.z !== flowId) {
60
+ throw new Error(`All nodes must belong to flow '${flowId}'. Use get-flow-nodes to verify which flow each node belongs to.`);
61
+ }
62
+ nodes.push(node);
63
+ }
64
+
65
+ let targetGroupId;
66
+ let created = false;
67
+ let updatedFlows = [...flows];
68
+
69
+ if (groupId) {
70
+ // ── Existing group ──────────────────────────────────────
71
+ const groupIndex = updatedFlows.findIndex(
72
+ (n) => n.type === 'group' && n.id === groupId,
73
+ );
74
+ if (groupIndex === -1) {
75
+ throw new Error(`Group '${groupId}' not found. Use get-flow-nodes to list groups in the flow, or search-nodes with type: "group" to find it.`);
76
+ }
77
+ targetGroupId = groupId;
78
+ } else {
79
+ // ── Create new group ─────────────────────────────────────
80
+ targetGroupId = randomUUID();
81
+ created = true;
82
+
83
+ const box = computeBoundingBox(nodes, 20);
84
+ const mergedStyle = { ...DEFAULT_GROUP_STYLE, ...style };
85
+
86
+ // Default name: use provided name, or first node's name, or "Group"
87
+ const defaultName = groupName || nodes[0]?.name || 'Group';
88
+
89
+ const newGroup = {
90
+ id: targetGroupId,
91
+ type: 'group',
92
+ z: flowId,
93
+ name: defaultName,
94
+ style: mergedStyle,
95
+ nodes: [],
96
+ x: box.x,
97
+ y: box.y,
98
+ w: box.w,
99
+ h: box.h,
100
+ };
101
+
102
+ updatedFlows = [...updatedFlows, newGroup];
103
+ }
104
+
105
+ // ── Assign nodes to the group ────────────────────────────
106
+ const groupIndex = updatedFlows.findIndex(
107
+ (n) => n.type === 'group' && n.id === targetGroupId,
108
+ );
109
+ const group = updatedFlows[groupIndex];
110
+ const groupNodes = new Set(group.nodes || []);
111
+
112
+ for (const node of nodes) {
113
+ const nodeIndex = updatedFlows.findIndex((n) => n.id === node.id);
114
+
115
+ // If node already belongs to a different group, remove it from that group
116
+ if (node.g && node.g !== targetGroupId) {
117
+ const prevGroupIndex = updatedFlows.findIndex(
118
+ (n) => n.type === 'group' && n.id === node.g,
119
+ );
120
+ if (prevGroupIndex !== -1) {
121
+ const prevGroup = updatedFlows[prevGroupIndex];
122
+ updatedFlows[prevGroupIndex] = {
123
+ ...prevGroup,
124
+ nodes: (prevGroup.nodes || []).filter((nid) => nid !== node.id),
125
+ };
126
+ }
127
+ }
128
+
129
+ // Set g on the node (idempotent)
130
+ updatedFlows[nodeIndex] = { ...node, g: targetGroupId };
131
+
132
+ // Add to group's nodes list (idempotent)
133
+ groupNodes.add(node.id);
134
+ }
135
+
136
+ // Update group's nodes array
137
+ updatedFlows[groupIndex] = {
138
+ ...updatedFlows[groupIndex],
139
+ nodes: [...groupNodes],
140
+ };
141
+
142
+ // Re-compute bounding box for the final group
143
+ const finalGroup = updatedFlows[groupIndex];
144
+ const allMemberNodes = (finalGroup.nodes || [])
145
+ .map((nid) => updatedFlows.find((n) => n.id === nid))
146
+ .filter(Boolean);
147
+ const boundingBox = computeBoundingBox(allMemberNodes, 20);
148
+
149
+ // Update bounding box on the group
150
+ updatedFlows[groupIndex] = {
151
+ ...updatedFlows[groupIndex],
152
+ x: boundingBox.x,
153
+ y: boundingBox.y,
154
+ w: boundingBox.w,
155
+ h: boundingBox.h,
156
+ };
157
+
158
+ return {
159
+ updatedFlows,
160
+ groupId: targetGroupId,
161
+ groupName: updatedFlows[groupIndex].name,
162
+ nodeIds: [...groupNodes],
163
+ boundingBox,
164
+ created,
165
+ };
166
+ }
167
+
168
+ /**
169
+ * Handler for the add-nodes-to-group MCP tool.
170
+ *
171
+ * @param {ReturnType<import('../nodered/client.js').createNodeRedClient>} client
172
+ * @param {object} params
173
+ * @param {string} params.flowId
174
+ * @param {string[]} params.nodeIds
175
+ * @param {string} [params.groupId]
176
+ * @param {string} [params.groupName]
177
+ * @param {object} [params.style]
178
+ * @returns {Promise<{ content: Array<{ type: string, text: string }> }>}
179
+ */
180
+ export async function handleAddNodesToGroup(staging, client, params) {
181
+ const { flowId, nodeIds, groupId, groupName, style } = params;
182
+
183
+ const result = await staging.applyMutation((rawResponse) => {
184
+ return applyAddNodesToGroup(rawResponse, flowId, nodeIds, {
185
+ groupId,
186
+ groupName,
187
+ style,
188
+ });
189
+ });
190
+
191
+ const responseData = {
192
+ groupId: result.groupId,
193
+ groupName: result.groupName,
194
+ nodeIds: result.nodeIds,
195
+ boundingBox: result.boundingBox,
196
+ created: result.created,
197
+ staging: staging.getStagingSummary(),
198
+ };
199
+
200
+ return {
201
+ content: [
202
+ {
203
+ type: 'text',
204
+ text: JSON.stringify(responseData, null, 2),
205
+ },
206
+ ],
207
+ structuredContent: responseData,
208
+ };
209
+ }
210
+
211
+ export const addNodesToGroupDefinition = {
212
+ name: 'add-nodes-to-group',
213
+ annotations: ANN_MUTATION,
214
+ outputSchema: AddNodesToGroupResponseSchema,
215
+ handler: handleAddNodesToGroup,
216
+ };
@@ -0,0 +1,115 @@
1
+ /**
2
+ * MCP tool: connect-nodes
3
+ *
4
+ * Adds a wire from a node's output port to a target node.
5
+ * Supports single-wire mode (outputPort + toNodeId) and batch mode (connections array).
6
+ * Idempotent — no-op if the wire already exists.
7
+ * Refuses to wire nodes in locked flows.
8
+ */
9
+
10
+ import { formatSuccess } from './response-utils.js';
11
+
12
+ import { ANN_MUTATION } from './constants.js';
13
+ import { WireChangeResponseSchema } from '../schemas/responses.js';
14
+ /**
15
+ * Apply a wire connection in the flows array.
16
+ *
17
+ * In single-wire mode, provide outputPort and toNodeId.
18
+ * In batch mode, provide connections (array of { outputPort, toNodeId });
19
+ * outputPort and toNodeId are ignored when connections is provided.
20
+ *
21
+ * @param {object} rawResponse - Raw GET /flows response (must contain `flows` array)
22
+ * @param {string} fromNodeId - ID of the source node
23
+ * @param {number} [outputPort=0] - Output port index (0-based) — ignored in batch mode
24
+ * @param {string} [toNodeId] - ID of the target node — ignored in batch mode
25
+ * @param {Array<{ outputPort: number, toNodeId: string }>} [connections] - Batch connections
26
+ * @returns {{ updatedFlows: object[], previousWires: string[][], currentWires: string[][] }}
27
+ */
28
+ export function applyConnect(rawResponse, fromNodeId, outputPort = 0, toNodeId, connections) {
29
+ const flows = rawResponse.flows ?? rawResponse;
30
+
31
+ // Find source node
32
+ const fromIndex = flows.findIndex((n) => n.id === fromNodeId);
33
+ if (fromIndex === -1) {
34
+ throw new Error(`Node '${fromNodeId}' not found. Use search-nodes with the node name or get-flow-nodes to list nodes in the parent flow.`);
35
+ }
36
+
37
+ const fromNode = flows[fromIndex];
38
+
39
+ // Check parent flow lock
40
+ const parentFlowId = fromNode.z;
41
+ if (parentFlowId) {
42
+ const parentFlow = flows.find(
43
+ (n) => (n.type === 'tab' || n.type === 'subflow') && n.id === parentFlowId,
44
+ );
45
+ if (parentFlow?.locked) {
46
+ throw new Error(`Flow '${parentFlowId}' is locked. This flow is locked (read-only). Use get-flow-nodes to inspect its nodes without modifying them.`);
47
+ }
48
+ }
49
+
50
+ // Determine entries: batch mode takes precedence
51
+ const entries = connections ?? [{ outputPort, toNodeId }];
52
+
53
+ // Validate all target nodes exist BEFORE any mutation (atomicity)
54
+ for (const entry of entries) {
55
+ const targetExists = flows.some((n) => n.id === entry.toNodeId);
56
+ if (!targetExists) {
57
+ throw new Error(`Node '${entry.toNodeId}' not found. Use search-nodes with the node name or get-flow-nodes to list nodes in the parent flow.`);
58
+ }
59
+ }
60
+
61
+ const previousWires = (fromNode.wires ?? []).map((port) => [...port]);
62
+
63
+ // Deep-copy the wires array so we can mutate safely
64
+ const newWires = (fromNode.wires ?? []).map((port) => [...port]);
65
+
66
+ // Apply each connection idempotently
67
+ for (const entry of entries) {
68
+ const port = entry.outputPort;
69
+
70
+ // Pad wires array to accommodate the requested output port
71
+ while (newWires.length <= port) {
72
+ newWires.push([]);
73
+ }
74
+
75
+ // Add connection idempotently
76
+ if (!newWires[port].includes(entry.toNodeId)) {
77
+ newWires[port].push(entry.toNodeId);
78
+ }
79
+ }
80
+
81
+ const currentWires = newWires;
82
+ const updatedNode = { ...fromNode, wires: currentWires };
83
+ const updatedFlows = flows.map((n, i) => (i === fromIndex ? updatedNode : n));
84
+
85
+ return { updatedFlows, previousWires, currentWires };
86
+ }
87
+
88
+ /**
89
+ * Handler for the connect-nodes MCP tool.
90
+ *
91
+ * @param {ReturnType<import('../nodered/client.js').createNodeRedClient>} client
92
+ * @param {object} params
93
+ * @param {string} params.fromNodeId
94
+ * @param {number} [params.outputPort=0]
95
+ * @param {string} [params.toNodeId]
96
+ * @param {Array<{ outputPort: number, toNodeId: string }>} [params.connections]
97
+ * @returns {Promise<{ content: Array<{ type: string, text: string }> }>}
98
+ */
99
+ export async function handleConnectNodes(staging, client, params) {
100
+ const { fromNodeId, outputPort = 0, toNodeId, connections } = params;
101
+
102
+ const { previousWires, currentWires } = await staging.applyMutation((rawResponse) => {
103
+ return applyConnect(rawResponse, fromNodeId, outputPort, toNodeId, connections);
104
+ });
105
+
106
+ const data = { fromNodeId, previousWires, currentWires, staging: staging.getStagingSummary() };
107
+ return formatSuccess(data);
108
+ }
109
+
110
+ export const connectNodesDefinition = {
111
+ name: 'connect-nodes',
112
+ annotations: ANN_MUTATION,
113
+ outputSchema: WireChangeResponseSchema,
114
+ handler: handleConnectNodes,
115
+ };