@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,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP tool: update-flow
|
|
3
|
+
*
|
|
4
|
+
* Updates metadata fields (label, disabled, info, env) of an existing Node-RED flow tab.
|
|
5
|
+
* Stages the change locally — call `deploy` to push to Node-RED.
|
|
6
|
+
* Preserves the nodes array unchanged. Refuses to update a locked flow.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { formatSuccess } from './response-utils.js';
|
|
10
|
+
|
|
11
|
+
import { ANN_MUTATION } from './constants.js';
|
|
12
|
+
import { UpdateFlowResponseSchema } from '../schemas/responses.js';
|
|
13
|
+
const ALLOWED_FIELDS = ['label', 'disabled', 'info', 'env'];
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Apply updates to a flow object, returning the merged result and the original.
|
|
17
|
+
*
|
|
18
|
+
* @param {object} currentFlow - Full flow object
|
|
19
|
+
* @param {object} updates - Fields to update (only label/disabled/info/env honoured)
|
|
20
|
+
* @returns {{ updatedFlow: object, previousState: object }}
|
|
21
|
+
*/
|
|
22
|
+
export function applyFlowUpdate(currentFlow, updates) {
|
|
23
|
+
if (!updates || Object.keys(updates).length === 0) {
|
|
24
|
+
throw new Error('No properties to update. Provide at least one of: label, disabled, info, env.');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (currentFlow.locked) {
|
|
28
|
+
throw new Error(`Flow '${currentFlow.id}' is locked. This flow is locked (read-only). Use get-flow-nodes to inspect its nodes without modifying them.`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const filteredUpdates = Object.fromEntries(
|
|
32
|
+
Object.entries(updates).filter(([k]) => ALLOWED_FIELDS.includes(k)),
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
const updatedFlow = {
|
|
36
|
+
...currentFlow,
|
|
37
|
+
...filteredUpdates,
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
return { updatedFlow, previousState: currentFlow };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Apply an update-flow mutation to the flows array.
|
|
45
|
+
*
|
|
46
|
+
* Finds the tab by ID, applies updates, replaces it in the array.
|
|
47
|
+
* No HTTP — pure data transformation.
|
|
48
|
+
*
|
|
49
|
+
* @param {object} rawResponse - Wrapper with `flows` array
|
|
50
|
+
* @param {string} flowId - ID of the flow tab to update
|
|
51
|
+
* @param {object} updates - Fields to update
|
|
52
|
+
* @returns {{ updatedFlows: object[], previousState: object, currentState: object }}
|
|
53
|
+
* @throws {Error} If flow not found or locked
|
|
54
|
+
*/
|
|
55
|
+
export function applyUpdateFlow(rawResponse, flowId, updates) {
|
|
56
|
+
const flows = rawResponse.flows ?? rawResponse;
|
|
57
|
+
|
|
58
|
+
const tabIndex = flows.findIndex((n) => n.type === 'tab' && n.id === flowId);
|
|
59
|
+
if (tabIndex === -1) {
|
|
60
|
+
throw new Error(`Flow '${flowId}' not found. Use get-flows to list available flow tabs.`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const { updatedFlow, previousState } = applyFlowUpdate(flows[tabIndex], updates);
|
|
64
|
+
|
|
65
|
+
const updatedFlows = flows.map((n, i) => (i === tabIndex ? updatedFlow : n));
|
|
66
|
+
|
|
67
|
+
return { updatedFlows, previousState, currentState: updatedFlow };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Handler for the update-flow MCP tool.
|
|
72
|
+
*
|
|
73
|
+
* @param {import('../staging-store.js').StagingStore} staging
|
|
74
|
+
* @param {object} params
|
|
75
|
+
* @param {string} params.flowId
|
|
76
|
+
* @param {object} params.updates
|
|
77
|
+
* @returns {Promise<{ content: Array<{ type: string, text: string }> }>}
|
|
78
|
+
*/
|
|
79
|
+
export async function handleUpdateFlow(staging, params) {
|
|
80
|
+
const { flowId, updates } = params;
|
|
81
|
+
|
|
82
|
+
const { previousState, currentState } = await staging.applyMutation((rawResponse) => {
|
|
83
|
+
return applyUpdateFlow(rawResponse, flowId, updates);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const data = { flowId, previousState, currentState, staging: staging.getStagingSummary() };
|
|
87
|
+
return formatSuccess(data);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export const updateFlowDefinition = {
|
|
91
|
+
name: 'update-flow',
|
|
92
|
+
annotations: ANN_MUTATION,
|
|
93
|
+
outputSchema: UpdateFlowResponseSchema,
|
|
94
|
+
handler: handleUpdateFlow,
|
|
95
|
+
};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP tool: update-group
|
|
3
|
+
*
|
|
4
|
+
* Modifies a Node-RED group node's metadata (name, style, position, size).
|
|
5
|
+
* Delegates to update-node's applyNodeUpdate after validating the target
|
|
6
|
+
* is a `type: "group"` node.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { applyNodeUpdate } from './update-node.js';
|
|
10
|
+
|
|
11
|
+
import { ANN_MUTATION } from './constants.js';
|
|
12
|
+
import { UpdateNodeResponseSchema } from '../schemas/responses.js';
|
|
13
|
+
/**
|
|
14
|
+
* Apply a property update to a group node in the flows array.
|
|
15
|
+
* Validates that the target node is `type: "group"` before delegating.
|
|
16
|
+
*
|
|
17
|
+
* @param {object} rawResponse - Raw GET /flows response (must contain `flows` array)
|
|
18
|
+
* @param {string} groupId - ID of the group node to update
|
|
19
|
+
* @param {object} properties - Properties to shallow-merge onto the group node
|
|
20
|
+
* @returns {{ updatedFlows: object[], previousState: object, currentState: object }}
|
|
21
|
+
*/
|
|
22
|
+
export function applyUpdateGroup(rawResponse, groupId, properties) {
|
|
23
|
+
const flows = rawResponse.flows ?? rawResponse;
|
|
24
|
+
|
|
25
|
+
// Validate the target is a group node
|
|
26
|
+
const groupNode = flows.find((n) => n.id === groupId);
|
|
27
|
+
if (!groupNode) {
|
|
28
|
+
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.`);
|
|
29
|
+
}
|
|
30
|
+
if (groupNode.type !== 'group') {
|
|
31
|
+
throw new Error(`Node '${groupId}' is not a group. Use get-flow-nodes to find group nodes or search-nodes with type: "group".`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Delegate to update-node's logic
|
|
35
|
+
return applyNodeUpdate(rawResponse, groupId, properties);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Handler for the update-group MCP tool.
|
|
40
|
+
*
|
|
41
|
+
* @param {ReturnType<import('../nodered/client.js').createNodeRedClient>} client
|
|
42
|
+
* @param {object} params
|
|
43
|
+
* @param {string} params.groupId
|
|
44
|
+
* @param {object} params.properties
|
|
45
|
+
* @returns {Promise<{ content: Array<{ type: string, text: string }> }>}
|
|
46
|
+
*/
|
|
47
|
+
export async function handleUpdateGroup(staging, client, params) {
|
|
48
|
+
const { groupId, properties } = params;
|
|
49
|
+
|
|
50
|
+
const result = await staging.applyMutation((rawResponse) => {
|
|
51
|
+
return applyUpdateGroup(rawResponse, groupId, properties);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const responseData = {
|
|
55
|
+
groupId,
|
|
56
|
+
previousState: result.previousState,
|
|
57
|
+
currentState: result.currentState,
|
|
58
|
+
staging: staging.getStagingSummary(),
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
content: [
|
|
63
|
+
{
|
|
64
|
+
type: 'text',
|
|
65
|
+
text: JSON.stringify(responseData, null, 2),
|
|
66
|
+
},
|
|
67
|
+
],
|
|
68
|
+
structuredContent: responseData,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export const updateGroupDefinition = {
|
|
73
|
+
name: 'update-group',
|
|
74
|
+
annotations: ANN_MUTATION,
|
|
75
|
+
outputSchema: UpdateNodeResponseSchema,
|
|
76
|
+
handler: handleUpdateGroup,
|
|
77
|
+
};
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP tool: update-node
|
|
3
|
+
*
|
|
4
|
+
* Shallow-merges a `properties` object onto an existing Node-RED node's
|
|
5
|
+
* configuration and deploys the result. Wiring changes are explicitly
|
|
6
|
+
* rejected — agents must use connect-nodes / disconnect-nodes instead.
|
|
7
|
+
* Refuses to mutate nodes in locked flows.
|
|
8
|
+
*
|
|
9
|
+
* Credential handling (via normalizeCredentials from flow-utils.js):
|
|
10
|
+
* When updating configuration nodes (e.g. mqtt-broker, http-proxy),
|
|
11
|
+
* credential fields like `username`, `password`, `key`, `token`, etc.
|
|
12
|
+
* must be nested under a `credentials` sub-object to match Node-RED's
|
|
13
|
+
* credential storage model. This module automatically detects and moves
|
|
14
|
+
* credential fields into the `credentials` object.
|
|
15
|
+
*
|
|
16
|
+
* Node-RED strips credential values from GET /flows responses for privacy,
|
|
17
|
+
* so the `credentials` property may be absent from the node. Detection uses
|
|
18
|
+
* a well-known set of credential field names as a fallback.
|
|
19
|
+
*
|
|
20
|
+
* Partial credential updates are supported: only the fields you specify
|
|
21
|
+
* are updated; unspecified credential fields retain their previous values.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { normalizeCredentials } from './flow-utils.js';
|
|
25
|
+
|
|
26
|
+
import { formatSuccess } from './response-utils.js';
|
|
27
|
+
import { ANN_MUTATION } from './constants.js';
|
|
28
|
+
import { UpdateNodeResponseSchema } from '../schemas/responses.js';
|
|
29
|
+
/**
|
|
30
|
+
* Apply a property update to a node in the flows array.
|
|
31
|
+
*
|
|
32
|
+
* @param {object} rawResponse - Raw GET /flows response (must contain `flows` array)
|
|
33
|
+
* @param {string} nodeId - ID of the node to update
|
|
34
|
+
* @param {object} properties - Properties to shallow-merge onto the node
|
|
35
|
+
* @param {string[]|null} [credentialKeys=null] - Authoritative credential field names from the API, or null to auto-detect
|
|
36
|
+
* @returns {{ updatedFlows: object[], previousState: object, currentState: object }}
|
|
37
|
+
*/
|
|
38
|
+
export function applyNodeUpdate(rawResponse, nodeId, properties, credentialKeys = null) {
|
|
39
|
+
// Reject wires in properties — wiring is managed by connect/disconnect tools
|
|
40
|
+
if (Object.prototype.hasOwnProperty.call(properties, 'wires')) {
|
|
41
|
+
throw new Error(
|
|
42
|
+
"Cannot set 'wires' via update-node. " +
|
|
43
|
+
"To add a connection: call connect-nodes with { fromNodeId, toNodeId, outputPort }. " +
|
|
44
|
+
"To remove a connection: call disconnect-nodes with { fromNodeId, toNodeId, outputPort }.",
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const flows = rawResponse.flows ?? rawResponse;
|
|
49
|
+
|
|
50
|
+
// Find the target node
|
|
51
|
+
const nodeIndex = flows.findIndex((n) => n.id === nodeId);
|
|
52
|
+
if (nodeIndex === -1) {
|
|
53
|
+
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.`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const node = flows[nodeIndex];
|
|
57
|
+
|
|
58
|
+
// Check parent flow lock
|
|
59
|
+
const parentFlowId = node.z;
|
|
60
|
+
if (parentFlowId) {
|
|
61
|
+
const parentFlow = flows.find(
|
|
62
|
+
(n) => (n.type === 'tab' || n.type === 'subflow') && n.id === parentFlowId,
|
|
63
|
+
);
|
|
64
|
+
if (parentFlow?.locked) {
|
|
65
|
+
throw new Error(`Flow '${parentFlowId}' is locked. This flow is locked (read-only). Use get-flow-nodes to inspect its nodes without modifying them.`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Normalize properties: move credential fields under `credentials` and
|
|
70
|
+
// deep-merge with existing credentials to preserve unspecified fields.
|
|
71
|
+
// credentialKeys comes from the Node-RED /credentials/:type/:id API when available.
|
|
72
|
+
const normalizedProperties = normalizeCredentials(properties, node, credentialKeys);
|
|
73
|
+
|
|
74
|
+
const previousState = { ...node };
|
|
75
|
+
const currentState = { ...node, ...normalizedProperties };
|
|
76
|
+
|
|
77
|
+
const updatedFlows = flows.map((n, i) => (i === nodeIndex ? currentState : n));
|
|
78
|
+
|
|
79
|
+
return { updatedFlows, previousState, currentState };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Handler for the update-node MCP tool.
|
|
84
|
+
*
|
|
85
|
+
* @param {import('../staging-store.js').StagingStore} staging
|
|
86
|
+
* @param {ReturnType<import('../nodered/client.js').createNodeRedClient>} client
|
|
87
|
+
* @param {object} params
|
|
88
|
+
* @param {string} params.nodeId
|
|
89
|
+
* @param {object} params.properties
|
|
90
|
+
* @returns {Promise<{ content: Array<{ type: string, text: string }> }>}
|
|
91
|
+
*/
|
|
92
|
+
export async function handleUpdateNode(staging, client, params) {
|
|
93
|
+
const { nodeId, properties } = params;
|
|
94
|
+
|
|
95
|
+
// Fetch credential metadata once (outside mutation — just for detection)
|
|
96
|
+
let credentialKeys = null;
|
|
97
|
+
try {
|
|
98
|
+
const initialFlows = await staging.getFlows();
|
|
99
|
+
const initialNode = initialFlows.find((n) => n.id === nodeId);
|
|
100
|
+
if (initialNode) {
|
|
101
|
+
try {
|
|
102
|
+
const credResponse = await client.request('GET', `/credentials/${encodeURIComponent(initialNode.type)}/${encodeURIComponent(nodeId)}`);
|
|
103
|
+
if (credResponse && typeof credResponse === 'object') {
|
|
104
|
+
credentialKeys = Object.keys(credResponse).flatMap((k) => {
|
|
105
|
+
if (k.startsWith('has_')) {
|
|
106
|
+
return [k.slice(4)]; // has_password → password
|
|
107
|
+
}
|
|
108
|
+
return [k];
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
} catch {
|
|
112
|
+
// Fall back to heuristic detection
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
} catch {
|
|
116
|
+
// Node not found or other error — applyNodeUpdate will handle validation
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const { previousState, currentState } = await staging.applyMutation((rawResponse) => {
|
|
120
|
+
return applyNodeUpdate(rawResponse, nodeId, properties, credentialKeys);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const data = { nodeId, previousState, currentState, staging: staging.getStagingSummary() };
|
|
124
|
+
return formatSuccess(data);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export const updateNodeDefinition = {
|
|
128
|
+
name: 'update-node',
|
|
129
|
+
annotations: ANN_MUTATION,
|
|
130
|
+
outputSchema: UpdateNodeResponseSchema,
|
|
131
|
+
handler: handleUpdateNode,
|
|
132
|
+
};
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP tool: update-subflow
|
|
3
|
+
*
|
|
4
|
+
* Updates metadata fields of an existing subflow definition.
|
|
5
|
+
* Allowed fields: name, info, category, color, icon, in, out.
|
|
6
|
+
* Performs a partial merge — unspecified fields are preserved.
|
|
7
|
+
* Refuses to update a locked subflow.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { formatSuccess } from './response-utils.js';
|
|
11
|
+
|
|
12
|
+
import { ANN_MUTATION } from './constants.js';
|
|
13
|
+
import { UpdateSubflowResponseSchema } from '../schemas/responses.js';
|
|
14
|
+
const ALLOWED_FIELDS = ['name', 'info', 'category', 'color', 'icon', 'in', 'out'];
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Apply updates to a subflow object, returning the merged result.
|
|
18
|
+
*
|
|
19
|
+
* @param {object} currentSubflow - Full subflow node from GET /flows
|
|
20
|
+
* @param {object} updates - Fields to update (only allowed fields honoured)
|
|
21
|
+
* @returns {{ updatedSubflow: object, previousState: object }}
|
|
22
|
+
* @throws {Error} If no valid updates provided or subflow is locked
|
|
23
|
+
*/
|
|
24
|
+
export function applySubflowUpdate(currentSubflow, updates) {
|
|
25
|
+
if (!updates || Object.keys(updates).length === 0) {
|
|
26
|
+
throw new Error('No properties to update. Provide at least one of: name, info, category, color, icon, in, out.');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (currentSubflow.locked) {
|
|
30
|
+
throw new Error(`Subflow '${currentSubflow.id}' is locked. This subflow is locked (read-only). Use get-subflow-detail to inspect it without modifying.`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const filteredUpdates = Object.fromEntries(
|
|
34
|
+
Object.entries(updates).filter(([k]) => ALLOWED_FIELDS.includes(k)),
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
if (Object.keys(filteredUpdates).length === 0) {
|
|
38
|
+
throw new Error('No valid properties to update. Allowed fields are: name, info, category, color, icon, in, out.');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const updatedSubflow = {
|
|
42
|
+
...currentSubflow,
|
|
43
|
+
...filteredUpdates,
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
return { updatedSubflow, previousState: currentSubflow };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Handler for the update-subflow MCP tool.
|
|
51
|
+
*
|
|
52
|
+
* @param {ReturnType<import('../nodered/client.js').createNodeRedClient>} client
|
|
53
|
+
* @param {object} params
|
|
54
|
+
* @param {string} params.subflowId
|
|
55
|
+
* @param {object} params.updates
|
|
56
|
+
* @returns {Promise<{ content: Array<{ type: string, text: string }> }>}
|
|
57
|
+
*/
|
|
58
|
+
export async function handleUpdateSubflow(staging, client, params) {
|
|
59
|
+
const { subflowId, updates } = params;
|
|
60
|
+
|
|
61
|
+
const { previousState, updatedSubflow: currentState } = await staging.applyMutation((rawResponse) => {
|
|
62
|
+
const flows = rawResponse.flows || [];
|
|
63
|
+
const subflowIndex = flows.findIndex(
|
|
64
|
+
(n) => n.type === 'subflow' && n.id === subflowId,
|
|
65
|
+
);
|
|
66
|
+
if (subflowIndex === -1) {
|
|
67
|
+
throw new Error(`Subflow '${subflowId}' not found. Use get-subflows to list available subflow definitions.`);
|
|
68
|
+
}
|
|
69
|
+
const { updatedSubflow, previousState } = applySubflowUpdate(flows[subflowIndex], updates);
|
|
70
|
+
const updatedFlows = [...flows];
|
|
71
|
+
updatedFlows[subflowIndex] = updatedSubflow;
|
|
72
|
+
return { updatedFlows, previousState, updatedSubflow };
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const data = { subflowId, previousState, currentState, staging: staging.getStagingSummary() };
|
|
76
|
+
return formatSuccess(data);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export const updateSubflowDefinition = {
|
|
80
|
+
name: 'update-subflow',
|
|
81
|
+
annotations: ANN_MUTATION,
|
|
82
|
+
outputSchema: UpdateSubflowResponseSchema,
|
|
83
|
+
handler: handleUpdateSubflow,
|
|
84
|
+
};
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Streamable HTTP transport for the MCP server.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import express from 'express';
|
|
6
|
+
import http from 'node:http';
|
|
7
|
+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
8
|
+
import { requireBearerAuth } from '@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js';
|
|
9
|
+
import { mcpAuthRouter, getOAuthProtectedResourceMetadataUrl } from '@modelcontextprotocol/sdk/server/auth/router.js';
|
|
10
|
+
import { randomUUID } from 'node:crypto';
|
|
11
|
+
import { WSServer } from './ws-server.js';
|
|
12
|
+
import { buildHTML } from '../renderer/html-builder.js';
|
|
13
|
+
import { createCompositeVerifier } from '../auth/composite-verifier.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Start the MCP server using Streamable HTTP transport via Express.
|
|
17
|
+
*
|
|
18
|
+
* @param {() => import('@modelcontextprotocol/sdk/server/mcp.js').McpServer} serverFactory
|
|
19
|
+
* @param {number} port
|
|
20
|
+
* @param {import('../staging-store.js').StagingStore} [initialStaging] - eagerly-loaded staging for the viewer/WS
|
|
21
|
+
* @param {object} [authOptions] - Authentication configuration
|
|
22
|
+
* @param {string|null} [authOptions.apiKey] - API key for Bearer token auth
|
|
23
|
+
* @param {import('../auth/oauth-provider.js').OAuthProvider} [authOptions.oauthProvider] - OAuth provider (when OAuth is enabled)
|
|
24
|
+
* @param {string} [authOptions.oauthIssuerUrl] - Base issuer URL for OAuth
|
|
25
|
+
*/
|
|
26
|
+
export async function startHttpTransport(serverFactory, port, initialStaging, authOptions) {
|
|
27
|
+
const app = express();
|
|
28
|
+
app.use(express.json());
|
|
29
|
+
|
|
30
|
+
// Map of sessionId -> transport
|
|
31
|
+
const transports = {};
|
|
32
|
+
|
|
33
|
+
// ── Authentication middleware ─────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
const hasAuth = !!(authOptions?.apiKey || authOptions?.oauthProvider);
|
|
36
|
+
|
|
37
|
+
if (hasAuth) {
|
|
38
|
+
// Build the composite verifier: API key first, then OAuth
|
|
39
|
+
const verifier = createCompositeVerifier({
|
|
40
|
+
apiKey: authOptions.apiKey,
|
|
41
|
+
oauthVerifier: authOptions.oauthProvider || null,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// Determine resource metadata URL for WWW-Authenticate header
|
|
45
|
+
const resourceMetadataUrl = authOptions.oauthProvider && authOptions.oauthIssuerUrl
|
|
46
|
+
? getOAuthProtectedResourceMetadataUrl(new URL('/mcp', authOptions.oauthIssuerUrl))
|
|
47
|
+
: undefined;
|
|
48
|
+
|
|
49
|
+
const authMiddleware = requireBearerAuth({
|
|
50
|
+
verifier,
|
|
51
|
+
requiredScopes: [],
|
|
52
|
+
resourceMetadataUrl,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// Apply auth BEFORE the logging middleware so unauthorized requests
|
|
56
|
+
// are rejected early. The SDK middleware handles 401/403 responses.
|
|
57
|
+
app.use('/mcp', authMiddleware);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Log every incoming request (runs after auth middleware if active)
|
|
61
|
+
app.use('/mcp', (req, _res, next) => {
|
|
62
|
+
const sid = req.headers['mcp-session-id'] || '(none)';
|
|
63
|
+
const accept = req.headers['accept'] || '(none)';
|
|
64
|
+
console.error(`[nodered-mcp] ${req.method} /mcp session=${sid} accept=${accept} bodyType=${typeof req.body}`);
|
|
65
|
+
next();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
app.get('/mcp', async (req, res) => {
|
|
69
|
+
const sessionId = req.headers['mcp-session-id'];
|
|
70
|
+
if (!sessionId || !transports[sessionId]) {
|
|
71
|
+
console.error(`[nodered-mcp] GET /mcp rejected - unknown session: ${sessionId}`);
|
|
72
|
+
res.status(400).json({ error: 'Missing or invalid session ID' });
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
console.error(`[nodered-mcp] GET /mcp SSE stream opened session=${sessionId}`);
|
|
76
|
+
await transports[sessionId].handleRequest(req, res);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
app.delete('/mcp', async (req, res) => {
|
|
80
|
+
const sessionId = req.headers['mcp-session-id'];
|
|
81
|
+
if (sessionId && transports[sessionId]) {
|
|
82
|
+
console.error(`[nodered-mcp] Session closed: ${sessionId}`);
|
|
83
|
+
await transports[sessionId].close();
|
|
84
|
+
delete transports[sessionId];
|
|
85
|
+
}
|
|
86
|
+
res.status(200).end();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// ── Staging snapshot endpoint ─────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
// Track the most recently created staging store for WebSocket/snapshot
|
|
92
|
+
let activeStaging = initialStaging || null;
|
|
93
|
+
|
|
94
|
+
app.get('/staging-snapshot', async (_req, res) => {
|
|
95
|
+
try {
|
|
96
|
+
if (!activeStaging) {
|
|
97
|
+
res.json({ flows: [], dirtyNodeIds: [], dirtyFlowIds: [] });
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
const flows = await activeStaging.getFlows();
|
|
101
|
+
const dirtyNodeIds = [...activeStaging.getDirtyNodeIds()];
|
|
102
|
+
const dirtyFlowIds = [...activeStaging.getDirtyFlowIds()];
|
|
103
|
+
res.json({ flows, dirtyNodeIds, dirtyFlowIds });
|
|
104
|
+
} catch (err) {
|
|
105
|
+
res.status(500).json({ error: err.message });
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// ── Staging refresh endpoint (re-fetches from Node-RED backend) ───
|
|
110
|
+
|
|
111
|
+
app.post('/staging-refresh', async (_req, res) => {
|
|
112
|
+
try {
|
|
113
|
+
if (!activeStaging) {
|
|
114
|
+
res.status(503).json({ error: 'No staging session active yet' });
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
await activeStaging.invalidate();
|
|
118
|
+
await activeStaging.ensureLoaded();
|
|
119
|
+
const flows = await activeStaging.getFlows();
|
|
120
|
+
const dirtyNodeIds = [...activeStaging.getDirtyNodeIds()];
|
|
121
|
+
const dirtyFlowIds = [...activeStaging.getDirtyFlowIds()];
|
|
122
|
+
res.json({ flows, dirtyNodeIds, dirtyFlowIds });
|
|
123
|
+
} catch (err) {
|
|
124
|
+
res.status(500).json({ error: err.message });
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// ── Staging visualization HTML endpoint ───────────────────────────
|
|
129
|
+
|
|
130
|
+
app.get('/staging', async (_req, res) => {
|
|
131
|
+
try {
|
|
132
|
+
if (!activeStaging) {
|
|
133
|
+
res.type('html').send('<html><body><h2>No staging session active yet</h2><p>Make a request to the MCP first to initialize staging.</p></body></html>');
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
const flows = await activeStaging.getFlows();
|
|
137
|
+
const dirtyNodeIds = activeStaging.getDirtyNodeIds();
|
|
138
|
+
const dirtyFlowIds = activeStaging.getDirtyFlowIds();
|
|
139
|
+
const html = buildHTML(flows, { highlightDirty: true, dirtyNodeIds, dirtyFlowIds });
|
|
140
|
+
res.type('html').send(html);
|
|
141
|
+
} catch (err) {
|
|
142
|
+
res.status(500).type('html').send(`<html><body><h2>Error</h2><pre>${err.message}</pre></body></html>`);
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// ── OAuth 2.0 authorization server (optional) ─────────────────────
|
|
147
|
+
|
|
148
|
+
if (authOptions?.oauthProvider && authOptions?.oauthIssuerUrl) {
|
|
149
|
+
const issuerUrl = new URL(authOptions.oauthIssuerUrl);
|
|
150
|
+
|
|
151
|
+
// Mount the full OAuth authorization server router
|
|
152
|
+
const authRouter = mcpAuthRouter({
|
|
153
|
+
provider: authOptions.oauthProvider,
|
|
154
|
+
issuerUrl,
|
|
155
|
+
baseUrl: issuerUrl, // Authorization server is at the same origin
|
|
156
|
+
scopesSupported: ['*'],
|
|
157
|
+
resourceName: 'Node-RED MCP Server',
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
app.use(authRouter);
|
|
161
|
+
|
|
162
|
+
console.error(`[nodered-mcp] OAuth authorization server mounted at ${issuerUrl.href}`);
|
|
163
|
+
console.error(`[nodered-mcp] OAuth discovery: ${issuerUrl.href}.well-known/oauth-authorization-server`);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ── WebSocket server ──────────────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
const wsServer = new WSServer();
|
|
169
|
+
|
|
170
|
+
// ── Create HTTP server explicitly for WebSocket upgrade support ────
|
|
171
|
+
|
|
172
|
+
const httpServer = http.createServer(app);
|
|
173
|
+
|
|
174
|
+
wsServer.attach(httpServer, async () => {
|
|
175
|
+
if (!activeStaging) return { flows: [], dirtyNodeIds: new Set(), dirtyFlowIds: new Set() };
|
|
176
|
+
const flows = await activeStaging.getFlows();
|
|
177
|
+
return { flows, dirtyNodeIds: activeStaging.getDirtyNodeIds(), dirtyFlowIds: activeStaging.getDirtyFlowIds() };
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// If we have an eagerly-loaded staging, subscribe to changes for WebSocket broadcast
|
|
181
|
+
if (initialStaging) {
|
|
182
|
+
initialStaging.on('staging:changed', () => {
|
|
183
|
+
if (activeStaging === initialStaging) {
|
|
184
|
+
initialStaging.getFlows().then((flows) => {
|
|
185
|
+
wsServer.broadcast({
|
|
186
|
+
flows,
|
|
187
|
+
dirtyNodeIds: initialStaging.getDirtyNodeIds(),
|
|
188
|
+
dirtyFlowIds: initialStaging.getDirtyFlowIds(),
|
|
189
|
+
});
|
|
190
|
+
}).catch(() => { /* ignore */ });
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ── Start listening ────────────────────────────────────────────────
|
|
196
|
+
|
|
197
|
+
httpServer.listen(port, () => {
|
|
198
|
+
console.error(`[nodered-mcp] Server running on HTTP transport at http://localhost:${port}/mcp`);
|
|
199
|
+
console.error(`[nodered-mcp] Staging viewer: http://localhost:${port}/staging`);
|
|
200
|
+
console.error(`[nodered-mcp] WebSocket endpoint: ws://localhost:${port}/staging-ws`);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
// ── MCP POST handler with staging capture for WebSocket/snapshot ──
|
|
204
|
+
app.post('/mcp', async (req, res) => {
|
|
205
|
+
try {
|
|
206
|
+
const sessionId = req.headers['mcp-session-id'] || randomUUID();
|
|
207
|
+
const isNew = !transports[sessionId];
|
|
208
|
+
let transport = transports[sessionId];
|
|
209
|
+
|
|
210
|
+
if (isNew) {
|
|
211
|
+
console.error(`[nodered-mcp] New session: ${sessionId}`);
|
|
212
|
+
transport = new StreamableHTTPServerTransport({
|
|
213
|
+
sessionIdGenerator: () => sessionId,
|
|
214
|
+
});
|
|
215
|
+
transports[sessionId] = transport;
|
|
216
|
+
const server = await serverFactory();
|
|
217
|
+
await server.connect(transport);
|
|
218
|
+
console.error(`[nodered-mcp] Session ${sessionId} connected`);
|
|
219
|
+
|
|
220
|
+
// Capture staging for WebSocket/snapshot
|
|
221
|
+
if (server.__staging) {
|
|
222
|
+
activeStaging = server.__staging;
|
|
223
|
+
|
|
224
|
+
// Subscribe to staging changes → broadcast via WebSocket
|
|
225
|
+
server.__staging.on('staging:changed', () => {
|
|
226
|
+
if (activeStaging === server.__staging) {
|
|
227
|
+
const dirtyNodeIds = server.__staging.getDirtyNodeIds();
|
|
228
|
+
const dirtyFlowIds = server.__staging.getDirtyFlowIds();
|
|
229
|
+
// We need to get flows - schedule async broadcast
|
|
230
|
+
server.__staging.getFlows().then((flows) => {
|
|
231
|
+
wsServer.broadcast({ flows, dirtyNodeIds, dirtyFlowIds });
|
|
232
|
+
}).catch(() => { /* ignore */ });
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const method = req.body?.method ?? '(notification/unknown)';
|
|
239
|
+
console.error(`[nodered-mcp] → ${method} session=${sessionId}`);
|
|
240
|
+
|
|
241
|
+
await transport.handleRequest(req, res, req.body);
|
|
242
|
+
|
|
243
|
+
console.error(`[nodered-mcp] ← ${method} responded status=${res.statusCode}`);
|
|
244
|
+
} catch (err) {
|
|
245
|
+
console.error('[nodered-mcp] HTTP transport error:', err.message);
|
|
246
|
+
console.error(err.stack);
|
|
247
|
+
if (!res.headersSent) {
|
|
248
|
+
res.status(500).json({ error: 'Internal server error' });
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* stdio transport for the MCP server.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Start the MCP server using stdio transport.
|
|
9
|
+
*
|
|
10
|
+
* @param {import('@modelcontextprotocol/sdk/server/mcp.js').McpServer} server
|
|
11
|
+
*/
|
|
12
|
+
export async function startStdioTransport(server) {
|
|
13
|
+
const transport = new StdioServerTransport();
|
|
14
|
+
await server.connect(transport);
|
|
15
|
+
console.error('[nodered-mcp] Server running on stdio transport');
|
|
16
|
+
}
|