@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,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
|
+
};
|