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