Skip to content

Commit cd1478a

Browse files
committed
feat: inject debug capture, debug-listen tool, WS array format fix
- node-red-inject: add optional waitForDebug param — triggers inject, listens on Node-RED WebSocket /comms, captures debug output - node-red-debug-listen: new standalone WebSocket debug capture tool with nodeId/flowId filtering - Both tools handle Node-RED array-batched WebSocket format - NodeRedClient.baseUrl made public getter (needed for WS URL) - flows-create: auto-generate IDs + remap wires to avoid collisions - flows-get: resolve labels to IDs on first request failure - publish.yml: add workflow_dispatch trigger - README: update to 31 tools, debug capture docs, PNG logo - coverage: 78.35%, 122 tests (15 files), 21 live tests pass
1 parent ee67d48 commit cd1478a

10 files changed

Lines changed: 232 additions & 20 deletions

File tree

.github/workflows/publish.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ on:
44
push:
55
branches: [main]
66
tags: ["v*"]
7+
workflow_dispatch:
78

89
jobs:
910
test:

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@ coverage/
55
*.tsbuildinfo
66
.DS_Store
77
Thumbs.db
8+
AGENTS.md

README.md

Lines changed: 30 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,5 @@
11
<p align="center">
2-
<picture>
3-
<source media="(prefers-color-scheme: dark)" srcset="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2Fmysterysd%2Fnode-red-mcp%2Fmain%2Fpackage.json&query=%24.version&prefix=v&label=node-red-mcp&color=%238F00FF&style=for-the-badge&logo=nodered&logoColor=white">
4-
<img alt="node-red-mcp" src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2Fmysterysd%2Fnode-red-mcp%2Fmain%2Fpackage.json&query=%24.version&prefix=v&label=node-red-mcp&color=%238F00FF&style=for-the-badge&logo=nodered&logoColor=black">
5-
</picture>
2+
<img alt="node-red-mcp" src="docs/node-red-ai-mcp.png" width="100%">
63
</p>
74

85
<div align="center">
@@ -15,7 +12,7 @@
1512

1613
**A production-ready [MCP](https://modelcontextprotocol.io) server that connects AI assistants (Claude, Copilot, etc.) to the [Node-RED](https://nodered.org/) Admin API.**
1714

18-
Inspect flows, manage nodes, analyze graph topology, apply JSON patches, and rollback changes — all through natural language.
15+
Inspect flows, manage nodes, analyze graph topology, capture debug output, apply JSON patches, and rollback changes — all through natural language.
1916

2017
</div>
2118

@@ -29,6 +26,7 @@ Inspect flows, manage nodes, analyze graph topology, apply JSON patches, and rol
2926
- [Configuration](#configuration)
3027
- [Authentication](#authentication)
3128
- [Usage](#usage)
29+
- [Debug Capture](#debug-capture)
3230
- [Transport Modes](#transport-modes)
3331
- [Tools Reference](#tools-reference)
3432
- [Auth](#auth)
@@ -49,9 +47,10 @@ Inspect flows, manage nodes, analyze graph topology, apply JSON patches, and rol
4947
## Features
5048

5149
- 🚀 **3 Transport Modes** — stdio (default), SSE, streamable HTTP
52-
- 🔌 **30 MCP Tools** — full coverage of flows, nodes, runtime, auth, inject, and graph analysis
50+
- 🔌 **31 MCP Tools** — full coverage of flows, nodes, runtime, auth, inject, graph analysis, and debug capture
5351
- 📊 **Graph Engine** — auto-builds directed acyclic graph (DAG) from flow topology, detects cycles, sources, sinks, and computes node categories
5452
- 🔍 **Semantic Search** — query flows by node name, type, topic, URL, or any metadata
53+
- 🐛 **Live Debug Capture** — WebSocket-based capture of Node-RED debug output, combined with inject or standalone
5554
- 🔧 **JSON Patch** — RFC 6902 compliant patch engine for incremental flow edits
5655
- 📸 **Snapshots** — in-memory 20-entry ring buffer per flow for rollback
5756
- 🔗 **6 Resources + 3 Prompts** — inspect runtime settings, diagnostics, flows, and get AI-assisted analysis
@@ -148,6 +147,23 @@ node-red-mcp sse
148147
node-red-mcp streamableHttp
149148
```
150149

150+
### Debug Capture
151+
152+
Two tools provide real-time debug output capture via Node-RED WebSocket (`/comms`):
153+
154+
| Tool | Description |
155+
|---|---|
156+
| `node-red-inject` (with `waitForDebug`) | Inject + capture — fires inject, listens, returns both result and debug messages in one call |
157+
| `node-red-debug-listen` | Standalone listener — connects, subscribes to `debug`, captures messages for N seconds with optional node/flow filtering |
158+
159+
**Example** (one-call inject + verify):
160+
```
161+
node-red-inject({ nodeId: "my-inject", waitForDebug: 5 })
162+
→ { status: "injected", debug: [...], debugCount: 3 }
163+
```
164+
165+
Both tools handle Node-RED's array-batched WebSocket format automatically.
166+
151167
### Transport Modes
152168

153169
| Mode | Protocol | Best For |
@@ -158,7 +174,7 @@ node-red-mcp streamableHttp
158174

159175
## Tools Reference
160176

161-
All 27 tools are registered with the MCP server. Each returns JSON output.
177+
All 31 tools are registered with the MCP server. Each returns JSON output.
162178

163179
### Auth
164180

@@ -176,20 +192,21 @@ All 27 tools are registered with the MCP server. Each returns JSON output.
176192
| `node-red-runtime-get-diagnostics` | Read runtime diagnostics |
177193
| `node-red-runtime-get-flow-state` | Read runtime flow state |
178194
| `node-red-runtime-set-flow-state` | Update runtime flow state |
195+
| `node-red-debug-listen` | Capture debug messages via WebSocket for a duration (optionally filtered by node/flow) |
179196

180197
### Flows
181198

182199
| Tool | Description |
183200
|---|---|
184201
| `node-red-flows-list` | List active flow tabs and metadata |
185202
| `node-red-flows-get` | Get a single flow by id or label |
186-
| `node-red-flows-create` | Create a new flow tab with nodes |
203+
| `node-red-flows-create` | Create a new flow tab with nodes (auto-generates IDs, remaps wires) |
187204
| `node-red-flows-update` | Replace an existing flow tab |
188205
| `node-red-flows-patch` | Apply JSON Patch (RFC 6902) operations to a flow |
189206
| `node-red-flows-delete` | Delete a flow tab |
190207
| `node-red-flows-clone` | Clone an existing flow tab |
191208
| `node-red-flows-rollback` | Rollback a flow to a previous snapshot |
192-
| `node-red-inject` | Trigger an inject node by its ID |
209+
| `node-red-inject` | Trigger an inject node by its ID (optionally `waitForDebug` to capture debug output in one call) |
193210

194211
### Graph
195212

@@ -260,6 +277,7 @@ await client.installNode({ module: "node-red-contrib-something" });
260277

261278
```typescript
262279
class NodeRedClient {
280+
baseUrl: string; // Public getter (for WebSocket URL construction)
263281
constructor(options: ClientOptions);
264282

265283
// Auth
@@ -370,19 +388,19 @@ npm run prettier:fix
370388
```
371389
src/
372390
├── config.ts # Centralized env var config (NODE_RED_URL, NODE_RED_TOKEN, MCP_SERVER_PORT)
373-
├── client/ # NodeRedClient — Admin API wrapper
391+
├── client/ # NodeRedClient — Admin API wrapper (baseUrl public getter)
374392
├── graph/ # Standalone graph engine (types, engine, search, patch)
375393
├── tools/
376394
│ ├── auth/ # get-scheme, login, revoke
377-
│ ├── runtime/ # get-settings, get-diagnostics, get-flow-state, set-flow-state
395+
│ ├── runtime/ # get-settings, get-diagnostics, get-flow-state, set-flow-state, debug-listen
378396
│ ├── flows/ # list, get, create, update, patch, delete, clone, rollback, inject
379397
│ ├── graph/ # analyze, summary, visualize, dependencies, query, pack, export
380398
│ └── nodes/ # list, install, get-module, toggle-module, remove-module, get-set, toggle-set
381399
├── resources/ # 6 MCP resource handlers
382400
├── prompts/ # 3 MCP prompt templates
383401
├── server/ # McpServer factory
384402
├── transports/ # stdio, SSE, streamableHttp
385-
├── __tests__/ # 58 unit tests
403+
├── __tests__/ # 122 unit tests (15 files), 21 live integration tests
386404
└── index.ts # CLI entry point
387405
```
388406

docs/node-red-ai-mcp.png

382 KB
Loading

package-lock.json

Lines changed: 35 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,13 @@
3535
"axios": "^1.11.0",
3636
"cors": "^2.8.5",
3737
"express": "^5.2.1",
38+
"ws": "^8.21.0",
3839
"zod": "^4.0.0"
3940
},
4041
"devDependencies": {
4142
"@types/cors": "^2.8.19",
4243
"@types/express": "^5.0.6",
44+
"@types/ws": "^8.18.1",
4345
"@vitest/coverage-v8": "^4.1.8",
4446
"prettier": "^2.8.8",
4547
"shx": "^0.3.4",

src/client/index.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,19 +43,20 @@ function formatError(status: number | undefined, body: unknown, fallback?: strin
4343
}
4444

4545
export class NodeRedClient {
46-
private readonly baseUrl: string;
46+
get baseUrl(): string { return this._baseUrl; }
47+
private readonly _baseUrl: string;
4748
private http: AxiosInstance;
4849
token: string;
4950
private username?: string;
5051
private password?: string;
5152

5253
constructor(options: ClientOptions) {
53-
this.baseUrl = options.baseUrl.replace(/\/$/, "");
54+
this._baseUrl = options.baseUrl.replace(/\/$/, "");
5455
this.token = options.accessToken ?? "";
5556
this.username = options.username;
5657
this.password = options.password;
5758
this.http = axios.create({
58-
baseURL: this.baseUrl,
59+
baseURL: this._baseUrl,
5960
validateStatus: () => true,
6061
});
6162
}

src/tools/flows/inject.ts

Lines changed: 72 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,89 @@
11
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
22
import { z } from "zod";
3+
import { WebSocket } from "ws";
34
import { type NodeRedClient } from "../../client/index.js";
45

6+
interface DebugMessage {
7+
topic: string;
8+
data: Record<string, unknown>;
9+
}
10+
511
export function registerInjectTool(server: McpServer, client: NodeRedClient): void {
612
server.registerTool(
713
"node-red-inject",
814
{
915
title: "Inject",
10-
description: "Trigger an inject node by its ID",
16+
description: "Trigger an inject node by its ID. Optionally capture debug output.",
1117
inputSchema: z.object({
1218
nodeId: z.string().describe("ID of the inject node to trigger"),
19+
waitForDebug: z.number().min(0).max(60).optional().default(0)
20+
.describe("Seconds to wait for debug output after injecting (0 = no wait)"),
1321
}),
1422
},
1523
async (args) => {
16-
const result = await client.inject(args.nodeId);
17-
return { content: [{ type: "text", text: JSON.stringify(result ?? { status: "injected" }, null, 2) }] };
24+
if (!args.waitForDebug || args.waitForDebug <= 0) {
25+
const result = await client.inject(args.nodeId);
26+
return { content: [{ type: "text", text: JSON.stringify(result ?? { status: "injected" }, null, 2) }] };
27+
}
28+
29+
const wsUrl = client.baseUrl.replace(/^http/, "ws") + "/comms";
30+
const captured: DebugMessage[] = [];
31+
32+
return new Promise((resolve) => {
33+
let ws: WebSocket;
34+
let settled = false;
35+
36+
const finish = () => {
37+
if (settled) return;
38+
settled = true;
39+
try { ws?.close(); } catch {}
40+
resolve({
41+
content: [
42+
{
43+
type: "text",
44+
text: JSON.stringify({ status: "injected", debug: captured, debugCount: captured.length }, null, 2),
45+
},
46+
],
47+
});
48+
};
49+
50+
try {
51+
ws = new WebSocket(wsUrl);
52+
} catch (e) {
53+
resolve({
54+
content: [
55+
{
56+
type: "text",
57+
text: JSON.stringify({ status: "injected", error: `WebSocket failed: ${(e as Error).message}` }, null, 2),
58+
},
59+
],
60+
});
61+
return;
62+
}
63+
64+
const timeout = setTimeout(() => finish(), args.waitForDebug! * 1000);
65+
66+
ws.on("open", () => {
67+
ws.send(JSON.stringify({ subscribe: "debug" }));
68+
client.inject(args.nodeId!).catch(() => {});
69+
});
70+
71+
ws.on("message", (raw) => {
72+
try {
73+
const items = JSON.parse(raw.toString());
74+
const arr = Array.isArray(items) ? items : [items];
75+
for (const item of arr) {
76+
if (item.topic === "debug" && item.data) {
77+
captured.push(item);
78+
if (captured.length >= 10) finish();
79+
}
80+
}
81+
} catch {}
82+
});
83+
84+
ws.on("error", () => finish());
85+
ws.on("close", () => finish());
86+
});
1887
}
1988
);
2089
}

0 commit comments

Comments
 (0)