@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
package/src/server.js
ADDED
|
@@ -0,0 +1,844 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP Server definition.
|
|
3
|
+
*
|
|
4
|
+
* Creates and configures the McpServer instance with all registered tools.
|
|
5
|
+
* Transport-agnostic: the caller decides how to connect it.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
9
|
+
import { z } from 'zod';
|
|
10
|
+
import { StagingStore } from './staging-store.js';
|
|
11
|
+
import {
|
|
12
|
+
StagingSummarySchema,
|
|
13
|
+
FlowSummarySchema,
|
|
14
|
+
SubflowSummarySchema,
|
|
15
|
+
NodeBasicSchema,
|
|
16
|
+
ConfigNodeSummarySchema,
|
|
17
|
+
PaletteNodeSchema,
|
|
18
|
+
CreateNodeResponseSchema,
|
|
19
|
+
CreateFlowResponseSchema,
|
|
20
|
+
CreateSubflowResponseSchema,
|
|
21
|
+
CreateSubflowInstanceResponseSchema,
|
|
22
|
+
UpdateNodeResponseSchema,
|
|
23
|
+
UpdateFlowResponseSchema,
|
|
24
|
+
UpdateSubflowResponseSchema,
|
|
25
|
+
WireChangeResponseSchema,
|
|
26
|
+
AddNodesToGroupResponseSchema,
|
|
27
|
+
RemoveNodesFromGroupResponseSchema,
|
|
28
|
+
ImportFlowResponseSchema,
|
|
29
|
+
DeleteNodeResponseSchema,
|
|
30
|
+
DeleteFlowResponseSchema,
|
|
31
|
+
DeleteSubflowResponseSchema,
|
|
32
|
+
DeleteGroupResponseSchema,
|
|
33
|
+
DeleteContextResponseSchema,
|
|
34
|
+
DeployResponseSchema,
|
|
35
|
+
InjectMessageResponseSchema,
|
|
36
|
+
DebugMessagesResponseSchema,
|
|
37
|
+
UninstallNodeResponseSchema,
|
|
38
|
+
RefreshStagingResponseSchema,
|
|
39
|
+
FlowNodesResponseSchema,
|
|
40
|
+
FlowDiagramResponseSchema,
|
|
41
|
+
ConfigNodesResponseSchema,
|
|
42
|
+
PaletteNodesResponseSchema,
|
|
43
|
+
SubflowDetailResponseSchema,
|
|
44
|
+
SkillListResponseSchema,
|
|
45
|
+
} from './schemas/responses.js';
|
|
46
|
+
import { handleGetFlows } from './tools/get-flows.js';
|
|
47
|
+
import { handleGetSubflows } from './tools/get-subflows.js';
|
|
48
|
+
import { handleGetSubflowDetail } from './tools/get-subflow-detail.js';
|
|
49
|
+
import { handleCreateSubflowInstance } from './tools/create-subflow-instance.js';
|
|
50
|
+
import { handleExportSubflow } from './tools/export-subflow.js';
|
|
51
|
+
import { handleCreateSubflow } from './tools/create-subflow.js';
|
|
52
|
+
import { handleUpdateSubflow } from './tools/update-subflow.js';
|
|
53
|
+
import { handleDeleteSubflow } from './tools/delete-subflow.js';
|
|
54
|
+
import { handleGetFlowNodes } from './tools/get-flow-nodes.js';
|
|
55
|
+
import { handleGetFlowDiagram } from './tools/get-flow-diagram.js';
|
|
56
|
+
import { handleGetConfigNodes } from './tools/get-config-nodes.js';
|
|
57
|
+
import { handleGetNodeDetail } from './tools/get-node-detail.js';
|
|
58
|
+
import { handleGetPaletteNodes } from './tools/get-palette-nodes.js';
|
|
59
|
+
import { handleGetNodeTypeDetail } from './tools/get-node-type-detail.js';
|
|
60
|
+
import { handleCreateFlow } from './tools/create-flow.js';
|
|
61
|
+
import { handleDeleteFlow } from './tools/delete-flow.js';
|
|
62
|
+
import { handleUpdateFlow } from './tools/update-flow.js';
|
|
63
|
+
import { handleUpdateNode } from './tools/update-node.js';
|
|
64
|
+
import { handleConnectNodes } from './tools/connect-nodes.js';
|
|
65
|
+
import { handleDisconnectNodes } from './tools/disconnect-nodes.js';
|
|
66
|
+
import { handleCreateNode } from './tools/create-node.js';
|
|
67
|
+
import { handleDeleteNode } from './tools/delete-node.js';
|
|
68
|
+
import { handleExportFlowJson } from './tools/export-flow.js';
|
|
69
|
+
import { handleImportFlow } from './tools/import-flow.js';
|
|
70
|
+
import { handleGetContext } from './tools/get-context.js';
|
|
71
|
+
import { handleDeleteContext } from './tools/delete-context.js';
|
|
72
|
+
import { handleSearchNodes } from './tools/search-nodes.js';
|
|
73
|
+
import { handleInjectMessage } from './tools/inject-message.js';
|
|
74
|
+
import { handleReadDebugMessages } from './tools/read-debug-messages.js';
|
|
75
|
+
import { handleInstallNode } from './tools/install-node.js';
|
|
76
|
+
import { handleUninstallNode } from './tools/uninstall-node.js';
|
|
77
|
+
import { handleAddNodesToGroup } from './tools/add-nodes-to-group.js';
|
|
78
|
+
import { handleRemoveNodesFromGroup } from './tools/remove-nodes-from-group.js';
|
|
79
|
+
import { handleUpdateGroup } from './tools/update-group.js';
|
|
80
|
+
import { handleDeleteGroup } from './tools/delete-group.js';
|
|
81
|
+
import { handleDeploy } from './tools/deploy.js';
|
|
82
|
+
import { handleGetStagingStatus } from './tools/get-staging-status.js';
|
|
83
|
+
import { handleRefreshStaging } from './tools/refresh-staging.js';
|
|
84
|
+
import { handleRenderStaging, renderStagingDefinition } from './tools/render-staging.js';
|
|
85
|
+
import { loadSkills } from './skills/loader.js';
|
|
86
|
+
import path from 'node:path';
|
|
87
|
+
import { fileURLToPath } from 'node:url';
|
|
88
|
+
import { getFlowsDefinition } from './tools/get-flows.js';
|
|
89
|
+
import { getSubflowsDefinition } from './tools/get-subflows.js';
|
|
90
|
+
import { getSubflowDetailDefinition } from './tools/get-subflow-detail.js';
|
|
91
|
+
import { createSubflowInstanceDefinition } from './tools/create-subflow-instance.js';
|
|
92
|
+
import { exportSubflowDefinition } from './tools/export-subflow.js';
|
|
93
|
+
import { createSubflowDefinition } from './tools/create-subflow.js';
|
|
94
|
+
import { updateSubflowDefinition } from './tools/update-subflow.js';
|
|
95
|
+
import { deleteSubflowDefinition } from './tools/delete-subflow.js';
|
|
96
|
+
import { getFlowNodesDefinition } from './tools/get-flow-nodes.js';
|
|
97
|
+
import { getFlowDiagramDefinition } from './tools/get-flow-diagram.js';
|
|
98
|
+
import { getConfigNodesDefinition } from './tools/get-config-nodes.js';
|
|
99
|
+
import { getNodeDetailDefinition } from './tools/get-node-detail.js';
|
|
100
|
+
import { getPaletteNodesDefinition } from './tools/get-palette-nodes.js';
|
|
101
|
+
import { getNodeTypeDetailDefinition } from './tools/get-node-type-detail.js';
|
|
102
|
+
import { createFlowDefinition } from './tools/create-flow.js';
|
|
103
|
+
import { deleteFlowDefinition } from './tools/delete-flow.js';
|
|
104
|
+
import { updateFlowDefinition } from './tools/update-flow.js';
|
|
105
|
+
import { updateNodeDefinition } from './tools/update-node.js';
|
|
106
|
+
import { connectNodesDefinition } from './tools/connect-nodes.js';
|
|
107
|
+
import { disconnectNodesDefinition } from './tools/disconnect-nodes.js';
|
|
108
|
+
import { createNodeDefinition } from './tools/create-node.js';
|
|
109
|
+
import { deleteNodeDefinition } from './tools/delete-node.js';
|
|
110
|
+
import { exportFlowDefinition } from './tools/export-flow.js';
|
|
111
|
+
import { importFlowDefinition } from './tools/import-flow.js';
|
|
112
|
+
import { getContextDefinition } from './tools/get-context.js';
|
|
113
|
+
import { deleteContextDefinition } from './tools/delete-context.js';
|
|
114
|
+
import { searchNodesDefinition } from './tools/search-nodes.js';
|
|
115
|
+
import { injectMessageDefinition } from './tools/inject-message.js';
|
|
116
|
+
import { readDebugMessagesDefinition } from './tools/read-debug-messages.js';
|
|
117
|
+
import { installNodeDefinition } from './tools/install-node.js';
|
|
118
|
+
import { uninstallNodeDefinition } from './tools/uninstall-node.js';
|
|
119
|
+
import { addNodesToGroupDefinition } from './tools/add-nodes-to-group.js';
|
|
120
|
+
import { removeNodesFromGroupDefinition } from './tools/remove-nodes-from-group.js';
|
|
121
|
+
import { updateGroupDefinition } from './tools/update-group.js';
|
|
122
|
+
import { deleteGroupDefinition } from './tools/delete-group.js';
|
|
123
|
+
import { deployDefinition } from './tools/deploy.js';
|
|
124
|
+
import { getStagingStatusDefinition } from './tools/get-staging-status.js';
|
|
125
|
+
import { refreshStagingDefinition } from './tools/refresh-staging.js';
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Create a configured MCP server with all tools registered.
|
|
129
|
+
*
|
|
130
|
+
* Eagerly loads the staging store from the Node-RED backend so that flows are
|
|
131
|
+
* available immediately without waiting for the first tool call.
|
|
132
|
+
*
|
|
133
|
+
* @param {ReturnType<import('./nodered/client.js').createNodeRedClient>} nodeRedClient
|
|
134
|
+
* @param {import('./nodered/comms-client.js').CommsClient} [commsClient]
|
|
135
|
+
* @returns {Promise<McpServer>}
|
|
136
|
+
*/
|
|
137
|
+
export async function createMcpServer(nodeRedClient, commsClient) {
|
|
138
|
+
const server = new McpServer({
|
|
139
|
+
name: 'nodered-mcp-server',
|
|
140
|
+
version: '0.1.0',
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// Create the in-memory staging store shared across all tool handlers
|
|
144
|
+
const staging = new StagingStore(nodeRedClient);
|
|
145
|
+
|
|
146
|
+
// Eagerly load staging from Node-RED backend on startup
|
|
147
|
+
console.error('[nodered-mcp] Loading staging from Node-RED backend...');
|
|
148
|
+
await staging.ensureLoaded();
|
|
149
|
+
console.error('[nodered-mcp] Staging loaded — ready to serve');
|
|
150
|
+
|
|
151
|
+
// Register: get-flows
|
|
152
|
+
server.tool(
|
|
153
|
+
'get-flows',
|
|
154
|
+
'Summarized list of all flow tabs: id, label, type, status, node count, and node types. ' +
|
|
155
|
+
'Use get-subflows for subflow definitions.',
|
|
156
|
+
{},
|
|
157
|
+
async () => handleGetFlows(staging),
|
|
158
|
+
{ annotations: getFlowsDefinition.annotations, outputSchema: getFlowsDefinition.outputSchema },
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
// Register: get-subflows
|
|
162
|
+
server.tool(
|
|
163
|
+
'get-subflows',
|
|
164
|
+
'Summarized list of all subflow definitions: id, name, ports, node count, instances. ' +
|
|
165
|
+
'Use get-subflow-detail for deep inspection of a specific subflow.',
|
|
166
|
+
{},
|
|
167
|
+
async () => handleGetSubflows(staging),
|
|
168
|
+
{ annotations: getSubflowsDefinition.annotations, outputSchema: getSubflowsDefinition.outputSchema },
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
// Register: get-subflow-detail
|
|
172
|
+
server.tool(
|
|
173
|
+
'get-subflow-detail',
|
|
174
|
+
'Full detail of a single subflow: definition, internal nodes, instances, and Mermaid diagram. ' +
|
|
175
|
+
'Use this to understand what a subflow does internally before instantiating or modifying it.',
|
|
176
|
+
{
|
|
177
|
+
subflowId: z.string().describe('ID of the subflow to inspect'),
|
|
178
|
+
},
|
|
179
|
+
async (params) => handleGetSubflowDetail(staging, params),
|
|
180
|
+
{ annotations: getSubflowDetailDefinition.annotations, outputSchema: getSubflowDetailDefinition.outputSchema },
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
// Register: create-subflow-instance
|
|
184
|
+
server.tool(
|
|
185
|
+
'create-subflow-instance',
|
|
186
|
+
'Place a reusable subflow instance into a flow tab (staged — deploy to apply). ' +
|
|
187
|
+
'Auto-sizes output wires to match the subflow definition. Validates subflow and target flow exist.',
|
|
188
|
+
{
|
|
189
|
+
subflowId: z.string().describe('ID of the subflow definition to instantiate'),
|
|
190
|
+
flowId: z.string().describe('ID of the target flow tab where the instance will be placed'),
|
|
191
|
+
name: z.string().optional().describe('Display name for the instance'),
|
|
192
|
+
env: z.array(z.object({
|
|
193
|
+
name: z.string().describe('Environment variable name'),
|
|
194
|
+
value: z.string().describe('Environment variable value'),
|
|
195
|
+
type: z.string().describe('Environment variable type (e.g. "str", "num", "bool")'),
|
|
196
|
+
})).optional().describe('Instance-level environment variables'),
|
|
197
|
+
x: z.number().int().optional().default(300).describe('X canvas position (default 300)'),
|
|
198
|
+
y: z.number().int().optional().default(200).describe('Y canvas position (default 200)'),
|
|
199
|
+
},
|
|
200
|
+
async (params) => handleCreateSubflowInstance(staging, nodeRedClient, params),
|
|
201
|
+
{ annotations: createSubflowInstanceDefinition.annotations, outputSchema: createSubflowInstanceDefinition.outputSchema },
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
// Register: export-subflow
|
|
205
|
+
server.tool(
|
|
206
|
+
'export-subflow',
|
|
207
|
+
'Export a subflow definition, internal nodes, and config nodes as JSON for import-flow. ' +
|
|
208
|
+
'Use to back up, share, or duplicate a subflow across instances.',
|
|
209
|
+
{
|
|
210
|
+
subflowId: z.string().describe('ID of the subflow to export'),
|
|
211
|
+
},
|
|
212
|
+
async (params) => handleExportSubflow(staging, params),
|
|
213
|
+
{ annotations: exportSubflowDefinition.annotations, outputSchema: exportSubflowDefinition.outputSchema },
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
// Register: create-subflow
|
|
217
|
+
server.tool(
|
|
218
|
+
'create-subflow',
|
|
219
|
+
'Create a new empty subflow definition (staged — deploy to apply). ' +
|
|
220
|
+
'Returns the subflowId for use with create-node and connect-nodes. Use update-subflow to define ports.',
|
|
221
|
+
{
|
|
222
|
+
name: z.string().describe('Display name for the subflow'),
|
|
223
|
+
info: z.string().optional().describe('Markdown description for the subflow'),
|
|
224
|
+
category: z.string().optional().describe('Palette category (e.g. "subflow"). Omit to place in the default "subflows" section.'),
|
|
225
|
+
color: z.string().optional().describe('Palette node color (e.g. "#DDAA99")'),
|
|
226
|
+
icon: z.string().optional().describe('Palette icon path (e.g. "node-red/subflow.svg")'),
|
|
227
|
+
in: z.array(z.object({}).passthrough()).optional().describe('Input port definitions'),
|
|
228
|
+
out: z.array(z.object({}).passthrough()).optional().describe('Output port definitions'),
|
|
229
|
+
},
|
|
230
|
+
async (params) => handleCreateSubflow(staging, nodeRedClient, params),
|
|
231
|
+
{ annotations: createSubflowDefinition.annotations, outputSchema: createSubflowDefinition.outputSchema },
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
// Register: update-subflow
|
|
235
|
+
server.tool(
|
|
236
|
+
'update-subflow',
|
|
237
|
+
'Update metadata of a subflow definition (staged — deploy to apply). ' +
|
|
238
|
+
'Partial merge — unspecified fields are preserved. Refuses locked subflows.',
|
|
239
|
+
{
|
|
240
|
+
subflowId: z.string().describe('ID of the subflow to update'),
|
|
241
|
+
updates: z.object({}).passthrough().describe('Fields to update: name, info, category, color, icon, in, out'),
|
|
242
|
+
},
|
|
243
|
+
async (params) => handleUpdateSubflow(staging, nodeRedClient, params),
|
|
244
|
+
{ annotations: updateSubflowDefinition.annotations, outputSchema: updateSubflowDefinition.outputSchema },
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
// Register: delete-subflow
|
|
248
|
+
server.tool(
|
|
249
|
+
'delete-subflow',
|
|
250
|
+
'Delete a subflow definition and optionally its instances (staged — deploy to apply). ' +
|
|
251
|
+
'Returns previousState for undo. Refuses locked subflows.',
|
|
252
|
+
{
|
|
253
|
+
subflowId: z.string().describe('ID of the subflow to delete'),
|
|
254
|
+
deleteInstances: z.boolean().optional().default(true)
|
|
255
|
+
.describe('Whether to also delete all instances of this subflow (default true)'),
|
|
256
|
+
},
|
|
257
|
+
async (params) => handleDeleteSubflow(staging, nodeRedClient, params),
|
|
258
|
+
{ annotations: deleteSubflowDefinition.annotations, outputSchema: deleteSubflowDefinition.outputSchema },
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
// Register: get-flow-nodes
|
|
262
|
+
server.tool(
|
|
263
|
+
'get-flow-nodes',
|
|
264
|
+
'Paginated list of nodes within a flow: id, type, name, position, wires, group membership. ' +
|
|
265
|
+
'Supports filtering by disabled state, node type, and connected subgraph. Large text fields excluded.',
|
|
266
|
+
{
|
|
267
|
+
flowId: z.string().describe('ID of the flow (tab or subflow) to inspect'),
|
|
268
|
+
disabledOnly: z.boolean().optional().describe('If true, return only disabled nodes'),
|
|
269
|
+
nodeType: z.string().optional().describe('Filter to nodes of this type (e.g. "function", "http in")'),
|
|
270
|
+
fromNodeId: z.string().optional().describe('Filter to the connected subgraph reachable from this node ID'),
|
|
271
|
+
direction: z.enum(['downstream', 'upstream', 'both']).optional().default('both')
|
|
272
|
+
.describe('Traversal direction when fromNodeId is set: downstream (follow wires forward), upstream (follow wires backward), or both (full connected component). Default: both'),
|
|
273
|
+
offset: z.number().int().min(0).optional().default(0).describe('Pagination offset (default 0)'),
|
|
274
|
+
limit: z.number().int().min(1).max(200).optional().default(50).describe('Max nodes to return (default 50, max 200)'),
|
|
275
|
+
},
|
|
276
|
+
async (params) => handleGetFlowNodes(staging, params),
|
|
277
|
+
{ annotations: getFlowNodesDefinition.annotations, outputSchema: getFlowNodesDefinition.outputSchema },
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
// Register: get-flow-diagram
|
|
281
|
+
server.tool(
|
|
282
|
+
'get-flow-diagram',
|
|
283
|
+
'Mermaid flowchart diagram of a flow\'s topology with node names, wires, and group containers. ' +
|
|
284
|
+
'Supports same filtering as get-flow-nodes. Use to visualize flow structure.',
|
|
285
|
+
{
|
|
286
|
+
flowId: z.string().describe('ID of the flow (tab or subflow) to diagram'),
|
|
287
|
+
disabledOnly: z.boolean().optional().describe('If true, include only disabled nodes in the diagram'),
|
|
288
|
+
nodeType: z.string().optional().describe('Include only nodes of this type in the diagram'),
|
|
289
|
+
fromNodeId: z.string().optional().describe('Include only the connected subgraph reachable from this node ID'),
|
|
290
|
+
direction: z.enum(['downstream', 'upstream', 'both']).optional().default('both')
|
|
291
|
+
.describe('Traversal direction when fromNodeId is set. Default: both'),
|
|
292
|
+
offset: z.number().int().min(0).optional().default(0).describe('Pagination offset (default 0)'),
|
|
293
|
+
limit: z.number().int().min(1).max(200).optional().default(50).describe('Max nodes to include in diagram (default 50, max 200)'),
|
|
294
|
+
},
|
|
295
|
+
async (params) => handleGetFlowDiagram(staging, params),
|
|
296
|
+
{ annotations: getFlowDiagramDefinition.annotations, outputSchema: getFlowDiagramDefinition.outputSchema },
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
// Register: get-config-nodes
|
|
300
|
+
server.tool(
|
|
301
|
+
'get-config-nodes',
|
|
302
|
+
'Paginated list of global config nodes (mqtt-broker, tls-config, etc.): id, type, name, config. ' +
|
|
303
|
+
'Supports type filtering and pagination. Credential values are never exposed.',
|
|
304
|
+
{
|
|
305
|
+
nodeType: z.string().optional().describe('Filter to config nodes of this type (e.g. "mqtt-broker")'),
|
|
306
|
+
offset: z.number().int().min(0).optional().default(0).describe('Pagination offset (default 0)'),
|
|
307
|
+
limit: z.number().int().min(1).max(200).optional().default(50).describe('Max config nodes to return (default 50, max 200)'),
|
|
308
|
+
},
|
|
309
|
+
async (params) => handleGetConfigNodes(staging, params),
|
|
310
|
+
{ annotations: getConfigNodesDefinition.annotations, outputSchema: getConfigNodesDefinition.outputSchema },
|
|
311
|
+
);
|
|
312
|
+
|
|
313
|
+
// Register: get-node-detail
|
|
314
|
+
server.tool(
|
|
315
|
+
'get-node-detail',
|
|
316
|
+
'Full detail of a single node by ID, including large text fields (func, template, html). ' +
|
|
317
|
+
'For config nodes, includes credential metadata (field names, has_<field> flags). Passwords are never exposed.',
|
|
318
|
+
{
|
|
319
|
+
nodeId: z.string().describe('ID of the node to retrieve'),
|
|
320
|
+
},
|
|
321
|
+
async (params) => handleGetNodeDetail(staging, nodeRedClient, params),
|
|
322
|
+
{ annotations: getNodeDetailDefinition.annotations, outputSchema: getNodeDetailDefinition.outputSchema },
|
|
323
|
+
);
|
|
324
|
+
|
|
325
|
+
// Register: get-palette-nodes
|
|
326
|
+
server.tool(
|
|
327
|
+
'get-palette-nodes',
|
|
328
|
+
'Paginated list of all installed node types: name, module, version, category, enabled state. ' +
|
|
329
|
+
'Use to discover available node types before building flows.',
|
|
330
|
+
{
|
|
331
|
+
offset: z.number().int().min(0).optional().default(0).describe('Pagination offset (0-based, default 0)'),
|
|
332
|
+
limit: z.number().int().min(1).max(200).optional().default(50).describe('Max items to return (default 50, max 200)'),
|
|
333
|
+
},
|
|
334
|
+
async (params) => handleGetPaletteNodes(nodeRedClient, params),
|
|
335
|
+
{ annotations: getPaletteNodesDefinition.annotations, outputSchema: getPaletteNodesDefinition.outputSchema },
|
|
336
|
+
);
|
|
337
|
+
|
|
338
|
+
// Register: get-node-type-detail
|
|
339
|
+
server.tool(
|
|
340
|
+
'get-node-type-detail',
|
|
341
|
+
'Detailed info about a specific node type: module, version, description, configurable parameters. ' +
|
|
342
|
+
'Use to understand what properties a node type accepts before using it in a flow.',
|
|
343
|
+
{
|
|
344
|
+
type: z.string().describe('The node type name to look up (e.g. "inject", "function", "http in")'),
|
|
345
|
+
},
|
|
346
|
+
async (params) => handleGetNodeTypeDetail(nodeRedClient, params),
|
|
347
|
+
{ annotations: getNodeTypeDetailDefinition.annotations, outputSchema: getNodeTypeDetailDefinition.outputSchema },
|
|
348
|
+
);
|
|
349
|
+
|
|
350
|
+
// Register: create-flow
|
|
351
|
+
server.tool(
|
|
352
|
+
'create-flow',
|
|
353
|
+
'Create a new flow tab (staged — deploy to apply). ' +
|
|
354
|
+
'Returns the new flow ID. Use before creating nodes inside the tab.',
|
|
355
|
+
{
|
|
356
|
+
label: z.string().describe('Display label for the new flow tab'),
|
|
357
|
+
disabled: z.boolean().optional().describe('Whether the flow is disabled (default false)'),
|
|
358
|
+
info: z.string().optional().describe('Description or notes for the flow (default empty string)'),
|
|
359
|
+
env: z.array(z.object({
|
|
360
|
+
name: z.string().describe('Environment variable name'),
|
|
361
|
+
value: z.string().describe('Environment variable value'),
|
|
362
|
+
type: z.string().describe('Environment variable type (e.g. "str", "num", "bool")'),
|
|
363
|
+
})).optional().describe('Flow-level environment variables (default empty array)'),
|
|
364
|
+
},
|
|
365
|
+
async (params) => handleCreateFlow(staging, params),
|
|
366
|
+
{ annotations: createFlowDefinition.annotations, outputSchema: createFlowDefinition.outputSchema },
|
|
367
|
+
);
|
|
368
|
+
|
|
369
|
+
// Register: delete-flow
|
|
370
|
+
server.tool(
|
|
371
|
+
'delete-flow',
|
|
372
|
+
'Delete a flow tab by ID (staged — deploy to apply). ' +
|
|
373
|
+
'Returns previousState for undo. Refuses locked flows.',
|
|
374
|
+
{
|
|
375
|
+
flowId: z.string().describe('ID of the flow tab to delete'),
|
|
376
|
+
},
|
|
377
|
+
async (params) => handleDeleteFlow(staging, params),
|
|
378
|
+
{ annotations: deleteFlowDefinition.annotations, outputSchema: deleteFlowDefinition.outputSchema },
|
|
379
|
+
);
|
|
380
|
+
|
|
381
|
+
// Register: update-flow
|
|
382
|
+
server.tool(
|
|
383
|
+
'update-flow',
|
|
384
|
+
'Update flow tab metadata: label, disabled, info, env (staged — deploy to apply). ' +
|
|
385
|
+
'Returns previousState and currentState. Refuses locked flows.',
|
|
386
|
+
{
|
|
387
|
+
flowId: z.string().describe('ID of the flow tab to update'),
|
|
388
|
+
updates: z.object({
|
|
389
|
+
label: z.string().optional().describe('New display label'),
|
|
390
|
+
disabled: z.boolean().optional().describe('New enabled/disabled state'),
|
|
391
|
+
info: z.string().optional().describe('New description or notes'),
|
|
392
|
+
env: z.array(z.object({
|
|
393
|
+
name: z.string().describe('Environment variable name'),
|
|
394
|
+
value: z.string().describe('Environment variable value'),
|
|
395
|
+
type: z.string().describe('Environment variable type (e.g. "str", "num", "bool")'),
|
|
396
|
+
})).optional().describe('Replacement flow-level environment variables'),
|
|
397
|
+
}).describe('Fields to update — at least one field is required'),
|
|
398
|
+
},
|
|
399
|
+
async (params) => handleUpdateFlow(staging, params),
|
|
400
|
+
{ annotations: updateFlowDefinition.annotations, outputSchema: updateFlowDefinition.outputSchema },
|
|
401
|
+
);
|
|
402
|
+
|
|
403
|
+
// Register: update-node
|
|
404
|
+
server.tool(
|
|
405
|
+
'update-node',
|
|
406
|
+
'Shallow-merge properties onto an existing node (staged — deploy to apply). ' +
|
|
407
|
+
'Do NOT include wires — use connect-nodes/disconnect-nodes. For credentials, nest in a credentials object.',
|
|
408
|
+
{
|
|
409
|
+
nodeId: z.string().describe('ID of the node to update'),
|
|
410
|
+
properties: z.record(z.unknown()).describe('Properties to shallow-merge onto the node — must NOT include wires; use connect-nodes to add connections'),
|
|
411
|
+
},
|
|
412
|
+
async (params) => handleUpdateNode(staging, nodeRedClient, params),
|
|
413
|
+
{ annotations: updateNodeDefinition.annotations, outputSchema: updateNodeDefinition.outputSchema },
|
|
414
|
+
);
|
|
415
|
+
|
|
416
|
+
// Register: connect-nodes
|
|
417
|
+
server.tool(
|
|
418
|
+
'connect-nodes',
|
|
419
|
+
'Add a wire between nodes (staged — deploy to apply). ' +
|
|
420
|
+
'Idempotent. Supports batch mode via connections array. Returns wire state before and after.',
|
|
421
|
+
{
|
|
422
|
+
fromNodeId: z.string().describe('ID of the source node'),
|
|
423
|
+
outputPort: z.number().int().min(0).optional().default(0).describe('Output port index (0-based, default 0) — ignored when `connections` is provided'),
|
|
424
|
+
toNodeId: z.string().optional().describe('ID of the target node to wire to — ignored when `connections` is provided'),
|
|
425
|
+
connections: z.array(z.object({
|
|
426
|
+
outputPort: z.number().int().min(0).describe('Output port index (0-based)'),
|
|
427
|
+
toNodeId: z.string().describe('ID of the target node to wire to'),
|
|
428
|
+
})).optional().describe('Batch mode: wire multiple output ports in one call. When provided, `outputPort` and `toNodeId` are ignored.'),
|
|
429
|
+
},
|
|
430
|
+
async (params) => handleConnectNodes(staging, nodeRedClient, params),
|
|
431
|
+
{ annotations: connectNodesDefinition.annotations, outputSchema: connectNodesDefinition.outputSchema },
|
|
432
|
+
);
|
|
433
|
+
|
|
434
|
+
// Register: disconnect-nodes
|
|
435
|
+
server.tool(
|
|
436
|
+
'disconnect-nodes',
|
|
437
|
+
'Remove wires between nodes (staged — deploy to apply). ' +
|
|
438
|
+
'Supports single-wire, clear-port, and batch modes. Returns wire state before and after.',
|
|
439
|
+
{
|
|
440
|
+
fromNodeId: z.string().describe('ID of the source node'),
|
|
441
|
+
outputPort: z.number().int().min(0).optional().default(0).describe('Output port index (0-based, default 0) — ignored when `connections` is provided'),
|
|
442
|
+
toNodeId: z.string().optional().describe('ID of the target node whose wire to remove — omitted in clear-port mode; ignored when `connections` is provided'),
|
|
443
|
+
clearPort: z.boolean().optional().default(false).describe('If true and `toNodeId` is omitted, remove ALL wires from `outputPort`. Ignored when `connections` is provided.'),
|
|
444
|
+
connections: z.array(z.object({
|
|
445
|
+
outputPort: z.number().int().min(0).describe('Output port index (0-based)'),
|
|
446
|
+
toNodeId: z.string().describe('ID of the target node to disconnect'),
|
|
447
|
+
})).optional().describe('Batch mode: remove multiple wires in one call. When provided, `outputPort`, `toNodeId`, and `clearPort` are ignored.'),
|
|
448
|
+
},
|
|
449
|
+
async (params) => handleDisconnectNodes(staging, nodeRedClient, params),
|
|
450
|
+
{ annotations: disconnectNodesDefinition.annotations, outputSchema: disconnectNodesDefinition.outputSchema },
|
|
451
|
+
);
|
|
452
|
+
|
|
453
|
+
// Register: create-node
|
|
454
|
+
server.tool(
|
|
455
|
+
'create-node',
|
|
456
|
+
'Create a new node of any installed palette type in a flow (staged — deploy to apply). ' +
|
|
457
|
+
'Returns the new node ID. After creation, wire it with connect-nodes. ' +
|
|
458
|
+
'For credentials, nest them in a credentials object inside properties.',
|
|
459
|
+
{
|
|
460
|
+
type: z.string().describe('Palette node type to create (e.g. "function", "debug", "http in")'),
|
|
461
|
+
flowId: z.string().describe('ID of the flow tab or subflow to place the node in'),
|
|
462
|
+
properties: z.record(z.unknown()).optional().describe('Type-specific configuration fields to set on the new node'),
|
|
463
|
+
x: z.number().optional().default(300).describe('X canvas position (default 300)'),
|
|
464
|
+
y: z.number().optional().default(200).describe('Y canvas position (default 200)'),
|
|
465
|
+
},
|
|
466
|
+
async (params) => handleCreateNode(staging, nodeRedClient, params),
|
|
467
|
+
{ annotations: createNodeDefinition.annotations, outputSchema: createNodeDefinition.outputSchema },
|
|
468
|
+
);
|
|
469
|
+
|
|
470
|
+
// Register: delete-node
|
|
471
|
+
server.tool(
|
|
472
|
+
'delete-node',
|
|
473
|
+
'Remove a node by ID (staged — deploy to apply). ' +
|
|
474
|
+
'Returns the deleted node for undo. Refuses locked flows.',
|
|
475
|
+
{
|
|
476
|
+
nodeId: z.string().describe('ID of the node to delete'),
|
|
477
|
+
},
|
|
478
|
+
async (params) => handleDeleteNode(staging, nodeRedClient, params),
|
|
479
|
+
{ annotations: deleteNodeDefinition.annotations, outputSchema: deleteNodeDefinition.outputSchema },
|
|
480
|
+
);
|
|
481
|
+
|
|
482
|
+
// Register: export-flow
|
|
483
|
+
server.tool(
|
|
484
|
+
'export-flow',
|
|
485
|
+
'Export a flow or selected nodes as JSON for import-flow. ' +
|
|
486
|
+
'Supports "flow" mode (full tab + config nodes) and "nodes" mode (selected nodes with trimmed wires).',
|
|
487
|
+
{
|
|
488
|
+
exportMode: z.enum(['flow', 'nodes']).optional().default('flow')
|
|
489
|
+
.describe('Export mode: "flow" (full tab + config nodes) or "nodes" (selected nodes with trimmed wires). Default: "flow"'),
|
|
490
|
+
flowId: z.string().optional()
|
|
491
|
+
.describe('ID of the flow tab to export (flow mode only). Omit to export all flows.'),
|
|
492
|
+
nodeIds: z.array(z.string()).optional()
|
|
493
|
+
.describe('IDs of nodes to export (nodes mode only). Required when exportMode is "nodes".'),
|
|
494
|
+
},
|
|
495
|
+
async (params) => handleExportFlowJson(staging, params),
|
|
496
|
+
{ annotations: exportFlowDefinition.annotations, outputSchema: exportFlowDefinition.outputSchema },
|
|
497
|
+
);
|
|
498
|
+
|
|
499
|
+
// Register: import-flow
|
|
500
|
+
server.tool(
|
|
501
|
+
'import-flow',
|
|
502
|
+
'Import flow JSON into staging (staged — deploy to apply). ' +
|
|
503
|
+
'Supports "regenerate" (safe, new IDs) and "overwrite" conflict strategies. Optional targetFlowId for injection.',
|
|
504
|
+
{
|
|
505
|
+
flowJson: z.string().describe('Node-RED flow JSON to import — a JSON array string or a JSON object string with a "nodes" array'),
|
|
506
|
+
conflictStrategy: z.enum(['regenerate', 'overwrite']).optional().default('regenerate')
|
|
507
|
+
.describe('How to handle ID collisions: "regenerate" (default) remaps all IDs to new UUIDs; "overwrite" replaces existing nodes by ID'),
|
|
508
|
+
targetFlowId: z.string().optional()
|
|
509
|
+
.describe('If provided, import all non-tab nodes into this existing flow tab (its ID must exist and must not be locked)'),
|
|
510
|
+
},
|
|
511
|
+
async (params) => handleImportFlow(staging, nodeRedClient, params),
|
|
512
|
+
{ annotations: importFlowDefinition.annotations, outputSchema: importFlowDefinition.outputSchema },
|
|
513
|
+
);
|
|
514
|
+
|
|
515
|
+
// Register: get-context
|
|
516
|
+
server.tool(
|
|
517
|
+
'get-context',
|
|
518
|
+
'Read a context variable from node, flow, or global scope. ' +
|
|
519
|
+
'Single-key or all-keys mode. In-memory values are lost on restart.',
|
|
520
|
+
{
|
|
521
|
+
scope: z.enum(['node', 'flow', 'global']).describe('Context scope to read from'),
|
|
522
|
+
id: z.string().optional().describe('Node or flow UUID — required when scope is "node" or "flow"'),
|
|
523
|
+
key: z.string().optional().describe('Context key to read; omit to return all keys in the scope'),
|
|
524
|
+
},
|
|
525
|
+
async (params) => handleGetContext(nodeRedClient, params),
|
|
526
|
+
{ annotations: getContextDefinition.annotations, outputSchema: getContextDefinition.outputSchema },
|
|
527
|
+
);
|
|
528
|
+
|
|
529
|
+
// Register: delete-context
|
|
530
|
+
server.tool(
|
|
531
|
+
'delete-context',
|
|
532
|
+
'Delete a context variable from node, flow, or global scope. ' +
|
|
533
|
+
'Returns confirmation. The API does not support writing context — use function nodes for that.',
|
|
534
|
+
{
|
|
535
|
+
scope: z.enum(['node', 'flow', 'global']).describe('Context scope to delete from'),
|
|
536
|
+
id: z.string().optional().describe('Node or flow UUID — required when scope is "node" or "flow"'),
|
|
537
|
+
key: z.string().describe('Context key to delete'),
|
|
538
|
+
},
|
|
539
|
+
async (params) => handleDeleteContext(nodeRedClient, params),
|
|
540
|
+
{ annotations: deleteContextDefinition.annotations, outputSchema: deleteContextDefinition.outputSchema },
|
|
541
|
+
);
|
|
542
|
+
|
|
543
|
+
// Register: search-nodes
|
|
544
|
+
server.tool(
|
|
545
|
+
'search-nodes',
|
|
546
|
+
'Deep-search all regular nodes by query string (plain text or regex). ' +
|
|
547
|
+
'Returns matching nodes with flow context. Use to find nodes by name, type, or property value.',
|
|
548
|
+
{
|
|
549
|
+
query: z.string().describe('The search term — plain text (case-insensitive substring) or regex pattern when regex: true'),
|
|
550
|
+
regex: z.boolean().optional().default(false).describe('If true, treat query as a JavaScript regex pattern (default false — plain text search)'),
|
|
551
|
+
flowId: z.string().optional().describe('Limit search to nodes in a specific flow tab or subflow (omit to search all flows)'),
|
|
552
|
+
limit: z.number().int().min(1).optional().default(50).describe('Max results to return (default 50)'),
|
|
553
|
+
},
|
|
554
|
+
async (params) => handleSearchNodes(staging, params),
|
|
555
|
+
{ annotations: searchNodesDefinition.annotations, outputSchema: searchNodesDefinition.outputSchema },
|
|
556
|
+
);
|
|
557
|
+
|
|
558
|
+
// Register: inject-message
|
|
559
|
+
server.tool(
|
|
560
|
+
'inject-message',
|
|
561
|
+
'Trigger an inject node by ID or name. ' +
|
|
562
|
+
'Must deploy first — undeployed inject nodes will fail. Use read-debug-messages to observe results.',
|
|
563
|
+
{
|
|
564
|
+
nodeId: z.string().optional().describe('Node UUID of the inject node to trigger (alternative to name)'),
|
|
565
|
+
name: z.string().optional().describe('Name of the inject node to trigger (alternative to nodeId)'),
|
|
566
|
+
flowId: z.string().optional().describe('Flow ID to scope the name search (optional, ignored when nodeId is provided)'),
|
|
567
|
+
},
|
|
568
|
+
async (params) => handleInjectMessage(staging, nodeRedClient)(params),
|
|
569
|
+
{ annotations: injectMessageDefinition.annotations, outputSchema: injectMessageDefinition.outputSchema },
|
|
570
|
+
);
|
|
571
|
+
|
|
572
|
+
// Register: install-node
|
|
573
|
+
server.tool(
|
|
574
|
+
'install-node',
|
|
575
|
+
'Install a Node-RED node module from npm. ' +
|
|
576
|
+
'Returns the installed module info. May take 10-30+ seconds. Some nodes require restart.',
|
|
577
|
+
{
|
|
578
|
+
module: z.string().describe('npm package name to install (plain name, no @version), e.g. "node-red-node-suncalc"'),
|
|
579
|
+
},
|
|
580
|
+
async (params) => handleInstallNode(nodeRedClient, params),
|
|
581
|
+
{ annotations: installNodeDefinition.annotations, outputSchema: installNodeDefinition.outputSchema },
|
|
582
|
+
);
|
|
583
|
+
|
|
584
|
+
// Register: uninstall-node
|
|
585
|
+
server.tool(
|
|
586
|
+
'uninstall-node',
|
|
587
|
+
'Uninstall a Node-RED node module. ' +
|
|
588
|
+
'WARNING: removes node types from ALL flows. Export flows first if unsure.',
|
|
589
|
+
{
|
|
590
|
+
module: z.string().describe('Module identifier to uninstall, as shown in get-palette-nodes, e.g. "node-red-node-suncalc"'),
|
|
591
|
+
},
|
|
592
|
+
async (params) => handleUninstallNode(nodeRedClient, params),
|
|
593
|
+
{ annotations: uninstallNodeDefinition.annotations, outputSchema: uninstallNodeDefinition.outputSchema },
|
|
594
|
+
);
|
|
595
|
+
|
|
596
|
+
// Register: add-nodes-to-group
|
|
597
|
+
server.tool(
|
|
598
|
+
'add-nodes-to-group',
|
|
599
|
+
'Add nodes to a group, creating the group if needed (staged — deploy to apply). ' +
|
|
600
|
+
'Returns groupId and bounding box. Nodes are reassigned if already in another group.',
|
|
601
|
+
{
|
|
602
|
+
flowId: z.string().describe('ID of the flow tab where the nodes and group reside'),
|
|
603
|
+
nodeIds: z.array(z.string()).describe('Array of node IDs to add to the group'),
|
|
604
|
+
groupId: z.string().optional().describe('ID of an existing group. If omitted, a new group is created'),
|
|
605
|
+
groupName: z.string().optional().describe('Name for a new group (ignored if groupId is provided). Defaults to "Group"'),
|
|
606
|
+
style: z.object({
|
|
607
|
+
label: z.boolean().optional(),
|
|
608
|
+
fill: z.string().optional(),
|
|
609
|
+
'fill-opacity': z.string().optional(),
|
|
610
|
+
stroke: z.string().optional(),
|
|
611
|
+
'label-position': z.string().optional(),
|
|
612
|
+
color: z.string().optional(),
|
|
613
|
+
}).optional().describe('Style overrides for a new group (merged with defaults). Ignored if groupId is provided'),
|
|
614
|
+
},
|
|
615
|
+
async (params) => handleAddNodesToGroup(staging, nodeRedClient, params),
|
|
616
|
+
{ annotations: addNodesToGroupDefinition.annotations, outputSchema: addNodesToGroupDefinition.outputSchema },
|
|
617
|
+
);
|
|
618
|
+
|
|
619
|
+
// Register: remove-nodes-from-group
|
|
620
|
+
server.tool(
|
|
621
|
+
'remove-nodes-from-group',
|
|
622
|
+
'Remove nodes from a group (staged — deploy to apply). ' +
|
|
623
|
+
'If no nodeIds provided, removes all members. Optionally repositions nodes outside the group bounds.',
|
|
624
|
+
{
|
|
625
|
+
groupId: z.string().describe('ID of the group to remove nodes from'),
|
|
626
|
+
nodeIds: z.array(z.string()).optional().describe('Specific node IDs to remove. If omitted, all members are removed'),
|
|
627
|
+
reposition: z.boolean().optional().default(false).describe('If true, reposition removed nodes to the right of the group bounds'),
|
|
628
|
+
},
|
|
629
|
+
async (params) => handleRemoveNodesFromGroup(staging, nodeRedClient, params),
|
|
630
|
+
{ annotations: removeNodesFromGroupDefinition.annotations, outputSchema: removeNodesFromGroupDefinition.outputSchema },
|
|
631
|
+
);
|
|
632
|
+
|
|
633
|
+
// Register: update-group
|
|
634
|
+
server.tool(
|
|
635
|
+
'update-group',
|
|
636
|
+
'Update group name, style, or dimensions (staged — deploy to apply). ' +
|
|
637
|
+
'Returns previous and current state for undo.',
|
|
638
|
+
{
|
|
639
|
+
groupId: z.string().describe('ID of the group node to update'),
|
|
640
|
+
properties: z.object({}).passthrough().describe('Properties to shallow-merge onto the group (name, style, x, y, w, h)'),
|
|
641
|
+
},
|
|
642
|
+
async (params) => handleUpdateGroup(staging, nodeRedClient, params),
|
|
643
|
+
{ annotations: updateGroupDefinition.annotations, outputSchema: updateGroupDefinition.outputSchema },
|
|
644
|
+
);
|
|
645
|
+
|
|
646
|
+
// Register: delete-group
|
|
647
|
+
server.tool(
|
|
648
|
+
'delete-group',
|
|
649
|
+
'Delete a group and optionally its member nodes (staged — deploy to apply). ' +
|
|
650
|
+
'Returns previousState for undo. Set deleteMembers: false to keep nodes.',
|
|
651
|
+
{
|
|
652
|
+
groupId: z.string().describe('ID of the group to delete'),
|
|
653
|
+
deleteMembers: z.boolean().optional().default(true).describe('Whether to also delete member nodes (default true). Set to false to keep nodes'),
|
|
654
|
+
},
|
|
655
|
+
async (params) => handleDeleteGroup(staging, nodeRedClient, params),
|
|
656
|
+
{ annotations: deleteGroupDefinition.annotations, outputSchema: deleteGroupDefinition.outputSchema },
|
|
657
|
+
);
|
|
658
|
+
|
|
659
|
+
// Register: deploy
|
|
660
|
+
server.tool(
|
|
661
|
+
'deploy',
|
|
662
|
+
'Deploy all staged changes to the Node-RED runtime. ' +
|
|
663
|
+
'No write operation takes effect until deploy. Supports full, flows, and nodes deploy types. ' +
|
|
664
|
+
'Best practice: batch edits, then deploy once. Check get-staging-status before deploying.',
|
|
665
|
+
{
|
|
666
|
+
deployType: z.enum(['full', 'flows', 'nodes']).optional().default('nodes')
|
|
667
|
+
.describe('Deploy scope: "full" (all), "flows" (modified flows), or "nodes" (modified nodes only). Default: "nodes"'),
|
|
668
|
+
},
|
|
669
|
+
async (params) => handleDeploy(staging)(params),
|
|
670
|
+
{ annotations: deployDefinition.annotations, outputSchema: deployDefinition.outputSchema },
|
|
671
|
+
);
|
|
672
|
+
|
|
673
|
+
// Register: get-staging-status
|
|
674
|
+
server.tool(
|
|
675
|
+
'get-staging-status',
|
|
676
|
+
'Current staging state: pending changes, dirty node/flow IDs, deployed flag. ' +
|
|
677
|
+
'Use to inspect pending changes before deploy or verify deploy success.',
|
|
678
|
+
{},
|
|
679
|
+
async () => handleGetStagingStatus(staging)(),
|
|
680
|
+
{ annotations: getStagingStatusDefinition.annotations, outputSchema: getStagingStatusDefinition.outputSchema },
|
|
681
|
+
);
|
|
682
|
+
|
|
683
|
+
// Register: refresh-staging
|
|
684
|
+
server.tool(
|
|
685
|
+
'refresh-staging',
|
|
686
|
+
'Discard all un-deployed changes and re-fetch latest state from Node-RED. ' +
|
|
687
|
+
'Use at the START of every editing session. Without this, stale state causes HTTP 409 on deploy.',
|
|
688
|
+
{},
|
|
689
|
+
async () => handleRefreshStaging(staging)(),
|
|
690
|
+
{ annotations: refreshStagingDefinition.annotations, outputSchema: refreshStagingDefinition.outputSchema },
|
|
691
|
+
);
|
|
692
|
+
|
|
693
|
+
// Register: read-debug-messages
|
|
694
|
+
if (commsClient) {
|
|
695
|
+
server.tool(
|
|
696
|
+
'read-debug-messages',
|
|
697
|
+
'Read buffered debug messages from the Node-RED WebSocket connection. ' +
|
|
698
|
+
'Filter by node, name, keyword, or time range. Use after inject-message to observe flow output.',
|
|
699
|
+
{
|
|
700
|
+
nodeId: z.string().optional().describe('Filter: exact match on debug node ID'),
|
|
701
|
+
nodeName: z.string().optional().describe('Filter: case-insensitive substring match on debug node name'),
|
|
702
|
+
keyword: z.string().optional().describe('Filter: case-insensitive substring match in the stringified message payload'),
|
|
703
|
+
after: z.number().optional().describe('Filter: inclusive lower bound timestamp (Unix ms) — only messages with timestamp >= after'),
|
|
704
|
+
before: z.number().optional().describe('Filter: inclusive upper bound timestamp (Unix ms) — only messages with timestamp <= before'),
|
|
705
|
+
last: z.number().int().min(1).optional().describe('Return the last N matching messages (tail mode). Mutually exclusive with limit.'),
|
|
706
|
+
limit: z.number().int().min(1).optional().describe('Return the first N matching messages (default 50). Mutually exclusive with last.'),
|
|
707
|
+
},
|
|
708
|
+
async (params) => handleReadDebugMessages(commsClient)(params),
|
|
709
|
+
{ annotations: readDebugMessagesDefinition.annotations, outputSchema: readDebugMessagesDefinition.outputSchema },
|
|
710
|
+
);
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
// ── Skills integration ──────────────────────────────────────────────
|
|
714
|
+
|
|
715
|
+
// Resolve project root (src/server.js → src/ → project root)
|
|
716
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
717
|
+
const __dirname = path.dirname(__filename);
|
|
718
|
+
const projectRoot = path.resolve(__dirname, '..');
|
|
719
|
+
const allSkills = loadSkills(projectRoot);
|
|
720
|
+
|
|
721
|
+
// Skills are loaded from resources/skills/ which contains only
|
|
722
|
+
// Node-RED skills — no prefix filtering needed.
|
|
723
|
+
const skills = allSkills;
|
|
724
|
+
|
|
725
|
+
// Register MCP Resources — one per skill
|
|
726
|
+
for (const [skillName, skill] of skills) {
|
|
727
|
+
server.resource(
|
|
728
|
+
skillName,
|
|
729
|
+
`nodered://skills/${skillName}`,
|
|
730
|
+
{ description: skill.description, mimeType: 'text/markdown' },
|
|
731
|
+
async () => ({
|
|
732
|
+
contents: [{
|
|
733
|
+
uri: `nodered://skills/${skillName}`,
|
|
734
|
+
text: skill.content,
|
|
735
|
+
mimeType: 'text/markdown',
|
|
736
|
+
}],
|
|
737
|
+
}),
|
|
738
|
+
);
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// Register: get-skill tool (fallback for clients that don't support MCP Resources)
|
|
742
|
+
server.tool(
|
|
743
|
+
'get-skill',
|
|
744
|
+
'Retrieve full content of a Node-RED skill by name. ' +
|
|
745
|
+
'Use list-skills first to discover available skills. Also available as MCP resources.',
|
|
746
|
+
{
|
|
747
|
+
name: z.string().describe('The skill name to retrieve. Use list-skills to see available values (e.g. "nodered-flow-builder", "nodered-node-reference")'),
|
|
748
|
+
},
|
|
749
|
+
async (params) => {
|
|
750
|
+
const skill = skills.get(params.name);
|
|
751
|
+
if (!skill) {
|
|
752
|
+
const available = [...skills.keys()].join(', ');
|
|
753
|
+
return {
|
|
754
|
+
content: [{
|
|
755
|
+
type: 'text',
|
|
756
|
+
text: `Skill "${params.name}" not found. Available skills: ${available || '(none)'}`,
|
|
757
|
+
}],
|
|
758
|
+
};
|
|
759
|
+
}
|
|
760
|
+
return {
|
|
761
|
+
content: [{
|
|
762
|
+
type: 'text',
|
|
763
|
+
text: JSON.stringify({
|
|
764
|
+
name: skill.name,
|
|
765
|
+
description: skill.description,
|
|
766
|
+
category: skill.category,
|
|
767
|
+
useCase: skill.useCase,
|
|
768
|
+
content: skill.content,
|
|
769
|
+
}),
|
|
770
|
+
}],
|
|
771
|
+
};
|
|
772
|
+
},
|
|
773
|
+
);
|
|
774
|
+
|
|
775
|
+
// Register: list-skills tool
|
|
776
|
+
server.tool(
|
|
777
|
+
'list-skills',
|
|
778
|
+
'List all available Node-RED skills, grouped by category, with names, descriptions, resource URIs, and use-case hints. ' +
|
|
779
|
+
'Call this first to discover what skill guides exist, then use get-skill to read full content.',
|
|
780
|
+
{},
|
|
781
|
+
async () => {
|
|
782
|
+
// Group skills by category
|
|
783
|
+
const categoriesMap = new Map();
|
|
784
|
+
for (const [name, s] of skills) {
|
|
785
|
+
const cat = s.category || 'uncategorized';
|
|
786
|
+
if (!categoriesMap.has(cat)) {
|
|
787
|
+
categoriesMap.set(cat, []);
|
|
788
|
+
}
|
|
789
|
+
categoriesMap.get(cat).push({
|
|
790
|
+
name,
|
|
791
|
+
description: s.description,
|
|
792
|
+
uri: `nodered://skills/${name}`,
|
|
793
|
+
useCase: s.useCase || s.description,
|
|
794
|
+
});
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
// Build structured categories array
|
|
798
|
+
const categories = [...categoriesMap].map(([name, skills]) => ({ name, skills }));
|
|
799
|
+
|
|
800
|
+
// Build Markdown table grouped by category (concise: Skill | Use Case)
|
|
801
|
+
let markdown = '';
|
|
802
|
+
for (const cat of categories) {
|
|
803
|
+
markdown += `## ${cat.name}\n\n`;
|
|
804
|
+
markdown += '| Skill | Use Case |\n';
|
|
805
|
+
markdown += '|-------|----------|\n';
|
|
806
|
+
for (const s of cat.skills) {
|
|
807
|
+
const uc = s.useCase.replace(/\n/g, ' ').replace(/\|/g, '\\|');
|
|
808
|
+
markdown += `| ${s.name} | ${uc} |\n`;
|
|
809
|
+
}
|
|
810
|
+
markdown += '\n';
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
return {
|
|
814
|
+
content: [{
|
|
815
|
+
type: 'text',
|
|
816
|
+
text: markdown.trim() || '(no skills available)',
|
|
817
|
+
}],
|
|
818
|
+
structuredContent: { categories },
|
|
819
|
+
};
|
|
820
|
+
},
|
|
821
|
+
{ outputSchema: SkillListResponseSchema },
|
|
822
|
+
);
|
|
823
|
+
|
|
824
|
+
// ── Render-staging tool ────────────────────────────────────────────
|
|
825
|
+
|
|
826
|
+
server.tool(
|
|
827
|
+
'render-staging',
|
|
828
|
+
'Render the current staging workspace as SVG, HTML, or Mermaid. ' +
|
|
829
|
+
'SVG shows node positions and wire curves. HTML is interactive. highlightDirty highlights un-deployed nodes.',
|
|
830
|
+
{
|
|
831
|
+
format: z.enum(['svg', 'html', 'mermaid']).optional().default('svg')
|
|
832
|
+
.describe('Output format: "svg" (static SVG), "html" (interactive page), or "mermaid" (topology diagram)'),
|
|
833
|
+
flowId: z.string().optional().describe('Filter to a single flow tab or subflow ID'),
|
|
834
|
+
highlightDirty: z.boolean().optional().default(true).describe('Highlight dirty nodes with orange border'),
|
|
835
|
+
},
|
|
836
|
+
async (params) => handleRenderStaging(staging)(params),
|
|
837
|
+
{ annotations: renderStagingDefinition.annotations },
|
|
838
|
+
);
|
|
839
|
+
|
|
840
|
+
// Expose staging for HTTP transport (WebSocket, snapshot endpoint)
|
|
841
|
+
server.__staging = staging;
|
|
842
|
+
|
|
843
|
+
return server;
|
|
844
|
+
}
|