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..55df03a 100644 --- a/src/client.ts +++ b/src/client.ts @@ -319,6 +319,25 @@ 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/${encodeURIComponent(module)}/${encodeURIComponent(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..46d44e7 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); 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..749d11f --- /dev/null +++ b/src/tools/get-node-help.ts @@ -0,0 +1,20 @@ +import { z } from 'zod'; +import type { NodeRedClient } from '../client.js'; + +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: [ + { + type: 'text' as const, + text: result, + }, + ], + }; +} diff --git a/tests/client-runtime.test.ts b/tests/client-runtime.test.ts index 3ec6bf8..90e1c56 100644 --- a/tests/client-runtime.test.ts +++ b/tests/client-runtime.test.ts @@ -108,4 +108,65 @@ 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 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, + 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..27886fe 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,35 @@ 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); + }); + + 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(); + }); + }); });