Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
```
Expand Down
19 changes: 19 additions & 0 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,25 @@ export class NodeRedClient {
return z.array(NodeModuleSchema).parse(data);
}

async getNodeHelp(module: string, set: string): Promise<string> {
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<NodeModule> {
const response = await request(`${this.baseUrl}/nodes`, {
method: 'POST',
Expand Down
24 changes: 24 additions & 0 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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.',
Expand Down Expand Up @@ -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':
Expand Down
20 changes: 20 additions & 0 deletions src/tools/get-node-help.ts
Original file line number Diff line number Diff line change
@@ -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,
},
],
};
}
61 changes: 61 additions & 0 deletions tests/client-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,4 +108,65 @@ describe('NodeRedClient - Runtime Info', () => {
expect(result).toEqual(diagnosticsWithExtras);
});
});

describe('getNodeHelp', () => {
it('should fetch node help HTML successfully', async () => {
const mockHtml = '<p>This node does something useful.</p>';

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 = '<p>Scoped package help.</p>';

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');
});
});
});
3 changes: 2 additions & 1 deletion tests/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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();
Expand Down
33 changes: 33 additions & 0 deletions tests/tools-runtime.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand All @@ -10,6 +11,7 @@ describe('Runtime Info Tool Handlers', () => {
mockClient = {
getSettings: vi.fn(),
getDiagnostics: vi.fn(),
getNodeHelp: vi.fn(),
} as any;
});

Expand Down Expand Up @@ -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 = '<p>This node connects to a Zigbee2MQTT device.</p>';
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 = '<p>Injects a message into a flow.</p>';
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();
});
});
});