@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,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP tool: read-debug-messages
|
|
3
|
+
*
|
|
4
|
+
* Reads buffered Node-RED debug messages from the CommsClient ring buffer
|
|
5
|
+
* with optional filtering by node ID, node name, keyword, and time range.
|
|
6
|
+
* Supports both head (first-N) and tail (last-N) retrieval modes.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { formatSuccess } from './response-utils.js';
|
|
10
|
+
|
|
11
|
+
import { ANN_READ_DEBUG } from './constants.js';
|
|
12
|
+
import { DebugMessagesResponseSchema } from '../schemas/responses.js';
|
|
13
|
+
/** Default limit when neither `last` nor `limit` is provided. */
|
|
14
|
+
const DEFAULT_LIMIT = 50;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Apply filters to an array of debug messages.
|
|
18
|
+
*
|
|
19
|
+
* This is a pure function — it does not mutate the input array.
|
|
20
|
+
*
|
|
21
|
+
* Filters are applied in order:
|
|
22
|
+
* 1. `after` / `before` — inclusive timestamp bounds
|
|
23
|
+
* 2. `nodeId` — exact match on message.id
|
|
24
|
+
* 3. `nodeName` — case-insensitive substring match on message.name
|
|
25
|
+
* 4. `keyword` — case-insensitive substring match against stringified message.msg
|
|
26
|
+
*
|
|
27
|
+
* After filtering:
|
|
28
|
+
* - If `last` is set, return the last N matching messages (chronological order)
|
|
29
|
+
* - Otherwise, return the first `limit` matches (default: 50)
|
|
30
|
+
*
|
|
31
|
+
* @param {object[]} messages - Array of debug message objects
|
|
32
|
+
* @param {object} filters
|
|
33
|
+
* @param {string} [filters.nodeId] - Exact match on message.id
|
|
34
|
+
* @param {string} [filters.nodeName] - Case-insensitive substring on message.name
|
|
35
|
+
* @param {string} [filters.keyword] - Case-insensitive substring in stringified msg
|
|
36
|
+
* @param {number} [filters.after] - Inclusive lower timestamp bound (ms)
|
|
37
|
+
* @param {number} [filters.before] - Inclusive upper timestamp bound (ms)
|
|
38
|
+
* @param {number} [filters.last] - Return last N (mutually exclusive with limit)
|
|
39
|
+
* @param {number} [filters.limit] - Return first N (default 50; mutually exclusive with last)
|
|
40
|
+
* @returns {{ messages: object[], total: number } | { error: string }}
|
|
41
|
+
*/
|
|
42
|
+
export function filterMessages(messages, {
|
|
43
|
+
nodeId,
|
|
44
|
+
nodeName,
|
|
45
|
+
keyword,
|
|
46
|
+
after,
|
|
47
|
+
before,
|
|
48
|
+
last,
|
|
49
|
+
limit,
|
|
50
|
+
} = {}) {
|
|
51
|
+
// Validate: last and limit are mutually exclusive
|
|
52
|
+
if (last !== undefined && limit !== undefined) {
|
|
53
|
+
return {
|
|
54
|
+
error: 'last and limit are mutually exclusive — use one or the other. Use last to get the most recent N messages (tail mode), or limit to get the first N matches (head mode, default 50).',
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Apply filters
|
|
59
|
+
let result = messages;
|
|
60
|
+
|
|
61
|
+
// Time range: after (inclusive lower bound)
|
|
62
|
+
if (after !== undefined && after !== null) {
|
|
63
|
+
result = result.filter((m) => m.timestamp >= after);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Time range: before (inclusive upper bound)
|
|
67
|
+
if (before !== undefined && before !== null) {
|
|
68
|
+
result = result.filter((m) => m.timestamp <= before);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Exact nodeId match
|
|
72
|
+
if (nodeId !== undefined && nodeId !== null && nodeId !== '') {
|
|
73
|
+
result = result.filter((m) => m.id === nodeId);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// nodeName substring (case-insensitive)
|
|
77
|
+
if (nodeName !== undefined && nodeName !== null && nodeName !== '') {
|
|
78
|
+
const lowerName = nodeName.toLowerCase();
|
|
79
|
+
result = result.filter((m) => {
|
|
80
|
+
if (!m.name) return false;
|
|
81
|
+
return String(m.name).toLowerCase().includes(lowerName);
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// keyword substring in stringified msg (case-insensitive)
|
|
86
|
+
if (keyword !== undefined && keyword !== null && keyword !== '') {
|
|
87
|
+
const lowerKeyword = keyword.toLowerCase();
|
|
88
|
+
result = result.filter((m) => {
|
|
89
|
+
if (m.msg === null || m.msg === undefined) return false;
|
|
90
|
+
return JSON.stringify(m.msg).toLowerCase().includes(lowerKeyword);
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const total = result.length;
|
|
95
|
+
|
|
96
|
+
// Apply last-N or first-N slicing
|
|
97
|
+
if (last !== undefined && last !== null) {
|
|
98
|
+
// Return the last `last` messages in chronological order
|
|
99
|
+
result = result.slice(Math.max(0, result.length - last));
|
|
100
|
+
} else {
|
|
101
|
+
// Return the first `limit` messages (default 50)
|
|
102
|
+
const effectiveLimit = (limit !== undefined && limit !== null) ? limit : DEFAULT_LIMIT;
|
|
103
|
+
result = result.slice(0, effectiveLimit);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return { messages: result, total };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Create a handler for the read-debug-messages MCP tool.
|
|
111
|
+
*
|
|
112
|
+
* @param {import('../nodered/comms-client.js').CommsClient} commsClient
|
|
113
|
+
* @returns {(params: object) => Promise<{ content: Array<{ type: string, text: string }> }>}
|
|
114
|
+
*/
|
|
115
|
+
export function handleReadDebugMessages(commsClient) {
|
|
116
|
+
return async (params) => {
|
|
117
|
+
const { nodeId, nodeName, keyword, after, before, last, limit } = params;
|
|
118
|
+
|
|
119
|
+
// Read from the ring buffer
|
|
120
|
+
const allMessages = commsClient.getMessages();
|
|
121
|
+
|
|
122
|
+
// Apply filters
|
|
123
|
+
const result = filterMessages(allMessages, {
|
|
124
|
+
nodeId,
|
|
125
|
+
nodeName,
|
|
126
|
+
keyword,
|
|
127
|
+
after,
|
|
128
|
+
before,
|
|
129
|
+
last,
|
|
130
|
+
limit,
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// Handle filter-level error (e.g. last + limit conflict)
|
|
134
|
+
if (result.error) {
|
|
135
|
+
const data = {
|
|
136
|
+
error: result.error,
|
|
137
|
+
};
|
|
138
|
+
return formatSuccess(data);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const data = {
|
|
142
|
+
messages: result.messages,
|
|
143
|
+
total: result.total,
|
|
144
|
+
bufferSize: commsClient.bufferSize,
|
|
145
|
+
};
|
|
146
|
+
return formatSuccess(data);
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export const readDebugMessagesDefinition = {
|
|
151
|
+
name: 'read-debug-messages',
|
|
152
|
+
annotations: ANN_READ_DEBUG,
|
|
153
|
+
outputSchema: DebugMessagesResponseSchema,
|
|
154
|
+
handler: handleReadDebugMessages,
|
|
155
|
+
};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { ANN_REFRESH } from './constants.js';
|
|
2
|
+
import { RefreshStagingResponseSchema } from '../schemas/responses.js';
|
|
3
|
+
/**
|
|
4
|
+
* MCP tool: refresh-staging
|
|
5
|
+
*
|
|
6
|
+
* Discards ALL un-deployed staged changes and re-fetches the latest flow
|
|
7
|
+
* state from the Node-RED Admin API (GET /flows).
|
|
8
|
+
*
|
|
9
|
+
* Use this when flows have been modified externally (e.g., via the
|
|
10
|
+
* Node-RED editor UI) and the MCP staging state is out of sync.
|
|
11
|
+
*
|
|
12
|
+
* ⚠️ WARNING: All un-deployed staged edits will be lost. Use
|
|
13
|
+
* get-staging-status first to review what would be discarded.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Handler for the refresh-staging MCP tool.
|
|
18
|
+
*
|
|
19
|
+
* @param {import('../staging-store.js').StagingStore} staging
|
|
20
|
+
* @returns {() => Promise<{ content: Array<{ type: string, text: string }> }>}
|
|
21
|
+
*/
|
|
22
|
+
export function handleRefreshStaging(staging) {
|
|
23
|
+
return async () => {
|
|
24
|
+
// Capture state before invalidation so we can report what was discarded
|
|
25
|
+
const previousSummary = staging.getStagingSummary();
|
|
26
|
+
|
|
27
|
+
// Discard all staged changes and re-fetch from Node-RED
|
|
28
|
+
staging.invalidate();
|
|
29
|
+
await staging.ensureLoaded();
|
|
30
|
+
|
|
31
|
+
// Capture state after re-fetch
|
|
32
|
+
const newSummary = staging.getStagingSummary();
|
|
33
|
+
|
|
34
|
+
const responseData = {
|
|
35
|
+
success: true,
|
|
36
|
+
warning:
|
|
37
|
+
'All un-deployed staged changes have been discarded. ' +
|
|
38
|
+
'The staging state now reflects the current Node-RED backend.',
|
|
39
|
+
previousPendingChanges: previousSummary.pendingChanges,
|
|
40
|
+
previousDirtyNodeIds: previousSummary.dirtyNodeIds,
|
|
41
|
+
previousDirtyFlowIds: previousSummary.dirtyFlowIds,
|
|
42
|
+
staging: newSummary,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
content: [
|
|
47
|
+
{
|
|
48
|
+
type: 'text',
|
|
49
|
+
text: JSON.stringify(responseData, null, 2),
|
|
50
|
+
},
|
|
51
|
+
],
|
|
52
|
+
structuredContent: responseData,
|
|
53
|
+
};
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export const refreshStagingDefinition = {
|
|
58
|
+
name: 'refresh-staging',
|
|
59
|
+
annotations: ANN_REFRESH,
|
|
60
|
+
outputSchema: RefreshStagingResponseSchema,
|
|
61
|
+
handler: handleRefreshStaging,
|
|
62
|
+
};
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { ANN_MUTATION } from './constants.js';
|
|
2
|
+
import { RemoveNodesFromGroupResponseSchema } from '../schemas/responses.js';
|
|
3
|
+
/**
|
|
4
|
+
* MCP tool: remove-nodes-from-group
|
|
5
|
+
*
|
|
6
|
+
* Detaches nodes from a Node-RED group. Optionally repositions detached
|
|
7
|
+
* nodes outside the group's bounding rectangle. If no specific node IDs
|
|
8
|
+
* are provided, all members are removed.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Apply the remove-nodes-from-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 remove nodes from
|
|
16
|
+
* @param {object} [options]
|
|
17
|
+
* @param {string[]} [options.nodeIds] - Specific node IDs to remove; if omitted, all members are removed
|
|
18
|
+
* @param {boolean} [options.reposition=false] - Whether to reposition removed nodes outside group bounds
|
|
19
|
+
* @returns {{ updatedFlows: object[], groupId: string, removedNodeIds: string[], remainingNodeIds: string[], repositionedNodes: Array<{ nodeId: string, x: number, y: number }> }}
|
|
20
|
+
*/
|
|
21
|
+
export function applyRemoveNodesFromGroup(rawResponse, groupId, options = {}) {
|
|
22
|
+
const { nodeIds, reposition = false } = options;
|
|
23
|
+
const flows = rawResponse.flows ?? rawResponse;
|
|
24
|
+
|
|
25
|
+
// Find the group
|
|
26
|
+
const groupIndex = flows.findIndex(
|
|
27
|
+
(n) => n.type === 'group' && n.id === groupId,
|
|
28
|
+
);
|
|
29
|
+
if (groupIndex === -1) {
|
|
30
|
+
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.`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const group = flows[groupIndex];
|
|
34
|
+
|
|
35
|
+
// Check parent flow lock
|
|
36
|
+
if (group.z) {
|
|
37
|
+
const parentFlow = flows.find(
|
|
38
|
+
(n) => (n.type === 'tab' || n.type === 'subflow') && n.id === group.z,
|
|
39
|
+
);
|
|
40
|
+
if (parentFlow?.locked) {
|
|
41
|
+
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.`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Determine which nodes to remove
|
|
46
|
+
const currentMembers = new Set(group.nodes || []);
|
|
47
|
+
const toRemove = nodeIds
|
|
48
|
+
? nodeIds.filter((nid) => currentMembers.has(nid))
|
|
49
|
+
: [...currentMembers];
|
|
50
|
+
|
|
51
|
+
// Track warnings for nodes not in group
|
|
52
|
+
const warnings = [];
|
|
53
|
+
if (nodeIds) {
|
|
54
|
+
const notMembers = nodeIds.filter((nid) => !currentMembers.has(nid));
|
|
55
|
+
for (const nid of notMembers) {
|
|
56
|
+
warnings.push(`Node '${nid}' is not a member of group '${groupId}' — skipped`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Compute reposition target for removed nodes
|
|
61
|
+
const repositionTarget = reposition
|
|
62
|
+
? { x: (group.x ?? 0) + (group.w ?? 100) + 40, startY: (group.y ?? 0) }
|
|
63
|
+
: null;
|
|
64
|
+
|
|
65
|
+
let updatedFlows = [...flows];
|
|
66
|
+
const removedList = [];
|
|
67
|
+
const repositionedList = [];
|
|
68
|
+
|
|
69
|
+
for (let i = 0; i < toRemove.length; i++) {
|
|
70
|
+
const nid = toRemove[i];
|
|
71
|
+
const nodeIndex = updatedFlows.findIndex((n) => n.id === nid);
|
|
72
|
+
if (nodeIndex === -1) continue;
|
|
73
|
+
|
|
74
|
+
const node = updatedFlows[nodeIndex];
|
|
75
|
+
|
|
76
|
+
// Remove g property
|
|
77
|
+
const { g: _g, ...rest } = node;
|
|
78
|
+
const updatedNode = { ...rest };
|
|
79
|
+
if (_g !== undefined) {
|
|
80
|
+
// Only delete if it matched this group
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Reposition if requested
|
|
84
|
+
if (repositionTarget) {
|
|
85
|
+
updatedNode.x = repositionTarget.x;
|
|
86
|
+
updatedNode.y = repositionTarget.startY + i * 40;
|
|
87
|
+
repositionedList.push({
|
|
88
|
+
nodeId: nid,
|
|
89
|
+
x: updatedNode.x,
|
|
90
|
+
y: updatedNode.y,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
updatedFlows[nodeIndex] = updatedNode;
|
|
95
|
+
removedList.push(nid);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Update group's nodes array
|
|
99
|
+
const remainingMembers = (group.nodes || []).filter(
|
|
100
|
+
(nid) => !toRemove.includes(nid),
|
|
101
|
+
);
|
|
102
|
+
updatedFlows[groupIndex] = {
|
|
103
|
+
...group,
|
|
104
|
+
nodes: remainingMembers,
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
updatedFlows,
|
|
109
|
+
groupId,
|
|
110
|
+
removedNodeIds: removedList,
|
|
111
|
+
remainingNodeIds: remainingMembers,
|
|
112
|
+
repositionedNodes: repositionedList,
|
|
113
|
+
warnings: warnings.length > 0 ? warnings : undefined,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Handler for the remove-nodes-from-group MCP tool.
|
|
119
|
+
*
|
|
120
|
+
* @param {ReturnType<import('../nodered/client.js').createNodeRedClient>} client
|
|
121
|
+
* @param {object} params
|
|
122
|
+
* @param {string} params.groupId
|
|
123
|
+
* @param {string[]} [params.nodeIds]
|
|
124
|
+
* @param {boolean} [params.reposition=false]
|
|
125
|
+
* @returns {Promise<{ content: Array<{ type: string, text: string }> }>}
|
|
126
|
+
*/
|
|
127
|
+
export async function handleRemoveNodesFromGroup(staging, client, params) {
|
|
128
|
+
const { groupId, nodeIds, reposition } = params;
|
|
129
|
+
|
|
130
|
+
const result = await staging.applyMutation((rawResponse) => {
|
|
131
|
+
return applyRemoveNodesFromGroup(rawResponse, groupId, {
|
|
132
|
+
nodeIds,
|
|
133
|
+
reposition,
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
const responseData = {
|
|
138
|
+
groupId: result.groupId,
|
|
139
|
+
removedNodeIds: result.removedNodeIds,
|
|
140
|
+
remainingNodeIds: result.remainingNodeIds,
|
|
141
|
+
repositionedNodes: result.repositionedNodes,
|
|
142
|
+
warnings: result.warnings,
|
|
143
|
+
staging: staging.getStagingSummary(),
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
content: [
|
|
148
|
+
{
|
|
149
|
+
type: 'text',
|
|
150
|
+
text: JSON.stringify(responseData, null, 2),
|
|
151
|
+
},
|
|
152
|
+
],
|
|
153
|
+
structuredContent: responseData,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export const removeNodesFromGroupDefinition = {
|
|
158
|
+
name: 'remove-nodes-from-group',
|
|
159
|
+
annotations: ANN_MUTATION,
|
|
160
|
+
outputSchema: RemoveNodesFromGroupResponseSchema,
|
|
161
|
+
handler: handleRemoveNodesFromGroup,
|
|
162
|
+
};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP tool: render-staging
|
|
3
|
+
*
|
|
4
|
+
* Renders the current staging workspace in SVG, HTML, or Mermaid format.
|
|
5
|
+
* Supports flow filtering, dirty highlighting, and interactive HTML output.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { ANN_READONLY } from './constants.js';
|
|
9
|
+
import { renderStaging } from '../renderer/index.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Handler for the render-staging MCP tool.
|
|
13
|
+
*
|
|
14
|
+
* @param {import('../staging-store.js').StagingStore} staging
|
|
15
|
+
* @returns {(params: object) => Promise<{ content: Array<{ type: string, text: string }> }>}
|
|
16
|
+
*/
|
|
17
|
+
export function handleRenderStaging(staging) {
|
|
18
|
+
return async (params) => {
|
|
19
|
+
const {
|
|
20
|
+
format = 'svg',
|
|
21
|
+
flowId,
|
|
22
|
+
highlightDirty = true,
|
|
23
|
+
} = params;
|
|
24
|
+
|
|
25
|
+
const flows = await staging.getFlows();
|
|
26
|
+
const summary = staging.getStagingSummary();
|
|
27
|
+
const dirtyNodeIds = new Set(summary.dirtyNodeIds);
|
|
28
|
+
const dirtyFlowIds = new Set(summary.dirtyFlowIds);
|
|
29
|
+
|
|
30
|
+
const result = renderStaging(flows, {
|
|
31
|
+
format,
|
|
32
|
+
flowId,
|
|
33
|
+
highlightDirty,
|
|
34
|
+
dirtyNodeIds,
|
|
35
|
+
dirtyFlowIds,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
switch (format) {
|
|
39
|
+
case 'svg':
|
|
40
|
+
return {
|
|
41
|
+
content: [{ type: 'text', text: result.svg }],
|
|
42
|
+
structuredContent: { format, flowId: flowId || null, highlightDirty },
|
|
43
|
+
};
|
|
44
|
+
case 'html':
|
|
45
|
+
return {
|
|
46
|
+
content: [{ type: 'text', text: result.html }],
|
|
47
|
+
structuredContent: { format, flowId: flowId || null, highlightDirty },
|
|
48
|
+
};
|
|
49
|
+
case 'mermaid':
|
|
50
|
+
return {
|
|
51
|
+
content: [
|
|
52
|
+
{
|
|
53
|
+
type: 'text',
|
|
54
|
+
text: `\`\`\`mermaid\n${result.mermaid}\n\`\`\``,
|
|
55
|
+
},
|
|
56
|
+
],
|
|
57
|
+
structuredContent: { format, flowId: flowId || null, highlightDirty },
|
|
58
|
+
};
|
|
59
|
+
default:
|
|
60
|
+
throw new Error(`Unknown format: ${format}. Use one of: "svg" (static diagram), "html" (interactive page), or "mermaid" (topology diagram).`);
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export const renderStagingDefinition = {
|
|
66
|
+
name: 'render-staging',
|
|
67
|
+
annotations: ANN_READONLY,
|
|
68
|
+
handler: handleRenderStaging,
|
|
69
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared response formatting utilities for MCP tool handlers.
|
|
3
|
+
*
|
|
4
|
+
* Provides consistent success/error response shapes across all tools.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Format a successful tool response.
|
|
9
|
+
*
|
|
10
|
+
* structuredContent is only included when `data` is a plain object (record),
|
|
11
|
+
* because the MCP SDK requires structuredContent to be a record, not an array.
|
|
12
|
+
*
|
|
13
|
+
* @param {any} data - The response data to serialize
|
|
14
|
+
* @returns {{ content: Array<{ type: string, text: string }>, structuredContent?: object }}
|
|
15
|
+
*/
|
|
16
|
+
export function formatSuccess(data) {
|
|
17
|
+
const response = {
|
|
18
|
+
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
|
|
19
|
+
};
|
|
20
|
+
// MCP SDK requires structuredContent to be a record (object); arrays are rejected
|
|
21
|
+
if (data !== null && typeof data === 'object' && !Array.isArray(data)) {
|
|
22
|
+
response.structuredContent = data;
|
|
23
|
+
}
|
|
24
|
+
return response;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Format an error response for a tool.
|
|
29
|
+
*
|
|
30
|
+
* @param {string} message - User-facing error message
|
|
31
|
+
* @param {object} [details] - Optional additional details
|
|
32
|
+
* @returns {{ content: Array<{ type: string, text: string }>, isError: boolean }}
|
|
33
|
+
*/
|
|
34
|
+
export function formatError(message, details) {
|
|
35
|
+
return {
|
|
36
|
+
content: [{
|
|
37
|
+
type: 'text',
|
|
38
|
+
text: JSON.stringify({ error: message, ...(details && { details }) }, null, 2),
|
|
39
|
+
}],
|
|
40
|
+
isError: true,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP tool: search-nodes
|
|
3
|
+
*
|
|
4
|
+
* Deep-searches all nodes across all flows (or a single flow via flowId) with a
|
|
5
|
+
* single query string. Serializes each node with JSON.stringify and matches
|
|
6
|
+
* against the full string. Supports plain text (case-insensitive substring) and
|
|
7
|
+
* regex modes.
|
|
8
|
+
*/
|
|
9
|
+
import { formatSuccess } from './response-utils.js';
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
import { ANN_READONLY } from './constants.js';
|
|
13
|
+
import { NodeBasicSchema } from '../schemas/responses.js';
|
|
14
|
+
import { z } from 'zod';
|
|
15
|
+
/**
|
|
16
|
+
* Search regular nodes across all flows and return enriched results.
|
|
17
|
+
*
|
|
18
|
+
* Only regular nodes (those with a `z` property, excluding tabs and subflow
|
|
19
|
+
* definitions) are searched. Each result includes flow context.
|
|
20
|
+
*
|
|
21
|
+
* @param {object[]} allNodes - Raw nodes from GET /flows
|
|
22
|
+
* @param {object} options
|
|
23
|
+
* @param {string} options.query - The search term
|
|
24
|
+
* @param {boolean} [options.regex=false] - Treat query as a regex pattern
|
|
25
|
+
* @param {string} [options.flowId] - Limit search to nodes in this flow
|
|
26
|
+
* @param {number} [options.limit=50] - Max results to return
|
|
27
|
+
* @returns {{ results: object[], total: number, truncated: boolean }}
|
|
28
|
+
* @throws {Error} If regex is true and the pattern is invalid
|
|
29
|
+
*/
|
|
30
|
+
export function searchNodes(allNodes, { query, regex = false, flowId, limit = 50 }) {
|
|
31
|
+
// --- Build flow index: flowId → { id, label } for tabs ---
|
|
32
|
+
const flowIndex = new Map();
|
|
33
|
+
for (const node of allNodes) {
|
|
34
|
+
if (node.type === 'tab') {
|
|
35
|
+
flowIndex.set(node.id, { id: node.id, label: node.label || '' });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// --- Filter to searchable nodes ---
|
|
40
|
+
// Regular nodes have a `z` property; exclude tabs and subflow definitions
|
|
41
|
+
let candidates = allNodes.filter(
|
|
42
|
+
(n) => n.z !== undefined && n.type !== 'tab' && n.type !== 'subflow',
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
// Optional flow scoping
|
|
46
|
+
if (flowId) {
|
|
47
|
+
candidates = candidates.filter((n) => n.z === flowId);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// --- Deep search ---
|
|
51
|
+
const results = [];
|
|
52
|
+
let total = 0;
|
|
53
|
+
|
|
54
|
+
for (const node of candidates) {
|
|
55
|
+
const serialized = JSON.stringify(node);
|
|
56
|
+
|
|
57
|
+
let matches = false;
|
|
58
|
+
if (regex) {
|
|
59
|
+
// Regex mode: compile and test
|
|
60
|
+
let pattern;
|
|
61
|
+
try {
|
|
62
|
+
pattern = new RegExp(query);
|
|
63
|
+
} catch {
|
|
64
|
+
throw new Error(`Invalid regex pattern: ${query}. Provide a valid JavaScript regular expression, e.g. "debug" for plain text or "/debug\\d+/" for regex.`);
|
|
65
|
+
}
|
|
66
|
+
matches = pattern.test(serialized);
|
|
67
|
+
} else {
|
|
68
|
+
// Plain text mode: case-insensitive substring
|
|
69
|
+
matches = serialized.toLowerCase().includes(query.toLowerCase());
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (matches) {
|
|
73
|
+
total++;
|
|
74
|
+
if (results.length < limit) {
|
|
75
|
+
const flowInfo = flowIndex.get(node.z);
|
|
76
|
+
results.push({
|
|
77
|
+
flowId: node.z,
|
|
78
|
+
flowLabel: flowInfo ? flowInfo.label : '',
|
|
79
|
+
nodeId: node.id,
|
|
80
|
+
type: node.type,
|
|
81
|
+
name: node.name || '',
|
|
82
|
+
x: node.x,
|
|
83
|
+
y: node.y,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
results,
|
|
91
|
+
total,
|
|
92
|
+
truncated: total > limit,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Handler for the search-nodes MCP tool.
|
|
98
|
+
*
|
|
99
|
+
* @param {ReturnType<import('../nodered/client.js').createNodeRedClient>} client
|
|
100
|
+
* @param {object} params - Validated input parameters
|
|
101
|
+
* @returns {Promise<{ content: Array<{ type: string, text: string }> }>}
|
|
102
|
+
*/
|
|
103
|
+
export async function handleSearchNodes(staging, params) {
|
|
104
|
+
const { query, regex, flowId, limit } = params;
|
|
105
|
+
|
|
106
|
+
// Validate query
|
|
107
|
+
if (!query || query.trim() === '') {
|
|
108
|
+
throw new Error('The "query" parameter is required and must be non-empty. Provide a search term to match against node properties (case-insensitive substring).');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Fetch all flows
|
|
112
|
+
const allNodes = await staging.getFlows();
|
|
113
|
+
|
|
114
|
+
// Validate flowId if provided
|
|
115
|
+
if (flowId) {
|
|
116
|
+
const flowExists = allNodes.some(
|
|
117
|
+
(n) => (n.type === 'tab' || n.type === 'subflow') && n.id === flowId,
|
|
118
|
+
);
|
|
119
|
+
if (!flowExists) {
|
|
120
|
+
throw new Error(`Flow not found: no tab or subflow with id "${flowId}". Use get-flows to list available flow tabs and subflows.`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const result = searchNodes(allNodes, { query, regex, flowId, limit });
|
|
125
|
+
|
|
126
|
+
return formatSuccess(result);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export const searchNodesDefinition = {
|
|
130
|
+
name: 'search-nodes',
|
|
131
|
+
annotations: ANN_READONLY,
|
|
132
|
+
outputSchema: z.array(NodeBasicSchema),
|
|
133
|
+
handler: handleSearchNodes,
|
|
134
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP tool: uninstall-node
|
|
3
|
+
*
|
|
4
|
+
* Uninstalls a Node-RED node module from the running instance via the Admin API's
|
|
5
|
+
* DELETE /nodes/:module endpoint. The module identifier comes from the palette
|
|
6
|
+
* listing (get-palette-nodes). Returns a confirmation object on success.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { formatSuccess } from './response-utils.js';
|
|
10
|
+
|
|
11
|
+
import { ANN_UNINSTALL } from './constants.js';
|
|
12
|
+
import { UninstallNodeResponseSchema } from '../schemas/responses.js';
|
|
13
|
+
/**
|
|
14
|
+
* Handle the uninstall-node MCP tool invocation.
|
|
15
|
+
*
|
|
16
|
+
* @param {ReturnType<import('../nodered/client.js').createNodeRedClient>} client
|
|
17
|
+
* @param {{ module: string }} params
|
|
18
|
+
* @returns {Promise<{ content: Array<{ type: string, text: string }> }>}
|
|
19
|
+
*/
|
|
20
|
+
export async function handleUninstallNode(client, { module: moduleName }) {
|
|
21
|
+
await client.request('DELETE', `/nodes/${encodeURIComponent(moduleName)}`);
|
|
22
|
+
const data = { uninstalled: true, module: moduleName };
|
|
23
|
+
return formatSuccess(data);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const uninstallNodeDefinition = {
|
|
27
|
+
name: 'uninstall-node',
|
|
28
|
+
annotations: ANN_UNINSTALL,
|
|
29
|
+
outputSchema: UninstallNodeResponseSchema,
|
|
30
|
+
handler: handleUninstallNode,
|
|
31
|
+
};
|