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