From 55682f36f229bfbca871126ee773ae397d7310ed Mon Sep 17 00:00:00 2001 From: debode <67194886+woytekbode@users.noreply.github.com> Date: Tue, 17 Mar 2026 23:38:14 +0100 Subject: [PATCH 1/2] feat: add get_node_help tool to retrieve node documentation Adds a new `get_node_help` tool that fetches the HTML help/documentation for a specific node type via the Node-RED Admin API. The Node-RED Admin API's GET /nodes/:module/:set endpoint returns HTML help content when requested with Accept: text/html. This is the same documentation shown in the Node-RED editor's info sidebar when selecting a node. This tool makes that documentation accessible to AI assistants, enabling them to understand node behavior, input/output formats, and configuration options. Changes: - src/client.ts: Add getNodeHelp(module, set) method - src/tools/get-node-help.ts: New tool handler - src/server.ts: Register get_node_help tool - tests/client-runtime.test.ts: Add client method tests - tests/tools-runtime.test.ts: Add tool handler tests - tests/server.test.ts: Add tool to listing assertion - README.md: Document new tool in features list --- README.md | 7 +++++- src/client.ts | 16 +++++++++++++ src/server.ts | 24 +++++++++++++++++++ src/tools/get-node-help.ts | 16 +++++++++++++ tests/client-runtime.test.ts | 46 ++++++++++++++++++++++++++++++++++++ tests/server.test.ts | 3 ++- tests/tools-runtime.test.ts | 27 +++++++++++++++++++++ 7 files changed, 137 insertions(+), 2 deletions(-) create mode 100644 src/tools/get-node-help.ts diff --git a/README.md b/README.md index eb4c5da..ce4d2ba 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Node-RED MCP Server -MCP server for Node-RED workflow management. Provides AI assistants with 17 tools to manage flows, node modules, context stores, and runtime settings through the Node-RED Admin API v2. +MCP server for Node-RED workflow management. Provides AI assistants with 18 tools to manage flows, node modules, context stores, and runtime settings through the Node-RED Admin API v2. ## Installation @@ -131,6 +131,7 @@ Note: No `NODE_RED_TOKEN` needed - credentials are in the URL. ### Node Module Management - **get_nodes**: List all installed node modules with versions and status +- **get_node_help**: Get the HTML help/documentation for a specific node type - **install_node**: Install a node module from the npm registry - **set_node_module_state**: Enable or disable an installed node module - **remove_node_module**: Uninstall a node module from Node-RED @@ -171,6 +172,10 @@ Delete the flow with ID "flow1" What node modules are installed? ``` +``` +Show me the help docs for the zigbee2mqtt-in node +``` + ``` Install the node-red-contrib-mqtt module ``` diff --git a/src/client.ts b/src/client.ts index 9d5e27c..9d9dc58 100644 --- a/src/client.ts +++ b/src/client.ts @@ -319,6 +319,22 @@ export class NodeRedClient { return z.array(NodeModuleSchema).parse(data); } + async getNodeHelp(module: string, set: string): Promise { + const headers = this.getHeaders(); + headers.Accept = 'text/html'; + const response = await request(`${this.baseUrl}/nodes/${module}/${set}`, { + method: 'GET', + headers, + }); + + if (response.statusCode !== 200) { + const body = await response.body.text(); + throw new Error(`Failed to get node help: ${response.statusCode}\n${body}`); + } + + return await response.body.text(); + } + async installNode(module: string): Promise { const response = await request(`${this.baseUrl}/nodes`, { method: 'POST', diff --git a/src/server.ts b/src/server.ts index 4bb8911..f79dc0a 100644 --- a/src/server.ts +++ b/src/server.ts @@ -10,6 +10,7 @@ import { getContext } from './tools/get-context.js'; import { getDiagnostics } from './tools/get-diagnostics.js'; import { getFlowState } from './tools/get-flow-state.js'; import { getFlows } from './tools/get-flows.js'; +import { getNodeHelp } from './tools/get-node-help.js'; import { getNodes } from './tools/get-nodes.js'; import { getSettings } from './tools/get-settings.js'; import { installNode } from './tools/install-node.js'; @@ -214,6 +215,27 @@ export function createServer() { properties: {}, }, }, + { + name: 'get_node_help', + description: + 'Get the HTML help/documentation for a specific node type. Returns the same help content shown in the Node-RED editor info sidebar. Use get_nodes first to discover available module and set names.', + inputSchema: { + type: 'object', + properties: { + module: { + type: 'string', + description: + 'Node module name (e.g. "node-red-contrib-zigbee2mqtt", "node-red")', + }, + set: { + type: 'string', + description: + 'Node set name within the module (e.g. "zigbee2mqtt-in", "inject")', + }, + }, + required: ['module', 'set'], + }, + }, { name: 'install_node', description: 'Install a new node module into Node-RED. Installs from the npm registry.', @@ -339,6 +361,8 @@ export function createServer() { return await deleteContext(client, request.params.arguments); case 'get_nodes': return await getNodes(client); + case 'get_node_help': + return await getNodeHelp(client, request.params.arguments as { module: string; set: string }); case 'install_node': return await installNode(client, request.params.arguments); case 'set_node_module_state': diff --git a/src/tools/get-node-help.ts b/src/tools/get-node-help.ts new file mode 100644 index 0000000..f3d8516 --- /dev/null +++ b/src/tools/get-node-help.ts @@ -0,0 +1,16 @@ +import type { NodeRedClient } from '../client.js'; + +export async function getNodeHelp( + client: NodeRedClient, + args: { module: string; set: string } +) { + const result = await client.getNodeHelp(args.module, args.set); + return { + content: [ + { + type: 'text' as const, + text: result, + }, + ], + }; +} diff --git a/tests/client-runtime.test.ts b/tests/client-runtime.test.ts index 3ec6bf8..7808a17 100644 --- a/tests/client-runtime.test.ts +++ b/tests/client-runtime.test.ts @@ -108,4 +108,50 @@ describe('NodeRedClient - Runtime Info', () => { expect(result).toEqual(diagnosticsWithExtras); }); }); + + describe('getNodeHelp', () => { + it('should fetch node help HTML successfully', async () => { + const mockHtml = '

This node does something useful.

'; + + vi.mocked(request).mockResolvedValue({ + statusCode: 200, + body: { text: vi.fn().mockResolvedValue(mockHtml) }, + } as any); + + const result = await client.getNodeHelp('node-red-contrib-zigbee2mqtt', 'zigbee2mqtt-in'); + expect(result).toBe(mockHtml); + expect(request).toHaveBeenCalledWith( + 'http://localhost:1880/nodes/node-red-contrib-zigbee2mqtt/zigbee2mqtt-in', + { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'Node-RED-API-Version': 'v2', + Authorization: 'Bearer test-token', + Accept: 'text/html', + }, + } + ); + }); + + it('should throw error on 404', async () => { + vi.mocked(request).mockResolvedValue({ + statusCode: 404, + body: { text: vi.fn().mockResolvedValue('Not Found') }, + } as any); + await expect( + client.getNodeHelp('nonexistent-module', 'nonexistent-set') + ).rejects.toThrow('Failed to get node help: 404'); + }); + + it('should throw error on 401', async () => { + vi.mocked(request).mockResolvedValue({ + statusCode: 401, + body: { text: vi.fn().mockResolvedValue('Unauthorized') }, + } as any); + await expect( + client.getNodeHelp('node-red', 'inject') + ).rejects.toThrow('Failed to get node help: 401'); + }); + }); }); diff --git a/tests/server.test.ts b/tests/server.test.ts index a047a33..feb6f47 100644 --- a/tests/server.test.ts +++ b/tests/server.test.ts @@ -42,7 +42,7 @@ describe('MCP Server', () => { expect(() => createServer()).toThrow(); }); - it('should list delete_flow in available tools', async () => { + it('should list all tools including get_node_help', async () => { process.env.NODE_RED_URL = 'http://localhost:1880'; process.env.NODE_RED_TOKEN = 'test-token'; @@ -61,6 +61,7 @@ describe('MCP Server', () => { expect(toolNames).toContain('update_flow'); expect(toolNames).toContain('validate_flow'); expect(toolNames).toContain('delete_flow'); + expect(toolNames).toContain('get_node_help'); await client.close(); await server.close(); diff --git a/tests/tools-runtime.test.ts b/tests/tools-runtime.test.ts index 60b1300..be23ca7 100644 --- a/tests/tools-runtime.test.ts +++ b/tests/tools-runtime.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { NodeRedClient } from '../src/client.js'; import { getDiagnostics } from '../src/tools/get-diagnostics.js'; +import { getNodeHelp } from '../src/tools/get-node-help.js'; import { getSettings } from '../src/tools/get-settings.js'; describe('Runtime Info Tool Handlers', () => { @@ -10,6 +11,7 @@ describe('Runtime Info Tool Handlers', () => { mockClient = { getSettings: vi.fn(), getDiagnostics: vi.fn(), + getNodeHelp: vi.fn(), } as any; }); @@ -57,4 +59,29 @@ describe('Runtime Info Tool Handlers', () => { expect(JSON.parse(result.content[0].text)).toEqual({}); }); }); + + describe('getNodeHelp', () => { + it('should return HTML help content', async () => { + const mockHtml = '

This node connects to a Zigbee2MQTT device.

'; + vi.mocked(mockClient.getNodeHelp).mockResolvedValue(mockHtml); + const result = await getNodeHelp(mockClient, { + module: 'node-red-contrib-zigbee2mqtt', + set: 'zigbee2mqtt-in', + }); + expect(result.content).toHaveLength(1); + expect(result.content[0].type).toBe('text'); + expect(result.content[0].text).toBe(mockHtml); + }); + + it('should return help for core nodes', async () => { + const mockHtml = '

Injects a message into a flow.

'; + vi.mocked(mockClient.getNodeHelp).mockResolvedValue(mockHtml); + const result = await getNodeHelp(mockClient, { + module: 'node-red', + set: 'inject', + }); + expect(result.content).toHaveLength(1); + expect(result.content[0].text).toBe(mockHtml); + }); + }); }); From 9176eabfdef697320217e7b2ad6f17bdbbbea86f Mon Sep 17 00:00:00 2001 From: debode <67194886+woytekbode@users.noreply.github.com> Date: Tue, 17 Mar 2026 23:48:48 +0100 Subject: [PATCH 2/2] fix: address Copilot review feedback - Add Zod schema validation in get-node-help tool handler (args: unknown) - Remove type assertion in server.ts switch case - URL-encode module and set path segments with encodeURIComponent() - Add test for scoped package URL encoding - Add test for invalid argument rejection --- src/client.ts | 11 +++++++---- src/server.ts | 2 +- src/tools/get-node-help.ts | 14 +++++++++----- tests/client-runtime.test.ts | 15 +++++++++++++++ tests/tools-runtime.test.ts | 6 ++++++ 5 files changed, 38 insertions(+), 10 deletions(-) diff --git a/src/client.ts b/src/client.ts index 9d9dc58..55df03a 100644 --- a/src/client.ts +++ b/src/client.ts @@ -322,10 +322,13 @@ export class NodeRedClient { async getNodeHelp(module: string, set: string): Promise { const headers = this.getHeaders(); headers.Accept = 'text/html'; - const response = await request(`${this.baseUrl}/nodes/${module}/${set}`, { - method: 'GET', - headers, - }); + const response = await request( + `${this.baseUrl}/nodes/${encodeURIComponent(module)}/${encodeURIComponent(set)}`, + { + method: 'GET', + headers, + } + ); if (response.statusCode !== 200) { const body = await response.body.text(); diff --git a/src/server.ts b/src/server.ts index f79dc0a..46d44e7 100644 --- a/src/server.ts +++ b/src/server.ts @@ -362,7 +362,7 @@ export function createServer() { case 'get_nodes': return await getNodes(client); case 'get_node_help': - return await getNodeHelp(client, request.params.arguments as { module: string; set: string }); + return await getNodeHelp(client, request.params.arguments); case 'install_node': return await installNode(client, request.params.arguments); case 'set_node_module_state': diff --git a/src/tools/get-node-help.ts b/src/tools/get-node-help.ts index f3d8516..749d11f 100644 --- a/src/tools/get-node-help.ts +++ b/src/tools/get-node-help.ts @@ -1,10 +1,14 @@ +import { z } from 'zod'; import type { NodeRedClient } from '../client.js'; -export async function getNodeHelp( - client: NodeRedClient, - args: { module: string; set: string } -) { - const result = await client.getNodeHelp(args.module, args.set); +const GetNodeHelpArgsSchema = z.object({ + module: z.string(), + set: z.string(), +}); + +export async function getNodeHelp(client: NodeRedClient, args: unknown) { + const parsed = GetNodeHelpArgsSchema.parse(args); + const result = await client.getNodeHelp(parsed.module, parsed.set); return { content: [ { diff --git a/tests/client-runtime.test.ts b/tests/client-runtime.test.ts index 7808a17..90e1c56 100644 --- a/tests/client-runtime.test.ts +++ b/tests/client-runtime.test.ts @@ -134,6 +134,21 @@ describe('NodeRedClient - Runtime Info', () => { ); }); + it('should URL-encode module and set for scoped packages', async () => { + const mockHtml = '

Scoped package help.

'; + + vi.mocked(request).mockResolvedValue({ + statusCode: 200, + body: { text: vi.fn().mockResolvedValue(mockHtml) }, + } as any); + + await client.getNodeHelp('@scope/node-red-contrib-foo', 'foo-node'); + expect(request).toHaveBeenCalledWith( + 'http://localhost:1880/nodes/%40scope%2Fnode-red-contrib-foo/foo-node', + expect.objectContaining({ method: 'GET' }) + ); + }); + it('should throw error on 404', async () => { vi.mocked(request).mockResolvedValue({ statusCode: 404, diff --git a/tests/tools-runtime.test.ts b/tests/tools-runtime.test.ts index be23ca7..27886fe 100644 --- a/tests/tools-runtime.test.ts +++ b/tests/tools-runtime.test.ts @@ -83,5 +83,11 @@ describe('Runtime Info Tool Handlers', () => { expect(result.content).toHaveLength(1); expect(result.content[0].text).toBe(mockHtml); }); + + it('should reject invalid arguments', async () => { + await expect(getNodeHelp(mockClient, {})).rejects.toThrow(); + await expect(getNodeHelp(mockClient, { module: 'foo' })).rejects.toThrow(); + await expect(getNodeHelp(mockClient, { set: 'bar' })).rejects.toThrow(); + }); }); });