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