@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,116 @@
1
+ import { ANN_DESTRUCTIVE } from './constants.js';
2
+ import { DeleteGroupResponseSchema } from '../schemas/responses.js';
3
+ /**
4
+ * MCP tool: delete-group
5
+ *
6
+ * Permanently removes a Node-RED group. By default, all member nodes are
7
+ * also deleted. Set `deleteMembers: false` to strip group membership and
8
+ * keep the nodes.
9
+ */
10
+
11
+ /**
12
+ * Apply the delete-group operation to the flows array.
13
+ *
14
+ * @param {object} rawResponse - Raw GET /flows response (must contain `flows` array)
15
+ * @param {string} groupId - ID of the group to delete
16
+ * @param {object} [options]
17
+ * @param {boolean} [options.deleteMembers=true] - Whether to also delete member nodes
18
+ * @returns {{ updatedFlows: object[], previousState: { group: object, members: object[] } }}
19
+ */
20
+ export function applyDeleteGroup(rawResponse, groupId, options = {}) {
21
+ const { deleteMembers = true } = options;
22
+ const flows = rawResponse.flows ?? rawResponse;
23
+
24
+ // Find the group
25
+ const groupIndex = flows.findIndex(
26
+ (n) => n.type === 'group' && n.id === groupId,
27
+ );
28
+ if (groupIndex === -1) {
29
+ throw new Error(`Group '${groupId}' not found. Use get-flow-nodes to list groups in the parent flow, or search-nodes with type: "group" to find it.`);
30
+ }
31
+
32
+ const group = flows[groupIndex];
33
+
34
+ // Check parent flow lock
35
+ if (group.z) {
36
+ const parentFlow = flows.find(
37
+ (n) => (n.type === 'tab' || n.type === 'subflow') && n.id === group.z,
38
+ );
39
+ if (parentFlow?.locked) {
40
+ throw new Error(`Flow '${group.z}' is locked. This flow is locked (read-only). Use get-flow-nodes to inspect its nodes without modifying them.`);
41
+ }
42
+ }
43
+
44
+ // Collect member nodes for previousState
45
+ const memberIds = group.nodes || [];
46
+ const members = flows.filter(
47
+ (n) => memberIds.includes(n.id),
48
+ );
49
+ const previousState = {
50
+ group: { ...group },
51
+ members: members.map((m) => ({ ...m })),
52
+ };
53
+
54
+ let updatedFlows = [...flows];
55
+
56
+ if (deleteMembers && memberIds.length > 0) {
57
+ // Delete all member nodes
58
+ updatedFlows = updatedFlows.filter(
59
+ (n) => !memberIds.includes(n.id),
60
+ );
61
+ } else if (!deleteMembers && memberIds.length > 0) {
62
+ // Strip g from all members, keep the nodes
63
+ updatedFlows = updatedFlows.map((n) => {
64
+ if (memberIds.includes(n.id)) {
65
+ const { g: _g, ...rest } = n;
66
+ return rest;
67
+ }
68
+ return n;
69
+ });
70
+ }
71
+
72
+ // Delete the group node itself
73
+ updatedFlows = updatedFlows.filter((n) => n.id !== groupId);
74
+
75
+ return { updatedFlows, previousState };
76
+ }
77
+
78
+ /**
79
+ * Handler for the delete-group MCP tool.
80
+ *
81
+ * @param {ReturnType<import('../nodered/client.js').createNodeRedClient>} client
82
+ * @param {object} params
83
+ * @param {string} params.groupId
84
+ * @param {boolean} [params.deleteMembers=true]
85
+ * @returns {Promise<{ content: Array<{ type: string, text: string }> }>}
86
+ */
87
+ export async function handleDeleteGroup(staging, client, params) {
88
+ const { groupId, deleteMembers } = params;
89
+
90
+ const result = await staging.applyMutation((rawResponse) => {
91
+ return applyDeleteGroup(rawResponse, groupId, { deleteMembers });
92
+ });
93
+
94
+ const responseData = {
95
+ groupId,
96
+ previousState: result.previousState,
97
+ staging: staging.getStagingSummary(),
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
+ export const deleteGroupDefinition = {
112
+ name: 'delete-group',
113
+ annotations: ANN_DESTRUCTIVE,
114
+ outputSchema: DeleteGroupResponseSchema,
115
+ handler: handleDeleteGroup,
116
+ };
@@ -0,0 +1,73 @@
1
+ /**
2
+ * MCP tool: delete-node
3
+ *
4
+ * Removes an existing node from a Node-RED flow by nodeId.
5
+ * Node-RED automatically cleans up dangling wire references on deploy.
6
+ * Refuses to delete nodes in locked flows.
7
+ */
8
+
9
+ import { formatSuccess } from './response-utils.js';
10
+
11
+ import { ANN_DESTRUCTIVE } from './constants.js';
12
+ import { DeleteNodeResponseSchema } from '../schemas/responses.js';
13
+ /**
14
+ * Apply the delete-node operation to the flows array.
15
+ *
16
+ * @param {object} rawResponse - Raw GET /flows response (must contain `flows` array)
17
+ * @param {string} nodeId - ID of the node to remove
18
+ * @returns {{ updatedFlows: object[], previousState: object }}
19
+ */
20
+ export function applyDeleteNode(rawResponse, nodeId) {
21
+ const flows = rawResponse.flows ?? rawResponse;
22
+
23
+ // Find the node to delete
24
+ const nodeIndex = flows.findIndex((n) => n.id === nodeId);
25
+ if (nodeIndex === -1) {
26
+ throw new Error(`Node '${nodeId}' not found. Use search-nodes with the node name or get-flow-nodes to list nodes in the parent flow.`);
27
+ }
28
+
29
+ const node = flows[nodeIndex];
30
+
31
+ // Check parent flow lock
32
+ const parentFlowId = node.z;
33
+ if (parentFlowId) {
34
+ const parentFlow = flows.find(
35
+ (n) => (n.type === 'tab' || n.type === 'subflow') && n.id === parentFlowId,
36
+ );
37
+ if (parentFlow?.locked) {
38
+ throw new Error(`Flow '${parentFlowId}' is locked. This flow is locked (read-only). Use get-flow-nodes to inspect its nodes without modifying them.`);
39
+ }
40
+ }
41
+
42
+ const previousState = { ...node };
43
+ const updatedFlows = flows.filter((n) => n.id !== nodeId);
44
+
45
+ return { updatedFlows, previousState };
46
+ }
47
+
48
+ /**
49
+ * Handler for the delete-node MCP tool.
50
+ *
51
+ * @param {ReturnType<import('../nodered/client.js').createNodeRedClient>} client
52
+ * @param {object} params
53
+ * @param {string} params.nodeId
54
+ * @returns {Promise<{ content: Array<{ type: string, text: string }> }>}
55
+ */
56
+
57
+ export async function handleDeleteNode(staging, client, params) {
58
+ const { nodeId } = params;
59
+
60
+ const { previousState } = await staging.applyMutation((rawResponse) => {
61
+ return applyDeleteNode(rawResponse, nodeId);
62
+ });
63
+
64
+ const data = { nodeId, previousState, staging: staging.getStagingSummary() };
65
+ return formatSuccess(data);
66
+ }
67
+
68
+ export const deleteNodeDefinition = {
69
+ name: 'delete-node',
70
+ annotations: ANN_DESTRUCTIVE,
71
+ outputSchema: DeleteNodeResponseSchema,
72
+ handler: handleDeleteNode,
73
+ };
@@ -0,0 +1,103 @@
1
+ /**
2
+ * MCP tool: delete-subflow
3
+ *
4
+ * Deletes a subflow definition, its internal nodes, and optionally
5
+ * its instances. Returns previousState for undo support.
6
+ * Refuses to delete a locked subflow.
7
+ */
8
+
9
+ import { formatSuccess } from './response-utils.js';
10
+
11
+ import { ANN_DESTRUCTIVE } from './constants.js';
12
+ import { DeleteSubflowResponseSchema } from '../schemas/responses.js';
13
+ /**
14
+ * Collect the full previous state of a subflow before deletion.
15
+ *
16
+ * @param {object[]} flows - All nodes from the flows array
17
+ * @param {string} subflowId - ID of the subflow to collect
18
+ * @returns {{ definition: object|undefined, internalNodes: object[], instances: object[] }}
19
+ * @throws {Error} If subflowId does not match any type: "subflow" node
20
+ */
21
+ export function collectSubflowState(flows, subflowId) {
22
+ const definition = flows.find(
23
+ (n) => n.type === 'subflow' && n.id === subflowId,
24
+ );
25
+
26
+ if (!definition) {
27
+ throw new Error(`Subflow '${subflowId}' not found. Use get-subflows to list available subflow definitions.`);
28
+ }
29
+
30
+ if (definition.locked) {
31
+ throw new Error(`Subflow '${subflowId}' is locked. This subflow is locked (read-only). Use get-subflow-detail to inspect it without modifying.`);
32
+ }
33
+
34
+ const internalNodes = flows.filter(
35
+ (n) => n.z === subflowId,
36
+ );
37
+
38
+ const instanceType = `subflow:${subflowId}`;
39
+ const instances = flows.filter(
40
+ (n) => n.type === instanceType,
41
+ );
42
+
43
+ return { definition, internalNodes, instances };
44
+ }
45
+
46
+ /**
47
+ * Remove a subflow and related nodes from the flows array.
48
+ *
49
+ * @param {object[]} flows - All nodes from the flows array
50
+ * @param {string} subflowId - ID of the subflow to delete
51
+ * @param {boolean} deleteInstances - Whether to also delete instances
52
+ * @returns {{ updatedFlows: object[], previousState: object }}
53
+ * @throws {Error} If subflow not found or is locked
54
+ */
55
+ export function applyDeleteSubflow(flows, subflowId, deleteInstances) {
56
+ const previousState = collectSubflowState(flows, subflowId);
57
+
58
+ // Build set of IDs to remove
59
+ const idsToRemove = new Set();
60
+ idsToRemove.add(subflowId);
61
+
62
+ for (const node of previousState.internalNodes) {
63
+ idsToRemove.add(node.id);
64
+ }
65
+
66
+ if (deleteInstances) {
67
+ for (const node of previousState.instances) {
68
+ idsToRemove.add(node.id);
69
+ }
70
+ }
71
+
72
+ const updatedFlows = flows.filter((n) => !idsToRemove.has(n.id));
73
+
74
+ return { updatedFlows, previousState };
75
+ }
76
+
77
+ /**
78
+ * Handler for the delete-subflow MCP tool.
79
+ *
80
+ * @param {ReturnType<import('../nodered/client.js').createNodeRedClient>} client
81
+ * @param {object} params
82
+ * @param {string} params.subflowId
83
+ * @param {boolean} [params.deleteInstances=true]
84
+ * @returns {Promise<{ content: Array<{ type: string, text: string }> }>}
85
+ */
86
+ export async function handleDeleteSubflow(staging, client, params) {
87
+ const { subflowId, deleteInstances = true } = params;
88
+
89
+ const { previousState } = await staging.applyMutation((rawResponse) => {
90
+ const flows = rawResponse.flows || [];
91
+ return applyDeleteSubflow(flows, subflowId, deleteInstances);
92
+ });
93
+
94
+ const data = { subflowId, previousState, staging: staging.getStagingSummary() };
95
+ return formatSuccess(data);
96
+ }
97
+
98
+ export const deleteSubflowDefinition = {
99
+ name: 'delete-subflow',
100
+ annotations: ANN_DESTRUCTIVE,
101
+ outputSchema: DeleteSubflowResponseSchema,
102
+ handler: handleDeleteSubflow,
103
+ };
@@ -0,0 +1,94 @@
1
+ import { ANN_DEPLOY } from './constants.js';
2
+ import { DeployResponseSchema } from '../schemas/responses.js';
3
+ /**
4
+ * MCP tool: deploy
5
+ *
6
+ * Sends all staged (undeployed) flow changes to the Node-RED runtime.
7
+ * Supports three deploy types: full, flows, and nodes.
8
+ *
9
+ * By default, deploys only modified nodes (`nodes` deploy) which is the
10
+ * least disruptive — only changed nodes are restarted.
11
+ *
12
+ * After a successful deploy, the staging store syncs with the Node-RED
13
+ * backend to obtain the latest rev and state.
14
+ */
15
+
16
+ /**
17
+ * Handler for the deploy MCP tool.
18
+ *
19
+ * @param {import('../staging-store.js').StagingStore} staging
20
+ * @returns {(params: { deployType?: string }) => Promise<{ content: Array<{ type: string, text: string }> }>}
21
+ */
22
+ export function handleDeploy(staging) {
23
+ return async (params) => {
24
+ const { deployType = 'nodes' } = params || {};
25
+
26
+ // Validate deploy type
27
+ const validTypes = ['full', 'flows', 'nodes'];
28
+ if (!validTypes.includes(deployType)) {
29
+ throw new Error(
30
+ `Invalid deploy type "${deployType}". Use one of: ${validTypes.join(', ')}`,
31
+ );
32
+ }
33
+
34
+ const summaryBefore = staging.getStagingSummary();
35
+
36
+ if (!staging.hasPendingChanges()) {
37
+ const noPendingData = {
38
+ success: true,
39
+ message: 'No pending changes to deploy.',
40
+ staging: summaryBefore,
41
+ };
42
+
43
+ return {
44
+ content: [
45
+ {
46
+ type: 'text',
47
+ text: JSON.stringify(noPendingData, null, 2),
48
+ },
49
+ ],
50
+ structuredContent: noPendingData,
51
+ };
52
+ }
53
+
54
+ try {
55
+ await staging.deploy(deployType);
56
+ } catch (err) {
57
+ const msg = err.message || '';
58
+ if (msg.includes('version_mismatch') || msg.includes('409')) {
59
+ throw new Error(
60
+ 'Deploy failed: version mismatch. The flows have been modified externally ' +
61
+ '(e.g., via the Node-RED editor). Your staged changes have been discarded. ' +
62
+ 'Call refresh-staging to sync with the latest server state, then re-apply your changes and deploy again.',
63
+ );
64
+ }
65
+ throw err;
66
+ }
67
+
68
+ const summaryAfter = staging.getStagingSummary();
69
+
70
+ const deployData = {
71
+ success: true,
72
+ deployType,
73
+ previousPendingChanges: summaryBefore.pendingChanges,
74
+ staging: summaryAfter,
75
+ };
76
+
77
+ return {
78
+ content: [
79
+ {
80
+ type: 'text',
81
+ text: JSON.stringify(deployData, null, 2),
82
+ },
83
+ ],
84
+ structuredContent: deployData,
85
+ };
86
+ };
87
+ }
88
+
89
+ export const deployDefinition = {
90
+ name: 'deploy',
91
+ annotations: ANN_DEPLOY,
92
+ outputSchema: DeployResponseSchema,
93
+ handler: handleDeploy,
94
+ };
@@ -0,0 +1,158 @@
1
+ /**
2
+ * MCP tool: disconnect-nodes
3
+ *
4
+ * Removes wires from a node's output ports.
5
+ * Supports three modes:
6
+ * 1. Single: remove one specific wire (outputPort + toNodeId)
7
+ * 2. Clear-port: remove all wires from an output port (clearPort=true, toNodeId omitted)
8
+ * 3. Batch: remove multiple wires via connections array
9
+ * Errors if a wire does not exist. Refuses to mutate nodes in locked flows.
10
+ */
11
+
12
+ import { formatSuccess } from './response-utils.js';
13
+
14
+ import { ANN_MUTATION } from './constants.js';
15
+ import { WireChangeResponseSchema } from '../schemas/responses.js';
16
+ /**
17
+ * Apply wire removal in the flows array.
18
+ *
19
+ * @param {object} rawResponse - Raw GET /flows response (must contain `flows` array)
20
+ * @param {string} fromNodeId - ID of the source node
21
+ * @param {number} [outputPort=0] - Output port index (0-based) — ignored in batch mode
22
+ * @param {string} [toNodeId] - ID of the target node to disconnect — ignored in batch/clear-port mode
23
+ * @param {boolean} [clearPort=false] - If true and toNodeId is absent, clear all wires from outputPort
24
+ * @param {Array<{ outputPort: number, toNodeId: string }>} [connections] - Batch removal entries
25
+ * @returns {{ updatedFlows: object[], previousWires: string[][], currentWires: string[][] }}
26
+ */
27
+ export function applyDisconnect(rawResponse, fromNodeId, outputPort = 0, toNodeId, clearPort = false, connections) {
28
+ const flows = rawResponse.flows ?? rawResponse;
29
+
30
+ // Find source node
31
+ const fromIndex = flows.findIndex((n) => n.id === fromNodeId);
32
+ if (fromIndex === -1) {
33
+ 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.`);
34
+ }
35
+
36
+ const fromNode = flows[fromIndex];
37
+
38
+ // Check parent flow lock
39
+ const parentFlowId = fromNode.z;
40
+ if (parentFlowId) {
41
+ const parentFlow = flows.find(
42
+ (n) => (n.type === 'tab' || n.type === 'subflow') && n.id === parentFlowId,
43
+ );
44
+ if (parentFlow?.locked) {
45
+ throw new Error(`Flow '${parentFlowId}' is locked. This flow is locked (read-only). Use get-flow-nodes to inspect its nodes without modifying them.`);
46
+ }
47
+ }
48
+
49
+ const previousWires = (fromNode.wires ?? []).map((port) => [...port]);
50
+
51
+ // Determine mode: batch > clear-port > single
52
+ if (connections) {
53
+ // --- Batch mode ---
54
+ // Validate all wires exist before removing any (atomicity)
55
+ for (const entry of connections) {
56
+ const port = entry.outputPort;
57
+ const portWires = previousWires[port] ?? [];
58
+ if (!portWires.includes(entry.toNodeId)) {
59
+ throw new Error(
60
+ `Wire from '${fromNodeId}'[${port}] to '${entry.toNodeId}' does not exist. Use get-node-detail to inspect the current wiring of the source node.`,
61
+ );
62
+ }
63
+ }
64
+
65
+ // Build new wires — deep copy and remove all target wires
66
+ const newWires = previousWires.map((port) => [...port]);
67
+ for (const entry of connections) {
68
+ const port = entry.outputPort;
69
+ if (newWires[port]) {
70
+ newWires[port] = newWires[port].filter((id) => id !== entry.toNodeId);
71
+ }
72
+ }
73
+
74
+ const currentWires = newWires;
75
+ const updatedNode = { ...fromNode, wires: currentWires };
76
+ const updatedFlows = flows.map((n, i) => (i === fromIndex ? updatedNode : n));
77
+
78
+ return { updatedFlows, previousWires, currentWires };
79
+ }
80
+
81
+ if (clearPort && !toNodeId) {
82
+ // --- Clear-port mode ---
83
+ const newWires = previousWires.map((port, i) => {
84
+ if (i === outputPort) {
85
+ return [];
86
+ }
87
+ return [...port];
88
+ });
89
+
90
+ const currentWires = newWires;
91
+ const updatedNode = { ...fromNode, wires: currentWires };
92
+ const updatedFlows = flows.map((n, i) => (i === fromIndex ? updatedNode : n));
93
+
94
+ return { updatedFlows, previousWires, currentWires };
95
+ }
96
+
97
+ // --- Single-wire mode ---
98
+ // Check the wire actually exists
99
+ const portConnections = previousWires[outputPort];
100
+ if (!portConnections || !portConnections.includes(toNodeId)) {
101
+ throw new Error(
102
+ `Wire from '${fromNodeId}'[${outputPort}] to '${toNodeId}' does not exist. Use get-node-detail to inspect the current wiring of the source node.`,
103
+ );
104
+ }
105
+
106
+ // Build new wires — deep copy and remove the target
107
+ const newWires = previousWires.map((port, i) => {
108
+ if (i === outputPort) {
109
+ return port.filter((id) => id !== toNodeId);
110
+ }
111
+ return [...port];
112
+ });
113
+
114
+ const currentWires = newWires;
115
+ const updatedNode = { ...fromNode, wires: currentWires };
116
+ const updatedFlows = flows.map((n, i) => (i === fromIndex ? updatedNode : n));
117
+
118
+ return { updatedFlows, previousWires, currentWires };
119
+ }
120
+
121
+ /**
122
+ * Handler for the disconnect-nodes MCP tool.
123
+ *
124
+ * @param {ReturnType<import('../nodered/client.js').createNodeRedClient>} client
125
+ * @param {object} params
126
+ * @param {string} params.fromNodeId
127
+ * @param {number} [params.outputPort=0]
128
+ * @param {string} [params.toNodeId]
129
+ * @param {boolean} [params.clearPort=false]
130
+ * @param {Array<{ outputPort: number, toNodeId: string }>} [params.connections]
131
+ * @returns {Promise<{ content: Array<{ type: string, text: string }> }>}
132
+ */
133
+ export async function handleDisconnectNodes(staging, client, params) {
134
+ const { fromNodeId, outputPort = 0, toNodeId, clearPort = false, connections } = params;
135
+
136
+ const { previousWires, currentWires } = await staging.applyMutation(
137
+ (rawResponse) => {
138
+ return applyDisconnect(
139
+ rawResponse,
140
+ fromNodeId,
141
+ outputPort,
142
+ toNodeId,
143
+ clearPort,
144
+ connections,
145
+ );
146
+ }
147
+ );
148
+
149
+ const data = { fromNodeId, previousWires, currentWires, staging: staging.getStagingSummary() };
150
+ return formatSuccess(data);
151
+ }
152
+
153
+ export const disconnectNodesDefinition = {
154
+ name: 'disconnect-nodes',
155
+ annotations: ANN_MUTATION,
156
+ outputSchema: WireChangeResponseSchema,
157
+ handler: handleDisconnectNodes,
158
+ };